diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt index fb2c7b9d5..f3973a8cb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt @@ -30,17 +30,20 @@ internal class AndroidFileSyncSessionSchedulingGuard { fun restorePersistedSession( load: () -> Session?, accountIdOf: (Session) -> String, + publishAccount: (Session?, String?) -> Unit = { _, _ -> }, ): Session? = synchronized(monitor) { val restored = load() if (restored == null) { if (accountId != null) generation += 1 accountId = null + publishAccount(null, null) } else { val restoredAccountId = accountIdOf(restored) if (accountId != null && accountId != restoredAccountId) { generation += 1 } accountId = restoredAccountId + publishAccount(restored, restoredAccountId) } restored } @@ -49,6 +52,7 @@ internal class AndroidFileSyncSessionSchedulingGuard { replacementAccountId: String, persist: () -> Unit, cancelAll: () -> Unit, + publishAccount: (String) -> Unit = {}, ) { synchronized(monitor) { val accountChanged = accountId != replacementAccountId @@ -57,6 +61,7 @@ internal class AndroidFileSyncSessionSchedulingGuard { try { persist() accountId = replacementAccountId + publishAccount(replacementAccountId) } finally { if (accountChanged) cancelAll() } @@ -66,12 +71,14 @@ internal class AndroidFileSyncSessionSchedulingGuard { fun clearSession( persist: () -> Unit, cancelAll: () -> Unit, + clearPublishedAccount: () -> Unit = {}, ) { synchronized(monitor) { generation += 1 accountId = null try { persist() + clearPublishedAccount() } finally { cancelAll() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 3c2b1d413..7a1b8a4ca 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -107,6 +107,7 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity import dev.obiente.nextcloudnative.app.SupportDiagnosticValuePrivacy +import dev.obiente.nextcloudnative.app.SupportDiagnosticsDeletionResult import dev.obiente.nextcloudnative.app.SupportDiagnosticsExportResult import dev.obiente.nextcloudnative.app.SupportDiagnosticsSummary import dev.obiente.nextcloudnative.app.JvmNetworkRequestAttempt @@ -416,6 +417,11 @@ internal class AndroidNextcloudServices( activity = activity, diagnostics = supportDiagnostics, ) + private val supportIntake = AndroidSupportIntakeCoordinator.get( + context = appContext, + diagnostics = supportDiagnostics, + client = httpClient, + ) init { supportDiagnostics.registerPrivateValue(System.getProperty("user.home")) @@ -615,14 +621,33 @@ internal class AndroidNextcloudServices( reproductionSteps: String, ): SupportDiagnosticsExportResult = supportBundleExporter.export( reproductionSteps = reproductionSteps, - featureState = listOf( + featureState = supportDiagnosticFeatureState(), + ) + + override fun supportDiagnosticsSubmissionStates() = supportIntake.states() + + override suspend fun submitSupportDiagnostics(reproductionSteps: String) = supportIntake.submit( + reproductionSteps = reproductionSteps, + channel = appUpdateSupport().channel.name.lowercase(), + featureState = supportDiagnosticFeatureState(), + ) + + override suspend fun retrySupportDiagnosticsSubmission() = supportIntake.retry() + + override suspend fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() + + override suspend fun deleteSubmittedSupportDiagnosticsReport( + deletionUrl: String, + ): SupportDiagnosticsDeletionResult = supportIntake.deleteCompletedReport(deletionUrl) + + private fun supportDiagnosticFeatureState(): List = + listOf( SupportDiagnosticFieldDraft("distribution", appUpdateSupport().channel.name.lowercase()), SupportDiagnosticFieldDraft("direct_updates", appUpdateSupport().canCheckDirectUpdates.toString()), SupportDiagnosticFieldDraft("virtual_files_supported", supportsVirtualFileStorage.toString()), SupportDiagnosticFieldDraft("bidirectional_sync", supportsBidirectionalFileSync.toString()), SupportDiagnosticFieldDraft("network_metered", isAndroidActiveNetworkMetered(appContext).toString()), - ), - ) + ) override suspend fun clearSupportDiagnostics(): Boolean = supportDiagnostics.clear() @@ -724,10 +749,12 @@ internal class AndroidNextcloudServices( }.getOrNull() }, accountIdOf = NextcloudDocumentIds::accountKey, - )?.also { session -> - registerSessionPrivateValues(session) - supportDiagnostics.setActiveAccountIdentity(NextcloudDocumentIds.accountKey(session)) - } + publishAccount = { session, accountIdentity -> + session?.let(::registerSessionPrivateValues) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + }, + ) } override suspend fun saveSession(session: NextcloudSession) { @@ -762,11 +789,14 @@ internal class AndroidNextcloudServices( .apply() }, cancelAll = scheduler::cancelAll, + publishAccount = { accountIdentity -> + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + }, ) if (previousAccountId != null && previousAccountId != replacementAccountId) { nativeMediaPreviewCache.clearAccount(previousAccountId) } - supportDiagnostics.setActiveAccountIdentity(NextcloudDocumentIds.accountKey(session)) notifyDocumentsRootsChanged() } @@ -803,10 +833,13 @@ internal class AndroidNextcloudServices( .apply() }, cancelAll = scheduler::cancelAll, + clearPublishedAccount = { + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + }, ) accountId?.let(nativeMediaPreviewCache::clearAccount) notifyDocumentsRootsChanged() - supportDiagnostics.setActiveAccountIdentity(null) } catch (failure: Throwable) { recordSupportDiagnostic( SupportDiagnosticEventDraft( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt index 905ed9db8..11f2ca30c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import dev.obiente.nextcloudnative.app.AsyncJvmSupportDiagnostics +import dev.obiente.nextcloudnative.app.JvmSupportIntake import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft @@ -18,6 +19,7 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import kotlin.system.exitProcess private val UNCAUGHT_DIAGNOSTIC_HANDLER_INSTALLED = AtomicBoolean(false) @@ -62,18 +64,48 @@ internal object AndroidSupportDiagnostics { val appContext = context.applicationContext ?: context instance ?: AsyncJvmSupportDiagnostics( root = File(appContext.filesDir, "support-diagnostics"), - environment = SupportDiagnosticsEnvironment( - appVersion = BuildConfig.VERSION_NAME, - packageVersion = BuildConfig.VERSION_CODE.toString(), - platform = "Android", - operatingSystemVersion = android.os.Build.VERSION.RELEASE.orEmpty(), - architecture = android.os.Build.SUPPORTED_ABIS.firstOrNull().orEmpty(), - ), + environment = androidSupportDiagnosticsEnvironment(), workerName = "nextcloud-support-diagnostics", ).also { instance = it } } } +/** + * Owns the one durable support-submission state machine for this Android process. + * + * Activities, workers, and providers each create their own service facade, but they all operate on + * the same no-backup directory. Sharing the coordinator prevents a replacement facade from + * restoring or mutating that directory while an earlier facade is still packaging or uploading. + */ +internal object AndroidSupportIntakeCoordinator { + @Volatile + private var instance: JvmSupportIntake? = null + + fun get( + context: Context, + diagnostics: AsyncJvmSupportDiagnostics, + client: OkHttpClient, + ): JvmSupportIntake = instance ?: synchronized(this) { + val appContext = context.applicationContext ?: context + instance ?: JvmSupportIntake( + diagnostics = diagnostics, + temporaryRoot = File(appContext.noBackupFilesDir, "support-submissions"), + environment = androidSupportDiagnosticsEnvironment(), + client = client.newBuilder().retryOnConnectionFailure(false).build(), + supportMutationsAllowed = appContext.cloudMutationGate(), + ).also { instance = it } + } +} + +internal fun androidSupportDiagnosticsEnvironment(): SupportDiagnosticsEnvironment = + SupportDiagnosticsEnvironment( + appVersion = BuildConfig.VERSION_NAME, + packageVersion = BuildConfig.VERSION_CODE.toString(), + platform = "Android", + operatingSystemVersion = android.os.Build.VERSION.RELEASE.orEmpty(), + architecture = android.os.Build.SUPPORTED_ABIS.firstOrNull().orEmpty(), + ) + internal class AndroidSupportBundleExporter( private val context: Context, private val activity: Activity?, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index acf75fa65..dcbfd396b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -387,6 +387,7 @@ class AndroidFileSyncEngineInvariantTest { events += "restore-old-authority" "account-old" }, + publishAccount = { _, accountId -> events += "publish-${accountId ?: "none"}" }, ) } loadThread.start() @@ -396,6 +397,7 @@ class AndroidFileSyncEngineInvariantTest { guard.clearSession( persist = { events += "clear-session" }, cancelAll = { events += "cancel-all" }, + clearPublishedAccount = { events += "publish-none" }, ) } clearThread.start() @@ -410,7 +412,9 @@ class AndroidFileSyncEngineInvariantTest { listOf( "read-old-session", "restore-old-authority", + "publish-account-old", "clear-session", + "publish-none", "cancel-all", ), events, @@ -437,6 +441,7 @@ class AndroidFileSyncEngineInvariantTest { events += "restore-old-authority" "account-old" }, + publishAccount = { _, accountId -> events += "publish-$accountId" }, ) } loadThread.start() @@ -447,6 +452,7 @@ class AndroidFileSyncEngineInvariantTest { replacementAccountId = "account-new", persist = { events += "save-new-session" }, cancelAll = { events += "cancel-old-work" }, + publishAccount = { accountId -> events += "publish-$accountId" }, ) } replacementThread.start() @@ -461,7 +467,9 @@ class AndroidFileSyncEngineInvariantTest { listOf( "read-old-session", "restore-old-authority", + "publish-account-old", "save-new-session", + "publish-account-new", "cancel-old-work", ), events, diff --git a/changes/unreleased/351-direct-support-intake.md b/changes/unreleased/351-direct-support-intake.md new file mode 100644 index 000000000..90c64debb --- /dev/null +++ b/changes/unreleased/351-direct-support-intake.md @@ -0,0 +1,7 @@ +category: feature +issue: 351 +pull: none +platforms: android, desktop +user-facing: yes + +Send a reviewed, privacy-filtered diagnostic report directly to Obiente Support while retaining the option to save a local copy. diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index a9a13cb05..0c2c19939 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -227,6 +227,10 @@ kotlin { implementation("net.java.dev.jna:jna:5.19.1") implementation("net.java.dev.jna:jna-platform:5.19.1") } + val desktopTest by getting + desktopTest.dependencies { + implementation("com.squareup.okhttp3:mockwebserver3:5.3.0") + } } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index a6e81338c..7a1c04585 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12508,11 +12508,36 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) LaunchedEffect(services, diagnosticsRevision, refresh) { summary = services.loadSupportDiagnosticsSummary() } - var reproductionSteps by remember { mutableStateOf("") } + var reproductionSteps by rememberSaveable { mutableStateOf("") } var exporting by remember { mutableStateOf(false) } var status by remember { mutableStateOf(null) } var confirmClear by remember { mutableStateOf(false) } + var confirmSend by rememberSaveable { mutableStateOf(false) } + var confirmDiscard by rememberSaveable { mutableStateOf(false) } var showPreview by rememberSaveable { mutableStateOf(false) } + var reportPageIndex by rememberSaveable { mutableStateOf(0) } + var reportDeletionTarget by remember { mutableStateOf(null) } + val submissionState by remember(services) { + services.supportDiagnosticsSubmissionStates() + }.collectAsState(SupportDiagnosticsSubmissionState.Initializing) + val submissionBusy = submissionState is SupportDiagnosticsSubmissionState.Initializing || + submissionState is SupportDiagnosticsSubmissionState.Packaging || + submissionState is SupportDiagnosticsSubmissionState.Cancelling || + submissionState is SupportDiagnosticsSubmissionState.DeletingSubmittedReport || + submissionState is SupportDiagnosticsSubmissionState.Uploading + val submissionCancellable = submissionState is SupportDiagnosticsSubmissionState.Packaging || + submissionState is SupportDiagnosticsSubmissionState.Uploading + val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure || + submissionState is SupportDiagnosticsSubmissionState.BlockedByAnotherAccount + val submissionUnavailable = submissionState is SupportDiagnosticsSubmissionState.Unsupported || + submissionState is SupportDiagnosticsSubmissionState.AccountRequired + + LaunchedEffect(submissionBusy, submissionPending, submissionUnavailable) { + if (submissionBusy || submissionPending || submissionUnavailable) { + confirmClear = false + confirmSend = false + } + } if (confirmClear) { AlertDialog( @@ -12526,6 +12551,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) }, confirmButton = { TextButton( + enabled = !submissionBusy && !submissionPending, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), onClick = { confirmClear = false @@ -12546,6 +12572,96 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) ) } + if (confirmSend) { + AlertDialog( + onDismissRequest = { if (!submissionBusy) confirmSend = false }, + title = { Text("Send this private report?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + Text( + "The sanitized report, the description you reviewed, and app release details will be sent to Obiente Support.", + ) + Text( + "It does not include account credentials, raw account identifiers, server URLs, filenames, or file contents. Reports can include a stable pseudonymous account scope, allowing Obiente Support to correlate reports from the same account on this installation. Private report data is retained for 30 days unless you delete it first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton( + enabled = !submissionBusy && !submissionUnavailable, + onClick = { + confirmSend = false + scope.launch { services.submitSupportDiagnostics(reproductionSteps) } + }, + ) { Text("Send privately") } + }, + dismissButton = { + TextButton(enabled = !submissionBusy, onClick = { confirmSend = false }) { Text("Cancel") } + }, + ) + } + + if (confirmDiscard) { + AlertDialog( + onDismissRequest = { confirmDiscard = false }, + title = { Text("Discard this pending report?") }, + text = { + Text( + "This permanently removes the report prepared on this device. If its upload result is uncertain, the app will first reconcile it and request deletion from Obiente Support.", + ) + }, + confirmButton = { + TextButton( + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + onClick = { + confirmDiscard = false + scope.launch { services.cancelSupportDiagnosticsSubmission() } + }, + ) { Text("Discard report") } + }, + dismissButton = { + TextButton(onClick = { confirmDiscard = false }) { Text("Keep report") } + }, + ) + } + + reportDeletionTarget?.let { report -> + AlertDialog( + onDismissRequest = { if (!submissionBusy) reportDeletionTarget = null }, + title = { Text("Delete this submitted report?") }, + text = { + Text( + "This permanently deletes report ${report.supportCode} from Obiente Support and removes its private receipt from this device.", + ) + }, + confirmButton = { + TextButton( + enabled = !submissionBusy, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + onClick = { + reportDeletionTarget = null + scope.launch { + status = when ( + val result = services.deleteSubmittedSupportDiagnosticsReport(report.deletionUrl) + ) { + SupportDiagnosticsDeletionResult.Deleted -> "Submitted support report deleted." + is SupportDiagnosticsDeletionResult.Failed -> result.message + is SupportDiagnosticsDeletionResult.Unsupported -> result.reason + } + } + }, + ) { Text("Delete report") } + }, + dismissButton = { + TextButton(enabled = !submissionBusy, onClick = { reportDeletionTarget = null }) { + Text("Keep report") + } + }, + ) + } + Surface( modifier = Modifier.fillMaxWidth(), color = NextcloudTheme.colors.appTile, @@ -12603,7 +12719,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) value = reproductionSteps, onValueChange = { reproductionSteps = it.take(MAX_SUPPORT_REPRODUCTION_STEPS_LENGTH) }, modifier = Modifier.fillMaxWidth(), - enabled = summary.available && !exporting, + enabled = summary.available && !exporting && !submissionBusy && !submissionPending, label = { Text("What happened? (optional)") }, placeholder = { Text("Describe what you did, what you expected, and what happened.") }, supportingText = { @@ -12672,7 +12788,14 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), ) { Button( - enabled = summary.available && !exporting, + enabled = summary.available && !exporting && !submissionBusy && !submissionPending && + !submissionUnavailable, + onClick = { confirmSend = true }, + ) { + Text("Send to support") + } + OutlinedButton( + enabled = summary.available && !exporting && !submissionBusy, onClick = { exporting = true status = null @@ -12701,15 +12824,176 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) Spacer(Modifier.size(8.dp)) } - Text(if (exporting) "Preparing..." else "Export report") + Text(if (exporting) "Preparing..." else "Save a copy") + } + if (submissionCancellable) { + OutlinedButton(onClick = { + scope.launch { services.cancelSupportDiagnosticsSubmission() } + }) { + Text("Cancel sending") + } } if (summary.eventCount > 0) { OutlinedButton( - enabled = !exporting, + enabled = !exporting && !submissionBusy && !submissionPending, onClick = { confirmClear = true }, ) { Text("Clear history") } } } + Column( + modifier = Modifier.fillMaxWidth().semantics { + liveRegion = LiveRegionMode.Polite + }, + ) { + when (val current = submissionState) { + SupportDiagnosticsSubmissionState.Initializing -> { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text("Restoring any pending private report...", style = MaterialTheme.typography.bodySmall) + } + SupportDiagnosticsSubmissionState.AccountRequired -> Text( + "Sign in before sending a private support report.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SupportDiagnosticsSubmissionState.Idle -> Unit + is SupportDiagnosticsSubmissionState.BlockedByAnotherAccount -> Text( + current.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + SupportDiagnosticsSubmissionState.Packaging -> { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text("Preparing the private report...", style = MaterialTheme.typography.bodySmall) + } + SupportDiagnosticsSubmissionState.Cancelling -> { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text("Finishing private report cancellation...", style = MaterialTheme.typography.bodySmall) + } + SupportDiagnosticsSubmissionState.DeletingSubmittedReport -> { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text("Deleting the submitted support report...", style = MaterialTheme.typography.bodySmall) + } + is SupportDiagnosticsSubmissionState.Uploading -> { + if (current.progress == null) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator( + progress = { current.progress }, + modifier = Modifier.fillMaxWidth(), + ) + } + Text("Sending the private report to Obiente Support...", style = MaterialTheme.typography.bodySmall) + } + is SupportDiagnosticsSubmissionState.RetryableFailure -> { + Text( + current.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedButton(onClick = { scope.launch { services.retrySupportDiagnosticsSubmission() } }) { + Text("Retry safely") + } + TextButton(onClick = { confirmDiscard = true }) { + Text("Discard pending report") + } + } + } + is SupportDiagnosticsSubmissionState.Rejected -> Text( + current.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + SupportDiagnosticsSubmissionState.Cancelled -> Text( + "Private report submission cancelled.", + style = MaterialTheme.typography.bodySmall, + ) + is SupportDiagnosticsSubmissionState.Submitted -> { + val reportPage = supportReportPage(current.reports, reportPageIndex) + LaunchedEffect(reportPageIndex, reportPage.pageIndex, current.reports.size) { + if (reportPageIndex != reportPage.pageIndex) { + reportPageIndex = reportPage.pageIndex + } + } + Text( + if (current.reports.size == 1) { + "Sent privately. Your report remains available until its retention period ends." + } else { + "${current.reports.size} private reports remain available until their retention periods end." + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + reportPage.items.forEach { report -> + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + Text( + "Support code: ${report.supportCode}", + style = MaterialTheme.typography.bodyMedium, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedButton( + onClick = { + status = if ( + services.copyTextToClipboard( + "Obiente support code", + report.supportCode, + ) + ) { + "Support code copied." + } else { + "The support code could not be copied." + } + }, + ) { Text("Copy support code") } + TextButton(onClick = { services.openExternalUrl(report.statusUrl) }) { + Text("Open private status") + } + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + onClick = { reportDeletionTarget = report }, + ) { + Text("Delete report") + } + } + } + } + if (reportPage.pageCount > 1) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedButton( + enabled = reportPage.pageIndex > 0, + onClick = { reportPageIndex = reportPage.pageIndex - 1 }, + ) { Text("Previous reports") } + Text( + "Page ${reportPage.pageIndex + 1} of ${reportPage.pageCount}", + modifier = Modifier.padding(vertical = 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton( + enabled = reportPage.pageIndex + 1 < reportPage.pageCount, + onClick = { reportPageIndex = reportPage.pageIndex + 1 }, + ) { Text("Next reports") } + } + } + } + is SupportDiagnosticsSubmissionState.Unsupported -> Text( + current.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } status?.let { message -> Text( message, @@ -12725,6 +13009,30 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) } } +internal data class SupportReportPage( + val items: List, + val pageIndex: Int, + val pageCount: Int, +) + +internal fun supportReportPage( + reports: List, + requestedPageIndex: Int, + pageSize: Int = SUPPORT_REPORT_PAGE_SIZE, +): SupportReportPage { + require(pageSize > 0) + val pageCount = if (reports.isEmpty()) 1 else ((reports.size - 1) / pageSize) + 1 + val pageIndex = requestedPageIndex.coerceIn(0, pageCount - 1) + val firstIndex = pageIndex * pageSize + return SupportReportPage( + items = reports.subList(firstIndex, minOf(firstIndex + pageSize, reports.size)), + pageIndex = pageIndex, + pageCount = pageCount, + ) +} + +private const val SUPPORT_REPORT_PAGE_SIZE = 5 + @Composable internal fun DesktopStartOnLoginSettingsCard( enabled: Boolean, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index c040d995f..780c28253 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -521,6 +521,25 @@ interface NextcloudPlatformServices { "Anonymized support reports are unavailable on this platform.", ) + /** Current explicit support submission. No implementation may start one automatically. */ + fun supportDiagnosticsSubmissionStates(): Flow = + flowOf(SupportDiagnosticsSubmissionState.Unsupported("Direct support submission is unavailable on this platform.")) + + /** Packages and submits the reviewed report after the UI confirmation step. */ + suspend fun submitSupportDiagnostics(reproductionSteps: String) = Unit + + /** Retries only a retained, idempotent submission after reconciliation. */ + suspend fun retrySupportDiagnosticsSubmission() = Unit + + /** Cancels packaging or upload and removes its app-private temporary archive. */ + suspend fun cancelSupportDiagnosticsSubmission(): Boolean = false + + /** Deletes one retained submitted report after an explicit user confirmation. */ + suspend fun deleteSubmittedSupportDiagnosticsReport(deletionUrl: String): SupportDiagnosticsDeletionResult = + SupportDiagnosticsDeletionResult.Unsupported( + "Deleting submitted support reports is unavailable on this platform.", + ) + /** Clears only diagnostic history. The private alias key remains stable across reports. */ suspend fun clearSupportDiagnostics(): Boolean = false diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt index 6b4bbaf0f..977f6f477 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -184,6 +184,84 @@ sealed interface SupportDiagnosticsExportResult { data class Unsupported(val reason: String) : SupportDiagnosticsExportResult } +sealed interface SupportDiagnosticsSubmissionState { + data object Initializing : SupportDiagnosticsSubmissionState + data object AccountRequired : SupportDiagnosticsSubmissionState + data object Idle : SupportDiagnosticsSubmissionState + data class BlockedByAnotherAccount(val message: String) : SupportDiagnosticsSubmissionState + data object Packaging : SupportDiagnosticsSubmissionState + data object Cancelling : SupportDiagnosticsSubmissionState + data object DeletingSubmittedReport : SupportDiagnosticsSubmissionState + data class Uploading(val progress: Float?) : SupportDiagnosticsSubmissionState { + init { + require(progress == null || progress in 0f..1f) + } + } + data class RetryableFailure(val message: String, val outcomeAmbiguous: Boolean) : + SupportDiagnosticsSubmissionState + data class Rejected(val message: String) : SupportDiagnosticsSubmissionState + data object Cancelled : SupportDiagnosticsSubmissionState + data class SubmittedReport( + val supportCode: String, + val statusUrl: String, + val deletionUrl: String, + val retentionUntil: String, + ) + data class Submitted(val reports: List) : SupportDiagnosticsSubmissionState { + init { + require(reports.isNotEmpty()) + } + + val supportCode: String get() = reports.first().supportCode + val statusUrl: String get() = reports.first().statusUrl + val retentionUntil: String get() = reports.first().retentionUntil + } + data class Unsupported(val reason: String) : SupportDiagnosticsSubmissionState +} + +sealed interface SupportDiagnosticsDeletionResult { + data object Deleted : SupportDiagnosticsDeletionResult + data class Failed(val message: String) : SupportDiagnosticsDeletionResult + data class Unsupported(val reason: String) : SupportDiagnosticsDeletionResult +} + +@Serializable +internal data class SupportIntakeRelease( + val version: String, + val channel: String, + val platform: String, + val osVersion: String, + val architecture: String, +) + +@Serializable +internal data class SupportIntakeMetadata( + val contractVersion: Int = SUPPORT_INTAKE_CONTRACT_VERSION, + val productId: String = SUPPORT_INTAKE_PRODUCT_ID, + val requestType: String = "bug", + val title: String, + val description: String, + val contact: String = "", + val source: String = "app", + val release: SupportIntakeRelease, + val privacyAccepted: Boolean = true, +) + +@Serializable +internal data class SupportIntakeReceipt( + val contractVersion: Int, + val supportCode: String, + val status: String, + val statusUrl: String, + val deletionUrl: String, + val createdAt: String, + val retentionUntil: String, +) + +internal const val SUPPORT_INTAKE_CONTRACT_VERSION = 1 +internal const val SUPPORT_INTAKE_PRODUCT_ID = "nextcloud-native" +internal const val DEFAULT_OBIENTE_SUPPORT_URL = "https://support.obiente.org" + internal class SupportDiagnosticSanitizer( private val pseudonymize: (String) -> String, ) { @@ -376,7 +454,7 @@ private const val MIN_UNBOUNDED_PRIVATE_VALUE_LENGTH = 3 private const val MAX_PRIVATE_VALUE_LENGTH = 4_096 private const val MAX_REGISTERED_PRIVATE_VALUES = 128 private const val MAX_SUPPORT_DIAGNOSTIC_RAW_TEXT_LENGTH = 16_384 -private const val MAX_SUPPORT_DIAGNOSTIC_FIELD_VALUE_LENGTH = 512 +internal const val MAX_SUPPORT_DIAGNOSTIC_FIELD_VALUE_LENGTH = 512 private const val MAX_SUPPORT_DIAGNOSTIC_CODE_LENGTH = 96 private const val MAX_SUPPORT_DIAGNOSTIC_EXCEPTION_FRAMES = 16 private const val MAX_SUPPORT_DIAGNOSTIC_CAUSE_DEPTH = 4 @@ -385,7 +463,7 @@ private const val MAX_SUPPORT_DIAGNOSTIC_METHOD_LENGTH = 120 private const val MAX_SUPPORT_DIAGNOSTIC_FILE_NAME_LENGTH = 120 internal const val SUPPORT_DIAGNOSTIC_ALIAS_LENGTH = 16 -private val SUPPORT_DIAGNOSTIC_FIELD_NAME = Regex("^[a-z][a-z0-9_.-]{0,63}$") +internal val SUPPORT_DIAGNOSTIC_FIELD_NAME = Regex("^[a-z][a-z0-9_.-]{0,63}$") private val SUPPORT_DIAGNOSTIC_OPERATION = Regex("^[a-z][a-z0-9._-]{0,79}$") private val SUPPORT_DIAGNOSTIC_CODE = Regex("^[A-Za-z0-9._:-]{1,96}$") private val SUPPORT_DIAGNOSTIC_ALIAS = Regex("^<[a-z-]+:[a-f0-9]{16}>$") diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnosticsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnosticsTest.kt index 69a5956a1..25d7621d1 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnosticsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnosticsTest.kt @@ -10,6 +10,18 @@ class SupportDiagnosticsTest { publicContentSha256(("test-key\u0000$value").encodeToByteArray()) } + @Test + fun retainedSupportReportsAreExposedInBoundedPages() { + val reports = (1..12).toList() + + assertEquals(listOf(1, 2, 3, 4, 5), supportReportPage(reports, requestedPageIndex = -1).items) + assertEquals(listOf(6, 7, 8, 9, 10), supportReportPage(reports, requestedPageIndex = 1).items) + val lastPage = supportReportPage(reports, requestedPageIndex = 99) + assertEquals(listOf(11, 12), lastPage.items) + assertEquals(2, lastPage.pageIndex) + assertEquals(3, lastPage.pageCount) + } + @Test fun sanitizesSecretsUrlsAccountsAndPathsBeforeCreatingEvent() { val server = "https://cloud.example.test/nextcloud" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 33e237571..3938a2851 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -917,6 +917,12 @@ internal fun combinedAutomaticCacheExcess( return (total - maximumBytes).coerceAtLeast(0L) } +internal class DesktopSessionPublicationGuard { + private val monitor = Any() + + fun serialize(action: () -> Result): Result = synchronized(monitor, action) +} + internal fun closeVirtualFileProviderForReplacement( provider: AutoCloseable?, detach: () -> Unit, @@ -930,6 +936,7 @@ class DesktopNextcloudServices( private val onDesktopUpdateInstallerOpened: (String) -> Unit = {}, supportDiagnosticsRoot: File? = null, providedSupportDiagnostics: AsyncJvmSupportDiagnostics? = null, + supportIntakeRoot: File? = null, ) : NextcloudPlatformServices, AutoCloseable { private val preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative") private val ownsTemporarySupportDiagnosticsRoot = providedSupportDiagnostics == null && supportDiagnosticsRoot == null @@ -942,12 +949,23 @@ class DesktopNextcloudServices( requireNotNull(resolvedSupportDiagnosticsRoot), ) private val supportBundleExporter = DesktopSupportBundleExporter(supportDiagnostics) + private val ownsTemporarySupportIntakeRoot = supportIntakeRoot == null && resolvedSupportDiagnosticsRoot == null + private val resolvedSupportIntakeRoot = supportIntakeRoot + ?: resolvedSupportDiagnosticsRoot?.resolve("support-submissions") + ?: Files.createTempDirectory("nextcloud-native-test-support-intake").toFile() private val secretStore = defaultDesktopSecretStore() + private val sessionPublicationGuard = DesktopSessionPublicationGuard() private val appUpdater = DesktopAppUpdater( preferences = preferences.node("app-updates-v1"), onInstallerConfirmationOpened = { target -> onDesktopUpdateInstallerOpened(target.platform) }, ) private val httpClient = OkHttpClient.Builder().trackJvmNetworkFailures().build() + private val supportIntake = JvmSupportIntake( + diagnostics = supportDiagnostics, + temporaryRoot = resolvedSupportIntakeRoot, + environment = desktopSupportDiagnosticsEnvironment(), + client = httpClient.newBuilder().retryOnConnectionFailure(false).build(), + ) private val loginPollHttpClient = httpClient.newBuilder().retryOnConnectionFailure(false).build() private val loginPollFallbackTokens = ConcurrentHashMap.newKeySet() private val fileMutationHttpExecutor = DesktopHttpMutationExecutor(httpClient) @@ -2536,7 +2554,9 @@ class DesktopNextcloudServices( // Closing its backend while holding the same lock reverses that order and deadlocks. runCatching { providersToClose.first?.unmount() } runCatching { providersToClose.second?.close() } + supportIntake.close() supportDiagnostics.close() + if (ownsTemporarySupportIntakeRoot) resolvedSupportIntakeRoot.deleteRecursively() if (ownsTemporarySupportDiagnosticsRoot) requireNotNull(resolvedSupportDiagnosticsRoot).deleteRecursively() } @@ -3356,7 +3376,27 @@ class DesktopNextcloudServices( reproductionSteps: String, ): SupportDiagnosticsExportResult = supportBundleExporter.export( reproductionSteps = reproductionSteps, - featureState = listOf( + featureState = supportDiagnosticFeatureState(), + ) + + override fun supportDiagnosticsSubmissionStates() = supportIntake.states() + + override suspend fun submitSupportDiagnostics(reproductionSteps: String) = supportIntake.submit( + reproductionSteps = reproductionSteps, + channel = appUpdateSupport().channel.name.lowercase(), + featureState = supportDiagnosticFeatureState(), + ) + + override suspend fun retrySupportDiagnosticsSubmission() = supportIntake.retry() + + override suspend fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() + + override suspend fun deleteSubmittedSupportDiagnosticsReport( + deletionUrl: String, + ): SupportDiagnosticsDeletionResult = supportIntake.deleteCompletedReport(deletionUrl) + + private fun supportDiagnosticFeatureState(): List = + listOf( SupportDiagnosticFieldDraft("distribution", appUpdateSupport().channel.name.lowercase()), SupportDiagnosticFieldDraft("direct_updates", appUpdateSupport().canCheckDirectUpdates.toString()), SupportDiagnosticFieldDraft("start_on_login_supported", supportsStartOnLogin.toString()), @@ -3366,8 +3406,7 @@ class DesktopNextcloudServices( (windowsCloudFilesProvider != null || linuxVirtualFileSystem != null).toString(), ), SupportDiagnosticFieldDraft("bidirectional_sync", supportsBidirectionalFileSync.toString()), - ), - ) + ) override suspend fun clearSupportDiagnostics(): Boolean = withContext(Dispatchers.IO) { supportDiagnostics.clear() @@ -3436,47 +3475,64 @@ class DesktopNextcloudServices( } override fun loadSession(): NextcloudSession? { - val server = preferences.get(KEY_SERVER, null) ?: return null - val login = preferences.get(KEY_LOGIN, null) ?: return null - val password = secretStore.load(desktopSessionSecretReference(server, login)) - ?.decodeToString() - ?.takeIf(String::isNotBlank) - ?: return null - listOf(server, login, password).forEach(supportDiagnostics::registerPrivateValue) - return NextcloudSession(server, login, password).also { session -> - supportDiagnostics.setActiveAccountIdentity(desktopFileCacheAccountId(session)) + return sessionPublicationGuard.serialize { + val server = preferences.get(KEY_SERVER, null) + val login = preferences.get(KEY_LOGIN, null) + if (server == null || login == null) { + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + return@serialize null + } + val password = secretStore.load(desktopSessionSecretReference(server, login)) + ?.decodeToString() + ?.takeIf(String::isNotBlank) + if (password == null) { + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + return@serialize null + } + listOf(server, login, password).forEach(supportDiagnostics::registerPrivateValue) + NextcloudSession(server, login, password).also { session -> + val accountIdentity = desktopFileCacheAccountId(session) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + } } } override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - listOf(session.serverUrl, session.loginName, session.appPassword) - .forEach(supportDiagnostics::registerPrivateValue) - try { - secretStore.save( - reference = desktopSessionSecretReference(session.serverUrl, session.loginName), - username = session.loginName, - secret = session.appPassword.encodeToByteArray(), - ) - } catch (failure: Throwable) { - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "credentials.save", - outcome = "failed", - code = if (failure is DesktopSecretStoreUnavailableException) { - "DESKTOP_SECRET_STORE_UNAVAILABLE" - } else { - "DESKTOP_SECRET_STORE_FAILED" - }, - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - throw failure + sessionPublicationGuard.serialize { + listOf(session.serverUrl, session.loginName, session.appPassword) + .forEach(supportDiagnostics::registerPrivateValue) + try { + secretStore.save( + reference = desktopSessionSecretReference(session.serverUrl, session.loginName), + username = session.loginName, + secret = session.appPassword.encodeToByteArray(), + ) + } catch (failure: Throwable) { + recordSupportDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Authentication, + operation = "credentials.save", + outcome = "failed", + code = if (failure is DesktopSecretStoreUnavailableException) { + "DESKTOP_SECRET_STORE_UNAVAILABLE" + } else { + "DESKTOP_SECRET_STORE_FAILED" + }, + exception = failure.toSupportDiagnosticExceptionDraft(), + ), + ) + throw failure + } + preferences.put(KEY_SERVER, session.serverUrl) + preferences.put(KEY_LOGIN, session.loginName) + val accountIdentity = desktopFileCacheAccountId(session) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) } - preferences.put(KEY_SERVER, session.serverUrl) - preferences.put(KEY_LOGIN, session.loginName) - supportDiagnostics.setActiveAccountIdentity(desktopFileCacheAccountId(session)) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() } @@ -3610,9 +3666,12 @@ class DesktopNextcloudServices( ), ) } - preferences.remove(KEY_SERVER) - preferences.remove(KEY_LOGIN) - supportDiagnostics.setActiveAccountIdentity(null) + sessionPublicationGuard.serialize { + preferences.remove(KEY_SERVER) + preferences.remove(KEY_LOGIN) + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + } cleared = true } finally { if (!cleared) { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSupportDiagnostics.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSupportDiagnostics.kt index 7e9026d68..f71870e76 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSupportDiagnostics.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSupportDiagnostics.kt @@ -75,15 +75,18 @@ internal fun createDesktopSupportDiagnostics( root: File = desktopSupportDiagnosticsDirectory(), ): AsyncJvmSupportDiagnostics = AsyncJvmSupportDiagnostics( root = root, - environment = SupportDiagnosticsEnvironment( + environment = desktopSupportDiagnosticsEnvironment(), + workerName = "nextcloud-support-diagnostics", +) + +internal fun desktopSupportDiagnosticsEnvironment(): SupportDiagnosticsEnvironment = + SupportDiagnosticsEnvironment( appVersion = System.getProperty(DESKTOP_VERSION_NAME_PROPERTY, "development"), packageVersion = System.getProperty(DESKTOP_PACKAGE_VERSION_PROPERTY, "development"), platform = desktopSupportPlatformName(), operatingSystemVersion = System.getProperty("os.version", "Unknown"), architecture = System.getProperty("os.arch", "Unknown"), - ), - workerName = "nextcloud-support-diagnostics", -) + ) internal fun installDesktopUncaughtDiagnosticHandler(diagnostics: AsyncJvmSupportDiagnostics) { DESKTOP_CRASH_DIAGNOSTICS.set(diagnostics) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt index 7dbc617fe..90279e292 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt @@ -124,6 +124,7 @@ fun main(arguments: Array) { } }, providedSupportDiagnostics = supportDiagnostics, + supportIntakeRoot = supportDiagnosticsRoot.resolve("support-submissions"), ).also { themePreference.value = it.loadThemePreference() keepRunningInBackground.value = it.loadKeepRunningInBackgroundPreference() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSessionPublicationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSessionPublicationGuardTest.kt new file mode 100644 index 000000000..904e65405 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSessionPublicationGuardTest.kt @@ -0,0 +1,56 @@ +package dev.obiente.nextcloudnative.app + +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopSessionPublicationGuardTest { + @Test + fun replacementCannotPublishBeforeAnOlderLoadFinishes() { + val guard = DesktopSessionPublicationGuard() + val loadEntered = CountDownLatch(1) + val allowLoadToFinish = CountDownLatch(1) + val events = Collections.synchronizedList(mutableListOf()) + + val loadThread = Thread { + guard.serialize { + events += "read-account-old" + loadEntered.countDown() + check(allowLoadToFinish.await(5, TimeUnit.SECONDS)) + events += "publish-account-old" + } + } + loadThread.start() + assertTrue(loadEntered.await(5, TimeUnit.SECONDS)) + + val replacementThread = Thread { + guard.serialize { + events += "save-account-new" + events += "publish-account-new" + } + } + replacementThread.start() + replacementThread.join(100) + assertTrue(replacementThread.isAlive) + + allowLoadToFinish.countDown() + loadThread.join(5_000) + replacementThread.join(5_000) + + assertFalse(loadThread.isAlive) + assertFalse(replacementThread.isAlive) + assertEquals( + listOf( + "read-account-old", + "publish-account-old", + "save-account-new", + "publish-account-new", + ), + events, + ) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt index c12770a7f..5ef0a4085 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt @@ -182,6 +182,32 @@ class JvmSupportDiagnosticsTest { } } + @Test + fun preparedSubmissionKeepsTheConfirmedEventSnapshotAcrossLaterChanges() { + val root = createTempDirectory("support-diagnostics-confirmed-snapshot").toFile() + var now = 1_000_000L + val diagnostics = diagnostics(root) { now } + diagnostics.record(failureEvent("/srv/fixtures/confirmed.jpg").copy(operation = "sync.confirmed")) + val context = diagnostics.prepareSubmissionContext("The confirmed failure.", emptyList()) + + diagnostics.clear() + now += 1_000L + diagnostics.record(failureEvent("/srv/fixtures/later.jpg").copy(operation = "sync.later")) + val first = File(root, "confirmed-first.zip") + val second = File(root, "confirmed-second.zip") + diagnostics.writeBundleForSubmission(first, context) + diagnostics.writeBundleForSubmission(second, context) + + ZipFile(first).use { zip -> + val events = zip.getInputStream(assertNotNull(zip.getEntry("events.jsonl"))) + .bufferedReader() + .use { it.readText() } + assertTrue("sync.confirmed" in events) + assertFalse("sync.later" in events) + } + assertEquals(first.readBytes().toList(), second.readBytes().toList()) + } + @Test fun storageFailurePublishesARevisionAndDisablesExportState() { val root = createTempDirectory("support-diagnostics-storage-failure").toFile() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt new file mode 100644 index 000000000..b9fc39f99 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -0,0 +1,1841 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.SocketEffect +import okhttp3.OkHttpClient + +class JvmSupportIntakeTest { + @Test + fun submitsSanitizedBundleAndRemovesTemporaryArchive() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + fixture.intake.submit("Visit https://private.example.test and refresh.", "nightly", emptyList()) + + val submitted = assertIs(fixture.intake.states().value) + assertEquals("OBI-ABCDE-23456", submitted.supportCode) + assertEquals( + 1, + fixture.completedDescriptors().size, + ) + val request = fixture.server.takeRequest(2, TimeUnit.SECONDS) + requireNotNull(request) + assertEquals("POST", request.method) + assertEquals("/api/v1/reports", request.url.encodedPath) + assertTrue(request.headers["Idempotency-Key"].orEmpty().matches(Regex("[A-Za-z0-9_-]{43}"))) + val body = request.body?.utf8().orEmpty() + assertTrue(body.contains("nextcloud-native")) + assertFalse(body.contains("private.example.test")) + assertTrue(body.contains(" + fixture.server.enqueue(MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(2, fixture.server.requestCount) + val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val reconcile = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], reconcile.headers["Idempotency-Key"]) + assertEquals("/api/v1/receipts", reconcile.url.encodedPath) + assertEquals( + 1, + fixture.completedDescriptors().size, + ) + } + } + + @Test + fun permanentRejectionRemovesTemporaryArchive() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + MockResponse.Builder().code(400) + .body("""{"contractVersion":1,"code":"invalid_report","message":"Report schema rejected."}""") + .build(), + ) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val rejected = assertIs(fixture.intake.states().value) + assertEquals("Report schema rejected.", rejected.message) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun restoresInterruptedSubmissionAndReusesIdempotencyKey() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val interrupted = assertIs(fixture.intake.states().value) + assertTrue(interrupted.outcomeAmbiguous) + val firstUpload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val idempotencyKey = requireNotNull(firstUpload.headers["Idempotency-Key"]) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + + fixture.intake.close() + val restored = fixture.newIntake() + assertIs(restored.states().value) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + restored.retry() + + assertIs(restored.states().value) + val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(idempotencyKey, retry.headers["Idempotency-Key"]) + assertEquals( + 1, + fixture.completedDescriptors().size, + ) + } + } + + @Test + fun restoresAmbiguousSubmissionWhenItsArchiveWasLost() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val descriptor = File(fixture.temporaryRoot, "pending.json") + val archiveName = requireNotNull( + Regex("\\\"archiveName\\\":\\\"([^\\\"]+)\\\"").find(descriptor.readText())?.groupValues?.get(1), + ) + fixture.intake.close() + assertTrue(File(fixture.temporaryRoot, archiveName).delete()) + + fixture.newIntake().use { restored -> + assertIs(restored.states().value) + assertTrue(descriptor.isFile) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + restored.retry() + + assertIs(restored.states().value) + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("GET", reconciliation.method) + assertEquals(upload.headers["Idempotency-Key"], reconciliation.headers["Idempotency-Key"]) + } + } + } + + @Test + fun exposesAccountNeutralBlockForAnotherLocalAccount() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + fixture.intake.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + + val blocked = assertIs( + fixture.intake.states().value, + ) + assertTrue(blocked.message.contains("another signed-in account")) + fixture.intake.submit("B also failed.", "nightly", emptyList()) + assertIs(fixture.intake.states().value) + fixture.intake.retry() + assertFalse(fixture.intake.cancel()) + assertEquals(1, fixture.server.requestCount) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + + fixture.intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) + + assertIs(fixture.intake.states().value) + Unit + } + } + + @Test + fun capturesDiagnosticsForTheAccountSnapshottedBySubmission() = runBlocking { + testFixture().use { fixture -> + fixture.diagnostics.recordForAccountIdentity( + TEST_ACCOUNT_IDENTITY, + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Network, + operation = "network.account_a", + outcome = "failed", + ), + ) + fixture.diagnostics.recordForAccountIdentity( + OTHER_ACCOUNT_IDENTITY, + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Network, + operation = "network.account_b", + outcome = "failed", + ), + ) + fixture.diagnostics.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val descriptor = File(fixture.temporaryRoot, "pending.json").readText() + assertTrue(descriptor.contains("network.account_a")) + assertFalse(descriptor.contains("network.account_b")) + } + } + + @Test + fun restoresSuccessfulReceiptForItsAccount() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + fixture.intake.close() + + val restored = fixture.newIntake() + + val submitted = assertIs(restored.states().value) + assertEquals("OBI-ABCDE-23456", submitted.supportCode) + assertEquals(fixture.statusUrl, submitted.statusUrl) + assertEquals(fixture.statusUrl, submitted.reports.single().deletionUrl) + assertEquals(1, fixture.completedDescriptors().size) + assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) + restored.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + assertIs(restored.states().value) + Unit + } + } + + @Test + fun deletesSubmittedReceiptAfterAcceptedDeletionIsReconciled() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + assertEquals(1, fixture.completedDescriptors().size) + fixture.server.enqueue(MockResponse.Builder().code(202).body("{}").build()) + fixture.server.enqueue(MockResponse.Builder().code(404).body("{}").build()) + + val result = fixture.intake.deleteCompletedReport(fixture.statusUrl) + + assertIs(result) + assertIs(fixture.intake.states().value) + assertTrue(fixture.completedDescriptors().isEmpty()) + assertEquals("POST", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertEquals("GET", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + } + } + + @Test + fun keepsSubmittedReceiptWhenEarlyDeletionFails() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + fixture.server.enqueue(MockResponse.Builder().code(503).body("{}").build()) + + val result = fixture.intake.deleteCompletedReport(fixture.statusUrl) + + assertIs(result) + assertIs(fixture.intake.states().value) + assertEquals(1, fixture.completedDescriptors().size) + } + } + + @Test + fun reportsCompletedDeletionOnlyAfterLocalReceiptRemovalIsDurable() = runBlocking { + var failDirectorySync = false + testFixture( + directorySync = { + if (failDirectorySync) throw IOException("Synthetic completed receipt deletion sync failure.") + }, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + failDirectorySync = true + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + val firstResult = fixture.intake.deleteCompletedReport(fixture.statusUrl) + + assertIs(firstResult) + assertIs(fixture.intake.states().value) + failDirectorySync = false + fixture.server.enqueue(MockResponse.Builder().code(404).body("{}").build()) + + val retryResult = fixture.intake.deleteCompletedReport(fixture.statusUrl) + + assertIs(retryResult) + assertIs(fixture.intake.states().value) + } + Unit + } + + @Test + fun deletionFailureFallsBackToIdleWhenTheReceiptExpiresInFlight() = runBlocking { + testFixture().use { fixture -> + val retentionUntil = Instant.now().plusSeconds(3) + fixture.server.enqueue(receiptResponse(fixture.statusUrl, retentionUntil = retentionUntil)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + fixture.server.enqueue( + MockResponse.Builder().code(503).body("{}").headersDelay(4, TimeUnit.SECONDS).build(), + ) + + val result = fixture.intake.deleteCompletedReport(fixture.statusUrl) + + assertIs(result) + assertIs(fixture.intake.states().value) + } + Unit + } + + @Test + fun requiresAnAccountBeforeSupportSubmission() = runBlocking { + testFixture().use { fixture -> + fixture.intake.setActiveAccountIdentity(null) + + assertIs(fixture.intake.states().value) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + } + } + + @Test + fun retriesTransientCompletedDescriptorReadWithoutDeletingTheReceipt() = runBlocking { + val failReads = AtomicBoolean(false) + testFixture( + descriptorCleanupRetryMillis = 10L, + completedDescriptorRead = { descriptor -> + if (failReads.get()) throw IOException("Synthetic transient completed receipt read failure.") + descriptor.readText() + }, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + val descriptor = fixture.completedDescriptors().single() + fixture.intake.close() + failReads.set(true) + + fixture.newIntake().use { restored -> + val unavailable = assertIs(restored.states().value) + assertTrue(unavailable.reason.contains("retry automatically")) + assertTrue(descriptor.isFile) + + failReads.set(false) + val submitted = withTimeout(5_000) { + restored.states().first { state -> state is SupportDiagnosticsSubmissionState.Submitted } + } + assertEquals("OBI-ABCDE-23456", assertIs(submitted).supportCode) + assertTrue(descriptor.isFile) + } + } + } + + @Test + fun retriesDurableCleanupOfRejectedCompletedReceipt() = runBlocking { + var directorySyncAttempts = 0 + testFixture( + directorySync = { + directorySyncAttempts += 1 + if (directorySyncAttempts == 1) throw IOException("Synthetic completed receipt sync failure.") + }, + descriptorCleanupRetryMillis = 10L, + invalidCompletedBeforeInitialization = true, + ).use { fixture -> + withTimeout(5_000) { + while (directorySyncAttempts < 2) delay(10) + } + assertTrue(fixture.completedDescriptors().isEmpty()) + } + } + + @Test + fun preservesCompletedReceiptsForEachAccountAndReport() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl, supportCode = "OBI-ABCDE-23456")) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl, supportCode = "OBI-MNPQR-34567")) + fixture.intake.submit("A second refresh failed.", "nightly", emptyList()) + + fixture.intake.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + fixture.diagnostics.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + fixture.server.enqueue(receiptResponse(fixture.statusUrl, supportCode = "OBI-FGHJK-6789A")) + fixture.intake.submit("B refresh failed.", "nightly", emptyList()) + + assertEquals(3, fixture.completedDescriptors().size) + fixture.intake.close() + val restored = fixture.newIntake() + val accountA = assertIs(restored.states().value) + assertEquals( + setOf("OBI-ABCDE-23456", "OBI-MNPQR-34567"), + accountA.reports.map { it.supportCode }.toSet(), + ) + + restored.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + + val accountB = assertIs(restored.states().value) + assertEquals(listOf("OBI-FGHJK-6789A"), accountB.reports.map { it.supportCode }) + } + } + + @Test + fun rejectsReceiptBeyondTheConsentedRetentionWindow() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl, retentionDays = 31)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val retryable = assertIs( + fixture.intake.states().value, + ) + assertTrue(retryable.outcomeAmbiguous) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + assertTrue(fixture.completedDescriptors().isEmpty()) + } + } + + @Test + fun rejectsFreshReceiptWithAFutureServerTimestamp() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl, createdAtOffsetDays = 1)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val retryable = assertIs( + fixture.intake.states().value, + ) + assertTrue(retryable.outcomeAmbiguous) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + assertTrue(fixture.completedDescriptors().isEmpty()) + } + } + + @Test + fun rejectsFreshReceiptThatHasAlreadyExpired() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + receiptResponse( + fixture.statusUrl, + createdAtOffsetDays = -1, + retentionUntil = Instant.now().minusSeconds(1), + ), + ) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val retryable = assertIs( + fixture.intake.states().value, + ) + assertTrue(retryable.outcomeAmbiguous) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().any { it.extension == "zip" }) + assertTrue(fixture.completedDescriptors().isEmpty()) + } + } + + @Test + fun rejectsReceiptWithAnUnusableDeletionCapability() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + receiptResponse( + fixture.statusUrl, + deletionUrl = "https://support.invalid/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678", + ), + ) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val retryable = assertIs( + fixture.intake.states().value, + ) + assertTrue(retryable.outcomeAmbiguous) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().any { it.extension == "zip" }) + assertTrue(fixture.completedDescriptors().isEmpty()) + } + } + + @Test + fun rejectsSupportUploadWhenThePlatformMutationGateIsClosed() = runBlocking { + var mutationsAllowed = false + testFixture(supportMutationsAllowed = { mutationsAllowed }).use { fixture -> + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + + mutationsAllowed = true + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(1, fixture.server.requestCount) + } + } + + @Test + fun rechecksThePlatformMutationGateAtTheUploadBoundary() = runBlocking { + var gateChecks = 0 + testFixture(supportMutationsAllowed = { ++gateChecks == 1 }).use { fixture -> + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val retryable = assertIs( + fixture.intake.states().value, + ) + assertFalse(retryable.outcomeAmbiguous) + assertEquals(0, fixture.server.requestCount) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + } + } + + @Test + fun keepsCancellationBusyUntilTheActiveOperationStops() = runBlocking { + val transportGateEntered = CountDownLatch(1) + val allowTransportGateToFinish = CountDownLatch(1) + var gateChecks = 0 + testFixture( + supportMutationsAllowed = { + gateChecks += 1 + if (gateChecks == 1) { + true + } else { + transportGateEntered.countDown() + check(allowTransportGateToFinish.await(5, TimeUnit.SECONDS)) + true + } + }, + ).use { fixture -> + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertTrue(transportGateEntered.await(5, TimeUnit.SECONDS)) + + assertTrue(fixture.intake.cancel()) + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + + allowTransportGateToFinish.countDown() + submission.join() + + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun cancellationWinsBeforeTheUploadCallIsRegistered() = runBlocking { + val registrationEntered = CountDownLatch(1) + val allowRegistration = CountDownLatch(1) + testFixture( + beforeCallRegistration = { + registrationEntered.countDown() + check(allowRegistration.await(5, TimeUnit.SECONDS)) + }, + ).use { fixture -> + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertTrue(registrationEntered.await(5, TimeUnit.SECONDS)) + + assertTrue(fixture.intake.cancel()) + assertEquals(0, fixture.server.requestCount) + + allowRegistration.countDown() + submission.join() + + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + } + } + + @Test + fun publishesBusyStateAndCancelsBeforeSubmissionPreparationCompletes() = runBlocking { + val preparationEntered = CountDownLatch(1) + val allowPreparation = CountDownLatch(1) + testFixture( + beforeSubmissionPreparation = { + preparationEntered.countDown() + check(allowPreparation.await(5, TimeUnit.SECONDS)) + }, + ).use { fixture -> + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertTrue(preparationEntered.await(5, TimeUnit.SECONDS)) + assertIs(fixture.intake.states().value) + + assertTrue(fixture.intake.cancel()) + assertIs(fixture.intake.states().value) + allowPreparation.countDown() + submission.join() + + assertIs(fixture.intake.states().value) + assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) + assertEquals(0, fixture.server.requestCount) + } + } + + @Test + fun preservesPreparationBlockAcrossAccountSwitchesUntilTheOperationEnds() = runBlocking { + val preparationEntered = CountDownLatch(1) + val allowPreparationFailure = CountDownLatch(1) + testFixture( + beforeSubmissionPreparation = { + preparationEntered.countDown() + check(allowPreparationFailure.await(5, TimeUnit.SECONDS)) + throw IOException("Synthetic preparation failure.") + }, + ).use { fixture -> + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertTrue(preparationEntered.await(5, TimeUnit.SECONDS)) + fixture.intake.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + + val blocked = assertIs( + fixture.intake.states().value, + ) + assertTrue(blocked.message.contains("another signed-in account")) + + allowPreparationFailure.countDown() + submission.join() + + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + } + } + + @Test + fun cancellationStopsTheActiveCallWhenIntentPersistenceFails() = runBlocking { + var directorySyncs = 0 + testFixture( + directorySync = { + directorySyncs += 1 + if (directorySyncs == 4) throw IOException("Synthetic cancellation persistence failure.") + }, + ).use { fixture -> + fixture.server.enqueue( + receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build(), + ) + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertEquals("POST", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + + assertFalse(fixture.intake.cancel()) + + withTimeout(5_000) { submission.join() } + assertEquals("GET", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertIs(fixture.intake.states().value) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + fixture.intake.retry() + + assertIs(fixture.intake.states().value) + assertEquals("GET", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + } + Unit + } + + @Test + fun publishesCancellingWhileAnInterruptedUploadIsReconciled() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build(), + ) + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertEquals("POST", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + + assertTrue(fixture.intake.cancel()) + assertIs(fixture.intake.states().value) + + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + withTimeout(5_000) { submission.join() } + assertIs(fixture.intake.states().value) + } + Unit + } + + @Test + fun packagingFailureDoesNotRestoreAReportCancelledDuringPackaging() = runBlocking { + val packagingEntered = CountDownLatch(1) + val allowPackagingFailure = CountDownLatch(1) + testFixture( + beforeBundlePackaging = { + packagingEntered.countDown() + check(allowPackagingFailure.await(5, TimeUnit.SECONDS)) + throw IOException("Synthetic packaging failure.") + }, + ).use { fixture -> + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + assertTrue(packagingEntered.await(5, TimeUnit.SECONDS)) + + assertTrue(fixture.intake.cancel()) + allowPackagingFailure.countDown() + submission.join() + + assertIs(fixture.intake.states().value) + assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) + assertEquals(0, fixture.server.requestCount) + } + } + + @Test + fun deletesAnArchivePromotedBeforePackagingCancellationIsObserved() = runBlocking { + testFixture( + afterBundlePackaging = { + throw CancellationException("Synthetic cancellation after archive promotion.") + }, + ).use { fixture -> + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + + submission.join() + + val retryable = assertIs( + fixture.intake.states().value, + ) + assertFalse(retryable.outcomeAmbiguous) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + assertFalse(fixture.temporaryRoot.listFiles().orEmpty().any { it.extension == "zip" }) + } + } + + @Test + fun expiresCompletedReceiptWhileTheProcessRemainsOpen() = runBlocking { + testFixture().use { fixture -> + val retentionUntil = Instant.now().plusSeconds(2) + fixture.server.enqueue(receiptResponse(fixture.statusUrl, retentionUntil = retentionUntil)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(1, fixture.completedDescriptors().size) + withTimeout(5_000) { + fixture.intake.states().first { it is SupportDiagnosticsSubmissionState.Idle } + } + withTimeout(5_000) { + while (fixture.completedDescriptors().isNotEmpty()) delay(10) + } + assertTrue(fixture.completedDescriptors().isEmpty()) + } + } + + @Test + fun keepsSubmittedStateWhenTerminalDirectorySyncNeedsARetry() = runBlocking { + var cleanupSyncAttempts = 0 + testFixture( + directorySync = { directory -> + if (!File(directory, "pending.json").exists()) { + cleanupSyncAttempts += 1 + if (cleanupSyncAttempts == 1) throw IOException("Synthetic directory sync failure.") + } + }, + descriptorCleanupRetryMillis = 10L, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(1, fixture.completedDescriptors().size) + withTimeout(5_000) { + while (cleanupSyncAttempts < 2) delay(10) + } + assertIs(fixture.intake.states().value) + assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) + } + } + + @Test + fun retriesTerminalCleanupWhenThePendingDescriptorCannotBeRead() = runBlocking { + var descriptorReads = 0 + val cleanupRetryEntered = CountDownLatch(1) + val allowCleanupRetry = CountDownLatch(1) + testFixture( + pendingDescriptorRead = { descriptor -> + descriptorReads += 1 + if (descriptorReads == 1) { + throw IOException("Synthetic pending descriptor read failure.") + } + cleanupRetryEntered.countDown() + check(allowCleanupRetry.await(5, TimeUnit.SECONDS)) + descriptor.readText() + }, + descriptorCleanupRetryMillis = 10L, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertTrue(cleanupRetryEntered.await(5, TimeUnit.SECONDS)) + assertIs(fixture.intake.states().value) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + allowCleanupRetry.countDown() + withTimeout(5_000) { + while (File(fixture.temporaryRoot, "pending.json").exists()) delay(10) + } + assertIs(fixture.intake.states().value) + } + Unit + } + + @Test + fun retriesTerminalArchiveDeletionWithoutChangingSubmittedState() = runBlocking { + var archiveDeleteAttempts = 0 + val retryEntered = CountDownLatch(1) + val allowRetry = CountDownLatch(1) + testFixture( + privateFileDelete = { archive -> + archiveDeleteAttempts += 1 + if (archiveDeleteAttempts == 1) { + false + } else { + retryEntered.countDown() + check(allowRetry.await(5, TimeUnit.SECONDS)) + archive.delete() + } + }, + descriptorCleanupRetryMillis = 10L, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertTrue(retryEntered.await(5, TimeUnit.SECONDS)) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().any { it.extension == "zip" }) + allowRetry.countDown() + withTimeout(5_000) { + while (fixture.temporaryRoot.listFiles().orEmpty().any { it.extension == "zip" }) delay(10) + } + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().none { it.extension == "zip" }) + assertIs(fixture.intake.states().value) + } + Unit + } + + @Test + fun retriesDeletionOfOrphanedPendingDescriptorTemporaries() = runBlocking { + var deleteAttempts = 0 + val retryEntered = CountDownLatch(1) + val allowRetry = CountDownLatch(1) + testFixture( + privateFileDelete = { file -> + deleteAttempts += 1 + if (deleteAttempts == 1) { + false + } else { + retryEntered.countDown() + check(allowRetry.await(5, TimeUnit.SECONDS)) + file.delete() + } + }, + descriptorCleanupRetryMillis = 10L, + pendingTemporaryBeforeInitialization = true, + ).use { fixture -> + val orphan = requireNotNull( + fixture.temporaryRoot.listFiles().orEmpty().singleOrNull { + it.name.startsWith(".pending-") && it.extension == "tmp" + }, + ) + assertTrue(retryEntered.await(5, TimeUnit.SECONDS)) + assertTrue(orphan.isFile) + allowRetry.countDown() + + withTimeout(5_000) { + while (orphan.exists()) delay(10) + } + assertFalse(orphan.exists()) + assertTrue(deleteAttempts >= 2) + } + } + + @Test + fun removesArchiveTemporariesLeftByInterruptedPackaging() = runBlocking { + testFixture(archiveTemporaryBeforeInitialization = true).use { fixture -> + assertTrue( + fixture.temporaryRoot.listFiles().orEmpty().none { file -> + file.name.startsWith(".support-") && file.name.endsWith(".tmp") + }, + ) + } + } + + @Test + fun reportsUnavailableSubmissionStorageDuringInitialization() = runBlocking { + testFixture(submissionStorageBlocked = true).use { fixture -> + val state = assertIs(fixture.intake.states().value) + assertTrue(state.reason.contains("storage is unavailable")) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) + + fixture.intake.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + + assertIs(fixture.intake.states().value) + } + Unit + } + + @Test + fun reconciledReceiptDoesNotDuplicateAnExistingCompletionAfterRestart() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + receiptResponse(fixture.statusUrl).newBuilder().headersDelay(1, TimeUnit.SECONDS).build(), + ) + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val persistedPending = File(fixture.temporaryRoot, "pending.json").readText().replace( + Regex("\\\"archiveName\\\":\\\"[^\\\"]+\\\""), + "\"archiveName\":null", + ) + submission.join() + assertEquals(1, fixture.completedDescriptors().size) + + File(fixture.temporaryRoot, "pending.json").writeText(persistedPending) + fixture.intake.close() + fixture.newIntake().use { restored -> + assertIs(restored.states().value) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + restored.retry() + + val submitted = assertIs(restored.states().value) + assertEquals(listOf("OBI-ABCDE-23456"), submitted.reports.map { it.supportCode }) + assertEquals(1, fixture.completedDescriptors().size) + assertEquals("GET", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + } + } + } + + @Test + fun doesNotAcceptCancellationAfterReceiptCompletion() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertFalse(fixture.intake.cancel()) + assertEquals(1, fixture.server.requestCount) + } + } + + @Test + fun cancellingAmbiguousSubmissionRequiresDeletionReconciliation() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertTrue(fixture.intake.cancel()) + assertIs(fixture.intake.states().value) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + fixture.intake.retry() + + assertIs(fixture.intake.states().value) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + fixture.intake.retry() + + assertIs(fixture.intake.states().value) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun serverFailureRemainsAmbiguousUntilDiscardReconcilesAndDeletes() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val retryable = assertIs(fixture.intake.states().value) + assertTrue(retryable.outcomeAmbiguous) + val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + fixture.intake.retry() + + assertIs(fixture.intake.states().value) + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val deletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], reconciliation.headers["Idempotency-Key"]) + assertEquals("GET", reconciliation.method) + assertEquals("DELETE", deletion.method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun cancellationRechecksInitialAbsenceAndDeletesLateReceipt() = runBlocking { + testFixture( + cancellationReconcileWindowMillis = 1_000L, + cancellationReconcilePollMillis = 1L, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + submission.join() + + assertIs(fixture.intake.states().value) + val firstReconcile = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val secondReconcile = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val deletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], firstReconcile.headers["Idempotency-Key"]) + assertEquals(upload.headers["Idempotency-Key"], secondReconcile.headers["Idempotency-Key"]) + assertEquals("GET", firstReconcile.method) + assertEquals("GET", secondReconcile.method) + assertEquals("DELETE", deletion.method) + assertTrue(deletion.url.encodedPath.startsWith("/api/v1/reports/")) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun cancellationStillDeletesWhenReceiptPersistenceFails() = runBlocking { + var directorySyncs = 0 + testFixture( + directorySync = { + directorySyncs += 1 + if (directorySyncs == 5) throw IOException("Synthetic receipt persistence failure.") + }, + ).use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + + assertTrue(fixture.intake.cancel()) + submission.join() + + assertIs(fixture.intake.states().value) + assertEquals("GET", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun doesNotForwardPrivateReceiptKeyAcrossRedirects() = runBlocking { + MockWebServer().use { redirectedServer -> + redirectedServer.start() + testFixture().use { fixture -> + fixture.server.enqueue( + MockResponse.Builder().code(307) + .addHeader("Location", redirectedServer.url("/capture")) + .build(), + ) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + assertEquals(1, fixture.server.requestCount) + assertEquals(0, redirectedServer.requestCount) + } + } + } + + @Test + fun removesOrphanedArchiveWhenPendingDescriptorIsUnreadable() = runBlocking { + testFixture().use { fixture -> + require(fixture.temporaryRoot.isDirectory || fixture.temporaryRoot.mkdirs()) + val orphan = File(fixture.temporaryRoot, "support-${UUID.randomUUID()}.zip") + orphan.writeBytes(byteArrayOf(1, 2, 3)) + val descriptor = File(fixture.temporaryRoot, "pending.json") + descriptor.writeText("not-json") + + fixture.newIntake() + + assertFalse(orphan.exists()) + assertFalse(descriptor.exists()) + } + } + + @Test + fun retriesTransientPendingDescriptorReadWithoutDeletingRecoveryFiles() = runBlocking { + val failReads = AtomicBoolean(false) + testFixture( + descriptorCleanupRetryMillis = 10L, + pendingDescriptorRead = { descriptor -> + if (failReads.get()) throw IOException("Synthetic transient descriptor read failure.") + descriptor.readText() + }, + ).use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + val descriptor = File(fixture.temporaryRoot, "pending.json") + val archive = requireNotNull( + fixture.temporaryRoot.listFiles().orEmpty().singleOrNull { file -> file.extension == "zip" }, + ) + assertTrue(descriptor.isFile) + assertTrue(archive.isFile) + fixture.intake.close() + failReads.set(true) + + fixture.newIntake().use { restored -> + val unavailable = assertIs(restored.states().value) + assertTrue(unavailable.reason.contains("retry automatically")) + assertTrue(descriptor.isFile) + assertTrue(archive.isFile) + + failReads.set(false) + withTimeout(5_000) { + restored.states().first { state -> + state is SupportDiagnosticsSubmissionState.RetryableFailure + } + } + assertTrue(descriptor.isFile) + assertTrue(archive.isFile) + } + } + } + + @Test + fun retriesCleanupOfAnUnreadablePendingDescriptor() = runBlocking { + var deleteAttempts = 0 + testFixture( + privateFileDelete = { file -> + deleteAttempts += 1 + deleteAttempts > 2 && file.delete() + }, + descriptorCleanupRetryMillis = 1_000L, + invalidPendingBeforeInitialization = true, + ).use { fixture -> + assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) + assertTrue( + fixture.temporaryRoot.listFiles().orEmpty().any { + it.name.startsWith(".pending-rejected-") && it.extension == "tmp" + }, + ) + + withTimeout(5_000) { + while (fixture.temporaryRoot.listFiles().orEmpty().any { it.name.startsWith(".pending-rejected-") }) { + delay(10) + } + } + assertTrue(deleteAttempts >= 3) + } + } + + @Test + fun serializesConcurrentSubmissionAttempts() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + receiptResponse(fixture.statusUrl).newBuilder().headersDelay(1, TimeUnit.SECONDS).build(), + ) + + val first = launch(Dispatchers.Default) { + fixture.intake.submit("The first refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val second = launch(Dispatchers.Default) { + fixture.intake.submit("The second refresh failed.", "nightly", emptyList()) + } + second.join() + first.join() + + assertIs(fixture.intake.states().value) + assertEquals(1, fixture.server.requestCount) + } + } + + @Test + fun preservesCancellationIntentAcrossRestart() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + submission.join() + + assertIs(fixture.intake.states().value) + val firstReconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("GET", firstReconciliation.method) + fixture.intake.close() + + val restored = fixture.newIntake() + assertIs(restored.states().value) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + restored.retry() + + assertIs(restored.states().value) + val retryReconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val deletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], retryReconciliation.headers["Idempotency-Key"]) + assertEquals("GET", retryReconciliation.method) + assertEquals("DELETE", deletion.method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun retainsThrottledSubmissionAndHonorsRetryAfter() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + MockResponse.Builder().code(429) + .addHeader("Retry-After", "1") + .body("""{"message":"Try later."}""") + .build(), + ) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + val first = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + fixture.intake.retry() + assertEquals(1, fixture.server.requestCount) + + Thread.sleep(1_100) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.retry() + + assertIs(fixture.intake.states().value) + val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(first.headers["Idempotency-Key"], retry.headers["Idempotency-Key"]) + } + } + + @Test + fun reconcilesRequestTimeoutBeforeRetrying() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(408).build()) + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + val upload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], reconciliation.headers["Idempotency-Key"]) + assertEquals("GET", reconciliation.method) + + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.retry() + + val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], retry.headers["Idempotency-Key"]) + assertIs(fixture.intake.states().value) + Unit + } + } + + @Test + fun restoresConfirmedSubmissionInterruptedBeforePackaging() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + fixture.intake.submit("Visit https://private.example.test and refresh.", "nightly", emptyList()) + + assertIs(fixture.intake.states().value) + val firstUpload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val descriptor = File(fixture.temporaryRoot, "pending.json") + val persisted = descriptor.readText() + val archiveName = requireNotNull( + Regex("\\\"archiveName\\\":\\\"([^\\\"]+)\\\"").find(persisted)?.groupValues?.get(1), + ) + File(fixture.temporaryRoot, archiveName).delete() + descriptor.writeText( + persisted.replace( + Regex("\\\"archiveName\\\":\\\"[^\\\"]+\\\""), + "\"archiveName\":null", + ), + ) + fixture.intake.close() + + val restored = fixture.newIntake() + assertIs(restored.states().value) + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + + restored.retry() + + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("GET", reconciliation.method) + assertIs(restored.states().value) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + + restored.retry() + + assertIs(restored.states().value) + val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("POST", retry.method) + assertEquals(firstUpload.headers["Idempotency-Key"], retry.headers["Idempotency-Key"]) + val body = retry.body?.utf8().orEmpty() + assertFalse(body.contains("private.example.test")) + assertTrue(body.contains(" + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + submission.join() + + assertIs(fixture.intake.states().value) + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val failedDeletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("GET", reconciliation.method) + assertEquals("DELETE", failedDeletion.method) + fixture.intake.close() + + val restored = fixture.newIntake() + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + restored.retry() + + assertIs(restored.states().value) + val retriedDeletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("DELETE", retriedDeletion.method) + assertEquals(4, fixture.server.requestCount) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun acceptedDeletionKeepsReceiptUntilStatusConfirmsRemoval() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(202).body("{}").build()) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + submission.join() + + assertIs(fixture.intake.states().value) + assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val acceptedDeletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val statusCheck = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("GET", reconciliation.method) + assertEquals("DELETE", acceptedDeletion.method) + assertEquals("GET", statusCheck.method) + + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + fixture.intake.retry() + + assertIs(fixture.intake.states().value) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun keepsDeletionCapabilityAfterTheLocalArchiveRetentionWindow() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + submission.join() + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + fixture.intake.close() + + val descriptor = File(fixture.temporaryRoot, "pending.json") + val agedCreatedAt = Instant.now().minus(25, ChronoUnit.DAYS).toEpochMilli() + descriptor.writeText( + descriptor.readText().replace( + Regex("\"createdAtEpochMillis\":\\d+"), + "\"createdAtEpochMillis\":$agedCreatedAt", + ), + ) + val restored = fixture.newIntake() + + assertIs(restored.states().value) + assertTrue(descriptor.isFile) + assertFalse(fixture.temporaryRoot.listFiles().orEmpty().any { it.extension == "zip" }) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + restored.retry() + + assertIs(restored.states().value) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun restoresDeletionCapabilityWhenTheWallClockMovesBackward() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + submission.join() + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + fixture.intake.close() + + val descriptor = File(fixture.temporaryRoot, "pending.json") + val futureCreatedAt = Instant.now().plus(1, ChronoUnit.DAYS).toEpochMilli() + descriptor.writeText( + descriptor.readText().replace( + Regex("\"createdAtEpochMillis\":\\d+"), + "\"createdAtEpochMillis\":$futureCreatedAt", + ), + ) + + val restored = fixture.newIntake() + + assertIs(restored.states().value) + assertTrue(descriptor.isFile) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + restored.retry() + + assertIs(restored.states().value) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun preservesLastPersistedReceiptWhenRetryStateCannotBeRewritten() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl).newBuilder().headersDelay(10, TimeUnit.SECONDS).build()) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.server.enqueue( + MockResponse.Builder().code(503).headersDelay(1, TimeUnit.SECONDS).build(), + ) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertTrue(fixture.intake.cancel()) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val retainedRoot = File(fixture.root, "submissions-retained") + Files.move(fixture.temporaryRoot.toPath(), retainedRoot.toPath()) + fixture.temporaryRoot.writeText("temporarily unavailable") + submission.join() + + val state = assertIs(fixture.intake.states().value) + assertTrue(state.message.contains("could not be stored")) + val descriptor = File(retainedRoot, "pending.json") + assertTrue(descriptor.isFile) + assertTrue(descriptor.readText().contains("OBI-ABCDE-23456")) + + assertTrue(fixture.temporaryRoot.delete()) + Files.move(retainedRoot.toPath(), fixture.temporaryRoot.toPath()) + fixture.intake.close() + val restored = fixture.newIntake() + assertIs(restored.states().value) + fixture.server.enqueue(MockResponse.Builder().code(200).body("{}").build()) + + restored.retry() + + assertIs(restored.states().value) + assertEquals("DELETE", requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)).method) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun preservesLastUploadRecordWhenRetryStateCannotBeRewritten() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + MockResponse.Builder().code(503).headersDelay(1, TimeUnit.SECONDS).build(), + ) + + val submission = launch(Dispatchers.Default) { + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + } + val firstUpload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val retainedRoot = File(fixture.root, "submissions-retained") + Files.move(fixture.temporaryRoot.toPath(), retainedRoot.toPath()) + fixture.temporaryRoot.writeText("temporarily unavailable") + submission.join() + + val state = assertIs(fixture.intake.states().value) + assertTrue(state.message.contains("updated retry state")) + val descriptor = File(retainedRoot, "pending.json") + assertTrue(descriptor.isFile) + assertTrue(descriptor.readText().contains(firstUpload.headers["Idempotency-Key"].orEmpty())) + + assertTrue(fixture.temporaryRoot.delete()) + Files.move(retainedRoot.toPath(), fixture.temporaryRoot.toPath()) + fixture.intake.close() + val restored = fixture.newIntake() + fixture.server.enqueue(MockResponse.Builder().code(404).build()) + restored.retry() + + assertIs(restored.states().value) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + restored.retry() + + assertIs(restored.states().value) + val reconciliation = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(firstUpload.headers["Idempotency-Key"], reconciliation.headers["Idempotency-Key"]) + assertEquals(firstUpload.headers["Idempotency-Key"], retry.headers["Idempotency-Key"]) + } + } + + @Test + fun clearsImplausibleRetryDelayWithoutDiscardingRecovery() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue( + MockResponse.Builder().code(429) + .addHeader("Retry-After", "300") + .build(), + ) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + val firstUpload = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + fixture.intake.close() + + val descriptor = File(fixture.temporaryRoot, "pending.json") + val futureRetryAt = Instant.now().plus(1, ChronoUnit.DAYS).toEpochMilli() + descriptor.writeText( + descriptor.readText().replace( + Regex("\"retryNotBeforeEpochMillis\":\\d+"), + "\"retryNotBeforeEpochMillis\":$futureRetryAt", + ), + ) + val restored = fixture.newIntake() + + assertIs(restored.states().value) + assertTrue(descriptor.isFile) + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + restored.retry() + + assertIs(restored.states().value) + val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(firstUpload.headers["Idempotency-Key"], retry.headers["Idempotency-Key"]) + } + } + + @Test + fun agesAmbiguousRecoveryFromTheLatestUploadAttempt() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(MockResponse.Builder().code(429).build()) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + assertIs(fixture.intake.states().value) + fixture.intake.close() + + val descriptor = File(fixture.temporaryRoot, "pending.json") + val preparedTwentyNineDaysAgo = Instant.now().minus(29, ChronoUnit.DAYS).toEpochMilli() + descriptor.writeText( + descriptor.readText().replace( + Regex("\"createdAtEpochMillis\":\\d+"), + "\"createdAtEpochMillis\":$preparedTwentyNineDaysAgo", + ), + ) + val lateRetry = fixture.newIntake() + assertIs(lateRetry.states().value) + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + lateRetry.retry() + + val ambiguous = assertIs(lateRetry.states().value) + assertTrue(ambiguous.outcomeAmbiguous) + assertTrue(descriptor.readText().contains("\"latestUploadAttemptAtEpochMillis\":")) + lateRetry.close() + + val preparedThirtyOneDaysAgo = Instant.now().minus(31, ChronoUnit.DAYS).toEpochMilli() + descriptor.writeText( + descriptor.readText().replace( + Regex("\"createdAtEpochMillis\":\\d+"), + "\"createdAtEpochMillis\":$preparedThirtyOneDaysAgo", + ), + ) + fixture.newIntake().use { restored -> + assertIs(restored.states().value) + assertTrue(descriptor.isFile) + } + } + } + + @Test + fun restrictsPendingSubmissionFilesToTheCurrentUnixUser() = runBlocking { + testFixture().use { fixture -> + if ( + Files.getFileAttributeView( + fixture.temporaryRoot.toPath(), + PosixFileAttributeView::class.java, + ) == null + ) { + return@use + } + fixture.server.enqueue(MockResponse.Builder().code(503).build()) + + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) + + val expectedDirectoryPermissions = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ) + val expectedFilePermissions = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ) + assertEquals(expectedDirectoryPermissions, Files.getPosixFilePermissions(fixture.temporaryRoot.toPath())) + fixture.temporaryRoot.listFiles().orEmpty().filter(File::isFile).forEach { file -> + assertEquals(expectedFilePermissions, Files.getPosixFilePermissions(file.toPath())) + } + } + } + + private fun testFixture( + supportMutationsAllowed: () -> Boolean = { true }, + directorySync: (File) -> Unit = {}, + descriptorCleanupRetryMillis: Long = 60_000L, + beforeCallRegistration: () -> Unit = {}, + beforeSubmissionPreparation: () -> Unit = {}, + beforeBundlePackaging: () -> Unit = {}, + afterBundlePackaging: () -> Unit = {}, + privateFileDelete: (File) -> Boolean = File::delete, + pendingDescriptorRead: (File) -> String = { descriptor -> descriptor.readText() }, + completedDescriptorRead: (File) -> String = { descriptor -> descriptor.readText() }, + cancellationReconcileWindowMillis: Long = 0L, + cancellationReconcilePollMillis: Long = 1L, + submissionStorageBlocked: Boolean = false, + pendingTemporaryBeforeInitialization: Boolean = false, + archiveTemporaryBeforeInitialization: Boolean = false, + invalidPendingBeforeInitialization: Boolean = false, + invalidCompletedBeforeInitialization: Boolean = false, + ): Fixture { + val root = createTempDirectory("support-intake-test").toFile() + val diagnosticRoot = File(root, "diagnostics") + val temporaryRoot = if (submissionStorageBlocked) { + val blockingParent = File(root, "submission-storage-blocked").apply { writeText("unavailable") } + File(blockingParent, "submissions") + } else { + File(root, "submissions") + } + if (pendingTemporaryBeforeInitialization) { + require(temporaryRoot.mkdirs()) + File(temporaryRoot, ".pending-orphan.tmp").writeText("private context") + } + if (archiveTemporaryBeforeInitialization) { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) + File(temporaryRoot, ".support-${UUID.randomUUID()}.zip.123456789.tmp") + .writeText("private context") + } + if (invalidPendingBeforeInitialization) { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) + File(temporaryRoot, "pending.json").writeText("not-json") + } + if (invalidCompletedBeforeInitialization) { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) + File(temporaryRoot, "completed-${UUID.randomUUID()}.json").writeText("not-json") + } + val environment = SupportDiagnosticsEnvironment( + appVersion = "0.1.0-test", + packageVersion = "1", + platform = "Synthetic desktop", + operatingSystemVersion = "Synthetic OS", + architecture = "x86_64", + ) + val diagnostics = AsyncJvmSupportDiagnostics(diagnosticRoot, environment, "support-intake-test") + diagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Network, + operation = "network.synthetic", + outcome = "failed", + ), + ) + val server = MockWebServer().also { it.start() } + return Fixture( + root = root, + temporaryRoot = temporaryRoot, + diagnostics = diagnostics, + environment = environment, + server = server, + supportMutationsAllowed = supportMutationsAllowed, + directorySync = directorySync, + descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, + beforeCallRegistration = beforeCallRegistration, + beforeSubmissionPreparation = beforeSubmissionPreparation, + beforeBundlePackaging = beforeBundlePackaging, + afterBundlePackaging = afterBundlePackaging, + privateFileDelete = privateFileDelete, + pendingDescriptorRead = pendingDescriptorRead, + completedDescriptorRead = completedDescriptorRead, + cancellationReconcileWindowMillis = cancellationReconcileWindowMillis, + cancellationReconcilePollMillis = cancellationReconcilePollMillis, + ) + } + + private fun receiptResponse( + statusUrl: String, + supportCode: String = "OBI-ABCDE-23456", + retentionDays: Long = 30, + createdAtOffsetDays: Long = 0, + retentionUntil: Instant? = null, + deletionUrl: String = statusUrl, + ): MockResponse { + val createdAt = Instant.now().plus(createdAtOffsetDays, ChronoUnit.DAYS).truncatedTo(ChronoUnit.SECONDS) + val resolvedRetentionUntil = retentionUntil ?: createdAt.plus(retentionDays, ChronoUnit.DAYS) + return MockResponse.Builder().code(201).body( + """ + { + "contractVersion": 1, + "supportCode": "$supportCode", + "status": "new", + "statusUrl": "$statusUrl", + "deletionUrl": "$deletionUrl", + "createdAt": "$createdAt", + "retentionUntil": "$resolvedRetentionUntil" + } + """.trimIndent(), + ).build() + } + + private data class Fixture( + val root: File, + val temporaryRoot: File, + val diagnostics: AsyncJvmSupportDiagnostics, + val environment: SupportDiagnosticsEnvironment, + val server: MockWebServer, + val supportMutationsAllowed: () -> Boolean, + val directorySync: (File) -> Unit, + val descriptorCleanupRetryMillis: Long, + val beforeCallRegistration: () -> Unit, + val beforeSubmissionPreparation: () -> Unit, + val beforeBundlePackaging: () -> Unit, + val afterBundlePackaging: () -> Unit, + val privateFileDelete: (File) -> Boolean, + val pendingDescriptorRead: (File) -> String, + val completedDescriptorRead: (File) -> String, + val cancellationReconcileWindowMillis: Long, + val cancellationReconcilePollMillis: Long, + ) : AutoCloseable { + val intake = newIntake() + val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() + + fun completedDescriptors(): List = temporaryRoot.listFiles().orEmpty() + .filter { file -> file.name.matches(Regex("completed-[0-9a-f-]{36}\\.json")) } + + fun newIntake() = JvmSupportIntake( + diagnostics = diagnostics, + temporaryRoot = temporaryRoot, + environment = environment, + client = OkHttpClient.Builder().retryOnConnectionFailure(false).build(), + supportBaseUrl = server.url("/").toString(), + supportMutationsAllowed = supportMutationsAllowed, + directorySync = directorySync, + descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, + beforeCallRegistration = beforeCallRegistration, + beforeSubmissionPreparation = beforeSubmissionPreparation, + beforeBundlePackaging = beforeBundlePackaging, + afterBundlePackaging = afterBundlePackaging, + privateFileDelete = privateFileDelete, + pendingDescriptorRead = pendingDescriptorRead, + completedDescriptorRead = completedDescriptorRead, + cancellationReconcileWindowMillis = cancellationReconcileWindowMillis, + cancellationReconcilePollMillis = cancellationReconcilePollMillis, + ).also { intake -> + intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) + runBlocking { intake.awaitInitialization() } + } + + override fun close() { + intake.close() + diagnostics.close() + server.close() + root.deleteRecursively() + } + } + + private companion object { + const val TEST_ACCOUNT_IDENTITY = "0123456789abcdef0123456789abcdef" + const val OTHER_ACCOUNT_IDENTITY = "fedcba9876543210fedcba9876543210" + } +} diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt index 4b35db2ce..0112df522 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt @@ -173,6 +173,31 @@ class AsyncJvmSupportDiagnostics( ready.await().also(::drainPendingSnapshot).writeBundle(destination, reproductionSteps, featureState) } + internal suspend fun writeBundleForSubmission( + destination: File, + context: PreparedSupportSubmissionContext, + ): PreparedSupportDiagnosticsBundle = withContext(dispatcher) { + ready.await().also(::drainPendingSnapshot) + .writeBundleForSubmission(destination, context) + } + + internal suspend fun prepareSubmissionContext( + reproductionSteps: String, + featureState: List, + ): PreparedSupportSubmissionContext = withContext(dispatcher) { + ready.await().also(::drainPendingSnapshot) + .prepareSubmissionContext(reproductionSteps, featureState) + } + + internal suspend fun prepareSubmissionContextForAccountIdentity( + reproductionSteps: String, + featureState: List, + accountIdentity: String, + ): PreparedSupportSubmissionContext = withContext(dispatcher) { + ready.await().also(::drainPendingSnapshot) + .prepareSubmissionContextForAccountIdentity(reproductionSteps, featureState, accountIdentity) + } + override fun close() { val shouldClose = synchronized(lock) { if (closing) { diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt index 13f4cb894..0a7e68068 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt @@ -216,22 +216,69 @@ class JvmSupportDiagnostics( destination: File, reproductionSteps: String, featureState: List, - ): File = synchronized(lock) { + ): File = writeBundleForSubmission( + destination, + prepareSubmissionContext(reproductionSteps, featureState), + ).archive + + internal fun prepareSubmissionContext( + reproductionSteps: String, + featureState: List, + ): PreparedSupportSubmissionContext = synchronized(lock) { + prepareSubmissionContextLocked(reproductionSteps, featureState, activeAccountScope) + } + + internal fun prepareSubmissionContextForAccountIdentity( + reproductionSteps: String, + featureState: List, + accountIdentity: String, + ): PreparedSupportSubmissionContext = synchronized(lock) { + prepareSubmissionContextLocked(reproductionSteps, featureState, accountScope(accountIdentity)) + } + + private fun prepareSubmissionContextLocked( + reproductionSteps: String, + featureState: List, + accountScope: String?, + ): PreparedSupportSubmissionContext { check(storageAvailable) { "Private diagnostic storage is unavailable." } require(featureState.size <= MAX_SUPPORT_DIAGNOSTIC_FIELDS) - val createdAt = nowEpochMillis().coerceAtLeast(0L) - discardedHistoryBytes += pruneEvents(createdAt) + val confirmedAtEpochMillis = nowEpochMillis().coerceAtLeast(0L) + discardedHistoryBytes += pruneEvents(confirmedAtEpochMillis) if (discardedHistoryBytes > 0L) persistHistory() - val snapshot = visibleEvents() + return PreparedSupportSubmissionContext( + sanitizedReproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank), + featureState = sanitizer.sanitizeFields(featureState), + confirmedAtEpochMillis = confirmedAtEpochMillis, + events = visibleEvents(accountScope), + ) + } + + internal fun writeBundleForSubmission( + destination: File, + context: PreparedSupportSubmissionContext, + ): PreparedSupportDiagnosticsBundle = synchronized(lock) { + require(context.featureState.size <= MAX_SUPPORT_DIAGNOSTIC_FIELDS) + require(context.sanitizedReproductionSteps.orEmpty().length <= MAX_SUPPORT_REPRODUCTION_STEPS_LENGTH) + require(context.confirmedAtEpochMillis >= 0L) + require(context.events.size <= MAX_SUPPORT_DIAGNOSTIC_EVENTS) + require(context.featureState.all { field -> + SUPPORT_DIAGNOSTIC_FIELD_NAME.matches(field.name) && + field.value.length <= MAX_SUPPORT_DIAGNOSTIC_FIELD_VALUE_LENGTH && + field.value.none(Char::isISOControl) + }) + require(context.events.sumOf(::encodedEventBytes) <= MAX_SUPPORT_DIAGNOSTIC_STORED_BYTES) + val createdAt = context.confirmedAtEpochMillis + val snapshot = context.events val report = SupportBundleReport( createdAtEpochMillis = createdAt, environment = environment.safeForReport(), - reproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank), + reproductionSteps = context.sanitizedReproductionSteps, eventCount = snapshot.size, warningCount = snapshot.count { it.severity == SupportDiagnosticSeverity.Warning }, errorCount = snapshot.count { it.severity == SupportDiagnosticSeverity.Error }, components = snapshot.map { it.component }.distinct().sortedBy(Enum<*>::name), - featureState = sanitizer.sanitizeFields(featureState), + featureState = context.featureState, ) val reportBytes = SUPPORT_JSON.encodeToString(report).encodeToByteArray() val eventBytes = snapshot.joinToString(separator = "\n", postfix = if (snapshot.isEmpty()) "" else "\n") { @@ -260,7 +307,7 @@ class JvmSupportDiagnostics( "The bounded diagnostic report is unexpectedly large." } writeZipAtomically(destination, completeContent, createdAt) - destination + PreparedSupportDiagnosticsBundle(destination, context.sanitizedReproductionSteps) } private fun loadHistory() { @@ -349,9 +396,8 @@ class JvmSupportDiagnostics( require(historyFile.length() <= MAX_SUPPORT_DIAGNOSTIC_PHYSICAL_HISTORY_BYTES) } - private fun visibleEvents(): List = events.filter { event -> - event.accountScope == null || event.accountScope == activeAccountScope - } + private fun visibleEvents(accountScope: String? = activeAccountScope): List = + events.filter { event -> event.accountScope == null || event.accountScope == accountScope } private fun accountScope(identity: String): String = "" @@ -396,6 +442,19 @@ class JvmSupportDiagnostics( } } +internal data class PreparedSupportDiagnosticsBundle( + val archive: File, + val sanitizedReproductionSteps: String?, +) + +@Serializable +internal data class PreparedSupportSubmissionContext( + val sanitizedReproductionSteps: String?, + val featureState: List, + val confirmedAtEpochMillis: Long, + val events: List, +) + fun Throwable.toSupportDiagnosticExceptionDraft( depth: Int = 0, ): SupportDiagnosticExceptionDraft = SupportDiagnosticExceptionDraft( @@ -414,7 +473,7 @@ fun Throwable.toSupportDiagnosticExceptionDraft( ?.toSupportDiagnosticExceptionDraft(depth + 1), ) -private fun SupportDiagnosticsEnvironment.safeForReport(): SupportDiagnosticsEnvironment = +internal fun SupportDiagnosticsEnvironment.safeForReport(): SupportDiagnosticsEnvironment = SupportDiagnosticsEnvironment( appVersion = appVersion.safeEnvironmentValue(), packageVersion = packageVersion.safeEnvironmentValue(), diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt new file mode 100644 index 000000000..057543398 --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -0,0 +1,1938 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission +import java.security.SecureRandom +import java.time.Duration +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Call +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.Buffer +import okio.BufferedSink +import okio.buffer + +class JvmSupportIntake( + private val diagnostics: AsyncJvmSupportDiagnostics, + private val temporaryRoot: File, + private val environment: SupportDiagnosticsEnvironment, + client: OkHttpClient, + supportBaseUrl: String = DEFAULT_OBIENTE_SUPPORT_URL, + private val supportMutationsAllowed: () -> Boolean = { true }, + private val directorySync: (File) -> Unit = ::syncPosixDirectoryEntry, + private val descriptorCleanupRetryMillis: Long = SUPPORT_DESCRIPTOR_DELETE_RETRY_MILLIS, + private val beforeCallRegistration: () -> Unit = {}, + private val beforeSubmissionPreparation: () -> Unit = {}, + private val beforeBundlePackaging: () -> Unit = {}, + private val afterBundlePackaging: () -> Unit = {}, + private val privateFileDelete: (File) -> Boolean = File::delete, + private val pendingDescriptorRead: (File) -> String = { descriptor -> + descriptor.readText(Charsets.UTF_8) + }, + private val completedDescriptorRead: (File) -> String = { descriptor -> + descriptor.readText(Charsets.UTF_8) + }, + private val cancellationReconcileWindowMillis: Long = SUPPORT_CANCELLATION_RECONCILE_WINDOW_MILLIS, + private val cancellationReconcilePollMillis: Long = SUPPORT_CANCELLATION_RECONCILE_POLL_MILLIS, +) : AutoCloseable { + private val baseUrl = supportBaseUrl.toHttpUrl() + private val client = client.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = false + } + private val state = MutableStateFlow( + SupportDiagnosticsSubmissionState.Initializing, + ) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val initialized = CompletableDeferred() + private val activeCall = AtomicReference() + private val cancellationRequested = AtomicBoolean(false) + private val shutdownRequested = AtomicBoolean(false) + private val operationActive = AtomicBoolean(false) + private val rejectedPendingDescriptorCleanup = AtomicBoolean(false) + private val pendingDescriptorRestorePending = AtomicBoolean(false) + private val completedDescriptorRestorePending = AtomicBoolean(false) + private val lock = Any() + private val persistenceLock = Any() + private var activeAccountIdentity: String? = null + private var actualState: SupportDiagnosticsSubmissionState = SupportDiagnosticsSubmissionState.Initializing + private var actualStateAccountIdentity: String? = null + private var pending: PendingSubmission? = null + private var completedSubmissions: List = emptyList() + private var completedExpiryJob: Job? = null + private var storageUnavailableMessage: String? = null + + init { + require(descriptorCleanupRetryMillis > 0L) + require(cancellationReconcileWindowMillis in 0L..MAX_CANCELLATION_RECONCILE_WINDOW_MILLIS) + require(cancellationReconcilePollMillis > 0L) + scope.launch { + try { + val storageFailure = runCatching { preparePrivateStorage() }.exceptionOrNull() + val restored = if (storageFailure == null) restorePendingSubmission() else null + val restoredCompleted = if (storageFailure == null) restoreCompletedSubmissions() else emptyList() + if (completedDescriptorRestorePending.get()) { + scope.launch { retryCompletedDescriptorRestoration() } + } + if (storageFailure == null && !pendingDescriptorRestorePending.get()) { + pruneTemporaryReports(restored?.archive) + } + synchronized(lock) { + storageUnavailableMessage = storageFailure?.let { SUPPORT_STORAGE_UNAVAILABLE_MESSAGE } + ?: currentRecoveryUnavailableMessage() + pending = restored + completedSubmissions = restoredCompleted + scheduleCompletedExpiryLocked() + publishRecoveredStateLocked() + } + } finally { + initialized.complete(Unit) + } + } + } + + fun states(): StateFlow = state.asStateFlow() + + fun setActiveAccountIdentity(accountIdentity: String?) { + synchronized(lock) { + activeAccountIdentity = accountIdentity?.takeIf(String::isNotBlank) + refreshVisibleStateLocked() + } + } + + internal suspend fun awaitInitialization() = initialized.await() + + suspend fun submit( + reproductionSteps: String, + channel: String, + featureState: List, + ) = withContext(Dispatchers.IO) { + awaitInitialization() + synchronized(lock) { storageUnavailableMessage }?.let { message -> + publishState(SupportDiagnosticsSubmissionState.Unsupported(message)) + return@withContext + } + if (!supportMutationsAreAllowed()) { + publishState(SupportDiagnosticsSubmissionState.Unsupported(READ_ONLY_SUPPORT_MESSAGE)) + return@withContext + } + if (!beginOperation()) return@withContext + try { + val existing = synchronized(lock) { pending } + if (existing != null) { + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( + "Finish or discard the pending private report before sending another one.", + outcomeAmbiguous = existing.outcomeAmbiguous, + )) + return@withContext + } + val originAccountIdentity = synchronized(lock) { activeAccountIdentity } + if (originAccountIdentity == null) { + publishState(SupportDiagnosticsSubmissionState.AccountRequired) + return@withContext + } + cancellationRequested.set(false) + publishState(SupportDiagnosticsSubmissionState.Packaging, originAccountIdentity) + val context = try { + beforeSubmissionPreparation() + preparePrivateStorage() + diagnostics.prepareSubmissionContextForAccountIdentity( + reproductionSteps, + featureState, + originAccountIdentity, + ) + } catch (cancellation: CancellationException) { + publishState(SupportDiagnosticsSubmissionState.Cancelled, originAccountIdentity) + throw cancellation + } catch (failure: Throwable) { + if (cancellationRequested.get()) { + publishState(SupportDiagnosticsSubmissionState.Cancelling, originAccountIdentity) + return@withContext + } + publishState(SupportDiagnosticsSubmissionState.Rejected( + failure.message?.take(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) + ?: "The private diagnostic report could not be prepared.", + ), originAccountIdentity) + return@withContext + } + if (cancellationRequested.get()) { + publishState(SupportDiagnosticsSubmissionState.Cancelling, originAccountIdentity) + return@withContext + } + val submission = PendingSubmission( + archive = null, + metadata = SupportIntakeMetadata( + title = "Nextcloud Native diagnostic report", + description = context.sanitizedReproductionSteps.toSupportIntakeDescription(), + release = environment.safeForReport().let { safe -> + SupportIntakeRelease( + version = safe.appVersion.filterSupportMetadata(80), + channel = channel.filterSupportMetadata(40), + platform = safe.platform.filterSupportMetadata(60), + osVersion = safe.operatingSystemVersion.filterSupportMetadata(120), + architecture = safe.architecture.filterSupportMetadata(40), + ) + }, + ), + idempotencyKey = secureIdempotencyKey(), + createdAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L), + originAccountIdentity = originAccountIdentity, + cancellationPending = cancellationRequested.get(), + context = context, + ) + synchronized(lock) { pending = submission } + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return@withContext + } + if (!packageSubmission(submission)) return@withContext + upload(submission) + } finally { + endOperation() + } + } + + suspend fun retry() = withContext(Dispatchers.IO) { + awaitInitialization() + synchronized(lock) { storageUnavailableMessage }?.let { message -> + publishState(SupportDiagnosticsSubmissionState.Unsupported(message)) + return@withContext + } + if (!supportMutationsAreAllowed()) { + val existing = synchronized(lock) { pending } + publishState( + existing?.let { + SupportDiagnosticsSubmissionState.RetryableFailure( + READ_ONLY_SUPPORT_MESSAGE, + outcomeAmbiguous = it.outcomeAmbiguous, + ) + } ?: SupportDiagnosticsSubmissionState.Unsupported(READ_ONLY_SUPPORT_MESSAGE), + existing?.originAccountIdentity, + ) + return@withContext + } + if (!beginOperation()) return@withContext + try { + val submission = synchronized(lock) { pending } + if (submission == null) { + publishState(SupportDiagnosticsSubmissionState.Rejected( + "There is no private support submission available to retry.", + )) + return@withContext + } + if (!submission.belongsTo(synchronized(lock) { activeAccountIdentity })) { + return@withContext + } + if (submission.recoveryExpired(System.currentTimeMillis())) { + finishRejected(submission, "The private report recovery capability expired and was removed from this device.") + return@withContext + } + if (submission.cancellationPending) { + cancellationRequested.set(true) + val receipt = submission.receipt + if (receipt == null) { + reconcileAfterAmbiguousResult( + submission, + IOException("Cancellation still needs to be reconciled."), + ) + } else { + deleteCancelledReceipt(submission, receipt) + } + return@withContext + } + val waitMillis = submission.retryNotBeforeEpochMillis?.minus(System.currentTimeMillis()) ?: 0L + if (waitMillis > 0L) { + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( + "Obiente Support asked the app to wait before retrying. Try again shortly.", + submission.outcomeAmbiguous, + )) + return@withContext + } + if (submission.outcomeAmbiguous) { + reconcileAfterAmbiguousResult( + submission, + IOException("The previous upload result still needs to be reconciled."), + ) + return@withContext + } + cancellationRequested.set(false) + submission.retryNotBeforeEpochMillis = null + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return@withContext + } + if (submission.archive == null && !packageSubmission(submission)) return@withContext + if (submission.archive?.isFile != true) { + finishRejected(submission, "The pending private report archive is unavailable.") + return@withContext + } + upload(submission) + } finally { + endOperation() + } + } + + private fun beginOperation(): Boolean = synchronized(lock) { + operationActive.compareAndSet(false, true) + } + + private fun supportMutationsAreAllowed(): Boolean = runCatching(supportMutationsAllowed).getOrDefault(false) + + private fun endOperation() { + synchronized(lock) { + if (actualState is SupportDiagnosticsSubmissionState.Cancelling && pending == null) { + publishStateLocked( + SupportDiagnosticsSubmissionState.Cancelled, + actualStateAccountIdentity, + ) + } + operationActive.set(false) + refreshVisibleStateLocked() + } + } + + suspend fun cancel(): Boolean { + awaitInitialization() + // Serialize the terminal receipt decision with publication of the user's intent. If receipt + // completion wins and clears pending first, cancellation is correctly reported as too late. + val pendingCancellation: Boolean? = synchronized(lock) { + val submission = pending + when { + submission?.belongsTo(activeAccountIdentity) == true -> { + cancellationRequested.set(true) + true + } + operationActive.get() && actualState is SupportDiagnosticsSubmissionState.Packaging -> { + cancellationRequested.set(true) + publishStateLocked(SupportDiagnosticsSubmissionState.Cancelling, activeAccountIdentity) + false + } + else -> null + } + } + return when (pendingCancellation) { + null -> false + false -> true + true -> withContext(Dispatchers.IO) { cancelAfterIntentPublished() } + } + } + + suspend fun deleteCompletedReport(deletionUrl: String): SupportDiagnosticsDeletionResult = + withContext(Dispatchers.IO) { + awaitInitialization() + synchronized(lock) { storageUnavailableMessage }?.let { message -> + return@withContext SupportDiagnosticsDeletionResult.Unsupported(message) + } + if (!supportMutationsAreAllowed()) { + return@withContext SupportDiagnosticsDeletionResult.Unsupported(READ_ONLY_SUPPORT_MESSAGE) + } + if (!beginOperation()) { + return@withContext SupportDiagnosticsDeletionResult.Failed( + "Another private support operation is still in progress.", + ) + } + try { + val completed = synchronized(lock) { + completedSubmissions.firstOrNull { submission -> + submission.originAccountIdentity == activeAccountIdentity && + submission.receipt.deletionUrl == deletionUrl + } + } ?: return@withContext SupportDiagnosticsDeletionResult.Failed( + "This submitted support report is no longer available on this device.", + ) + publishState( + SupportDiagnosticsSubmissionState.DeletingSubmittedReport, + completed.originAccountIdentity, + ) + deleteCompletedReportFromServer(completed) + } finally { + endOperation() + } + } + + private fun cancelAfterIntentPublished(): Boolean { + val submission = synchronized(lock) { pending } + if (submission != null) { + if (!submission.outcomeAmbiguous && activeCall.get() == null) { + finishCancelled(submission) + return true + } + submission.cancellationPending = true + submission.outcomeAmbiguous = true + val cancellationPersisted = persistPendingSafely(submission) + val call = activeCall.getAndSet(null) + if (!cancellationPersisted) { + call?.cancel() + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( + "Cancellation could not be stored safely. Keep the app open and retry to reconcile the private report.", + outcomeAmbiguous = true, + )) + return false + } + if (call != null) { + publishState( + SupportDiagnosticsSubmissionState.Cancelling, + submission.originAccountIdentity, + ) + call.cancel() + return true + } + } + if (submission != null) { + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( + "Cancellation could not be confirmed. Retry safely to reconcile and delete the private report.", + outcomeAmbiguous = true, + )) + return true + } + cancellationRequested.compareAndSet(true, false) + return false + } + + override fun close() { + val call = synchronized(lock) { + shutdownRequested.set(true) + activeCall.getAndSet(null) + } + call?.cancel() + scope.cancel() + } + + private fun registerActiveCall( + submission: PendingSubmission, + call: Call, + allowCancellationRequested: Boolean, + ): Boolean = synchronized(lock) { + if ( + shutdownRequested.get() || + pending !== submission || + (!allowCancellationRequested && cancellationRequested.get()) + ) { + false + } else { + activeCall.compareAndSet(null, call) + } + } + + private fun registerActiveCall(call: Call): Boolean = synchronized(lock) { + !shutdownRequested.get() && activeCall.compareAndSet(null, call) + } + + private fun deleteCompletedReportFromServer( + completed: CompletedSubmission, + ): SupportDiagnosticsDeletionResult { + val capability = try { + val statusUrl = validateReceipt(completed.receipt) + val deletionUrl = completed.receipt.deletionUrl.toHttpUrl() + require( + deletionUrl.scheme == statusUrl.scheme && + deletionUrl.host == statusUrl.host && + deletionUrl.port == statusUrl.port && + deletionUrl.encodedPath == statusUrl.encodedPath && + deletionUrl.encodedQuery == null && + deletionUrl.fragment == null, + ) + statusUrl.pathSegments.last() + } catch (_: IllegalArgumentException) { + return failCompletedDeletion(completed, "The private deletion capability is invalid.") + } + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) + .header("Accept", "application/json") + .delete() + .build() + val call = client.newCall(request) + if (!registerActiveCall(call)) { + call.cancel() + return failCompletedDeletion(completed, "Deletion was interrupted before it could start.") + } + return try { + call.execute().use { response -> + response.readBoundedText() + activeCall.compareAndSet(call, null) + when { + response.code in TERMINAL_DELETION_STATUS_CODES || response.code == 404 -> + finishCompletedDeletion(completed) + response.isSuccessful -> verifyCompletedDeletion(completed, capability) + else -> failCompletedDeletion( + completed, + "The submitted support report could not be deleted. Try again.", + ) + } + } + } catch (_: IOException) { + failCompletedDeletion( + completed, + "Deletion could not be confirmed. Check your connection, then try again.", + ) + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun verifyCompletedDeletion( + completed: CompletedSubmission, + capability: String, + ): SupportDiagnosticsDeletionResult { + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) + .header("Accept", "application/json") + .get() + .build() + val call = client.newCall(request) + if (!registerActiveCall(call)) { + call.cancel() + return failCompletedDeletion(completed, "Deletion verification was interrupted. Try again.") + } + return try { + call.execute().use { response -> + response.readBoundedText() + if (response.code == 404) { + finishCompletedDeletion(completed) + } else { + failCompletedDeletion( + completed, + "Deletion is still being processed. Try again to verify it was removed.", + ) + } + } + } catch (_: IOException) { + failCompletedDeletion( + completed, + "Deletion was accepted but could not be verified. Check your connection, then try again.", + ) + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun finishCompletedDeletion(completed: CompletedSubmission): SupportDiagnosticsDeletionResult { + if (!deleteCompletedDescriptorSafely(completedDescriptor(completed.recordId))) { + return failCompletedDeletion( + completed, + "The report was deleted from support, but its private receipt could not be removed from this device. Try again.", + ) + } + val next = synchronized(lock) { + completedSubmissions = completedSubmissions.filterNot { submission -> + submission.recordId == completed.recordId + } + scheduleCompletedExpiryLocked() + latestCompletedFor(completed.originAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle + } + publishState(next, completed.originAccountIdentity) + return SupportDiagnosticsDeletionResult.Deleted + } + + private fun failCompletedDeletion( + completed: CompletedSubmission, + message: String, + ): SupportDiagnosticsDeletionResult.Failed { + val next = synchronized(lock) { + latestCompletedFor(completed.originAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle + } + publishState(next, completed.originAccountIdentity) + return SupportDiagnosticsDeletionResult.Failed(message) + } + + private suspend fun packageSubmission(submission: PendingSubmission): Boolean { + if (cancellationRequested.get()) { + finishCancelled(submission) + return false + } + publishState(SupportDiagnosticsSubmissionState.Packaging) + val destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip") + val prepared = try { + beforeBundlePackaging() + diagnostics.writeBundleForSubmission(destination, submission.context).also { + afterBundlePackaging() + } + } catch (cancellation: CancellationException) { + deletePrivateFileOrRetry(destination) + if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { + finishCancelled(submission) + } else { + retainForRetry( + submission, + "Private report preparation was interrupted. You can retry it safely.", + ambiguous = false, + ) + } + throw cancellation + } catch (_: Throwable) { + deletePrivateFileOrRetry(destination) + if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { + finishCancelled(submission) + } else { + retainForRetry( + submission, + "The private diagnostic report could not be prepared. You can retry safely.", + ambiguous = false, + ) + } + return false + } + try { + restrictOwnerOnlyFile(prepared.archive) + } catch (_: Throwable) { + deletePrivateFileOrRetry(prepared.archive) + retainForRetry( + submission, + "The private report could not be protected on this device. You can retry safely.", + ambiguous = false, + ) + return false + } + if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { + deletePrivateFileOrRetry(prepared.archive) + return false + } + submission.archive = prepared.archive + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return false + } + if (cancellationRequested.get()) { + finishCancelled(submission) + return false + } + return true + } + + private suspend fun upload(submission: PendingSubmission) { + if (cancellationRequested.get()) { + finishCancelled(submission) + return + } + val mutationAllowedBeforePreparation = supportMutationsAreAllowed() + if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { + finishCancelled(submission) + return + } + if (!mutationAllowedBeforePreparation) { + retainForRetry(submission, READ_ONLY_SUPPORT_MESSAGE, ambiguous = false) + return + } + submission.latestUploadAttemptAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L) + submission.outcomeAmbiguous = true + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return + } + val archive = requireNotNull(submission.archive) { "The private support archive has not been prepared." } + require(archive.isFile && archive.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + val metadata = json.encodeToString(SupportIntakeMetadata.serializer(), submission.metadata) + val progressBody = ProgressRequestBody( + delegate = archive.asRequestBody(SUPPORT_ARCHIVE_MEDIA_TYPE), + onProgress = { uploaded, total -> + if (!cancellationRequested.get()) { + publishState(SupportDiagnosticsSubmissionState.Uploading( + total.takeIf { it > 0L }?.let { uploaded.toFloat() / it.toFloat() }?.coerceIn(0f, 1f), + )) + } + }, + ) + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("metadata", "metadata.json", metadata.toRequestBody(SUPPORT_METADATA_MEDIA_TYPE)) + .addFormDataPart("diagnostics", "diagnostics.zip", progressBody) + .build() + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").build()) + .header("Accept", "application/json") + .header("Idempotency-Key", submission.idempotencyKey) + .post(body) + .build() + val mutationAllowedAtTransport = supportMutationsAreAllowed() + if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { + finishCancelled(submission) + return + } + if (!mutationAllowedAtTransport) { + retainForRetry(submission, READ_ONLY_SUPPORT_MESSAGE, ambiguous = false) + return + } + publishState(SupportDiagnosticsSubmissionState.Uploading(0f)) + val call = client.newCall(request) + beforeCallRegistration() + if (!registerActiveCall(submission, call, allowCancellationRequested = false)) { + call.cancel() + when { + cancellationRequested.get() -> finishCancelled(submission) + synchronized(lock) { pending === submission } -> retainForRetry( + submission, + "The private support submission was interrupted before upload. You can retry it safely.", + ambiguous = false, + ) + } + return + } + try { + call.execute().use { response -> + val responseText = response.readBoundedText() + activeCall.compareAndSet(call, null) + when { + response.isSuccessful -> finishReceived(submission, decodeReceipt(responseText)) + response.code == 408 -> reconcileAfterAmbiguousResult( + submission, + IOException("Obiente Support timed out while accepting the report."), + ) + response.code in RETRYABLE_CLIENT_STATUS_CODES -> retainForRetry( + submission, + "Obiente Support is temporarily limiting submissions. You can retry safely.", + ambiguous = false, + retryNotBeforeEpochMillis = response.retryNotBeforeEpochMillis(), + ) + response.code in 400..499 -> finishRejected(submission, decodeProblem(responseText)) + else -> retainForRetry( + submission, + "Obiente Support is temporarily unavailable.", + ambiguous = response.code in 300..399 || response.code in 500..599, + retryNotBeforeEpochMillis = response.retryNotBeforeEpochMillis(), + ) + } + } + } catch (failure: IOException) { + activeCall.compareAndSet(call, null) + if (shutdownRequested.get()) { + retainForRetry( + submission, + "The private support submission was interrupted while the app closed. You can retry it safely.", + true, + ) + } else { + reconcileAfterAmbiguousResult(submission, failure) + } + } catch (_: IllegalArgumentException) { + retainForRetry(submission, "Obiente Support returned an invalid receipt.", true) + } catch (cancellation: CancellationException) { + finishCancelled(submission) + throw cancellation + } finally { + activeCall.compareAndSet(call, null) + } + } + + private suspend fun reconcileAfterAmbiguousResult( + submission: PendingSubmission, + uploadFailure: IOException, + ) { + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/receipts").build()) + .header("Accept", "application/json") + .header("Idempotency-Key", submission.idempotencyKey) + .get() + .build() + var cancellationDeadlineNanos: Long? = null + while (true) { + val call = client.newCall(request) + if (!registerActiveCall(submission, call, allowCancellationRequested = true)) { + call.cancel() + if (synchronized(lock) { pending === submission }) { + retainForRetry( + submission, + "The upload result still needs to be reconciled. You can retry it safely.", + ambiguous = true, + ) + } + return + } + val responseResult = try { + call.execute().use { response -> + response.code to response.readBoundedText() + } + } catch (_: IOException) { + retainForRetry( + submission, + if (cancellationRequested.get()) { + "Cancellation could not be confirmed. Reconcile the private submission before retrying." + } else uploadFailure.message?.filterSupportMetadata(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) + ?.takeIf(String::isNotBlank) + ?: "The upload result is uncertain. Check your connection before retrying.", + true, + ) + return + } finally { + activeCall.compareAndSet(call, null) + } + val (responseCode, responseText) = responseResult + when { + responseCode in 200..299 -> { + try { + finishReceived(submission, decodeReceipt(responseText)) + } catch (_: IOException) { + retainForRetry(submission, "Obiente Support returned an invalid receipt.", true) + } catch (_: IllegalArgumentException) { + retainForRetry(submission, "Obiente Support returned an invalid receipt.", true) + } + return + } + responseCode == 404 && cancellationRequested.get() -> { + val nowNanos = System.nanoTime() + val deadlineNanos = cancellationDeadlineNanos + ?: nowNanos.saturatingAdd(cancellationReconcileWindowMillis * NANOS_PER_MILLISECOND) + .also { cancellationDeadlineNanos = it } + val remainingNanos = deadlineNanos - nowNanos + if (remainingNanos > 0L) { + val delayMillis = minOf( + cancellationReconcilePollMillis, + (remainingNanos / NANOS_PER_MILLISECOND).coerceAtLeast(1L), + ) + delay(delayMillis) + continue + } + retainForRetry( + submission, + "Support has not confirmed receipt yet. Retry again to finish deleting the private report safely.", + ambiguous = true, + ) + return + } + responseCode == 404 -> { + retainForRetry(submission, "The upload did not complete. You can retry it safely.", false) + return + } + else -> { + retainForRetry( + submission, + "The upload result is uncertain. Check your connection before retrying.", + true, + ) + return + } + } + } + } + + private fun finishReceived(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + validateReceipt(receipt, enforceCurrentRetentionWindow = true) + val submitReceivedReport = synchronized(lock) { + if (pending !== submission) return + if (cancellationRequested.get()) { + false + } else { + // Clearing pending is the terminal decision. cancel() takes the same lock and will + // return false if it starts after this point instead of claiming deletion began. + pending = null + true + } + } + if (!submitReceivedReport) { + deleteCancelledReceipt(submission, receipt) + } else { + finishSubmitted(submission, receipt) + } + } + + private fun deleteCancelledReceipt(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + val statusUrl = validateReceipt(receipt) + val deletionUrl = receipt.deletionUrl.toHttpUrl() + require( + deletionUrl.scheme == statusUrl.scheme && + deletionUrl.host == statusUrl.host && + deletionUrl.port == statusUrl.port && + deletionUrl.encodedPath == statusUrl.encodedPath && + deletionUrl.encodedQuery == null && + deletionUrl.fragment == null, + ) + submission.cancellationPending = true + submission.outcomeAmbiguous = true + submission.receipt = receipt + persistPendingSafely(submission) + val capability = statusUrl.pathSegments.last() + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) + .header("Accept", "application/json") + .delete() + .build() + if (!supportMutationsAreAllowed()) { + retainCancellationForRetry(submission, receipt, READ_ONLY_SUPPORT_MESSAGE) + return + } + val call = client.newCall(request) + if (!registerActiveCall(submission, call, allowCancellationRequested = true)) { + call.cancel() + if (synchronized(lock) { pending === submission }) { + retainCancellationForRetry(submission, receipt, "Deletion still needs to be confirmed. Retry safely.") + } + return + } + try { + call.execute().use { response -> + response.readBoundedText() + activeCall.compareAndSet(call, null) + when { + response.code in TERMINAL_DELETION_STATUS_CODES || response.code == 404 -> + finishCancelled(submission) + response.isSuccessful -> verifyDeletionAfterAccepted( + submission, + receipt, + capability, + ) + else -> retainCancellationForRetry( + submission, + receipt, + "Deletion could not be confirmed. Retry safely to delete the private report.", + ) + } + } + } catch (_: IOException) { + retainCancellationForRetry( + submission, + receipt, + "Deletion could not be confirmed. Check your connection, then retry safely.", + ) + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun verifyDeletionAfterAccepted( + submission: PendingSubmission, + receipt: SupportIntakeReceipt, + capability: String, + ) { + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) + .header("Accept", "application/json") + .get() + .build() + val call = client.newCall(request) + if (!registerActiveCall(submission, call, allowCancellationRequested = true)) { + call.cancel() + if (synchronized(lock) { pending === submission }) { + retainCancellationForRetry( + submission, + receipt, + "Deletion verification was interrupted. Retry safely.", + ) + } + return + } + try { + call.execute().use { response -> + response.readBoundedText() + if (response.code == 404) { + finishCancelled(submission) + } else { + retainCancellationForRetry( + submission, + receipt, + "Deletion is still being processed. Retry safely to verify the private report was removed.", + ) + } + } + } catch (_: IOException) { + retainCancellationForRetry( + submission, + receipt, + "Deletion was accepted but could not be verified. Check your connection, then retry safely.", + ) + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + validateReceipt(receipt) + val existingCompletion = synchronized(lock) { + completedSubmissions.firstOrNull { completed -> + completed.originAccountIdentity == submission.originAccountIdentity && + completed.receipt.statusUrl == receipt.statusUrl && + completed.receipt.supportCode == receipt.supportCode + } + } + if (existingCompletion != null) { + finishTerminal(submission) + publishState( + submittedStateFor(submission.originAccountIdentity), + submission.originAccountIdentity, + ) + return + } + val completedSubmission = CompletedSubmission( + recordId = UUID.randomUUID().toString(), + originAccountIdentity = submission.originAccountIdentity, + receipt = receipt, + ) + if (!persistCompletedSafely(completedSubmission)) { + synchronized(lock) { pending = submission } + publishState( + SupportDiagnosticsSubmissionState.RetryableFailure( + "The report was received, but its private status could not be stored. Retry safely to recover it.", + outcomeAmbiguous = true, + ), + submission.originAccountIdentity, + ) + return + } + synchronized(lock) { + completedSubmissions = completedSubmissions + completedSubmission + scheduleCompletedExpiryLocked() + } + finishTerminal(submission) + publishState(submittedStateFor(submission.originAccountIdentity), submission.originAccountIdentity) + } + + private fun validateReceipt( + receipt: SupportIntakeReceipt, + enforceCurrentRetentionWindow: Boolean = false, + ): okhttp3.HttpUrl { + require(receipt.contractVersion == SUPPORT_INTAKE_CONTRACT_VERSION) + require(receipt.supportCode.matches(SUPPORT_CODE_PATTERN)) + require(receipt.status.matches(SUPPORT_RECEIPT_STATUS_PATTERN)) + val createdAt = runCatching { Instant.parse(receipt.createdAt) } + .getOrElse { throw IllegalArgumentException("Invalid receipt timestamp.", it) } + val retentionUntil = runCatching { Instant.parse(receipt.retentionUntil) } + .getOrElse { throw IllegalArgumentException("Invalid receipt timestamp.", it) } + require(!retentionUntil.isBefore(createdAt)) + val now = Instant.now() + require( + Duration.between(createdAt, retentionUntil) <= + Duration.ofMillis(SUPPORT_SERVER_RETENTION_MAX_AGE_MILLIS), + ) + if (enforceCurrentRetentionWindow) { + require(!createdAt.isAfter(now.plusMillis(SUPPORT_RECEIPT_CLOCK_SKEW_MILLIS))) + require(retentionUntil.isAfter(now)) + require( + !retentionUntil.isAfter( + now.plusMillis(SUPPORT_SERVER_RETENTION_MAX_AGE_MILLIS + SUPPORT_RECEIPT_CLOCK_SKEW_MILLIS), + ), + ) + } + val statusUrl = receipt.statusUrl.toHttpUrl() + require( + statusUrl.scheme == baseUrl.scheme && + statusUrl.host == baseUrl.host && + statusUrl.port == baseUrl.port && + statusUrl.encodedPath.matches(SUPPORT_STATUS_PATH_PATTERN) && + statusUrl.encodedQuery == null && + statusUrl.fragment == null, + ) + val deletionUrl = receipt.deletionUrl.toHttpUrl() + require( + deletionUrl.scheme == statusUrl.scheme && + deletionUrl.host == statusUrl.host && + deletionUrl.port == statusUrl.port && + deletionUrl.encodedPath == statusUrl.encodedPath && + deletionUrl.encodedQuery == null && + deletionUrl.fragment == null, + ) + return statusUrl + } + + private fun finishTerminal(submission: PendingSubmission) { + synchronized(lock) { + if (pending === submission) pending = null + } + deletePrivateFileOrRetry(submission.archive) + if (!cleanupPendingDescriptorSafely(submission)) { + scope.launch { retryPendingDescriptorCleanup(submission) } + } + } + + private fun deletePrivateFileOrRetry(file: File?) { + if (file == null || deletePrivateFileSafely(file)) return + scope.launch { + while (!shutdownRequested.get()) { + delay(descriptorCleanupRetryMillis) + if (deletePrivateFileSafely(file)) return@launch + } + } + } + + private fun deletePrivateFileSafely(file: File): Boolean = + !file.exists() || runCatching { privateFileDelete(file) }.getOrDefault(false) || !file.exists() + + private fun cleanupPendingDescriptorSafely(submission: PendingSubmission): Boolean = + synchronized(persistenceLock) { + runCatching { + val descriptor = pendingDescriptor() + if (descriptor.isFile) { + val persistedIdempotencyKey = try { + json.decodeFromString( + PersistedPendingSubmission.serializer(), + pendingDescriptorRead(descriptor), + ).idempotencyKey + } catch (_: Throwable) { + return@synchronized false + } + if (persistedIdempotencyKey != submission.idempotencyKey) return@synchronized true + } + deletePrivateDescriptorDurably(descriptor) + }.isSuccess + } + + private suspend fun retryPendingDescriptorCleanup(submission: PendingSubmission) { + while (!shutdownRequested.get()) { + delay(descriptorCleanupRetryMillis) + if (cleanupPendingDescriptorSafely(submission)) return + } + } + + private fun finishRejected(submission: PendingSubmission, message: String) { + finishTerminal(submission) + publishState(SupportDiagnosticsSubmissionState.Rejected(message), submission.originAccountIdentity) + } + + private fun finishCancelled(submission: PendingSubmission) { + finishTerminal(submission) + publishState( + if (operationActive.get()) { + SupportDiagnosticsSubmissionState.Cancelling + } else { + SupportDiagnosticsSubmissionState.Cancelled + }, + submission.originAccountIdentity, + ) + } + + private fun retainForRetry( + submission: PendingSubmission, + message: String, + ambiguous: Boolean, + retryNotBeforeEpochMillis: Long? = null, + ) { + submission.outcomeAmbiguous = ambiguous + submission.retryNotBeforeEpochMillis = retryNotBeforeEpochMillis + synchronized(lock) { pending = submission } + if (persistPendingSafely(submission)) { + publishState(SupportDiagnosticsSubmissionState.RetryableFailure(message, ambiguous)) + } else { + // Atomic replacement keeps the descriptor from immediately before the request. That + // record retains the idempotency key and conservatively requires reconciliation. + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( + "The updated retry state could not be stored. Keep the app open and retry safely to reconcile the report.", + outcomeAmbiguous = true, + )) + } + } + + private fun retainCancellationForRetry( + submission: PendingSubmission, + receipt: SupportIntakeReceipt, + message: String, + ) { + submission.cancellationPending = true + submission.outcomeAmbiguous = true + submission.receipt = receipt + synchronized(lock) { pending = submission } + if (persistPendingSafely(submission)) { + publishState(SupportDiagnosticsSubmissionState.RetryableFailure(message, outcomeAmbiguous = true)) + } else { + // The receipt was persisted before deletion began. Atomic replacement leaves that last + // valid recovery record in place when this newer retry-state write fails. + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( + "Deletion was not confirmed and its updated retry state could not be stored. Keep the app open and retry.", + outcomeAmbiguous = true, + )) + } + } + + private fun persistPendingSafely(submission: PendingSubmission): Boolean = synchronized(persistenceLock) { + if (synchronized(lock) { pending !== submission }) return@synchronized false + runCatching { persistPending(submission) }.isSuccess + } + + private fun decodeReceipt(response: String): SupportIntakeReceipt = try { + json.decodeFromString(SupportIntakeReceipt.serializer(), response) + } catch (failure: SerializationException) { + throw IOException("Obiente Support returned an invalid receipt.", failure) + } + + private fun decodeProblem(response: String): String = runCatching { + json.parseToJsonElement(response).jsonObject["message"]?.jsonPrimitive?.content + }.getOrNull() + ?.filterSupportMetadata(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) + ?.takeIf(String::isNotBlank) + ?: "Obiente Support rejected this diagnostic report." + + private fun pruneTemporaryReports(retainedArchive: File?) { + val cutoff = System.currentTimeMillis() - SUPPORT_TEMPORARY_MAX_AGE_MILLIS + temporaryRoot.listFiles().orEmpty() + .filter { file -> + file.isFile && file.name.matches(SUPPORT_TEMPORARY_FILE_PATTERN) && + (file != retainedArchive || file.lastModified() < cutoff) + } + .forEach(::deletePrivateFileOrRetry) + temporaryRoot.listFiles().orEmpty() + .filter { file -> + file.isFile && ( + file.name.matches(SUPPORT_PENDING_TEMPORARY_FILE_PATTERN) || + file.name.matches(SUPPORT_ARCHIVE_TEMPORARY_FILE_PATTERN) + ) + } + .forEach(::deletePrivateFileOrRetry) + } + + private fun preparePrivateStorage() { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) { + "Could not prepare private support submission storage." + } + restrictOwnerOnlyDirectory(temporaryRoot) + } + + private fun restrictOwnerOnlyDirectory(directory: File) { + val path = directory.toPath() + if (Files.getFileAttributeView(path, PosixFileAttributeView::class.java) == null) return + Files.setPosixFilePermissions( + path, + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } + + private fun restrictOwnerOnlyFile(file: File) { + val path = file.toPath() + if (Files.getFileAttributeView(path, PosixFileAttributeView::class.java) == null) return + Files.setPosixFilePermissions( + path, + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } + + private fun persistPending(submission: PendingSubmission) { + val descriptor = pendingDescriptor() + val parent = requireNotNull(descriptor.parentFile) + preparePrivateStorage() + val temporary = Files.createTempFile(parent.toPath(), ".pending-", ".tmp").toFile() + try { + restrictOwnerOnlyFile(temporary) + val encoded = json.encodeToString( + PersistedPendingSubmission.serializer(), + PersistedPendingSubmission( + archiveName = submission.archive?.name, + metadata = submission.metadata, + idempotencyKey = submission.idempotencyKey, + createdAtEpochMillis = submission.createdAtEpochMillis, + originAccountIdentity = submission.originAccountIdentity, + context = submission.context, + cancellationPending = submission.cancellationPending, + outcomeAmbiguous = submission.outcomeAmbiguous, + latestUploadAttemptAtEpochMillis = submission.latestUploadAttemptAtEpochMillis, + retryNotBeforeEpochMillis = submission.retryNotBeforeEpochMillis, + receipt = submission.receipt, + ), + ).encodeToByteArray() + FileOutputStream(temporary).use { output -> + output.write(encoded) + output.fd.sync() + } + runCatching { + Files.move( + temporary.toPath(), + descriptor.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + }.recoverCatching { + Files.move( + temporary.toPath(), + descriptor.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + }.getOrThrow() + restrictOwnerOnlyFile(descriptor) + syncDirectoryEntry(parent) + } finally { + temporary.delete() + } + } + + private fun persistCompletedSafely(submission: CompletedSubmission): Boolean = synchronized(persistenceLock) { + runCatching { persistCompleted(submission) }.isSuccess + } + + private fun persistCompleted(submission: CompletedSubmission) { + val descriptor = completedDescriptor(submission.recordId) + preparePrivateStorage() + writePrivateDescriptorAtomically( + descriptor, + json.encodeToString( + PersistedCompletedSubmission.serializer(), + PersistedCompletedSubmission(submission.originAccountIdentity, submission.receipt), + ).encodeToByteArray(), + ".completed-", + ) + } + + private fun writePrivateDescriptorAtomically( + descriptor: File, + encoded: ByteArray, + temporaryPrefix: String, + ) { + val parent = requireNotNull(descriptor.parentFile) + val temporary = Files.createTempFile(parent.toPath(), temporaryPrefix, ".tmp").toFile() + try { + restrictOwnerOnlyFile(temporary) + FileOutputStream(temporary).use { output -> + output.write(encoded) + output.fd.sync() + } + runCatching { + Files.move( + temporary.toPath(), + descriptor.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + }.recoverCatching { + Files.move( + temporary.toPath(), + descriptor.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + }.getOrThrow() + restrictOwnerOnlyFile(descriptor) + syncDirectoryEntry(parent) + } finally { + temporary.delete() + } + } + + private fun syncDirectoryEntry(directory: File) { + directorySync(directory) + } + + private fun deletePrivateDescriptorDurably(descriptor: File) { + val parent = descriptor.parentFile ?: return + Files.deleteIfExists(descriptor.toPath()) + syncDirectoryEntry(parent) + } + + private fun restorePendingSubmission(scheduleRetry: Boolean = true): PendingSubmission? = try { + val descriptor = pendingDescriptor() + val descriptorAttributes = try { + Files.readAttributes(descriptor.toPath(), BasicFileAttributes::class.java) + } catch (_: NoSuchFileException) { + pendingDescriptorRestorePending.set(false) + return null + } + require(descriptorAttributes.isRegularFile) + require(descriptorAttributes.size() in 1L..MAX_PENDING_DESCRIPTOR_BYTES) + val persisted = json.decodeFromString( + PersistedPendingSubmission.serializer(), + pendingDescriptorRead(descriptor), + ) + require(persisted.archiveName == null || persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) + require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) + require(persisted.originAccountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) + require(persisted.createdAtEpochMillis >= 0L) + require(persisted.latestUploadAttemptAtEpochMillis == null || persisted.latestUploadAttemptAtEpochMillis >= 0L) + val nowEpochMillis = System.currentTimeMillis() + val retryNotBeforeEpochMillis = persisted.retryNotBeforeEpochMillis?.takeIf { deadline -> + deadline <= nowEpochMillis.saturatingAdd(MAX_SUPPORT_RETRY_AFTER_MILLIS) + } + persisted.receipt?.let { receipt -> + require(persisted.cancellationPending) + validateReceipt(receipt) + } + val recoveryDeadlineEpochMillis = persisted.receipt + ?.let { receipt -> Instant.parse(receipt.retentionUntil).toEpochMilli() } + ?: if (persisted.outcomeAmbiguous) { + (persisted.latestUploadAttemptAtEpochMillis ?: persisted.createdAtEpochMillis) + .saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + } else { + persisted.createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + } + require(nowEpochMillis <= recoveryDeadlineEpochMillis) + val archiveAgeMillis = (nowEpochMillis - persisted.createdAtEpochMillis).coerceAtLeast(0L) + val archiveIsRetained = archiveAgeMillis <= SUPPORT_TEMPORARY_MAX_AGE_MILLIS + val archive = persisted.archiveName?.let { archiveName -> + val candidate = File(temporaryRoot, archiveName).absoluteFile.normalize() + require(candidate.parentFile == temporaryRoot.absoluteFile.normalize()) + if (!archiveIsRetained) { + deletePrivateFileOrRetry(candidate) + null + } else { + val archiveAttributes = try { + Files.readAttributes(candidate.toPath(), BasicFileAttributes::class.java) + } catch (_: NoSuchFileException) { + null + } + archiveAttributes?.let { attributes -> + require(attributes.isRegularFile) + require(attributes.size() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + restrictOwnerOnlyFile(candidate) + candidate + } + } + } + pendingDescriptorRestorePending.set(false) + PendingSubmission( + archive = archive, + metadata = persisted.metadata, + idempotencyKey = persisted.idempotencyKey, + createdAtEpochMillis = persisted.createdAtEpochMillis, + originAccountIdentity = persisted.originAccountIdentity, + context = persisted.context, + cancellationPending = persisted.cancellationPending, + outcomeAmbiguous = persisted.outcomeAmbiguous, + latestUploadAttemptAtEpochMillis = persisted.latestUploadAttemptAtEpochMillis, + retryNotBeforeEpochMillis = retryNotBeforeEpochMillis, + receipt = persisted.receipt, + ) + } catch (failure: Throwable) { + if (failure is IOException || failure is SecurityException) { + val retryWasNotScheduled = pendingDescriptorRestorePending.compareAndSet(false, true) + if (scheduleRetry && retryWasNotScheduled) { + scope.launch { retryPendingDescriptorRestoration() } + } + return null + } + pendingDescriptorRestorePending.set(false) + if (!quarantineRejectedPendingDescriptorSafely()) { + rejectedPendingDescriptorCleanup.set(true) + scope.launch { retryRejectedPendingDescriptorCleanup() } + } + null + } + + private fun quarantineRejectedPendingDescriptorSafely(): Boolean { + val descriptor = pendingDescriptor() + if (!descriptor.exists()) return true + val quarantined = File(temporaryRoot, ".pending-rejected-${UUID.randomUUID()}.tmp") + synchronized(persistenceLock) { + runCatching { + runCatching { + Files.move( + descriptor.toPath(), + quarantined.toPath(), + StandardCopyOption.ATOMIC_MOVE, + ) + }.recoverCatching { + Files.move(descriptor.toPath(), quarantined.toPath()) + }.getOrThrow() + restrictOwnerOnlyFile(quarantined) + syncDirectoryEntry(temporaryRoot) + } + } + if (quarantined.isFile) deletePrivateFileOrRetry(quarantined) + return !descriptor.exists() + } + + private suspend fun retryRejectedPendingDescriptorCleanup() { + while (!shutdownRequested.get()) { + delay(descriptorCleanupRetryMillis) + if (!quarantineRejectedPendingDescriptorSafely()) continue + rejectedPendingDescriptorCleanup.set(false) + synchronized(lock) { + if (storageUnavailableMessage == SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE) { + storageUnavailableMessage = currentRecoveryUnavailableMessage() + publishRecoveredStateLocked() + } + } + return + } + } + + private suspend fun retryPendingDescriptorRestoration() { + initialized.await() + while (!shutdownRequested.get()) { + delay(descriptorCleanupRetryMillis) + val restored = restorePendingSubmission(scheduleRetry = false) + if (pendingDescriptorRestorePending.get()) continue + pruneTemporaryReports(restored?.archive) + synchronized(lock) { + pending = restored + storageUnavailableMessage = currentRecoveryUnavailableMessage() + publishRecoveredStateLocked() + } + return + } + } + + private suspend fun retryCompletedDescriptorRestoration() { + initialized.await() + while (!shutdownRequested.get()) { + delay(descriptorCleanupRetryMillis) + val restored = restoreCompletedSubmissions() + if (completedDescriptorRestorePending.get()) continue + synchronized(lock) { + completedSubmissions = restored + scheduleCompletedExpiryLocked() + storageUnavailableMessage = currentRecoveryUnavailableMessage() + publishRecoveredStateLocked() + } + return + } + } + + private fun currentRecoveryUnavailableMessage(): String? = when { + pendingDescriptorRestorePending.get() -> SUPPORT_PENDING_RESTORE_MESSAGE + completedDescriptorRestorePending.get() -> SUPPORT_COMPLETED_RESTORE_MESSAGE + rejectedPendingDescriptorCleanup.get() -> SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE + else -> null + } + + private fun publishRecoveredStateLocked() { + val pendingSubmission = pending + val visibleCompleted = latestCompletedFor(activeAccountIdentity) + publishStateLocked( + storageUnavailableMessage?.let { unavailableMessage -> + SupportDiagnosticsSubmissionState.Unsupported(unavailableMessage) + } ?: pendingSubmission?.let { submission -> + SupportDiagnosticsSubmissionState.RetryableFailure( + if (submission.cancellationPending) { + "Cancellation was interrupted. Retry safely to reconcile and delete the private report." + } else if (submission.archive == null) { + "Private report preparation was interrupted. You can retry it safely." + } else { + "A private support submission was interrupted. You can retry it safely." + }, + outcomeAmbiguous = submission.outcomeAmbiguous, + ) + } ?: visibleCompleted?.let { submittedStateFor(it.originAccountIdentity) } + ?: idleStateForActiveAccountLocked(), + pendingSubmission?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, + ) + } + + private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) + + private fun restoreCompletedSubmissions(): List { + completedDescriptorRestorePending.set(false) + val descriptors = try { + Files.newDirectoryStream(temporaryRoot.toPath()).use { entries -> + entries.map { path -> path.toFile() } + .filter { descriptor -> descriptor.name.matches(SUPPORT_COMPLETED_FILE_PATTERN) } + } + } catch (_: IOException) { + completedDescriptorRestorePending.set(true) + return emptyList() + } catch (_: SecurityException) { + completedDescriptorRestorePending.set(true) + return emptyList() + } + return descriptors.mapNotNull(::restoreCompletedSubmission) + } + + private fun restoreCompletedSubmission(descriptor: File): CompletedSubmission? = try { + val descriptorAttributes = Files.readAttributes(descriptor.toPath(), BasicFileAttributes::class.java) + require(descriptorAttributes.isRegularFile) + require(descriptorAttributes.size() in 1L..MAX_COMPLETED_DESCRIPTOR_BYTES) + val recordId = requireNotNull(SUPPORT_COMPLETED_FILE_PATTERN.matchEntire(descriptor.name)) + .groupValues[1] + val persisted = json.decodeFromString( + PersistedCompletedSubmission.serializer(), + completedDescriptorRead(descriptor), + ) + require(persisted.originAccountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) + validateReceipt(persisted.receipt) + require(System.currentTimeMillis() <= Instant.parse(persisted.receipt.retentionUntil).toEpochMilli()) + CompletedSubmission(recordId, persisted.originAccountIdentity, persisted.receipt) + } catch (_: IOException) { + completedDescriptorRestorePending.set(true) + null + } catch (_: SecurityException) { + completedDescriptorRestorePending.set(true) + null + } catch (_: Throwable) { + deleteCompletedDescriptorOrRetry(descriptor) + null + } + + private fun deleteCompletedDescriptorOrRetry(descriptor: File) { + if (deleteCompletedDescriptorSafely(descriptor)) return + scope.launch { deleteCompletedDescriptorsWithRetry(listOf(descriptor)) } + } + + private fun deleteCompletedDescriptorSafely(descriptor: File): Boolean = synchronized(persistenceLock) { + runCatching { deletePrivateDescriptorDurably(descriptor) }.isSuccess + } + + private fun completedDescriptor(recordId: String): File { + require(recordId.matches(SUPPORT_COMPLETED_RECORD_ID_PATTERN)) + return File(temporaryRoot, "completed-$recordId.json") + } + + private data class PendingSubmission( + var archive: File?, + val metadata: SupportIntakeMetadata, + val idempotencyKey: String, + val createdAtEpochMillis: Long, + val originAccountIdentity: String, + val context: PreparedSupportSubmissionContext, + var cancellationPending: Boolean = false, + var outcomeAmbiguous: Boolean = false, + var latestUploadAttemptAtEpochMillis: Long? = null, + var retryNotBeforeEpochMillis: Long? = null, + var receipt: SupportIntakeReceipt? = null, + ) { + fun belongsTo(accountIdentity: String?): Boolean = originAccountIdentity == accountIdentity + + fun recoveryExpired(nowEpochMillis: Long): Boolean { + val deadline = receipt + ?.let { value -> runCatching { Instant.parse(value.retentionUntil).toEpochMilli() }.getOrNull() } + ?: if (outcomeAmbiguous) { + (latestUploadAttemptAtEpochMillis ?: createdAtEpochMillis) + .saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + } else { + createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + } + return nowEpochMillis > deadline + } + } + + private data class CompletedSubmission( + val recordId: String, + val originAccountIdentity: String, + val receipt: SupportIntakeReceipt, + ) { + val retentionUntilEpochMillis: Long + get() = Instant.parse(receipt.retentionUntil).toEpochMilli() + + fun isRetained(nowEpochMillis: Long): Boolean = nowEpochMillis <= retentionUntilEpochMillis + } + + @Serializable + private data class PersistedPendingSubmission( + val archiveName: String?, + val metadata: SupportIntakeMetadata, + val idempotencyKey: String, + val createdAtEpochMillis: Long, + val originAccountIdentity: String, + val context: PreparedSupportSubmissionContext, + val cancellationPending: Boolean = false, + val outcomeAmbiguous: Boolean = true, + val latestUploadAttemptAtEpochMillis: Long? = null, + val retryNotBeforeEpochMillis: Long? = null, + val receipt: SupportIntakeReceipt? = null, + ) + + @Serializable + private data class PersistedCompletedSubmission( + val originAccountIdentity: String, + val receipt: SupportIntakeReceipt, + ) + + private fun publishState( + next: SupportDiagnosticsSubmissionState, + accountIdentity: String? = null, + ) { + synchronized(lock) { + publishStateLocked(next, accountIdentity ?: pending?.originAccountIdentity ?: activeAccountIdentity) + } + } + + private fun publishStateLocked( + next: SupportDiagnosticsSubmissionState, + accountIdentity: String? = pending?.originAccountIdentity, + ) { + if (pruneExpiredCompletedLocked()) { + scheduleCompletedExpiryLocked() + } + actualState = next + actualStateAccountIdentity = accountIdentity + state.value = if (accountIdentity != null && accountIdentity != activeAccountIdentity) { + if (pending?.originAccountIdentity == accountIdentity) { + SupportDiagnosticsSubmissionState.BlockedByAnotherAccount( + "A pending private report belongs to another signed-in account. Switch back to finish or discard it.", + ) + } else if (operationActive.get()) { + blockedByAnotherAccountOperation() + } else { + idleStateForActiveAccountLocked() + } + } else { + next + } + } + + private fun refreshVisibleStateLocked() { + val pendingSubmission = pending + when { + actualState is SupportDiagnosticsSubmissionState.Initializing -> state.value = actualState + storageUnavailableMessage != null -> state.value = SupportDiagnosticsSubmissionState.Unsupported( + requireNotNull(storageUnavailableMessage), + ) + pendingSubmission != null -> publishStateLocked(actualState, pendingSubmission.originAccountIdentity) + operationActive.get() && actualStateAccountIdentity != activeAccountIdentity -> { + state.value = blockedByAnotherAccountOperation() + } + activeAccountIdentity == null -> state.value = SupportDiagnosticsSubmissionState.AccountRequired + actualStateAccountIdentity == activeAccountIdentity -> state.value = actualState + else -> state.value = latestCompletedFor(activeAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: idleStateForActiveAccountLocked() + } + } + + private fun blockedByAnotherAccountOperation() = + SupportDiagnosticsSubmissionState.BlockedByAnotherAccount( + "A private support report is being prepared for another signed-in account. Wait for it to finish or switch back.", + ) + + private fun idleStateForActiveAccountLocked(): SupportDiagnosticsSubmissionState = + if (activeAccountIdentity == null) { + SupportDiagnosticsSubmissionState.AccountRequired + } else { + SupportDiagnosticsSubmissionState.Idle + } + + private fun latestCompletedFor(accountIdentity: String?): CompletedSubmission? = + completedSubmissions.filter { + it.originAccountIdentity == accountIdentity && it.isRetained(System.currentTimeMillis()) + } + .maxByOrNull { Instant.parse(it.receipt.createdAt) } + + private fun submittedStateFor(accountIdentity: String): SupportDiagnosticsSubmissionState.Submitted = + SupportDiagnosticsSubmissionState.Submitted( + completedSubmissions + .filter { + it.originAccountIdentity == accountIdentity && it.isRetained(System.currentTimeMillis()) + } + .sortedWith( + compareByDescending { Instant.parse(it.receipt.createdAt) } + .thenByDescending(CompletedSubmission::recordId), + ) + .map { completed -> + SupportDiagnosticsSubmissionState.SubmittedReport( + supportCode = completed.receipt.supportCode, + statusUrl = completed.receipt.statusUrl, + deletionUrl = completed.receipt.deletionUrl, + retentionUntil = completed.receipt.retentionUntil, + ) + }, + ) + + private fun scheduleCompletedExpiryLocked() { + completedExpiryJob?.cancel() + val nextExpiry = completedSubmissions.minOfOrNull(CompletedSubmission::retentionUntilEpochMillis) + if (nextExpiry == null) { + completedExpiryJob = null + return + } + val now = System.currentTimeMillis() + val waitMillis = if (nextExpiry <= now) { + 1L + } else { + (nextExpiry - now).takeIf { it > 0L } ?: Long.MAX_VALUE + } + completedExpiryJob = scope.launch { + delay(waitMillis) + synchronized(lock) { + completedExpiryJob = null + pruneExpiredCompletedLocked() + refreshVisibleStateLocked() + scheduleCompletedExpiryLocked() + } + } + } + + private fun pruneExpiredCompletedLocked(nowEpochMillis: Long = System.currentTimeMillis()): Boolean { + val expired = completedSubmissions.filterNot { it.isRetained(nowEpochMillis) } + if (expired.isEmpty()) return false + completedSubmissions = completedSubmissions.filter { it.isRetained(nowEpochMillis) } + if (actualState is SupportDiagnosticsSubmissionState.Submitted) { + actualState = latestCompletedFor(actualStateAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle + } + scope.launch { + deleteCompletedDescriptorsWithRetry(expired.map { submission -> completedDescriptor(submission.recordId) }) + } + return true + } + + private suspend fun deleteCompletedDescriptorsWithRetry(descriptors: List) { + var remaining = descriptors + while (remaining.isNotEmpty()) { + remaining = remaining.filterNot(::deleteCompletedDescriptorSafely) + if (remaining.isNotEmpty()) delay(descriptorCleanupRetryMillis) + } + } +} + +private fun syncPosixDirectoryEntry(directory: File) { + if (Files.getFileAttributeView(directory.toPath(), PosixFileAttributeView::class.java) == null) return + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> + channel.force(true) + } +} + +private fun Long.saturatingAdd(increment: Long): Long = + if (this > Long.MAX_VALUE - increment) Long.MAX_VALUE else this + increment + +private class ProgressRequestBody( + private val delegate: RequestBody, + private val onProgress: (Long, Long) -> Unit, +) : RequestBody() { + override fun contentType() = delegate.contentType() + override fun contentLength(): Long = delegate.contentLength() + + override fun writeTo(sink: BufferedSink) { + val total = contentLength() + val forwarding = object : okio.ForwardingSink(sink) { + var uploaded = 0L + override fun write(source: okio.Buffer, byteCount: Long) { + super.write(source, byteCount) + uploaded += byteCount + onProgress(uploaded, total) + } + } + val buffered = forwarding.buffer() + delegate.writeTo(buffered) + buffered.flush() + } +} + +private fun secureIdempotencyKey(): String { + val bytes = ByteArray(32).also(SecureRandom()::nextBytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} + +private fun Response.readBoundedText(): String { + val source = body.source() + val buffer = Buffer() + val limit = MAX_SUPPORT_INTAKE_RESPONSE_BYTES.toLong() + 1L + while (buffer.size < limit) { + val read = source.read(buffer, minOf(8_192L, limit - buffer.size)) + if (read == -1L) break + } + if (buffer.size > MAX_SUPPORT_INTAKE_RESPONSE_BYTES) { + throw IOException("Obiente Support returned an oversized response.") + } + return buffer.readString(Charsets.UTF_8) +} + +private fun Response.retryNotBeforeEpochMillis(nowEpochMillis: Long = System.currentTimeMillis()): Long? { + val value = header("Retry-After")?.trim()?.takeIf(String::isNotEmpty) ?: return null + val requestedDelayMillis = value.toLongOrNull()?.let { seconds -> + seconds.coerceAtLeast(0L).coerceAtMost(MAX_SUPPORT_RETRY_AFTER_SECONDS) * 1_000L + } ?: runCatching { + (ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() - nowEpochMillis) + .coerceAtLeast(0L) + .coerceAtMost(MAX_SUPPORT_RETRY_AFTER_MILLIS) + }.getOrNull() + return requestedDelayMillis?.let { nowEpochMillis + it } +} + +private fun String.filterSupportMetadata(maximumBytes: Int): String = + filterNot(Char::isISOControl).trim().takeUtf8Bytes(maximumBytes) + +private fun String?.toSupportIntakeDescription(): String { + val sanitized = orEmpty().takeUtf8Bytes(MAX_SUPPORT_INTAKE_DESCRIPTION_BYTES) + if (sanitized.isBlank()) return "The diagnostic report was submitted without additional reproduction steps." + return if (sanitized.toByteArray(StandardCharsets.UTF_8).size < MIN_SUPPORT_INTAKE_DESCRIPTION_BYTES) { + "User note: $sanitized" + } else { + sanitized + } +} + +private fun String.takeUtf8Bytes(maximumBytes: Int): String { + require(maximumBytes >= 0) + if (toByteArray(StandardCharsets.UTF_8).size <= maximumBytes) return this + val bounded = StringBuilder(length) + var byteCount = 0 + var index = 0 + while (index < length) { + val codePoint = codePointAt(index) + val encoded = String(Character.toChars(codePoint)).toByteArray(StandardCharsets.UTF_8) + if (byteCount + encoded.size > maximumBytes) break + bounded.appendCodePoint(codePoint) + byteCount += encoded.size + index += Character.charCount(codePoint) + } + return bounded.toString() +} + +private val SUPPORT_METADATA_MEDIA_TYPE = "application/json".toMediaType() +private val SUPPORT_ARCHIVE_MEDIA_TYPE = "application/zip".toMediaType() +private val SUPPORT_CODE_PATTERN = Regex("OBI-[A-HJ-KM-NP-Z2-9]{5}-[A-HJ-KM-NP-Z2-9]{5}") +private val SUPPORT_RECEIPT_STATUS_PATTERN = Regex("[a-z][a-z_]{1,31}") +private val SUPPORT_STATUS_PATH_PATTERN = Regex("/r/[A-Za-z0-9_-]{43}") +private val SUPPORT_TEMPORARY_FILE_PATTERN = Regex("support-[0-9a-f-]{36}\\.zip") +private val SUPPORT_PENDING_TEMPORARY_FILE_PATTERN = Regex("\\.(?:pending|completed)-[A-Za-z0-9._-]+\\.tmp") +private val SUPPORT_ARCHIVE_TEMPORARY_FILE_PATTERN = + Regex("\\.support-[0-9a-f-]{36}\\.zip\\.[A-Za-z0-9._-]+\\.tmp") +private val SUPPORT_COMPLETED_RECORD_ID_PATTERN = + Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +private val SUPPORT_COMPLETED_FILE_PATTERN = Regex("completed-(${SUPPORT_COMPLETED_RECORD_ID_PATTERN.pattern})\\.json") +private val SUPPORT_IDEMPOTENCY_PATTERN = Regex("[A-Za-z0-9_-]{43}") +private val SUPPORT_ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") +private val RETRYABLE_CLIENT_STATUS_CODES = setOf(425, 429) +private val TERMINAL_DELETION_STATUS_CODES = setOf(200, 204) +private const val MAX_SUPPORT_INTAKE_MESSAGE_LENGTH = 240 +private const val MAX_SUPPORT_INTAKE_RESPONSE_BYTES = 64 * 1024 +private const val MAX_SUPPORT_INTAKE_DESCRIPTION_BYTES = 8_000 +private const val MIN_SUPPORT_INTAKE_DESCRIPTION_BYTES = 10 +private const val MAX_PENDING_DESCRIPTOR_BYTES = 4L * 1024L * 1024L +private const val MAX_COMPLETED_DESCRIPTOR_BYTES = 64L * 1024L +private const val MAX_SUPPORT_ARCHIVE_BYTES = 4L * 1024L * 1024L +private const val SUPPORT_PENDING_DESCRIPTOR = "pending.json" +private const val SUPPORT_TEMPORARY_MAX_AGE_MILLIS = 24L * 60L * 60L * 1_000L +private const val SUPPORT_RECOVERY_MAX_AGE_MILLIS = 30L * 24L * 60L * 60L * 1_000L +private const val SUPPORT_SERVER_RETENTION_MAX_AGE_MILLIS = 30L * 24L * 60L * 60L * 1_000L +private const val SUPPORT_RECEIPT_CLOCK_SKEW_MILLIS = 5L * 60L * 1_000L +private const val SUPPORT_DESCRIPTOR_DELETE_RETRY_MILLIS = 60L * 1_000L +private const val SUPPORT_CANCELLATION_RECONCILE_WINDOW_MILLIS = 10L * 1_000L +private const val SUPPORT_CANCELLATION_RECONCILE_POLL_MILLIS = 500L +private const val MAX_CANCELLATION_RECONCILE_WINDOW_MILLIS = 60L * 1_000L +private const val NANOS_PER_MILLISECOND = 1_000_000L +private const val SUPPORT_PENDING_RESTORE_MESSAGE = + "Private support report recovery is temporarily unavailable. The app will retry automatically." +private const val SUPPORT_COMPLETED_RESTORE_MESSAGE = + "Submitted support report recovery is temporarily unavailable. The app will retry automatically." +private const val MAX_SUPPORT_RETRY_AFTER_SECONDS = 5L * 60L +private const val MAX_SUPPORT_RETRY_AFTER_MILLIS = MAX_SUPPORT_RETRY_AFTER_SECONDS * 1_000L +private const val READ_ONLY_SUPPORT_MESSAGE = + "Private support uploads are unavailable while the shared read-only audit session is active." +private const val SUPPORT_STORAGE_UNAVAILABLE_MESSAGE = + "Private support submission storage is unavailable on this device. " + + "Check available storage and app permissions, then restart the app." +private const val SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE = + "An invalid private support recovery record is still being removed. Try sending again shortly." diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 212bd7ea3..b00c9abce 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -252,7 +252,7 @@ "gradle/wrapper/gradle-wrapper.properties": "aef287d114ce3153c3d535697a61928f5034990d570cbb2e93df549e9671483d", "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "3c96e83e1ba2d715b1cda9cedf036fc97b78c3ca63b7fc930325ed536940c1f3", - "ui/build.gradle.kts": "d2ceb55f0ce6e045686fe135e21fb82b08b9e92aa770ec4f16492da4f42b3a39", + "ui/build.gradle.kts": "d059de12ee24196c9f27dbd383e9c36e57ad22331430c9d54f8d43f9a2e5f229", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt": "625e281281f28e5a2d0497626efc882f4fb2b5e778fcbdcac34425c853f83730", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt": "17eee9756397bf7ecc589f781a3a405e12451a26fdbd41e6e35bb4747806b1d4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AdminAppManagement.kt": "b11fbc2c93ab17a20f123c8bde25a993c612ffb1b1525329a7e341952fefbf7d", @@ -364,12 +364,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "53cc22fed51fe03ef0aaab6a3f849222b0dbfb04fac9c4e3a73057b85c524290", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "02b9f088ad91cfc7d3daac268f1fb4f7fbf84647bcef6ec9a3bd1470bc21d068", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "b507277cb88ac86f9183ad6cd173b70eacf88d71129fac9d60f41a6c4e836aac", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "944103dba7d35abd17d21cb2bf5818c19b8148abf6b8d37fcdafdab029abe56b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "9223dd455c6a1c35a10769616fceb9dfb2d92ef4d43b0a52c2c29e40f1a196cf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "3445cee9cb4e039c3992103f1d71ab5cee4fea9751e5f9282160c64b44e84d4c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "353c4086d7c454835bfa48bb70489bb754ff0fe884421cb9682037052a15f751", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "84c0d8ef8685cd7608b9c81e07c08db5494d18314bf8c8ebd127edd60f60569c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e0eb6645fdfb786a942af58ada15d5385c477b4f1a1fc5613ecc14a8c0089c4d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "8d8e3362282230175bdda673d76baccf537220f175539404cf67295fe05bb44b", @@ -404,7 +404,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceSelection.kt": "a41835093c50db3b4c8414525dafe7542aa71aef295b480f352c7d5afc7abc24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt": "43f8f21693a5a120d2f057bf52e78230e1cf486a6d96445d93c1ff2687572b9c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsWorkspace.kt": "639b4d009f942bc236d70226df54c191684e42b82dec009baf76155066378516", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "d90c0f1ef76fce0abf2b66b127cf38bce94aa7256b74d730b71474f0db3906b8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "70395d62c76898cf627096c8b801fe0c87d7417612490d95f8e61ea23ccf3ae9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SystemTags.kt": "452abd9589c627dc7ae9dae03265a3bfdb837809ba0ca82da0698ef73db64332", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkAttachmentReader.kt": "f444161c7c719880f3a44108255ef781148baac083c88148509ad023f27a43e9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkMessageCards.kt": "baeba1dff08ab4be69cf4b39daad45b6450bfda9464dc8a60de2e1555c73267b", @@ -466,7 +466,7 @@ "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DesktopBackgroundSettingsVisualQaMain.kt": "7d90dc22854409b9837b82ba739f7221d425554531a962296ff9e75b0a178c2d", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt": "7402f5b4bf3bbf3cf20761eaa10eca9131fb6e63da2988d0aa6038942d706c53", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncTrayVisualQaMain.kt": "a0533aec3f991b48d57f69ddf7e95124b969631b6a55110f550826ac46ffc1ad", - "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt": "1d46b38a5b7c7f904e1374c14d3babeddae67e3ed9ce5fc6e513edba37098510", + "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt": "77cc53f0d028dd3ff1aab452837971fd7bdfd196c797004defd87c3c6572a09e", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureMain.kt": "759783eb557e8f500d623cdd635a67f94c45ebf9197e96ac2d67e4b4691e5fde", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt": "67cf94e6f215167a2384f14086d135532961c7e91cecb69be8f15e4ee6324606", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt": "6970352c6e42d09b793c55ea296991d756d232f216d8d152aa00319b2ddb8d20",