From 6f312e234902ab696b722d206ce18c9644dff170 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 17:03:22 +0200 Subject: [PATCH 01/29] feat: send diagnostic reports to support --- .../AndroidNextcloudServices.kt | 28 +- .../AndroidSupportDiagnostics.kt | 17 +- .../unreleased/351-direct-support-intake.md | 7 + ui/build.gradle.kts | 4 + .../nextcloudnative/app/NextcloudNativeApp.kt | 131 ++++- .../nextcloudnative/app/NextcloudPlatform.kt | 13 + .../nextcloudnative/app/SupportDiagnostics.kt | 57 ++ .../app/DesktopNextcloudServices.kt | 34 +- .../app/DesktopSupportDiagnostics.kt | 11 +- .../nextcloudnative/nativeui/preview/Main.kt | 1 + .../app/JvmSupportIntakeTest.kt | 216 +++++++ .../app/AsyncJvmSupportDiagnostics.kt | 9 + .../app/JvmSupportDiagnostics.kt | 20 +- .../nextcloudnative/app/JvmSupportIntake.kt | 548 ++++++++++++++++++ 14 files changed, 1071 insertions(+), 25 deletions(-) create mode 100644 changes/unreleased/351-direct-support-intake.md create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt create mode 100644 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 6866ff143..78ca606d3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -111,6 +111,7 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticsSummary import dev.obiente.nextcloudnative.app.JvmNetworkRequestAttempt import dev.obiente.nextcloudnative.app.JvmNetworkFailureDiagnostic import dev.obiente.nextcloudnative.app.JvmNetworkResponseTruncatedIOException +import dev.obiente.nextcloudnative.app.JvmSupportIntake import dev.obiente.nextcloudnative.app.isReadOnlyJvmNetworkMethod import dev.obiente.nextcloudnative.app.isJvmLocalUploadSourceFailure import dev.obiente.nextcloudnative.app.requireExactJvmNetworkResponseBytes @@ -412,6 +413,12 @@ internal class AndroidNextcloudServices( activity = activity, diagnostics = supportDiagnostics, ) + private val supportIntake = JvmSupportIntake( + diagnostics = supportDiagnostics, + temporaryRoot = File(appContext.cacheDir, "support-submissions"), + environment = androidSupportDiagnosticsEnvironment(), + client = httpClient.newBuilder().retryOnConnectionFailure(false).build(), + ) init { supportDiagnostics.registerPrivateValue(System.getProperty("user.home")) @@ -611,14 +618,29 @@ 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 fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() + + 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() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt index 905ed9db8..4c0f86cd0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt @@ -62,18 +62,21 @@ 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 } } } +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/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 902f79d12..c98de4fce 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12510,7 +12510,14 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) 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 showPreview by rememberSaveable { mutableStateOf(false) } + val submissionState by remember(services) { + services.supportDiagnosticsSubmissionStates() + }.collectAsState(SupportDiagnosticsSubmissionState.Idle) + val submissionBusy = submissionState is SupportDiagnosticsSubmissionState.Packaging || + submissionState is SupportDiagnosticsSubmissionState.Uploading + val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure if (confirmClear) { AlertDialog( @@ -12544,6 +12551,37 @@ 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, server URLs, filenames, file contents, or a stable device identifier. 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, + onClick = { + confirmSend = false + scope.launch { services.submitSupportDiagnostics(reproductionSteps) } + }, + ) { Text("Send privately") } + }, + dismissButton = { + TextButton(enabled = !submissionBusy, onClick = { confirmSend = false }) { Text("Cancel") } + }, + ) + } + Surface( modifier = Modifier.fillMaxWidth(), color = NextcloudTheme.colors.appTile, @@ -12601,7 +12639,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 = { @@ -12670,7 +12708,13 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), ) { Button( - enabled = summary.available && !exporting, + enabled = summary.available && !exporting && !submissionBusy && !submissionPending, + onClick = { confirmSend = true }, + ) { + Text("Send to support") + } + OutlinedButton( + enabled = summary.available && !exporting && !submissionBusy, onClick = { exporting = true status = null @@ -12699,15 +12743,94 @@ 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 (submissionBusy) { + OutlinedButton(onClick = { services.cancelSupportDiagnosticsSubmission() }) { + Text("Cancel sending") + } } if (summary.eventCount > 0) { OutlinedButton( - enabled = !exporting, + enabled = !exporting && !submissionBusy, onClick = { confirmClear = true }, ) { Text("Clear history") } } } + when (val current = submissionState) { + SupportDiagnosticsSubmissionState.Idle -> Unit + SupportDiagnosticsSubmissionState.Packaging -> { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text("Preparing the private 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 = { services.cancelSupportDiagnosticsSubmission() }) { + 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 -> { + Text( + "Sent privately. Support code: ${current.supportCode}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedButton( + onClick = { + status = if (services.copyTextToClipboard("Obiente support code", current.supportCode)) { + "Support code copied." + } else { + "The support code could not be copied." + } + }, + ) { Text("Copy support code") } + TextButton(onClick = { services.openExternalUrl(current.statusUrl) }) { + Text("Open private status") + } + } + } + is SupportDiagnosticsSubmissionState.Unsupported -> Text( + current.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } status?.let { message -> Text( message, 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 aeb373462..bc79a4d7c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -515,6 +515,19 @@ 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. */ + fun cancelSupportDiagnosticsSubmission(): Boolean = false + /** 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..d5eb24002 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,63 @@ sealed interface SupportDiagnosticsExportResult { data class Unsupported(val reason: String) : SupportDiagnosticsExportResult } +sealed interface SupportDiagnosticsSubmissionState { + data object Idle : SupportDiagnosticsSubmissionState + data object Packaging : 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 Submitted( + val supportCode: String, + val statusUrl: String, + val retentionUntil: String, + ) : SupportDiagnosticsSubmissionState + data class Unsupported(val reason: String) : SupportDiagnosticsSubmissionState +} + +@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, ) { 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 9b3d0234a..3535c5c48 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -929,6 +929,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 @@ -941,12 +942,22 @@ 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 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) @@ -2534,7 +2545,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() } @@ -3354,7 +3367,23 @@ 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 fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() + + private fun supportDiagnosticFeatureState(): List = + listOf( SupportDiagnosticFieldDraft("distribution", appUpdateSupport().channel.name.lowercase()), SupportDiagnosticFieldDraft("direct_updates", appUpdateSupport().canCheckDirectUpdates.toString()), SupportDiagnosticFieldDraft("start_on_login_supported", supportsStartOnLogin.toString()), @@ -3364,8 +3393,7 @@ class DesktopNextcloudServices( (windowsCloudFilesProvider != null || linuxVirtualFileSystem != null).toString(), ), SupportDiagnosticFieldDraft("bidirectional_sync", supportsBidirectionalFileSync.toString()), - ), - ) + ) override suspend fun clearSupportDiagnostics(): Boolean = withContext(Dispatchers.IO) { supportDiagnostics.clear() 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/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt new file mode 100644 index 000000000..29f3647e1 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -0,0 +1,216 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.util.concurrent.TimeUnit +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.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +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) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + 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) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @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"]) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun cancellingRetainedSubmissionDeletesPrivateTemporaryFiles() = 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(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + @Test + fun cancellationReconcilesAndDeletesReceiptAcceptedDuringUpload() = 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(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 reconcile = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val deletion = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals(upload.headers["Idempotency-Key"], reconcile.headers["Idempotency-Key"]) + assertEquals("GET", reconcile.method) + assertEquals("DELETE", deletion.method) + assertTrue(deletion.url.encodedPath.startsWith("/api/v1/reports/")) + assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + } + } + + private fun testFixture(): Fixture { + val root = createTempDirectory("support-intake-test").toFile() + val diagnosticRoot = File(root, "diagnostics") + val temporaryRoot = File(root, "submissions") + 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, + ) + } + + private fun receiptResponse(statusUrl: String): MockResponse = MockResponse.Builder().code(201).body( + """ + { + "contractVersion": 1, + "supportCode": "OBI-ABCDE-23456", + "status": "new", + "statusUrl": "$statusUrl", + "deletionUrl": "$statusUrl", + "createdAt": "2026-08-13T12:00:00Z", + "retentionUntil": "2026-09-12T12:00:00Z" + } + """.trimIndent(), + ).build() + + private data class Fixture( + val root: File, + val temporaryRoot: File, + val diagnostics: AsyncJvmSupportDiagnostics, + val environment: SupportDiagnosticsEnvironment, + val server: MockWebServer, + ) : AutoCloseable { + val intake = newIntake() + val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() + + fun newIntake() = JvmSupportIntake( + diagnostics = diagnostics, + temporaryRoot = temporaryRoot, + environment = environment, + client = OkHttpClient.Builder().retryOnConnectionFailure(false).build(), + supportBaseUrl = server.url("/").toString(), + ) + + override fun close() { + intake.cancel() + diagnostics.close() + server.close() + root.deleteRecursively() + } + } +} 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..971cd2c02 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,15 @@ class AsyncJvmSupportDiagnostics( ready.await().also(::drainPendingSnapshot).writeBundle(destination, reproductionSteps, featureState) } + internal suspend fun writeBundleForSubmission( + destination: File, + reproductionSteps: String, + featureState: List, + ): PreparedSupportDiagnosticsBundle = withContext(dispatcher) { + ready.await().also(::drainPendingSnapshot) + .writeBundleForSubmission(destination, reproductionSteps, featureState) + } + 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..bae4c896f 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt @@ -216,17 +216,24 @@ class JvmSupportDiagnostics( destination: File, reproductionSteps: String, featureState: List, - ): File = synchronized(lock) { + ): File = writeBundleForSubmission(destination, reproductionSteps, featureState).archive + + internal fun writeBundleForSubmission( + destination: File, + reproductionSteps: String, + featureState: List, + ): PreparedSupportDiagnosticsBundle = synchronized(lock) { check(storageAvailable) { "Private diagnostic storage is unavailable." } require(featureState.size <= MAX_SUPPORT_DIAGNOSTIC_FIELDS) val createdAt = nowEpochMillis().coerceAtLeast(0L) discardedHistoryBytes += pruneEvents(createdAt) if (discardedHistoryBytes > 0L) persistHistory() val snapshot = visibleEvents() + val sanitizedReproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank) val report = SupportBundleReport( createdAtEpochMillis = createdAt, environment = environment.safeForReport(), - reproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank), + reproductionSteps = sanitizedReproductionSteps, eventCount = snapshot.size, warningCount = snapshot.count { it.severity == SupportDiagnosticSeverity.Warning }, errorCount = snapshot.count { it.severity == SupportDiagnosticSeverity.Error }, @@ -260,7 +267,7 @@ class JvmSupportDiagnostics( "The bounded diagnostic report is unexpectedly large." } writeZipAtomically(destination, completeContent, createdAt) - destination + PreparedSupportDiagnosticsBundle(destination, sanitizedReproductionSteps) } private fun loadHistory() { @@ -396,6 +403,11 @@ class JvmSupportDiagnostics( } } +internal data class PreparedSupportDiagnosticsBundle( + val archive: File, + val sanitizedReproductionSteps: String?, +) + fun Throwable.toSupportDiagnosticExceptionDraft( depth: Int = 0, ): SupportDiagnosticExceptionDraft = SupportDiagnosticExceptionDraft( @@ -414,7 +426,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..07ec5b436 --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -0,0 +1,548 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.StandardCopyOption +import java.security.SecureRandom +import java.time.Instant +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.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +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, + private val client: OkHttpClient, + supportBaseUrl: String = DEFAULT_OBIENTE_SUPPORT_URL, +) : AutoCloseable { + private val baseUrl = supportBaseUrl.toHttpUrl() + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = false + } + private val state = MutableStateFlow(SupportDiagnosticsSubmissionState.Idle) + private val activeCall = AtomicReference() + private val cancellationRequested = AtomicBoolean(false) + private val shutdownRequested = AtomicBoolean(false) + private val lock = Any() + private var pending: PendingSubmission? = null + + init { + restorePendingSubmission()?.let { restored -> + pending = restored + state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + "A private support submission was interrupted. You can retry it safely.", + outcomeAmbiguous = true, + ) + } + } + + fun states(): StateFlow = state.asStateFlow() + + suspend fun submit( + reproductionSteps: String, + channel: String, + featureState: List, + ) = withContext(Dispatchers.IO) { + discardPending() + cancellationRequested.set(false) + state.value = SupportDiagnosticsSubmissionState.Packaging + val prepared = try { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) { + "Could not prepare private support submission storage." + } + pruneStaleTemporaryReports() + diagnostics.writeBundleForSubmission( + destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip"), + reproductionSteps = reproductionSteps, + featureState = featureState, + ) + } catch (cancellation: CancellationException) { + state.value = SupportDiagnosticsSubmissionState.Cancelled + throw cancellation + } catch (failure: Throwable) { + state.value = SupportDiagnosticsSubmissionState.Rejected( + failure.message?.take(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) + ?: "The private diagnostic report could not be prepared.", + ) + return@withContext + } + val submission = PendingSubmission( + archive = prepared.archive, + metadata = SupportIntakeMetadata( + title = "Nextcloud Native diagnostic report", + description = prepared.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(), + ) + synchronized(lock) { pending = submission } + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return@withContext + } + upload(submission) + } + + suspend fun retry() = withContext(Dispatchers.IO) { + val submission = synchronized(lock) { pending } + if (submission == null || !submission.archive.isFile) { + state.value = SupportDiagnosticsSubmissionState.Rejected( + "There is no private support submission available to retry.", + ) + return@withContext + } + cancellationRequested.set(false) + upload(submission) + } + + fun cancel(): Boolean { + val call = activeCall.getAndSet(null) + cancellationRequested.set(true) + if (call != null) { + call.cancel() + return true + } + val submission = synchronized(lock) { pending.also { pending = null } } + if (submission != null || state.value == SupportDiagnosticsSubmissionState.Packaging) { + submission?.archive?.delete() + pendingDescriptor().delete() + state.value = SupportDiagnosticsSubmissionState.Cancelled + return true + } + return false + } + + override fun close() { + shutdownRequested.set(true) + activeCall.getAndSet(null)?.cancel() + } + + private fun upload(submission: PendingSubmission) { + if (cancellationRequested.get()) { + finishCancelled(submission) + return + } + val metadata = json.encodeToString(SupportIntakeMetadata.serializer(), submission.metadata) + val progressBody = ProgressRequestBody( + delegate = submission.archive.asRequestBody(SUPPORT_ARCHIVE_MEDIA_TYPE), + onProgress = { uploaded, total -> + if (!cancellationRequested.get()) { + state.value = 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() + state.value = SupportDiagnosticsSubmissionState.Uploading(0f) + val call = client.newCall(request) + activeCall.set(call) + try { + call.execute().use { response -> + val responseText = response.readBoundedText() + when { + response.isSuccessful -> finishReceived(submission, decodeReceipt(responseText)) + response.code in 400..499 -> finishRejected(submission, decodeProblem(responseText)) + else -> retainForRetry(submission, "Obiente Support is temporarily unavailable.", false) + } + } + } catch (failure: IOException) { + 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 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() + val call = client.newCall(request) + activeCall.set(call) + try { + call.execute().use { response -> + val responseText = response.readBoundedText() + when { + response.isSuccessful -> finishReceived(submission, decodeReceipt(responseText)) + response.code == 404 && cancellationRequested.get() -> finishCancelled(submission) + response.code == 404 -> retainForRetry(submission, "The upload did not complete. You can retry it safely.", false) + else -> retainForRetry( + submission, + "The upload result is uncertain. Check your connection before retrying.", + true, + ) + } + } + } 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, + ) + } catch (_: IllegalArgumentException) { + retainForRetry(submission, "Obiente Support returned an invalid receipt.", true) + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun finishReceived(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + if (cancellationRequested.get()) { + 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, + ) + 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() + val call = client.newCall(request) + activeCall.set(call) + try { + call.execute().use { response -> + response.readBoundedText() + if (response.isSuccessful || response.code == 404) { + finishCancelled(submission) + } else { + finishSubmitted(submission, receipt) + } + } + } catch (_: IOException) { + finishSubmitted(submission, receipt) + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + validateReceipt(receipt) + finishTerminal(submission) + state.value = SupportDiagnosticsSubmissionState.Submitted( + supportCode = receipt.supportCode, + statusUrl = receipt.statusUrl, + retentionUntil = receipt.retentionUntil, + ) + } + + private fun validateReceipt(receipt: SupportIntakeReceipt): 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 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, + ) + return statusUrl + } + + private fun finishTerminal(submission: PendingSubmission) { + synchronized(lock) { + if (pending === submission) pending = null + } + submission.archive.delete() + pendingDescriptor().delete() + } + + private fun finishRejected(submission: PendingSubmission, message: String) { + finishTerminal(submission) + state.value = SupportDiagnosticsSubmissionState.Rejected(message) + } + + private fun finishCancelled(submission: PendingSubmission) { + finishTerminal(submission) + state.value = SupportDiagnosticsSubmissionState.Cancelled + } + + private fun retainForRetry(submission: PendingSubmission, message: String, ambiguous: Boolean) { + synchronized(lock) { pending = submission } + if (persistPendingSafely(submission)) { + state.value = SupportDiagnosticsSubmissionState.RetryableFailure(message, ambiguous) + } else { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + } + } + + private fun persistPendingSafely(submission: PendingSubmission): Boolean = + 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 discardPending() { + synchronized(lock) { + pending?.archive?.delete() + pending = null + } + pendingDescriptor().delete() + } + + private fun pruneStaleTemporaryReports() { + val cutoff = System.currentTimeMillis() - SUPPORT_TEMPORARY_MAX_AGE_MILLIS + temporaryRoot.listFiles().orEmpty() + .filter { file -> file.isFile && file.name.matches(SUPPORT_TEMPORARY_FILE_PATTERN) && file.lastModified() < cutoff } + .forEach(File::delete) + } + + private fun persistPending(submission: PendingSubmission) { + val descriptor = pendingDescriptor() + val parent = requireNotNull(descriptor.parentFile) + require(parent.isDirectory || parent.mkdirs()) + val temporary = File(parent, ".pending-${UUID.randomUUID()}.tmp") + try { + val encoded = json.encodeToString( + PersistedPendingSubmission.serializer(), + PersistedPendingSubmission( + archiveName = submission.archive.name, + metadata = submission.metadata, + idempotencyKey = submission.idempotencyKey, + createdAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L), + ), + ).encodeToByteArray() + FileOutputStream(temporary).use { output -> + output.write(encoded) + output.fd.sync() + } + runCatching { + java.nio.file.Files.move( + temporary.toPath(), + descriptor.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + }.recoverCatching { + java.nio.file.Files.move( + temporary.toPath(), + descriptor.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + }.getOrThrow() + } finally { + temporary.delete() + } + } + + private fun restorePendingSubmission(): PendingSubmission? = runCatching { + val descriptor = pendingDescriptor() + if (!descriptor.isFile || descriptor.length() !in 1L..MAX_PENDING_DESCRIPTOR_BYTES) return@runCatching null + val persisted = json.decodeFromString( + PersistedPendingSubmission.serializer(), + descriptor.readText(Charsets.UTF_8), + ) + require(persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) + require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) + require(System.currentTimeMillis() - persisted.createdAtEpochMillis in 0L..SUPPORT_TEMPORARY_MAX_AGE_MILLIS) + val archive = File(temporaryRoot, persisted.archiveName).absoluteFile.normalize() + require(archive.parentFile == temporaryRoot.absoluteFile.normalize()) + require(archive.isFile && archive.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + PendingSubmission(archive, persisted.metadata, persisted.idempotencyKey) + }.getOrElse { + pendingDescriptor().delete() + null + } + + private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) + + private data class PendingSubmission( + val archive: File, + val metadata: SupportIntakeMetadata, + val idempotencyKey: String, + ) + + @Serializable + private data class PersistedPendingSubmission( + val archiveName: String, + val metadata: SupportIntakeMetadata, + val idempotencyKey: String, + val createdAtEpochMillis: Long, + ) +} + +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 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_IDEMPOTENCY_PATTERN = Regex("[A-Za-z0-9_-]{43}") +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 = 32L * 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 From fd6279583fdaedf8c7902f95b91557eb1bf21650 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:09:27 +0000 Subject: [PATCH 02/29] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 0e976c320..6545ea902 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": "c833b0aa0b958c1971ec35ac21ff428bb5a656f638f24401ef2aef7d502ecda0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "f16522ebe12a9de3a910fc1dd3acbf4df376bd1a6eb3b6419369bbc90b4b7285", "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": "680e9f1b45c0769c86b7a398041567f429c23066bc12d4333fda28e9a189bc8e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "d8a55d960b889512f392e06985e6b80c79a627cf40694e74977a50b14f1220a2", "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": "dc6ed759b1e0fee2a6e190a9b5c20c06682eb95a534a988f718c153a03cdf10e", "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", From cfc193ac01c3b0f3c5d92706c01ac5662ed5dabb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 17:37:43 +0200 Subject: [PATCH 03/29] fix: harden support report recovery --- .../AndroidNextcloudServices.kt | 2 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 132 ++++---- .../app/JvmSupportIntakeTest.kt | 153 ++++++++- .../nextcloudnative/app/JvmSupportIntake.kt | 292 +++++++++++++----- 4 files changed, 440 insertions(+), 139 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 78ca606d3..870d92d00 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -415,7 +415,7 @@ internal class AndroidNextcloudServices( ) private val supportIntake = JvmSupportIntake( diagnostics = supportDiagnostics, - temporaryRoot = File(appContext.cacheDir, "support-submissions"), + temporaryRoot = File(appContext.noBackupFilesDir, "support-submissions"), environment = androidSupportDiagnosticsEnvironment(), client = httpClient.newBuilder().retryOnConnectionFailure(false).build(), ) 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 c98de4fce..df3d58e1b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12757,79 +12757,87 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) ) { Text("Clear history") } } } - when (val current = submissionState) { - SupportDiagnosticsSubmissionState.Idle -> Unit - SupportDiagnosticsSubmissionState.Packaging -> { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Text("Preparing the private report...", style = MaterialTheme.typography.bodySmall) - } - is SupportDiagnosticsSubmissionState.Uploading -> { - if (current.progress == null) { + Column( + modifier = Modifier.fillMaxWidth().semantics { + liveRegion = LiveRegionMode.Polite + }, + ) { + when (val current = submissionState) { + SupportDiagnosticsSubmissionState.Idle -> Unit + SupportDiagnosticsSubmissionState.Packaging -> { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } else { - LinearProgressIndicator( - progress = { current.progress }, - modifier = Modifier.fillMaxWidth(), + Text("Preparing the private 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 = { services.cancelSupportDiagnosticsSubmission() }) { + Text("Discard pending report") + } + } } - Text("Sending the private report to Obiente Support...", style = MaterialTheme.typography.bodySmall) - } - is SupportDiagnosticsSubmissionState.RetryableFailure -> { - Text( + is SupportDiagnosticsSubmissionState.Rejected -> 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 = { services.cancelSupportDiagnosticsSubmission() }) { - 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 -> { - Text( - "Sent privately. Support code: ${current.supportCode}", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, + SupportDiagnosticsSubmissionState.Cancelled -> Text( + "Private report submission cancelled.", + style = MaterialTheme.typography.bodySmall, ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), - verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), - ) { - OutlinedButton( - onClick = { - status = if (services.copyTextToClipboard("Obiente support code", current.supportCode)) { - "Support code copied." - } else { - "The support code could not be copied." - } - }, - ) { Text("Copy support code") } - TextButton(onClick = { services.openExternalUrl(current.statusUrl) }) { - Text("Open private status") + is SupportDiagnosticsSubmissionState.Submitted -> { + Text( + "Sent privately. Support code: ${current.supportCode}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedButton( + onClick = { + status = if ( + services.copyTextToClipboard("Obiente support code", current.supportCode) + ) { + "Support code copied." + } else { + "The support code could not be copied." + } + }, + ) { Text("Copy support code") } + TextButton(onClick = { services.openExternalUrl(current.statusUrl) }) { + Text("Open private status") + } } } + is SupportDiagnosticsSubmissionState.Unsupported -> Text( + current.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - is SupportDiagnosticsSubmissionState.Unsupported -> Text( - current.reason, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } status?.let { message -> Text( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 29f3647e1..238a58272 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import java.io.File +import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.io.path.createTempDirectory import kotlin.test.Test @@ -105,7 +106,7 @@ class JvmSupportIntakeTest { } @Test - fun cancellingRetainedSubmissionDeletesPrivateTemporaryFiles() = runBlocking { + fun cancellingAmbiguousSubmissionRequiresDeletionReconciliation() = runBlocking { testFixture().use { fixture -> fixture.server.enqueue(MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build()) fixture.server.enqueue(MockResponse.Builder().code(503).build()) @@ -114,6 +115,12 @@ class JvmSupportIntakeTest { 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(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) } @@ -144,6 +151,150 @@ class JvmSupportIntakeTest { } } + @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 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 + } + } + private fun testFixture(): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 07ec5b436..240f47320 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -7,6 +7,8 @@ import java.nio.charset.StandardCharsets import java.nio.file.StandardCopyOption import java.security.SecureRandom 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 @@ -40,10 +42,14 @@ class JvmSupportIntake( private val diagnostics: AsyncJvmSupportDiagnostics, private val temporaryRoot: File, private val environment: SupportDiagnosticsEnvironment, - private val client: OkHttpClient, + client: OkHttpClient, supportBaseUrl: String = DEFAULT_OBIENTE_SUPPORT_URL, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() + private val client = client.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() private val json = Json { encodeDefaults = true ignoreUnknownKeys = false @@ -52,15 +58,22 @@ class JvmSupportIntake( private val activeCall = AtomicReference() private val cancellationRequested = AtomicBoolean(false) private val shutdownRequested = AtomicBoolean(false) + private val operationActive = AtomicBoolean(false) private val lock = Any() private var pending: PendingSubmission? = null init { - restorePendingSubmission()?.let { restored -> + val restored = restorePendingSubmission() + pruneTemporaryReports(restored?.archive) + restored?.let { pending = restored state.value = SupportDiagnosticsSubmissionState.RetryableFailure( - "A private support submission was interrupted. You can retry it safely.", - outcomeAmbiguous = true, + if (restored.cancellationPending) { + "Cancellation was interrupted. Retry safely to reconcile and delete the private report." + } else { + "A private support submission was interrupted. You can retry it safely." + }, + outcomeAmbiguous = restored.outcomeAmbiguous, ) } } @@ -72,77 +85,142 @@ class JvmSupportIntake( channel: String, featureState: List, ) = withContext(Dispatchers.IO) { - discardPending() - cancellationRequested.set(false) - state.value = SupportDiagnosticsSubmissionState.Packaging - val prepared = try { - require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) { - "Could not prepare private support submission storage." + if (!operationActive.compareAndSet(false, true)) return@withContext + try { + val existing = synchronized(lock) { pending } + if (existing != null) { + state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + "Finish or discard the pending private report before sending another one.", + outcomeAmbiguous = existing.outcomeAmbiguous, + ) + return@withContext } - pruneStaleTemporaryReports() - diagnostics.writeBundleForSubmission( - destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip"), - reproductionSteps = reproductionSteps, - featureState = featureState, - ) - } catch (cancellation: CancellationException) { - state.value = SupportDiagnosticsSubmissionState.Cancelled - throw cancellation - } catch (failure: Throwable) { - state.value = SupportDiagnosticsSubmissionState.Rejected( - failure.message?.take(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) - ?: "The private diagnostic report could not be prepared.", + cancellationRequested.set(false) + state.value = SupportDiagnosticsSubmissionState.Packaging + val prepared = try { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) { + "Could not prepare private support submission storage." + } + diagnostics.writeBundleForSubmission( + destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip"), + reproductionSteps = reproductionSteps, + featureState = featureState, + ) + } catch (cancellation: CancellationException) { + state.value = SupportDiagnosticsSubmissionState.Cancelled + throw cancellation + } catch (failure: Throwable) { + state.value = SupportDiagnosticsSubmissionState.Rejected( + failure.message?.take(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) + ?: "The private diagnostic report could not be prepared.", + ) + return@withContext + } + val submission = PendingSubmission( + archive = prepared.archive, + metadata = SupportIntakeMetadata( + title = "Nextcloud Native diagnostic report", + description = prepared.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), + cancellationPending = cancellationRequested.get(), ) - return@withContext - } - val submission = PendingSubmission( - archive = prepared.archive, - metadata = SupportIntakeMetadata( - title = "Nextcloud Native diagnostic report", - description = prepared.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(), - ) - synchronized(lock) { pending = submission } - if (!persistPendingSafely(submission)) { - finishRejected(submission, "The private support submission could not be retained safely on this device.") - return@withContext + synchronized(lock) { pending = submission } + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return@withContext + } + upload(submission) + } finally { + operationActive.set(false) } - upload(submission) } suspend fun retry() = withContext(Dispatchers.IO) { - val submission = synchronized(lock) { pending } - if (submission == null || !submission.archive.isFile) { - state.value = SupportDiagnosticsSubmissionState.Rejected( - "There is no private support submission available to retry.", - ) - return@withContext + if (!operationActive.compareAndSet(false, true)) return@withContext + try { + val submission = synchronized(lock) { pending } + if (submission == null || !submission.archive.isFile) { + state.value = SupportDiagnosticsSubmissionState.Rejected( + "There is no private support submission available to retry.", + ) + return@withContext + } + if (System.currentTimeMillis() - submission.createdAtEpochMillis !in 0L..SUPPORT_TEMPORARY_MAX_AGE_MILLIS) { + finishRejected(submission, "The pending private report expired and was removed from this device.") + return@withContext + } + if (submission.cancellationPending) { + cancellationRequested.set(true) + reconcileAfterAmbiguousResult( + submission, + IOException("Cancellation still needs to be reconciled."), + ) + return@withContext + } + val waitMillis = submission.retryNotBeforeEpochMillis?.minus(System.currentTimeMillis()) ?: 0L + if (waitMillis > 0L) { + state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + "Obiente Support asked the app to wait before retrying. Try again shortly.", + submission.outcomeAmbiguous, + ) + 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 + } + upload(submission) + } finally { + operationActive.set(false) } - cancellationRequested.set(false) - upload(submission) } fun cancel(): Boolean { - val call = activeCall.getAndSet(null) + val submission = synchronized(lock) { pending } + if (submission != null) { + if (!submission.outcomeAmbiguous && activeCall.get() == null) { + cancellationRequested.set(true) + finishCancelled(submission) + return true + } + submission.cancellationPending = true + submission.outcomeAmbiguous = true + if (!persistPendingSafely(submission)) { + submission.cancellationPending = false + state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + "Cancellation could not be retained safely. The current submission was left unchanged.", + outcomeAmbiguous = true, + ) + return false + } + } cancellationRequested.set(true) + val call = activeCall.getAndSet(null) if (call != null) { call.cancel() return true } - val submission = synchronized(lock) { pending.also { pending = null } } - if (submission != null || state.value == SupportDiagnosticsSubmissionState.Packaging) { - submission?.archive?.delete() - pendingDescriptor().delete() + if (submission != null) { + state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + "Cancellation could not be confirmed. Retry safely to reconcile and delete the private report.", + outcomeAmbiguous = true, + ) + return true + } + if (state.value == SupportDiagnosticsSubmissionState.Packaging) { state.value = SupportDiagnosticsSubmissionState.Cancelled return true } @@ -159,6 +237,11 @@ class JvmSupportIntake( finishCancelled(submission) return } + submission.outcomeAmbiguous = true + if (!persistPendingSafely(submission)) { + finishRejected(submission, "The private support submission could not be retained safely on this device.") + return + } val metadata = json.encodeToString(SupportIntakeMetadata.serializer(), submission.metadata) val progressBody = ProgressRequestBody( delegate = submission.archive.asRequestBody(SUPPORT_ARCHIVE_MEDIA_TYPE), @@ -189,8 +272,23 @@ class JvmSupportIntake( val responseText = response.readBoundedText() 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.", false) + else -> retainForRetry( + submission, + "Obiente Support is temporarily unavailable.", + ambiguous = response.code in 300..399, + retryNotBeforeEpochMillis = response.retryNotBeforeEpochMillis(), + ) } } } catch (failure: IOException) { @@ -345,7 +443,14 @@ class JvmSupportIntake( state.value = SupportDiagnosticsSubmissionState.Cancelled } - private fun retainForRetry(submission: PendingSubmission, message: String, ambiguous: Boolean) { + 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)) { state.value = SupportDiagnosticsSubmissionState.RetryableFailure(message, ambiguous) @@ -370,18 +475,16 @@ class JvmSupportIntake( ?.takeIf(String::isNotBlank) ?: "Obiente Support rejected this diagnostic report." - private fun discardPending() { - synchronized(lock) { - pending?.archive?.delete() - pending = null - } - pendingDescriptor().delete() - } - - private fun pruneStaleTemporaryReports() { + 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.lastModified() < cutoff } + .filter { file -> + file.isFile && file.name.matches(SUPPORT_TEMPORARY_FILE_PATTERN) && + (file != retainedArchive || file.lastModified() < cutoff) + } + .forEach(File::delete) + temporaryRoot.listFiles().orEmpty() + .filter { file -> file.isFile && file.name.matches(SUPPORT_PENDING_TEMPORARY_FILE_PATTERN) } .forEach(File::delete) } @@ -397,7 +500,10 @@ class JvmSupportIntake( archiveName = submission.archive.name, metadata = submission.metadata, idempotencyKey = submission.idempotencyKey, - createdAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L), + createdAtEpochMillis = submission.createdAtEpochMillis, + cancellationPending = submission.cancellationPending, + outcomeAmbiguous = submission.outcomeAmbiguous, + retryNotBeforeEpochMillis = submission.retryNotBeforeEpochMillis, ), ).encodeToByteArray() FileOutputStream(temporary).use { output -> @@ -425,7 +531,8 @@ class JvmSupportIntake( private fun restorePendingSubmission(): PendingSubmission? = runCatching { val descriptor = pendingDescriptor() - if (!descriptor.isFile || descriptor.length() !in 1L..MAX_PENDING_DESCRIPTOR_BYTES) return@runCatching null + if (!descriptor.isFile) return@runCatching null + require(descriptor.length() in 1L..MAX_PENDING_DESCRIPTOR_BYTES) val persisted = json.decodeFromString( PersistedPendingSubmission.serializer(), descriptor.readText(Charsets.UTF_8), @@ -433,10 +540,22 @@ class JvmSupportIntake( require(persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) require(System.currentTimeMillis() - persisted.createdAtEpochMillis in 0L..SUPPORT_TEMPORARY_MAX_AGE_MILLIS) + require( + persisted.retryNotBeforeEpochMillis == null || + persisted.retryNotBeforeEpochMillis <= System.currentTimeMillis() + MAX_SUPPORT_RETRY_AFTER_MILLIS, + ) val archive = File(temporaryRoot, persisted.archiveName).absoluteFile.normalize() require(archive.parentFile == temporaryRoot.absoluteFile.normalize()) require(archive.isFile && archive.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) - PendingSubmission(archive, persisted.metadata, persisted.idempotencyKey) + PendingSubmission( + archive = archive, + metadata = persisted.metadata, + idempotencyKey = persisted.idempotencyKey, + createdAtEpochMillis = persisted.createdAtEpochMillis, + cancellationPending = persisted.cancellationPending, + outcomeAmbiguous = persisted.outcomeAmbiguous, + retryNotBeforeEpochMillis = persisted.retryNotBeforeEpochMillis, + ) }.getOrElse { pendingDescriptor().delete() null @@ -448,6 +567,10 @@ class JvmSupportIntake( val archive: File, val metadata: SupportIntakeMetadata, val idempotencyKey: String, + val createdAtEpochMillis: Long, + var cancellationPending: Boolean = false, + var outcomeAmbiguous: Boolean = false, + var retryNotBeforeEpochMillis: Long? = null, ) @Serializable @@ -456,6 +579,9 @@ class JvmSupportIntake( val metadata: SupportIntakeMetadata, val idempotencyKey: String, val createdAtEpochMillis: Long, + val cancellationPending: Boolean = false, + val outcomeAmbiguous: Boolean = true, + val retryNotBeforeEpochMillis: Long? = null, ) } @@ -501,6 +627,18 @@ private fun Response.readBoundedText(): String { 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) @@ -537,7 +675,9 @@ private val SUPPORT_CODE_PATTERN = Regex("OBI-[A-HJ-KM-NP-Z2-9]{5}-[A-HJ-KM-NP-Z 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-[0-9a-f-]{36}\\.tmp") private val SUPPORT_IDEMPOTENCY_PATTERN = Regex("[A-Za-z0-9_-]{43}") +private val RETRYABLE_CLIENT_STATUS_CODES = setOf(425, 429) 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 @@ -546,3 +686,5 @@ private const val MAX_PENDING_DESCRIPTOR_BYTES = 32L * 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 MAX_SUPPORT_RETRY_AFTER_SECONDS = 5L * 60L +private const val MAX_SUPPORT_RETRY_AFTER_MILLIS = MAX_SUPPORT_RETRY_AFTER_SECONDS * 1_000L From ffbebe9ed88723133cc418866e8dbfafa0ccf9af Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:50:31 +0000 Subject: [PATCH 04/29] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 9c33bfed8..9bd038ac4 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -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": "2ea67449d78b03eb6496c5684f9dc8704bbe58300fb394ed832b116aa3142cfb", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "69bf1cfd06c58d3c79b39ae8ea9eae9c3e32eda1854fba5c384848bb91152338", "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": "fab7b5a39ca0c25f3aeb62af82bf9d2c34a2f4d995e3b7e925227b8541a4eedd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "c27316e49f8dd9a120b0f6c7e2fe9b4143a4c9e4ccec49506db8db7460c7906d", "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", From efd2e801f9704e68f1d40b08ad109b1f3bff53b4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 19:08:19 +0200 Subject: [PATCH 05/29] fix(support): preserve retryable diagnostic submissions --- .../nextcloudnative/app/SupportDiagnostics.kt | 4 +- .../app/JvmSupportIntakeTest.kt | 104 ++++++++ .../app/AsyncJvmSupportDiagnostics.kt | 11 +- .../app/JvmSupportDiagnostics.kt | 41 ++- .../nextcloudnative/app/JvmSupportIntake.kt | 237 +++++++++++++++--- 5 files changed, 345 insertions(+), 52 deletions(-) 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 d5eb24002..f142e103b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -433,7 +433,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 @@ -442,7 +442,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/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 238a58272..32c3ef1fd 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -1,6 +1,9 @@ package dev.obiente.nextcloudnative.app import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.io.path.createTempDirectory @@ -295,6 +298,107 @@ class JvmSupportIntakeTest { } } + @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(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"]) + 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 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(): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") 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 971cd2c02..513258b9c 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt @@ -175,11 +175,18 @@ class AsyncJvmSupportDiagnostics( 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, - ): PreparedSupportDiagnosticsBundle = withContext(dispatcher) { + ): PreparedSupportSubmissionContext = withContext(dispatcher) { ready.await().also(::drainPendingSnapshot) - .writeBundleForSubmission(destination, reproductionSteps, featureState) + .prepareSubmissionContext(reproductionSteps, featureState) } override fun close() { 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 bae4c896f..8485751f0 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt @@ -216,29 +216,48 @@ class JvmSupportDiagnostics( destination: File, reproductionSteps: String, featureState: List, - ): File = writeBundleForSubmission(destination, reproductionSteps, featureState).archive + ): File = writeBundleForSubmission( + destination, + prepareSubmissionContext(reproductionSteps, featureState), + ).archive - internal fun writeBundleForSubmission( - destination: File, + internal fun prepareSubmissionContext( reproductionSteps: String, featureState: List, - ): PreparedSupportDiagnosticsBundle = synchronized(lock) { + ): PreparedSupportSubmissionContext = synchronized(lock) { check(storageAvailable) { "Private diagnostic storage is unavailable." } require(featureState.size <= MAX_SUPPORT_DIAGNOSTIC_FIELDS) + PreparedSupportSubmissionContext( + sanitizedReproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank), + featureState = sanitizer.sanitizeFields(featureState), + ) + } + + internal fun writeBundleForSubmission( + destination: File, + context: PreparedSupportSubmissionContext, + ): PreparedSupportDiagnosticsBundle = synchronized(lock) { + check(storageAvailable) { "Private diagnostic storage is unavailable." } + require(context.featureState.size <= MAX_SUPPORT_DIAGNOSTIC_FIELDS) + require(context.sanitizedReproductionSteps.orEmpty().length <= MAX_SUPPORT_REPRODUCTION_STEPS_LENGTH) + 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) + }) val createdAt = nowEpochMillis().coerceAtLeast(0L) discardedHistoryBytes += pruneEvents(createdAt) if (discardedHistoryBytes > 0L) persistHistory() val snapshot = visibleEvents() - val sanitizedReproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank) val report = SupportBundleReport( createdAtEpochMillis = createdAt, environment = environment.safeForReport(), - reproductionSteps = sanitizedReproductionSteps, + 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") { @@ -267,7 +286,7 @@ class JvmSupportDiagnostics( "The bounded diagnostic report is unexpectedly large." } writeZipAtomically(destination, completeContent, createdAt) - PreparedSupportDiagnosticsBundle(destination, sanitizedReproductionSteps) + PreparedSupportDiagnosticsBundle(destination, context.sanitizedReproductionSteps) } private fun loadHistory() { @@ -408,6 +427,12 @@ internal data class PreparedSupportDiagnosticsBundle( val sanitizedReproductionSteps: String?, ) +@Serializable +internal data class PreparedSupportSubmissionContext( + val sanitizedReproductionSteps: String?, + val featureState: List, +) + fun Throwable.toSupportDiagnosticExceptionDraft( depth: Int = 0, ): SupportDiagnosticExceptionDraft = SupportDiagnosticExceptionDraft( diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 240f47320..daeddda64 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -4,7 +4,10 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.nio.charset.StandardCharsets +import java.nio.file.Files import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission import java.security.SecureRandom import java.time.Instant import java.time.ZonedDateTime @@ -60,16 +63,20 @@ class JvmSupportIntake( private val shutdownRequested = AtomicBoolean(false) private val operationActive = AtomicBoolean(false) private val lock = Any() + private val persistenceLock = Any() private var pending: PendingSubmission? = null init { - val restored = restorePendingSubmission() - pruneTemporaryReports(restored?.archive) + val storageReady = runCatching { preparePrivateStorage() }.isSuccess + val restored = if (storageReady) restorePendingSubmission() else null + if (storageReady) pruneTemporaryReports(restored?.archive) restored?.let { pending = restored state.value = SupportDiagnosticsSubmissionState.RetryableFailure( if (restored.cancellationPending) { "Cancellation was interrupted. Retry safely to reconcile and delete the private report." + } else if (restored.archive == null) { + "Private report preparation was interrupted. You can retry it safely." } else { "A private support submission was interrupted. You can retry it safely." }, @@ -96,16 +103,9 @@ class JvmSupportIntake( return@withContext } cancellationRequested.set(false) - state.value = SupportDiagnosticsSubmissionState.Packaging - val prepared = try { - require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) { - "Could not prepare private support submission storage." - } - diagnostics.writeBundleForSubmission( - destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip"), - reproductionSteps = reproductionSteps, - featureState = featureState, - ) + val context = try { + preparePrivateStorage() + diagnostics.prepareSubmissionContext(reproductionSteps, featureState) } catch (cancellation: CancellationException) { state.value = SupportDiagnosticsSubmissionState.Cancelled throw cancellation @@ -117,10 +117,10 @@ class JvmSupportIntake( return@withContext } val submission = PendingSubmission( - archive = prepared.archive, + archive = null, metadata = SupportIntakeMetadata( title = "Nextcloud Native diagnostic report", - description = prepared.sanitizedReproductionSteps.toSupportIntakeDescription(), + description = context.sanitizedReproductionSteps.toSupportIntakeDescription(), release = environment.safeForReport().let { safe -> SupportIntakeRelease( version = safe.appVersion.filterSupportMetadata(80), @@ -134,12 +134,14 @@ class JvmSupportIntake( idempotencyKey = secureIdempotencyKey(), createdAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L), 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 { operationActive.set(false) @@ -150,7 +152,7 @@ class JvmSupportIntake( if (!operationActive.compareAndSet(false, true)) return@withContext try { val submission = synchronized(lock) { pending } - if (submission == null || !submission.archive.isFile) { + if (submission == null) { state.value = SupportDiagnosticsSubmissionState.Rejected( "There is no private support submission available to retry.", ) @@ -162,10 +164,15 @@ class JvmSupportIntake( } if (submission.cancellationPending) { cancellationRequested.set(true) - reconcileAfterAmbiguousResult( - submission, - IOException("Cancellation still needs to be reconciled."), - ) + 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 @@ -182,6 +189,11 @@ class JvmSupportIntake( 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 { operationActive.set(false) @@ -189,10 +201,10 @@ class JvmSupportIntake( } fun cancel(): Boolean { + cancellationRequested.set(true) val submission = synchronized(lock) { pending } if (submission != null) { if (!submission.outcomeAmbiguous && activeCall.get() == null) { - cancellationRequested.set(true) finishCancelled(submission) return true } @@ -200,6 +212,7 @@ class JvmSupportIntake( submission.outcomeAmbiguous = true if (!persistPendingSafely(submission)) { submission.cancellationPending = false + cancellationRequested.compareAndSet(true, false) state.value = SupportDiagnosticsSubmissionState.RetryableFailure( "Cancellation could not be retained safely. The current submission was left unchanged.", outcomeAmbiguous = true, @@ -207,7 +220,6 @@ class JvmSupportIntake( return false } } - cancellationRequested.set(true) val call = activeCall.getAndSet(null) if (call != null) { call.cancel() @@ -224,6 +236,7 @@ class JvmSupportIntake( state.value = SupportDiagnosticsSubmissionState.Cancelled return true } + cancellationRequested.compareAndSet(true, false) return false } @@ -232,6 +245,57 @@ class JvmSupportIntake( activeCall.getAndSet(null)?.cancel() } + private suspend fun packageSubmission(submission: PendingSubmission): Boolean { + if (cancellationRequested.get()) { + finishCancelled(submission) + return false + } + state.value = SupportDiagnosticsSubmissionState.Packaging + val destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip") + val prepared = try { + diagnostics.writeBundleForSubmission(destination, submission.context) + } catch (cancellation: CancellationException) { + retainForRetry( + submission, + "Private report preparation was interrupted. You can retry it safely.", + ambiguous = false, + ) + throw cancellation + } catch (_: Throwable) { + retainForRetry( + submission, + "The private diagnostic report could not be prepared. You can retry safely.", + ambiguous = false, + ) + return false + } + try { + restrictOwnerOnlyFile(prepared.archive) + } catch (_: Throwable) { + prepared.archive.delete() + 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 }) { + prepared.archive.delete() + 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 fun upload(submission: PendingSubmission) { if (cancellationRequested.get()) { finishCancelled(submission) @@ -242,9 +306,11 @@ class JvmSupportIntake( 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 = submission.archive.asRequestBody(SUPPORT_ARCHIVE_MEDIA_TYPE), + delegate = archive.asRequestBody(SUPPORT_ARCHIVE_MEDIA_TYPE), onProgress = { uploaded, total -> if (!cancellationRequested.get()) { state.value = SupportDiagnosticsSubmissionState.Uploading( @@ -352,7 +418,16 @@ class JvmSupportIntake( } private fun finishReceived(submission: PendingSubmission, receipt: SupportIntakeReceipt) { - if (cancellationRequested.get()) { + val submitReceivedReport = synchronized(lock) { + if (pending !== submission) return + if (cancellationRequested.get()) { + false + } else { + pending = null + true + } + } + if (!submitReceivedReport) { deleteCancelledReceipt(submission, receipt) } else { finishSubmitted(submission, receipt) @@ -370,6 +445,13 @@ class JvmSupportIntake( deletionUrl.encodedQuery == null && deletionUrl.fragment == null, ) + submission.cancellationPending = true + submission.outcomeAmbiguous = true + submission.receipt = receipt + if (!persistPendingSafely(submission)) { + finishSubmitted(submission, receipt) + return + } val capability = statusUrl.pathSegments.last() val request = Request.Builder() .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) @@ -384,11 +466,19 @@ class JvmSupportIntake( if (response.isSuccessful || response.code == 404) { finishCancelled(submission) } else { - finishSubmitted(submission, receipt) + retainCancellationForRetry( + submission, + receipt, + "Deletion could not be confirmed. Retry safely to delete the private report.", + ) } } } catch (_: IOException) { - finishSubmitted(submission, receipt) + retainCancellationForRetry( + submission, + receipt, + "Deletion could not be confirmed. Check your connection, then retry safely.", + ) } finally { activeCall.compareAndSet(call, null) } @@ -429,8 +519,10 @@ class JvmSupportIntake( synchronized(lock) { if (pending === submission) pending = null } - submission.archive.delete() - pendingDescriptor().delete() + submission.archive?.delete() + synchronized(persistenceLock) { + pendingDescriptor().delete() + } } private fun finishRejected(submission: PendingSubmission, message: String) { @@ -459,8 +551,26 @@ class JvmSupportIntake( } } - private fun persistPendingSafely(submission: PendingSubmission): Boolean = + 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)) { + state.value = SupportDiagnosticsSubmissionState.RetryableFailure(message, outcomeAmbiguous = true) + } else { + finishSubmitted(submission, receipt) + } + } + + 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) @@ -488,22 +598,54 @@ class JvmSupportIntake( .forEach(File::delete) } + 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) - require(parent.isDirectory || parent.mkdirs()) - val temporary = File(parent, ".pending-${UUID.randomUUID()}.tmp") + preparePrivateStorage() + val temporary = Files.createTempFile(parent.toPath(), ".pending-", ".tmp").toFile() try { + restrictOwnerOnlyFile(temporary) val encoded = json.encodeToString( PersistedPendingSubmission.serializer(), PersistedPendingSubmission( - archiveName = submission.archive.name, + archiveName = submission.archive?.name, metadata = submission.metadata, idempotencyKey = submission.idempotencyKey, createdAtEpochMillis = submission.createdAtEpochMillis, + context = submission.context, cancellationPending = submission.cancellationPending, outcomeAmbiguous = submission.outcomeAmbiguous, retryNotBeforeEpochMillis = submission.retryNotBeforeEpochMillis, + receipt = submission.receipt, ), ).encodeToByteArray() FileOutputStream(temporary).use { output -> @@ -511,19 +653,20 @@ class JvmSupportIntake( output.fd.sync() } runCatching { - java.nio.file.Files.move( + Files.move( temporary.toPath(), descriptor.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING, ) }.recoverCatching { - java.nio.file.Files.move( + Files.move( temporary.toPath(), descriptor.toPath(), StandardCopyOption.REPLACE_EXISTING, ) }.getOrThrow() + restrictOwnerOnlyFile(descriptor) } finally { temporary.delete() } @@ -537,24 +680,34 @@ class JvmSupportIntake( PersistedPendingSubmission.serializer(), descriptor.readText(Charsets.UTF_8), ) - require(persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) + require(persisted.archiveName == null || persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) require(System.currentTimeMillis() - persisted.createdAtEpochMillis in 0L..SUPPORT_TEMPORARY_MAX_AGE_MILLIS) require( persisted.retryNotBeforeEpochMillis == null || persisted.retryNotBeforeEpochMillis <= System.currentTimeMillis() + MAX_SUPPORT_RETRY_AFTER_MILLIS, ) - val archive = File(temporaryRoot, persisted.archiveName).absoluteFile.normalize() - require(archive.parentFile == temporaryRoot.absoluteFile.normalize()) - require(archive.isFile && archive.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + persisted.receipt?.let { receipt -> + require(persisted.cancellationPending) + validateReceipt(receipt) + } + val archive = persisted.archiveName?.let { archiveName -> + File(temporaryRoot, archiveName).absoluteFile.normalize().also { candidate -> + require(candidate.parentFile == temporaryRoot.absoluteFile.normalize()) + require(candidate.isFile && candidate.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + restrictOwnerOnlyFile(candidate) + } + } PendingSubmission( archive = archive, metadata = persisted.metadata, idempotencyKey = persisted.idempotencyKey, createdAtEpochMillis = persisted.createdAtEpochMillis, + context = persisted.context, cancellationPending = persisted.cancellationPending, outcomeAmbiguous = persisted.outcomeAmbiguous, retryNotBeforeEpochMillis = persisted.retryNotBeforeEpochMillis, + receipt = persisted.receipt, ) }.getOrElse { pendingDescriptor().delete() @@ -564,24 +717,28 @@ class JvmSupportIntake( private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) private data class PendingSubmission( - val archive: File, + var archive: File?, val metadata: SupportIntakeMetadata, val idempotencyKey: String, val createdAtEpochMillis: Long, + val context: PreparedSupportSubmissionContext, var cancellationPending: Boolean = false, var outcomeAmbiguous: Boolean = false, var retryNotBeforeEpochMillis: Long? = null, + var receipt: SupportIntakeReceipt? = null, ) @Serializable private data class PersistedPendingSubmission( - val archiveName: String, + val archiveName: String?, val metadata: SupportIntakeMetadata, val idempotencyKey: String, val createdAtEpochMillis: Long, + val context: PreparedSupportSubmissionContext, val cancellationPending: Boolean = false, val outcomeAmbiguous: Boolean = true, val retryNotBeforeEpochMillis: Long? = null, + val receipt: SupportIntakeReceipt? = null, ) } @@ -675,7 +832,7 @@ private val SUPPORT_CODE_PATTERN = Regex("OBI-[A-HJ-KM-NP-Z2-9]{5}-[A-HJ-KM-NP-Z 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-[0-9a-f-]{36}\\.tmp") +private val SUPPORT_PENDING_TEMPORARY_FILE_PATTERN = Regex("\\.pending-[A-Za-z0-9._-]+\\.tmp") private val SUPPORT_IDEMPOTENCY_PATTERN = Regex("[A-Za-z0-9_-]{43}") private val RETRYABLE_CLIENT_STATUS_CODES = setOf(425, 429) private const val MAX_SUPPORT_INTAKE_MESSAGE_LENGTH = 240 From 1fe254a5abdd427637968da723adad48778f343c Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:13:15 +0000 Subject: [PATCH 06/29] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 9bd038ac4..4ddc6c955 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -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": "dc6ed759b1e0fee2a6e190a9b5c20c06682eb95a534a988f718c153a03cdf10e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "846b01b30231074af032e2a8054b9f36f1a62cb8340de852be469f641c6397ea", "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", From 964ef47050079b47ac6a2fa25a5176a3165a89d0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 23:13:44 +0200 Subject: [PATCH 07/29] fix(support): preserve confirmed report recovery --- .../nextcloudnative/app/NextcloudNativeApp.kt | 29 +++- .../app/JvmSupportDiagnosticsTest.kt | 26 ++++ .../app/JvmSupportIntakeTest.kt | 138 ++++++++++++++++-- .../app/JvmSupportDiagnostics.kt | 17 ++- .../nextcloudnative/app/JvmSupportIntake.kt | 92 ++++++++++-- 5 files changed, 270 insertions(+), 32 deletions(-) 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 d1f846812..1ba3b7e81 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12506,11 +12506,12 @@ 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) } val submissionState by remember(services) { services.supportDiagnosticsSubmissionStates() @@ -12582,6 +12583,30 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) ) } + 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 + services.cancelSupportDiagnosticsSubmission() + }, + ) { Text("Discard report") } + }, + dismissButton = { + TextButton(onClick = { confirmDiscard = false }) { Text("Keep report") } + }, + ) + } + Surface( modifier = Modifier.fillMaxWidth(), color = NextcloudTheme.colors.appTile, @@ -12792,7 +12817,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) OutlinedButton(onClick = { scope.launch { services.retrySupportDiagnosticsSubmission() } }) { Text("Retry safely") } - TextButton(onClick = { services.cancelSupportDiagnosticsSubmission() }) { + TextButton(onClick = { confirmDiscard = true }) { Text("Discard pending report") } } 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 index 32c3ef1fd..628169f04 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -4,6 +4,8 @@ import java.io.File 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.TimeUnit import kotlin.io.path.createTempDirectory @@ -129,6 +131,32 @@ class JvmSupportIntakeTest { } } + @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 cancellationReconcilesAndDeletesReceiptAcceptedDuringUpload() = runBlocking { testFixture().use { fixture -> @@ -322,12 +350,20 @@ class JvmSupportIntakeTest { 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")) @@ -368,6 +404,78 @@ class JvmSupportIntakeTest { } } + @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 restrictsPendingSubmissionFilesToTheCurrentUnixUser() = runBlocking { testFixture().use { fixture -> @@ -429,19 +537,23 @@ class JvmSupportIntakeTest { ) } - private fun receiptResponse(statusUrl: String): MockResponse = MockResponse.Builder().code(201).body( - """ - { - "contractVersion": 1, - "supportCode": "OBI-ABCDE-23456", - "status": "new", - "statusUrl": "$statusUrl", - "deletionUrl": "$statusUrl", - "createdAt": "2026-08-13T12:00:00Z", - "retentionUntil": "2026-09-12T12:00:00Z" - } - """.trimIndent(), - ).build() + private fun receiptResponse(statusUrl: String): MockResponse { + val createdAt = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val retentionUntil = createdAt.plus(30, ChronoUnit.DAYS) + return MockResponse.Builder().code(201).body( + """ + { + "contractVersion": 1, + "supportCode": "OBI-ABCDE-23456", + "status": "new", + "statusUrl": "$statusUrl", + "deletionUrl": "$statusUrl", + "createdAt": "$createdAt", + "retentionUntil": "$retentionUntil" + } + """.trimIndent(), + ).build() + } private data class Fixture( val root: File, 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 8485751f0..50d2df95d 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt @@ -227,9 +227,14 @@ class JvmSupportDiagnostics( ): PreparedSupportSubmissionContext = synchronized(lock) { check(storageAvailable) { "Private diagnostic storage is unavailable." } require(featureState.size <= MAX_SUPPORT_DIAGNOSTIC_FIELDS) + val confirmedAtEpochMillis = nowEpochMillis().coerceAtLeast(0L) + discardedHistoryBytes += pruneEvents(confirmedAtEpochMillis) + if (discardedHistoryBytes > 0L) persistHistory() PreparedSupportSubmissionContext( sanitizedReproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank), featureState = sanitizer.sanitizeFields(featureState), + confirmedAtEpochMillis = confirmedAtEpochMillis, + events = visibleEvents(), ) } @@ -237,18 +242,18 @@ class JvmSupportDiagnostics( destination: File, context: PreparedSupportSubmissionContext, ): PreparedSupportDiagnosticsBundle = synchronized(lock) { - check(storageAvailable) { "Private diagnostic storage is unavailable." } 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) }) - val createdAt = nowEpochMillis().coerceAtLeast(0L) - discardedHistoryBytes += pruneEvents(createdAt) - if (discardedHistoryBytes > 0L) persistHistory() - val snapshot = visibleEvents() + 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(), @@ -431,6 +436,8 @@ internal data class PreparedSupportDiagnosticsBundle( internal data class PreparedSupportSubmissionContext( val sanitizedReproductionSteps: String?, val featureState: List, + val confirmedAtEpochMillis: Long, + val events: List, ) fun Throwable.toSupportDiagnosticExceptionDraft( diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index daeddda64..b14fb68b5 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -158,8 +158,8 @@ class JvmSupportIntake( ) return@withContext } - if (System.currentTimeMillis() - submission.createdAtEpochMillis !in 0L..SUPPORT_TEMPORARY_MAX_AGE_MILLIS) { - finishRejected(submission, "The pending private report expired and was removed from this device.") + if (submission.recoveryExpired(System.currentTimeMillis())) { + finishRejected(submission, "The private report recovery capability expired and was removed from this device.") return@withContext } if (submission.cancellationPending) { @@ -183,6 +183,13 @@ class JvmSupportIntake( ) 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)) { @@ -352,7 +359,7 @@ class JvmSupportIntake( else -> retainForRetry( submission, "Obiente Support is temporarily unavailable.", - ambiguous = response.code in 300..399, + ambiguous = response.code in 300..399 || response.code in 500..599, retryNotBeforeEpochMillis = response.retryNotBeforeEpochMillis(), ) } @@ -463,13 +470,54 @@ class JvmSupportIntake( try { call.execute().use { response -> response.readBoundedText() - if (response.isSuccessful || response.code == 404) { + 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) + activeCall.set(call) + try { + call.execute().use { response -> + response.readBoundedText() + if (response.code == 404) { finishCancelled(submission) } else { retainCancellationForRetry( submission, receipt, - "Deletion could not be confirmed. Retry safely to delete the private report.", + "Deletion is still being processed. Retry safely to verify the private report was removed.", ) } } @@ -477,7 +525,7 @@ class JvmSupportIntake( retainCancellationForRetry( submission, receipt, - "Deletion could not be confirmed. Check your connection, then retry safely.", + "Deletion was accepted but could not be verified. Check your connection, then retry safely.", ) } finally { activeCall.compareAndSet(call, null) @@ -682,7 +730,8 @@ class JvmSupportIntake( ) require(persisted.archiveName == null || persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) - require(System.currentTimeMillis() - persisted.createdAtEpochMillis in 0L..SUPPORT_TEMPORARY_MAX_AGE_MILLIS) + val nowEpochMillis = System.currentTimeMillis() + require(nowEpochMillis >= persisted.createdAtEpochMillis) require( persisted.retryNotBeforeEpochMillis == null || persisted.retryNotBeforeEpochMillis <= System.currentTimeMillis() + MAX_SUPPORT_RETRY_AFTER_MILLIS, @@ -691,13 +740,22 @@ class JvmSupportIntake( require(persisted.cancellationPending) validateReceipt(receipt) } + val recoveryDeadlineEpochMillis = persisted.receipt + ?.let { receipt -> Instant.parse(receipt.retentionUntil).toEpochMilli() } + ?: persisted.createdAtEpochMillis + SUPPORT_RECOVERY_MAX_AGE_MILLIS + require(nowEpochMillis <= recoveryDeadlineEpochMillis) + val archiveIsRetained = nowEpochMillis - persisted.createdAtEpochMillis <= SUPPORT_TEMPORARY_MAX_AGE_MILLIS val archive = persisted.archiveName?.let { archiveName -> File(temporaryRoot, archiveName).absoluteFile.normalize().also { candidate -> require(candidate.parentFile == temporaryRoot.absoluteFile.normalize()) - require(candidate.isFile && candidate.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) - restrictOwnerOnlyFile(candidate) + if (archiveIsRetained) { + require(candidate.isFile && candidate.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + restrictOwnerOnlyFile(candidate) + } else { + candidate.delete() + } } - } + }?.takeIf { archiveIsRetained } PendingSubmission( archive = archive, metadata = persisted.metadata, @@ -726,7 +784,15 @@ class JvmSupportIntake( var outcomeAmbiguous: Boolean = false, var retryNotBeforeEpochMillis: Long? = null, var receipt: SupportIntakeReceipt? = null, - ) + ) { + fun recoveryExpired(nowEpochMillis: Long): Boolean { + if (nowEpochMillis < createdAtEpochMillis) return true + val deadline = receipt + ?.let { value -> runCatching { Instant.parse(value.retentionUntil).toEpochMilli() }.getOrNull() } + ?: (createdAtEpochMillis + SUPPORT_RECOVERY_MAX_AGE_MILLIS) + return nowEpochMillis > deadline + } + } @Serializable private data class PersistedPendingSubmission( @@ -835,13 +901,15 @@ private val SUPPORT_TEMPORARY_FILE_PATTERN = Regex("support-[0-9a-f-]{36}\\.zip" private val SUPPORT_PENDING_TEMPORARY_FILE_PATTERN = Regex("\\.pending-[A-Za-z0-9._-]+\\.tmp") private val SUPPORT_IDEMPOTENCY_PATTERN = Regex("[A-Za-z0-9_-]{43}") 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 = 32L * 1024L +private const val MAX_PENDING_DESCRIPTOR_BYTES = 4L * 1024L * 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 MAX_SUPPORT_RETRY_AFTER_SECONDS = 5L * 60L private const val MAX_SUPPORT_RETRY_AFTER_MILLIS = MAX_SUPPORT_RETRY_AFTER_SECONDS * 1_000L From cefea00932b82f93b2e5ee04a040b82a38605c4a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 13 Aug 2026 23:44:22 +0200 Subject: [PATCH 08/29] fix(support): coordinate durable cancellation --- .../AndroidNextcloudServices.kt | 10 +-- .../AndroidSupportDiagnostics.kt | 28 +++++++ .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +- .../nextcloudnative/app/NextcloudPlatform.kt | 2 +- .../app/DesktopNextcloudServices.kt | 2 +- .../app/JvmSupportIntakeTest.kt | 83 ++++++++++++++++++- .../nextcloudnative/app/JvmSupportIntake.kt | 32 ++++--- 7 files changed, 142 insertions(+), 21 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 0fe9e7723..490abe297 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -113,7 +113,6 @@ import dev.obiente.nextcloudnative.app.JvmNetworkRequestAttempt import dev.obiente.nextcloudnative.app.JvmNetworkFailureDiagnostic import dev.obiente.nextcloudnative.app.JvmNetworkFailurePhase import dev.obiente.nextcloudnative.app.JvmNetworkResponseTruncatedIOException -import dev.obiente.nextcloudnative.app.JvmSupportIntake import dev.obiente.nextcloudnative.app.isReadOnlyJvmNetworkMethod import dev.obiente.nextcloudnative.app.isJvmLocalUploadSourceFailure import dev.obiente.nextcloudnative.app.requireExactJvmNetworkResponseBytes @@ -417,11 +416,10 @@ internal class AndroidNextcloudServices( activity = activity, diagnostics = supportDiagnostics, ) - private val supportIntake = JvmSupportIntake( + private val supportIntake = AndroidSupportIntakeCoordinator.get( + context = appContext, diagnostics = supportDiagnostics, - temporaryRoot = File(appContext.noBackupFilesDir, "support-submissions"), - environment = androidSupportDiagnosticsEnvironment(), - client = httpClient.newBuilder().retryOnConnectionFailure(false).build(), + client = httpClient, ) init { @@ -635,7 +633,7 @@ internal class AndroidNextcloudServices( override suspend fun retrySupportDiagnosticsSubmission() = supportIntake.retry() - override fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() + override suspend fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() private fun supportDiagnosticFeatureState(): List = listOf( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt index 4c0f86cd0..c768e2698 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) @@ -68,6 +70,32 @@ internal object AndroidSupportDiagnostics { } } +/** + * 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(), + ).also { instance = it } + } +} + internal fun androidSupportDiagnosticsEnvironment(): SupportDiagnosticsEnvironment = SupportDiagnosticsEnvironment( appVersion = BuildConfig.VERSION_NAME, 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 1ba3b7e81..9f7e1baef 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12597,7 +12597,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), onClick = { confirmDiscard = false - services.cancelSupportDiagnosticsSubmission() + scope.launch { services.cancelSupportDiagnosticsSubmission() } }, ) { Text("Discard report") } }, @@ -12771,7 +12771,9 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) Text(if (exporting) "Preparing..." else "Save a copy") } if (submissionBusy) { - OutlinedButton(onClick = { services.cancelSupportDiagnosticsSubmission() }) { + OutlinedButton(onClick = { + scope.launch { services.cancelSupportDiagnosticsSubmission() } + }) { Text("Cancel sending") } } 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 1a9513a79..1f7c266eb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -532,7 +532,7 @@ interface NextcloudPlatformServices { suspend fun retrySupportDiagnosticsSubmission() = Unit /** Cancels packaging or upload and removes its app-private temporary archive. */ - fun cancelSupportDiagnosticsSubmission(): Boolean = false + suspend fun cancelSupportDiagnosticsSubmission(): Boolean = false /** Clears only diagnostic history. The private alias key remains stable across reports. */ suspend fun clearSupportDiagnostics(): Boolean = false 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 d61d8bc39..4611b500a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3382,7 +3382,7 @@ class DesktopNextcloudServices( override suspend fun retrySupportDiagnosticsSubmission() = supportIntake.retry() - override fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() + override suspend fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel() private fun supportDiagnosticFeatureState(): List = listOf( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 628169f04..37c49dfde 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -476,6 +476,87 @@ class JvmSupportIntakeTest { } } + @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 restrictsPendingSubmissionFilesToTheCurrentUnixUser() = runBlocking { testFixture().use { fixture -> @@ -574,7 +655,7 @@ class JvmSupportIntakeTest { ) override fun close() { - intake.cancel() + intake.close() diagnostics.close() server.close() root.deleteRecursively() diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index b14fb68b5..0cd85f14e 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -207,8 +207,14 @@ class JvmSupportIntake( } } - fun cancel(): Boolean { + suspend fun cancel(): Boolean { + // Publish the user's intent before leaving the caller's context so a racing upload response + // cannot be finalized as submitted while durable cancellation state is being written. cancellationRequested.set(true) + return withContext(Dispatchers.IO) { cancelAfterIntentPublished() } + } + + private fun cancelAfterIntentPublished(): Boolean { val submission = synchronized(lock) { pending } if (submission != null) { if (!submission.outcomeAmbiguous && activeCall.get() == null) { @@ -218,10 +224,8 @@ class JvmSupportIntake( submission.cancellationPending = true submission.outcomeAmbiguous = true if (!persistPendingSafely(submission)) { - submission.cancellationPending = false - cancellationRequested.compareAndSet(true, false) state.value = SupportDiagnosticsSubmissionState.RetryableFailure( - "Cancellation could not be retained safely. The current submission was left unchanged.", + "Cancellation could not be stored safely. Keep the app open and retry to reconcile the private report.", outcomeAmbiguous = true, ) return false @@ -611,7 +615,12 @@ class JvmSupportIntake( if (persistPendingSafely(submission)) { state.value = SupportDiagnosticsSubmissionState.RetryableFailure(message, outcomeAmbiguous = true) } else { - finishSubmitted(submission, receipt) + // The receipt was persisted before deletion began. Atomic replacement leaves that last + // valid recovery record in place when this newer retry-state write fails. + state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + "Deletion was not confirmed and its updated retry state could not be stored. Keep the app open and retry.", + outcomeAmbiguous = true, + ) } } @@ -730,8 +739,8 @@ class JvmSupportIntake( ) require(persisted.archiveName == null || persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) + require(persisted.createdAtEpochMillis >= 0L) val nowEpochMillis = System.currentTimeMillis() - require(nowEpochMillis >= persisted.createdAtEpochMillis) require( persisted.retryNotBeforeEpochMillis == null || persisted.retryNotBeforeEpochMillis <= System.currentTimeMillis() + MAX_SUPPORT_RETRY_AFTER_MILLIS, @@ -742,9 +751,10 @@ class JvmSupportIntake( } val recoveryDeadlineEpochMillis = persisted.receipt ?.let { receipt -> Instant.parse(receipt.retentionUntil).toEpochMilli() } - ?: persisted.createdAtEpochMillis + SUPPORT_RECOVERY_MAX_AGE_MILLIS + ?: persisted.createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) require(nowEpochMillis <= recoveryDeadlineEpochMillis) - val archiveIsRetained = nowEpochMillis - persisted.createdAtEpochMillis <= SUPPORT_TEMPORARY_MAX_AGE_MILLIS + val archiveAgeMillis = (nowEpochMillis - persisted.createdAtEpochMillis).coerceAtLeast(0L) + val archiveIsRetained = archiveAgeMillis <= SUPPORT_TEMPORARY_MAX_AGE_MILLIS val archive = persisted.archiveName?.let { archiveName -> File(temporaryRoot, archiveName).absoluteFile.normalize().also { candidate -> require(candidate.parentFile == temporaryRoot.absoluteFile.normalize()) @@ -786,10 +796,9 @@ class JvmSupportIntake( var receipt: SupportIntakeReceipt? = null, ) { fun recoveryExpired(nowEpochMillis: Long): Boolean { - if (nowEpochMillis < createdAtEpochMillis) return true val deadline = receipt ?.let { value -> runCatching { Instant.parse(value.retentionUntil).toEpochMilli() }.getOrNull() } - ?: (createdAtEpochMillis + SUPPORT_RECOVERY_MAX_AGE_MILLIS) + ?: createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) return nowEpochMillis > deadline } } @@ -808,6 +817,9 @@ class JvmSupportIntake( ) } +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, From 5c1abbdd00447be1adc0552bf06af74a0658497f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 01:30:22 +0200 Subject: [PATCH 09/29] fix(support): scope pending report lifecycle --- .../AndroidNextcloudServices.kt | 9 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 16 +- .../nextcloudnative/app/SupportDiagnostics.kt | 1 + .../app/DesktopNextcloudServices.kt | 9 +- .../app/JvmSupportIntakeTest.kt | 47 ++++- .../nextcloudnative/app/JvmSupportIntake.kt | 186 +++++++++++++----- 6 files changed, 210 insertions(+), 58 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 490abe297..61719b81e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -746,7 +746,9 @@ internal class AndroidNextcloudServices( accountIdOf = NextcloudDocumentIds::accountKey, )?.also { session -> registerSessionPrivateValues(session) - supportDiagnostics.setActiveAccountIdentity(NextcloudDocumentIds.accountKey(session)) + val accountIdentity = NextcloudDocumentIds.accountKey(session) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) } } @@ -786,7 +788,9 @@ internal class AndroidNextcloudServices( if (previousAccountId != null && previousAccountId != replacementAccountId) { nativeMediaPreviewCache.clearAccount(previousAccountId) } - supportDiagnostics.setActiveAccountIdentity(NextcloudDocumentIds.accountKey(session)) + val accountIdentity = NextcloudDocumentIds.accountKey(session) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) notifyDocumentsRootsChanged() } @@ -827,6 +831,7 @@ internal class AndroidNextcloudServices( accountId?.let(nativeMediaPreviewCache::clearAccount) notifyDocumentsRootsChanged() supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) } catch (failure: Throwable) { recordSupportDiagnostic( SupportDiagnosticEventDraft( 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 9f7e1baef..2ff07e10c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12515,11 +12515,16 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) var showPreview by rememberSaveable { mutableStateOf(false) } val submissionState by remember(services) { services.supportDiagnosticsSubmissionStates() - }.collectAsState(SupportDiagnosticsSubmissionState.Idle) - val submissionBusy = submissionState is SupportDiagnosticsSubmissionState.Packaging || + }.collectAsState(SupportDiagnosticsSubmissionState.Initializing) + val submissionBusy = submissionState is SupportDiagnosticsSubmissionState.Initializing || + submissionState is SupportDiagnosticsSubmissionState.Packaging || submissionState is SupportDiagnosticsSubmissionState.Uploading val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure + LaunchedEffect(submissionBusy, submissionPending) { + if (submissionBusy || submissionPending) confirmClear = false + } + if (confirmClear) { AlertDialog( onDismissRequest = { confirmClear = false }, @@ -12532,6 +12537,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) }, confirmButton = { TextButton( + enabled = !submissionBusy && !submissionPending, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), onClick = { confirmClear = false @@ -12779,7 +12785,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) } if (summary.eventCount > 0) { OutlinedButton( - enabled = !exporting && !submissionBusy, + enabled = !exporting && !submissionBusy && !submissionPending, onClick = { confirmClear = true }, ) { Text("Clear history") } } @@ -12790,6 +12796,10 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) }, ) { when (val current = submissionState) { + SupportDiagnosticsSubmissionState.Initializing -> { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Text("Restoring any pending private report...", style = MaterialTheme.typography.bodySmall) + } SupportDiagnosticsSubmissionState.Idle -> Unit SupportDiagnosticsSubmissionState.Packaging -> { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) 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 f142e103b..9ee91a556 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -185,6 +185,7 @@ sealed interface SupportDiagnosticsExportResult { } sealed interface SupportDiagnosticsSubmissionState { + data object Initializing : SupportDiagnosticsSubmissionState data object Idle : SupportDiagnosticsSubmissionState data object Packaging : SupportDiagnosticsSubmissionState data class Uploading(val progress: Float?) : SupportDiagnosticsSubmissionState { 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 4611b500a..3264ddffe 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3472,7 +3472,9 @@ class DesktopNextcloudServices( ?: return null listOf(server, login, password).forEach(supportDiagnostics::registerPrivateValue) return NextcloudSession(server, login, password).also { session -> - supportDiagnostics.setActiveAccountIdentity(desktopFileCacheAccountId(session)) + val accountIdentity = desktopFileCacheAccountId(session) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) } } @@ -3486,7 +3488,9 @@ class DesktopNextcloudServices( ) preferences.put(KEY_SERVER, session.serverUrl) preferences.put(KEY_LOGIN, session.loginName) - supportDiagnostics.setActiveAccountIdentity(desktopFileCacheAccountId(session)) + val accountIdentity = desktopFileCacheAccountId(session) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() } @@ -3623,6 +3627,7 @@ class DesktopNextcloudServices( preferences.remove(KEY_SERVER) preferences.remove(KEY_LOGIN) supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) cleared = true } finally { if (!cleared) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 37c49dfde..351d7a22d 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -110,6 +110,43 @@ class JvmSupportIntakeTest { } } + @Test + fun hidesPendingSubmissionFromAnotherLocalAccount() = 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) + + 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 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 -> @@ -652,7 +689,10 @@ class JvmSupportIntakeTest { environment = environment, client = OkHttpClient.Builder().retryOnConnectionFailure(false).build(), supportBaseUrl = server.url("/").toString(), - ) + ).also { intake -> + intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) + runBlocking { intake.awaitInitialization() } + } override fun close() { intake.close() @@ -661,4 +701,9 @@ class JvmSupportIntakeTest { 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/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 0cd85f14e..d94e7de9d 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -17,10 +17,15 @@ 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.SupervisorJob +import kotlinx.coroutines.cancel 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 @@ -57,49 +62,83 @@ class JvmSupportIntake( encodeDefaults = true ignoreUnknownKeys = false } - private val state = MutableStateFlow(SupportDiagnosticsSubmissionState.Idle) + 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 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 init { - val storageReady = runCatching { preparePrivateStorage() }.isSuccess - val restored = if (storageReady) restorePendingSubmission() else null - if (storageReady) pruneTemporaryReports(restored?.archive) - restored?.let { - pending = restored - state.value = SupportDiagnosticsSubmissionState.RetryableFailure( - if (restored.cancellationPending) { - "Cancellation was interrupted. Retry safely to reconcile and delete the private report." - } else if (restored.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 = restored.outcomeAmbiguous, - ) + scope.launch { + try { + val storageReady = runCatching { preparePrivateStorage() }.isSuccess + val restored = if (storageReady) restorePendingSubmission() else null + if (storageReady) pruneTemporaryReports(restored?.archive) + synchronized(lock) { + pending = restored + publishStateLocked( + restored?.let { + SupportDiagnosticsSubmissionState.RetryableFailure( + if (restored.cancellationPending) { + "Cancellation was interrupted. Retry safely to reconcile and delete the private report." + } else if (restored.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 = restored.outcomeAmbiguous, + ) + } ?: SupportDiagnosticsSubmissionState.Idle, + ) + } + } finally { + initialized.complete(Unit) + } } } fun states(): StateFlow = state.asStateFlow() + fun setActiveAccountIdentity(accountIdentity: String?) { + synchronized(lock) { + activeAccountIdentity = accountIdentity?.takeIf(String::isNotBlank) + publishStateLocked(actualState, actualStateAccountIdentity) + } + } + + internal suspend fun awaitInitialization() = initialized.await() + suspend fun submit( reproductionSteps: String, channel: String, featureState: List, ) = withContext(Dispatchers.IO) { + awaitInitialization() if (!operationActive.compareAndSet(false, true)) return@withContext try { val existing = synchronized(lock) { pending } if (existing != null) { - state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + 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.Rejected( + "Sign in before sending a private support report.", + )) return@withContext } cancellationRequested.set(false) @@ -107,13 +146,13 @@ class JvmSupportIntake( preparePrivateStorage() diagnostics.prepareSubmissionContext(reproductionSteps, featureState) } catch (cancellation: CancellationException) { - state.value = SupportDiagnosticsSubmissionState.Cancelled + publishState(SupportDiagnosticsSubmissionState.Cancelled) throw cancellation } catch (failure: Throwable) { - state.value = SupportDiagnosticsSubmissionState.Rejected( + publishState(SupportDiagnosticsSubmissionState.Rejected( failure.message?.take(MAX_SUPPORT_INTAKE_MESSAGE_LENGTH) ?: "The private diagnostic report could not be prepared.", - ) + )) return@withContext } val submission = PendingSubmission( @@ -133,6 +172,7 @@ class JvmSupportIntake( ), idempotencyKey = secureIdempotencyKey(), createdAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L), + originAccountIdentity = originAccountIdentity, cancellationPending = cancellationRequested.get(), context = context, ) @@ -149,13 +189,17 @@ class JvmSupportIntake( } suspend fun retry() = withContext(Dispatchers.IO) { + awaitInitialization() if (!operationActive.compareAndSet(false, true)) return@withContext try { val submission = synchronized(lock) { pending } if (submission == null) { - state.value = SupportDiagnosticsSubmissionState.Rejected( + 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())) { @@ -177,10 +221,10 @@ class JvmSupportIntake( } val waitMillis = submission.retryNotBeforeEpochMillis?.minus(System.currentTimeMillis()) ?: 0L if (waitMillis > 0L) { - state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( "Obiente Support asked the app to wait before retrying. Try again shortly.", submission.outcomeAmbiguous, - ) + )) return@withContext } if (submission.outcomeAmbiguous) { @@ -208,9 +252,19 @@ class JvmSupportIntake( } suspend fun cancel(): Boolean { - // Publish the user's intent before leaving the caller's context so a racing upload response - // cannot be finalized as submitted while durable cancellation state is being written. - cancellationRequested.set(true) + 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 accepted = synchronized(lock) { + val submission = pending + if (submission == null || !submission.belongsTo(activeAccountIdentity)) { + false + } else { + cancellationRequested.set(true) + true + } + } + if (!accepted) return false return withContext(Dispatchers.IO) { cancelAfterIntentPublished() } } @@ -224,10 +278,10 @@ class JvmSupportIntake( submission.cancellationPending = true submission.outcomeAmbiguous = true if (!persistPendingSafely(submission)) { - state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( "Cancellation could not be stored safely. Keep the app open and retry to reconcile the private report.", outcomeAmbiguous = true, - ) + )) return false } } @@ -237,14 +291,10 @@ class JvmSupportIntake( return true } if (submission != null) { - state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( "Cancellation could not be confirmed. Retry safely to reconcile and delete the private report.", outcomeAmbiguous = true, - ) - return true - } - if (state.value == SupportDiagnosticsSubmissionState.Packaging) { - state.value = SupportDiagnosticsSubmissionState.Cancelled + )) return true } cancellationRequested.compareAndSet(true, false) @@ -254,6 +304,7 @@ class JvmSupportIntake( override fun close() { shutdownRequested.set(true) activeCall.getAndSet(null)?.cancel() + scope.cancel() } private suspend fun packageSubmission(submission: PendingSubmission): Boolean { @@ -261,7 +312,7 @@ class JvmSupportIntake( finishCancelled(submission) return false } - state.value = SupportDiagnosticsSubmissionState.Packaging + publishState(SupportDiagnosticsSubmissionState.Packaging) val destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip") val prepared = try { diagnostics.writeBundleForSubmission(destination, submission.context) @@ -324,9 +375,9 @@ class JvmSupportIntake( delegate = archive.asRequestBody(SUPPORT_ARCHIVE_MEDIA_TYPE), onProgress = { uploaded, total -> if (!cancellationRequested.get()) { - state.value = SupportDiagnosticsSubmissionState.Uploading( + publishState(SupportDiagnosticsSubmissionState.Uploading( total.takeIf { it > 0L }?.let { uploaded.toFloat() / it.toFloat() }?.coerceIn(0f, 1f), - ) + )) } }, ) @@ -341,7 +392,7 @@ class JvmSupportIntake( .header("Idempotency-Key", submission.idempotencyKey) .post(body) .build() - state.value = SupportDiagnosticsSubmissionState.Uploading(0f) + publishState(SupportDiagnosticsSubmissionState.Uploading(0f)) val call = client.newCall(request) activeCall.set(call) try { @@ -434,6 +485,8 @@ class JvmSupportIntake( 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 } @@ -539,10 +592,13 @@ class JvmSupportIntake( private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { validateReceipt(receipt) finishTerminal(submission) - state.value = SupportDiagnosticsSubmissionState.Submitted( - supportCode = receipt.supportCode, - statusUrl = receipt.statusUrl, - retentionUntil = receipt.retentionUntil, + publishState( + SupportDiagnosticsSubmissionState.Submitted( + supportCode = receipt.supportCode, + statusUrl = receipt.statusUrl, + retentionUntil = receipt.retentionUntil, + ), + submission.originAccountIdentity, ) } @@ -579,12 +635,12 @@ class JvmSupportIntake( private fun finishRejected(submission: PendingSubmission, message: String) { finishTerminal(submission) - state.value = SupportDiagnosticsSubmissionState.Rejected(message) + publishState(SupportDiagnosticsSubmissionState.Rejected(message), submission.originAccountIdentity) } private fun finishCancelled(submission: PendingSubmission) { finishTerminal(submission) - state.value = SupportDiagnosticsSubmissionState.Cancelled + publishState(SupportDiagnosticsSubmissionState.Cancelled, submission.originAccountIdentity) } private fun retainForRetry( @@ -597,7 +653,7 @@ class JvmSupportIntake( submission.retryNotBeforeEpochMillis = retryNotBeforeEpochMillis synchronized(lock) { pending = submission } if (persistPendingSafely(submission)) { - state.value = SupportDiagnosticsSubmissionState.RetryableFailure(message, ambiguous) + publishState(SupportDiagnosticsSubmissionState.RetryableFailure(message, ambiguous)) } else { finishRejected(submission, "The private support submission could not be retained safely on this device.") } @@ -613,14 +669,14 @@ class JvmSupportIntake( submission.receipt = receipt synchronized(lock) { pending = submission } if (persistPendingSafely(submission)) { - state.value = SupportDiagnosticsSubmissionState.RetryableFailure(message, outcomeAmbiguous = true) + 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. - state.value = SupportDiagnosticsSubmissionState.RetryableFailure( + publishState(SupportDiagnosticsSubmissionState.RetryableFailure( "Deletion was not confirmed and its updated retry state could not be stored. Keep the app open and retry.", outcomeAmbiguous = true, - ) + )) } } @@ -698,6 +754,7 @@ class JvmSupportIntake( metadata = submission.metadata, idempotencyKey = submission.idempotencyKey, createdAtEpochMillis = submission.createdAtEpochMillis, + originAccountIdentity = submission.originAccountIdentity, context = submission.context, cancellationPending = submission.cancellationPending, outcomeAmbiguous = submission.outcomeAmbiguous, @@ -739,6 +796,7 @@ class JvmSupportIntake( ) 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) val nowEpochMillis = System.currentTimeMillis() require( @@ -771,6 +829,7 @@ class JvmSupportIntake( metadata = persisted.metadata, idempotencyKey = persisted.idempotencyKey, createdAtEpochMillis = persisted.createdAtEpochMillis, + originAccountIdentity = persisted.originAccountIdentity, context = persisted.context, cancellationPending = persisted.cancellationPending, outcomeAmbiguous = persisted.outcomeAmbiguous, @@ -789,12 +848,15 @@ class JvmSupportIntake( val metadata: SupportIntakeMetadata, val idempotencyKey: String, val createdAtEpochMillis: Long, + val originAccountIdentity: String, val context: PreparedSupportSubmissionContext, var cancellationPending: Boolean = false, var outcomeAmbiguous: Boolean = false, 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() } @@ -809,12 +871,35 @@ class JvmSupportIntake( val metadata: SupportIntakeMetadata, val idempotencyKey: String, val createdAtEpochMillis: Long, + val originAccountIdentity: String, val context: PreparedSupportSubmissionContext, val cancellationPending: Boolean = false, val outcomeAmbiguous: Boolean = true, val retryNotBeforeEpochMillis: Long? = null, val receipt: SupportIntakeReceipt? = null, ) + + 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, + ) { + actualState = next + actualStateAccountIdentity = accountIdentity + state.value = if (accountIdentity != null && accountIdentity != activeAccountIdentity) { + SupportDiagnosticsSubmissionState.Idle + } else { + next + } + } } private fun Long.saturatingAdd(increment: Long): Long = @@ -912,6 +997,7 @@ 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-[A-Za-z0-9._-]+\\.tmp") 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 From 5e4b5b65a1869672f145acb9ccda5dc8cccf310d Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:38:55 +0000 Subject: [PATCH 10/29] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index d1df850d1..57c31cd0e 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -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": "846b01b30231074af032e2a8054b9f36f1a62cb8340de852be469f641c6397ea", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "42d75532882a33747a837b1c328444a7fbc47547e19ab5281e1138138d5ea8ae", "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", From 8f2ad7c3521ab808e1bac6708b2820a9563e9a67 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 02:14:26 +0200 Subject: [PATCH 11/29] fix(support): preserve account-scoped report recovery --- .../nextcloudnative/app/NextcloudNativeApp.kt | 8 +- .../nextcloudnative/app/SupportDiagnostics.kt | 1 + .../app/JvmSupportIntakeTest.kt | 150 +++++++++++++++++- .../app/AsyncJvmSupportDiagnostics.kt | 9 ++ .../app/JvmSupportDiagnostics.kt | 25 ++- .../nextcloudnative/app/JvmSupportIntake.kt | 145 ++++++++++++++--- .../public/screenshots/capture-manifest.json | 4 +- 7 files changed, 311 insertions(+), 31 deletions(-) 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 5743f6979..6a3947634 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12521,7 +12521,8 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) val submissionBusy = submissionState is SupportDiagnosticsSubmissionState.Initializing || submissionState is SupportDiagnosticsSubmissionState.Packaging || submissionState is SupportDiagnosticsSubmissionState.Uploading - val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure + val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure || + submissionState is SupportDiagnosticsSubmissionState.BlockedByAnotherAccount LaunchedEffect(submissionBusy, submissionPending) { if (submissionBusy || submissionPending) confirmClear = false @@ -12803,6 +12804,11 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) Text("Restoring any pending private report...", style = MaterialTheme.typography.bodySmall) } 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) 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 9ee91a556..b60ebd82f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -187,6 +187,7 @@ sealed interface SupportDiagnosticsExportResult { sealed interface SupportDiagnosticsSubmissionState { data object Initializing : SupportDiagnosticsSubmissionState data object Idle : SupportDiagnosticsSubmissionState + data class BlockedByAnotherAccount(val message: String) : SupportDiagnosticsSubmissionState data object Packaging : SupportDiagnosticsSubmissionState data class Uploading(val progress: Float?) : SupportDiagnosticsSubmissionState { init { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 351d7a22d..bd4770788 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -32,7 +32,10 @@ class JvmSupportIntakeTest { val submitted = assertIs(fixture.intake.states().value) assertEquals("OBI-ABCDE-23456", submitted.supportCode) - assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + assertEquals( + setOf("completed.json"), + fixture.temporaryRoot.listFiles().orEmpty().map(File::getName).toSet(), + ) val request = fixture.server.takeRequest(2, TimeUnit.SECONDS) requireNotNull(request) assertEquals("POST", request.method) @@ -60,7 +63,10 @@ class JvmSupportIntakeTest { 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) - assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + assertEquals( + setOf("completed.json"), + fixture.temporaryRoot.listFiles().orEmpty().map(File::getName).toSet(), + ) } } @@ -106,12 +112,15 @@ class JvmSupportIntakeTest { assertIs(restored.states().value) val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) assertEquals(idempotencyKey, retry.headers["Idempotency-Key"]) - assertTrue(fixture.temporaryRoot.listFiles().orEmpty().isEmpty()) + assertEquals( + setOf("completed.json"), + fixture.temporaryRoot.listFiles().orEmpty().map(File::getName).toSet(), + ) } } @Test - fun hidesPendingSubmissionFromAnotherLocalAccount() = runBlocking { + fun exposesAccountNeutralBlockForAnotherLocalAccount() = runBlocking { testFixture().use { fixture -> fixture.server.enqueue(MockResponse.Builder().code(503).build()) @@ -121,7 +130,12 @@ class JvmSupportIntakeTest { assertTrue(File(fixture.temporaryRoot, "pending.json").isFile) fixture.intake.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) - assertIs(fixture.intake.states().value) + 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) @@ -134,6 +148,58 @@ class JvmSupportIntakeTest { } } + @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) + assertTrue(File(fixture.temporaryRoot, "completed.json").isFile) + assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) + restored.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + assertIs(restored.states().value) + Unit + } + } + @Test fun doesNotAcceptCancellationAfterReceiptCompletion() = runBlocking { testFixture().use { fixture -> @@ -594,6 +660,80 @@ class JvmSupportIntakeTest { } } + @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 restrictsPendingSubmissionFilesToTheCurrentUnixUser() = runBlocking { testFixture().use { fixture -> 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 513258b9c..0112df522 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/AsyncJvmSupportDiagnostics.kt @@ -189,6 +189,15 @@ class AsyncJvmSupportDiagnostics( .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 50d2df95d..0a7e68068 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt @@ -225,16 +225,32 @@ class JvmSupportDiagnostics( 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 confirmedAtEpochMillis = nowEpochMillis().coerceAtLeast(0L) discardedHistoryBytes += pruneEvents(confirmedAtEpochMillis) if (discardedHistoryBytes > 0L) persistHistory() - PreparedSupportSubmissionContext( + return PreparedSupportSubmissionContext( sanitizedReproductionSteps = sanitizer.sanitizeUserDescription(reproductionSteps).takeIf(String::isNotBlank), featureState = sanitizer.sanitizeFields(featureState), confirmedAtEpochMillis = confirmedAtEpochMillis, - events = visibleEvents(), + events = visibleEvents(accountScope), ) } @@ -380,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 = "" diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index d94e7de9d..a8226915e 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -83,6 +83,7 @@ class JvmSupportIntake( try { val storageReady = runCatching { preparePrivateStorage() }.isSuccess val restored = if (storageReady) restorePendingSubmission() else null + val restoredCompleted = if (storageReady) restoreCompletedSubmission() else null if (storageReady) pruneTemporaryReports(restored?.archive) synchronized(lock) { pending = restored @@ -98,7 +99,8 @@ class JvmSupportIntake( }, outcomeAmbiguous = restored.outcomeAmbiguous, ) - } ?: SupportDiagnosticsSubmissionState.Idle, + } ?: restoredCompleted?.toSubmissionState() ?: SupportDiagnosticsSubmissionState.Idle, + restored?.originAccountIdentity ?: restoredCompleted?.originAccountIdentity, ) } } finally { @@ -144,7 +146,11 @@ class JvmSupportIntake( cancellationRequested.set(false) val context = try { preparePrivateStorage() - diagnostics.prepareSubmissionContext(reproductionSteps, featureState) + diagnostics.prepareSubmissionContextForAccountIdentity( + reproductionSteps, + featureState, + originAccountIdentity, + ) } catch (cancellation: CancellationException) { publishState(SupportDiagnosticsSubmissionState.Cancelled) throw cancellation @@ -591,15 +597,20 @@ class JvmSupportIntake( private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { validateReceipt(receipt) + val completedSubmission = CompletedSubmission(submission.originAccountIdentity, 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 + } finishTerminal(submission) - publishState( - SupportDiagnosticsSubmissionState.Submitted( - supportCode = receipt.supportCode, - statusUrl = receipt.statusUrl, - retentionUntil = receipt.retentionUntil, - ), - submission.originAccountIdentity, - ) + publishState(completedSubmission.toSubmissionState(), submission.originAccountIdentity) } private fun validateReceipt(receipt: SupportIntakeReceipt): okhttp3.HttpUrl { @@ -655,7 +666,12 @@ class JvmSupportIntake( if (persistPendingSafely(submission)) { publishState(SupportDiagnosticsSubmissionState.RetryableFailure(message, ambiguous)) } else { - finishRejected(submission, "The private support submission could not be retained safely on this device.") + // 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, + )) } } @@ -786,6 +802,56 @@ class JvmSupportIntake( } } + private fun persistCompletedSafely(submission: CompletedSubmission): Boolean = synchronized(persistenceLock) { + runCatching { persistCompleted(submission) }.isSuccess + } + + private fun persistCompleted(submission: CompletedSubmission) { + val descriptor = completedDescriptor() + 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) + } finally { + temporary.delete() + } + } + private fun restorePendingSubmission(): PendingSubmission? = runCatching { val descriptor = pendingDescriptor() if (!descriptor.isFile) return@runCatching null @@ -799,10 +865,9 @@ class JvmSupportIntake( require(persisted.originAccountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) require(persisted.createdAtEpochMillis >= 0L) val nowEpochMillis = System.currentTimeMillis() - require( - persisted.retryNotBeforeEpochMillis == null || - persisted.retryNotBeforeEpochMillis <= System.currentTimeMillis() + MAX_SUPPORT_RETRY_AFTER_MILLIS, - ) + val retryNotBeforeEpochMillis = persisted.retryNotBeforeEpochMillis?.takeIf { deadline -> + deadline <= nowEpochMillis.saturatingAdd(MAX_SUPPORT_RETRY_AFTER_MILLIS) + } persisted.receipt?.let { receipt -> require(persisted.cancellationPending) validateReceipt(receipt) @@ -833,7 +898,7 @@ class JvmSupportIntake( context = persisted.context, cancellationPending = persisted.cancellationPending, outcomeAmbiguous = persisted.outcomeAmbiguous, - retryNotBeforeEpochMillis = persisted.retryNotBeforeEpochMillis, + retryNotBeforeEpochMillis = retryNotBeforeEpochMillis, receipt = persisted.receipt, ) }.getOrElse { @@ -843,6 +908,25 @@ class JvmSupportIntake( private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) + private fun restoreCompletedSubmission(): CompletedSubmission? = runCatching { + val descriptor = completedDescriptor() + if (!descriptor.isFile) return@runCatching null + require(descriptor.length() in 1L..MAX_COMPLETED_DESCRIPTOR_BYTES) + val persisted = json.decodeFromString( + PersistedCompletedSubmission.serializer(), + descriptor.readText(Charsets.UTF_8), + ) + require(persisted.originAccountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) + validateReceipt(persisted.receipt) + require(System.currentTimeMillis() <= Instant.parse(persisted.receipt.retentionUntil).toEpochMilli()) + CompletedSubmission(persisted.originAccountIdentity, persisted.receipt) + }.getOrElse { + completedDescriptor().delete() + null + } + + private fun completedDescriptor(): File = File(temporaryRoot, SUPPORT_COMPLETED_DESCRIPTOR) + private data class PendingSubmission( var archive: File?, val metadata: SupportIntakeMetadata, @@ -865,6 +949,17 @@ class JvmSupportIntake( } } + private data class CompletedSubmission( + val originAccountIdentity: String, + val receipt: SupportIntakeReceipt, + ) { + fun toSubmissionState() = SupportDiagnosticsSubmissionState.Submitted( + supportCode = receipt.supportCode, + statusUrl = receipt.statusUrl, + retentionUntil = receipt.retentionUntil, + ) + } + @Serializable private data class PersistedPendingSubmission( val archiveName: String?, @@ -879,6 +974,12 @@ class JvmSupportIntake( val receipt: SupportIntakeReceipt? = null, ) + @Serializable + private data class PersistedCompletedSubmission( + val originAccountIdentity: String, + val receipt: SupportIntakeReceipt, + ) + private fun publishState( next: SupportDiagnosticsSubmissionState, accountIdentity: String? = null, @@ -895,7 +996,13 @@ class JvmSupportIntake( actualState = next actualStateAccountIdentity = accountIdentity state.value = if (accountIdentity != null && accountIdentity != activeAccountIdentity) { - SupportDiagnosticsSubmissionState.Idle + if (pending?.originAccountIdentity == accountIdentity) { + SupportDiagnosticsSubmissionState.BlockedByAnotherAccount( + "A pending private report belongs to another signed-in account. Switch back to finish or discard it.", + ) + } else { + SupportDiagnosticsSubmissionState.Idle + } } else { next } @@ -995,7 +1102,7 @@ private val SUPPORT_CODE_PATTERN = Regex("OBI-[A-HJ-KM-NP-Z2-9]{5}-[A-HJ-KM-NP-Z 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-[A-Za-z0-9._-]+\\.tmp") +private val SUPPORT_PENDING_TEMPORARY_FILE_PATTERN = Regex("\\.(?:pending|completed)-[A-Za-z0-9._-]+\\.tmp") 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) @@ -1005,8 +1112,10 @@ 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_COMPLETED_DESCRIPTOR = "completed.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 MAX_SUPPORT_RETRY_AFTER_SECONDS = 5L * 60L diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 57c31cd0e..98ce3a26f 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "62b56dad774df2bc1f06bfff061dbb4f8fea3c391a914a95e682935bdbaf76f0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "3866a61221a9a95025c7bf94434160d959d7aafb6f3361c8fa066dae092f72d5", "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", @@ -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": "42d75532882a33747a837b1c328444a7fbc47547e19ab5281e1138138d5ea8ae", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "f0ddb1f2e55aed2e4fb7c73bccf2f8ec94008155589a4eee17238013ccafd1c5", "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", From a45733bf82fe3dc24b2a1b30b6e31f34b2a2b2f6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 02:37:01 +0200 Subject: [PATCH 12/29] fix(support): retain durable receipt capabilities --- .../app/JvmSupportIntakeTest.kt | 86 ++++++++++++++--- .../nextcloudnative/app/JvmSupportIntake.kt | 93 ++++++++++++++++--- 2 files changed, 154 insertions(+), 25 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index bd4770788..bd7764acd 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -33,8 +33,8 @@ class JvmSupportIntakeTest { val submitted = assertIs(fixture.intake.states().value) assertEquals("OBI-ABCDE-23456", submitted.supportCode) assertEquals( - setOf("completed.json"), - fixture.temporaryRoot.listFiles().orEmpty().map(File::getName).toSet(), + 1, + fixture.completedDescriptors().size, ) val request = fixture.server.takeRequest(2, TimeUnit.SECONDS) requireNotNull(request) @@ -64,8 +64,8 @@ class JvmSupportIntakeTest { assertEquals(upload.headers["Idempotency-Key"], reconcile.headers["Idempotency-Key"]) assertEquals("/api/v1/receipts", reconcile.url.encodedPath) assertEquals( - setOf("completed.json"), - fixture.temporaryRoot.listFiles().orEmpty().map(File::getName).toSet(), + 1, + fixture.completedDescriptors().size, ) } } @@ -113,8 +113,8 @@ class JvmSupportIntakeTest { val retry = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) assertEquals(idempotencyKey, retry.headers["Idempotency-Key"]) assertEquals( - setOf("completed.json"), - fixture.temporaryRoot.listFiles().orEmpty().map(File::getName).toSet(), + 1, + fixture.completedDescriptors().size, ) } } @@ -192,7 +192,7 @@ class JvmSupportIntakeTest { val submitted = assertIs(restored.states().value) assertEquals("OBI-ABCDE-23456", submitted.supportCode) assertEquals(fixture.statusUrl, submitted.statusUrl) - assertTrue(File(fixture.temporaryRoot, "completed.json").isFile) + assertEquals(1, fixture.completedDescriptors().size) assertFalse(File(fixture.temporaryRoot, "pending.json").exists()) restored.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) assertIs(restored.states().value) @@ -200,6 +200,62 @@ class JvmSupportIntakeTest { } } + @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.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(2, fixture.completedDescriptors().size) + fixture.intake.close() + val restored = fixture.newIntake() + val accountA = assertIs(restored.states().value) + assertEquals("OBI-ABCDE-23456", accountA.supportCode) + + restored.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + + val accountB = assertIs(restored.states().value) + assertEquals("OBI-FGHJK-6789A", accountB.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 doesNotAcceptCancellationAfterReceiptCompletion() = runBlocking { testFixture().use { fixture -> @@ -795,14 +851,19 @@ class JvmSupportIntakeTest { ) } - private fun receiptResponse(statusUrl: String): MockResponse { - val createdAt = Instant.now().truncatedTo(ChronoUnit.SECONDS) - val retentionUntil = createdAt.plus(30, ChronoUnit.DAYS) + private fun receiptResponse( + statusUrl: String, + supportCode: String = "OBI-ABCDE-23456", + retentionDays: Long = 30, + createdAtOffsetDays: Long = 0, + ): MockResponse { + val createdAt = Instant.now().plus(createdAtOffsetDays, ChronoUnit.DAYS).truncatedTo(ChronoUnit.SECONDS) + val retentionUntil = createdAt.plus(retentionDays, ChronoUnit.DAYS) return MockResponse.Builder().code(201).body( """ { "contractVersion": 1, - "supportCode": "OBI-ABCDE-23456", + "supportCode": "$supportCode", "status": "new", "statusUrl": "$statusUrl", "deletionUrl": "$statusUrl", @@ -823,6 +884,9 @@ class JvmSupportIntakeTest { 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, diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index a8226915e..ae63a8aee 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -3,12 +3,15 @@ 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.StandardCopyOption +import java.nio.file.StandardOpenOption 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 @@ -77,16 +80,19 @@ class JvmSupportIntake( private var actualState: SupportDiagnosticsSubmissionState = SupportDiagnosticsSubmissionState.Initializing private var actualStateAccountIdentity: String? = null private var pending: PendingSubmission? = null + private var completedSubmissions: List = emptyList() init { scope.launch { try { val storageReady = runCatching { preparePrivateStorage() }.isSuccess val restored = if (storageReady) restorePendingSubmission() else null - val restoredCompleted = if (storageReady) restoreCompletedSubmission() else null + val restoredCompleted = if (storageReady) restoreCompletedSubmissions() else emptyList() if (storageReady) pruneTemporaryReports(restored?.archive) synchronized(lock) { pending = restored + completedSubmissions = restoredCompleted + val visibleCompleted = latestCompletedFor(activeAccountIdentity) publishStateLocked( restored?.let { SupportDiagnosticsSubmissionState.RetryableFailure( @@ -99,8 +105,8 @@ class JvmSupportIntake( }, outcomeAmbiguous = restored.outcomeAmbiguous, ) - } ?: restoredCompleted?.toSubmissionState() ?: SupportDiagnosticsSubmissionState.Idle, - restored?.originAccountIdentity ?: restoredCompleted?.originAccountIdentity, + } ?: visibleCompleted?.toSubmissionState() ?: SupportDiagnosticsSubmissionState.Idle, + restored?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, ) } } finally { @@ -114,7 +120,7 @@ class JvmSupportIntake( fun setActiveAccountIdentity(accountIdentity: String?) { synchronized(lock) { activeAccountIdentity = accountIdentity?.takeIf(String::isNotBlank) - publishStateLocked(actualState, actualStateAccountIdentity) + refreshVisibleStateLocked() } } @@ -486,6 +492,7 @@ class JvmSupportIntake( } private fun finishReceived(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + validateReceipt(receipt, enforceCurrentRetentionWindow = true) val submitReceivedReport = synchronized(lock) { if (pending !== submission) return if (cancellationRequested.get()) { @@ -597,7 +604,11 @@ class JvmSupportIntake( private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { validateReceipt(receipt) - val completedSubmission = CompletedSubmission(submission.originAccountIdentity, receipt) + val completedSubmission = CompletedSubmission( + recordId = UUID.randomUUID().toString(), + originAccountIdentity = submission.originAccountIdentity, + receipt = receipt, + ) if (!persistCompletedSafely(completedSubmission)) { synchronized(lock) { pending = submission } publishState( @@ -609,11 +620,15 @@ class JvmSupportIntake( ) return } + synchronized(lock) { completedSubmissions = completedSubmissions + completedSubmission } finishTerminal(submission) publishState(completedSubmission.toSubmissionState(), submission.originAccountIdentity) } - private fun validateReceipt(receipt: SupportIntakeReceipt): okhttp3.HttpUrl { + 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)) @@ -622,6 +637,19 @@ class JvmSupportIntake( 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.plusMillis(SUPPORT_SERVER_RETENTION_MAX_AGE_MILLIS + SUPPORT_RECEIPT_CLOCK_SKEW_MILLIS), + ), + ) + } val statusUrl = receipt.statusUrl.toHttpUrl() require( statusUrl.scheme == baseUrl.scheme && @@ -797,6 +825,7 @@ class JvmSupportIntake( ) }.getOrThrow() restrictOwnerOnlyFile(descriptor) + syncDirectoryEntry(parent) } finally { temporary.delete() } @@ -807,7 +836,7 @@ class JvmSupportIntake( } private fun persistCompleted(submission: CompletedSubmission) { - val descriptor = completedDescriptor() + val descriptor = completedDescriptor(submission.recordId) preparePrivateStorage() writePrivateDescriptorAtomically( descriptor, @@ -847,11 +876,19 @@ class JvmSupportIntake( ) }.getOrThrow() restrictOwnerOnlyFile(descriptor) + syncDirectoryEntry(parent) } finally { temporary.delete() } } + private fun syncDirectoryEntry(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 restorePendingSubmission(): PendingSubmission? = runCatching { val descriptor = pendingDescriptor() if (!descriptor.isFile) return@runCatching null @@ -908,10 +945,15 @@ class JvmSupportIntake( private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) - private fun restoreCompletedSubmission(): CompletedSubmission? = runCatching { - val descriptor = completedDescriptor() - if (!descriptor.isFile) return@runCatching null + private fun restoreCompletedSubmissions(): List = + temporaryRoot.listFiles().orEmpty() + .filter { descriptor -> descriptor.isFile && descriptor.name.matches(SUPPORT_COMPLETED_FILE_PATTERN) } + .mapNotNull(::restoreCompletedSubmission) + + private fun restoreCompletedSubmission(descriptor: File): CompletedSubmission? = runCatching { require(descriptor.length() in 1L..MAX_COMPLETED_DESCRIPTOR_BYTES) + val recordId = requireNotNull(SUPPORT_COMPLETED_FILE_PATTERN.matchEntire(descriptor.name)) + .groupValues[1] val persisted = json.decodeFromString( PersistedCompletedSubmission.serializer(), descriptor.readText(Charsets.UTF_8), @@ -919,13 +961,16 @@ class JvmSupportIntake( require(persisted.originAccountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) validateReceipt(persisted.receipt) require(System.currentTimeMillis() <= Instant.parse(persisted.receipt.retentionUntil).toEpochMilli()) - CompletedSubmission(persisted.originAccountIdentity, persisted.receipt) + CompletedSubmission(recordId, persisted.originAccountIdentity, persisted.receipt) }.getOrElse { - completedDescriptor().delete() + descriptor.delete() null } - private fun completedDescriptor(): File = File(temporaryRoot, SUPPORT_COMPLETED_DESCRIPTOR) + 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?, @@ -950,6 +995,7 @@ class JvmSupportIntake( } private data class CompletedSubmission( + val recordId: String, val originAccountIdentity: String, val receipt: SupportIntakeReceipt, ) { @@ -1007,6 +1053,21 @@ class JvmSupportIntake( next } } + + private fun refreshVisibleStateLocked() { + val pendingSubmission = pending + when { + actualState is SupportDiagnosticsSubmissionState.Initializing -> state.value = actualState + pendingSubmission != null -> publishStateLocked(actualState, pendingSubmission.originAccountIdentity) + actualStateAccountIdentity == activeAccountIdentity -> state.value = actualState + else -> state.value = latestCompletedFor(activeAccountIdentity)?.toSubmissionState() + ?: SupportDiagnosticsSubmissionState.Idle + } + } + + private fun latestCompletedFor(accountIdentity: String?): CompletedSubmission? = + completedSubmissions.filter { it.originAccountIdentity == accountIdentity } + .maxByOrNull { Instant.parse(it.receipt.createdAt) } } private fun Long.saturatingAdd(increment: Long): Long = @@ -1103,6 +1164,9 @@ 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_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) @@ -1115,8 +1179,9 @@ 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_COMPLETED_DESCRIPTOR = "completed.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 MAX_SUPPORT_RETRY_AFTER_SECONDS = 5L * 60L private const val MAX_SUPPORT_RETRY_AFTER_MILLIS = MAX_SUPPORT_RETRY_AFTER_SECONDS * 1_000L From 3b3692065f7ec94e88a91f7340bf98cdaf95e502 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 03:37:59 +0200 Subject: [PATCH 13/29] fix(support): expose all retained receipts --- .../nextcloudnative/app/NextcloudNativeApp.kt | 49 ++++++++++++------- .../nextcloudnative/app/SupportDiagnostics.kt | 13 ++++- .../app/JvmSupportIntakeTest.kt | 11 +++-- .../nextcloudnative/app/JvmSupportIntake.kt | 33 +++++++++---- .../public/screenshots/capture-manifest.json | 4 +- 5 files changed, 76 insertions(+), 34 deletions(-) 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 6a3947634..bd2485df3 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12853,27 +12853,42 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) ) is SupportDiagnosticsSubmissionState.Submitted -> { Text( - "Sent privately. Support code: ${current.supportCode}", + 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, ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), - verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), - ) { - OutlinedButton( - onClick = { - status = if ( - services.copyTextToClipboard("Obiente support code", current.supportCode) - ) { - "Support code copied." - } else { - "The support code could not be copied." + current.reports.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") } - }, - ) { Text("Copy support code") } - TextButton(onClick = { services.openExternalUrl(current.statusUrl) }) { - Text("Open private status") + } } } } 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 b60ebd82f..2e9fe8a31 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -198,11 +198,20 @@ sealed interface SupportDiagnosticsSubmissionState { SupportDiagnosticsSubmissionState data class Rejected(val message: String) : SupportDiagnosticsSubmissionState data object Cancelled : SupportDiagnosticsSubmissionState - data class Submitted( + data class SubmittedReport( val supportCode: String, val statusUrl: String, val retentionUntil: String, - ) : SupportDiagnosticsSubmissionState + ) + 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 } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index bd7764acd..cae965d60 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -205,22 +205,27 @@ class JvmSupportIntakeTest { 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(2, fixture.completedDescriptors().size) + assertEquals(3, fixture.completedDescriptors().size) fixture.intake.close() val restored = fixture.newIntake() val accountA = assertIs(restored.states().value) - assertEquals("OBI-ABCDE-23456", accountA.supportCode) + 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("OBI-FGHJK-6789A", accountB.supportCode) + assertEquals(listOf("OBI-FGHJK-6789A"), accountB.reports.map { it.supportCode }) } } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index ae63a8aee..305909cbf 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -105,7 +105,8 @@ class JvmSupportIntake( }, outcomeAmbiguous = restored.outcomeAmbiguous, ) - } ?: visibleCompleted?.toSubmissionState() ?: SupportDiagnosticsSubmissionState.Idle, + } ?: visibleCompleted?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle, restored?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, ) } @@ -622,7 +623,7 @@ class JvmSupportIntake( } synchronized(lock) { completedSubmissions = completedSubmissions + completedSubmission } finishTerminal(submission) - publishState(completedSubmission.toSubmissionState(), submission.originAccountIdentity) + publishState(submittedStateFor(submission.originAccountIdentity), submission.originAccountIdentity) } private fun validateReceipt( @@ -998,13 +999,7 @@ class JvmSupportIntake( val recordId: String, val originAccountIdentity: String, val receipt: SupportIntakeReceipt, - ) { - fun toSubmissionState() = SupportDiagnosticsSubmissionState.Submitted( - supportCode = receipt.supportCode, - statusUrl = receipt.statusUrl, - retentionUntil = receipt.retentionUntil, - ) - } + ) @Serializable private data class PersistedPendingSubmission( @@ -1060,7 +1055,8 @@ class JvmSupportIntake( actualState is SupportDiagnosticsSubmissionState.Initializing -> state.value = actualState pendingSubmission != null -> publishStateLocked(actualState, pendingSubmission.originAccountIdentity) actualStateAccountIdentity == activeAccountIdentity -> state.value = actualState - else -> state.value = latestCompletedFor(activeAccountIdentity)?.toSubmissionState() + else -> state.value = latestCompletedFor(activeAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } ?: SupportDiagnosticsSubmissionState.Idle } } @@ -1068,6 +1064,23 @@ class JvmSupportIntake( private fun latestCompletedFor(accountIdentity: String?): CompletedSubmission? = completedSubmissions.filter { it.originAccountIdentity == accountIdentity } .maxByOrNull { Instant.parse(it.receipt.createdAt) } + + private fun submittedStateFor(accountIdentity: String): SupportDiagnosticsSubmissionState.Submitted = + SupportDiagnosticsSubmissionState.Submitted( + completedSubmissions + .filter { it.originAccountIdentity == accountIdentity } + .sortedWith( + compareByDescending { Instant.parse(it.receipt.createdAt) } + .thenByDescending(CompletedSubmission::recordId), + ) + .map { completed -> + SupportDiagnosticsSubmissionState.SubmittedReport( + supportCode = completed.receipt.supportCode, + statusUrl = completed.receipt.statusUrl, + retentionUntil = completed.receipt.retentionUntil, + ) + }, + ) } private fun Long.saturatingAdd(increment: Long): Long = diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 98ce3a26f..e619b5180 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "3866a61221a9a95025c7bf94434160d959d7aafb6f3361c8fa066dae092f72d5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "613e116d350bcb76abf44d38e86fa95bc459a4bdcbcd9c2324a95cfe912e2158", "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", @@ -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": "f0ddb1f2e55aed2e4fb7c73bccf2f8ec94008155589a4eee17238013ccafd1c5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "7ff233de81ded3fbc0c06662ba04ec1567caa7091fdcd00f30129c27548a5768", "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", From 19f25c28a1e39c161db9329f40e2b23fa0be6426 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 04:08:34 +0200 Subject: [PATCH 14/29] fix(support): close durable review races --- .../AndroidFileSyncScheduler.kt | 7 + .../AndroidNextcloudServices.kt | 25 +-- .../AndroidSupportDiagnostics.kt | 1 + .../AndroidFileSyncEngineInvariantTest.kt | 8 + .../nextcloudnative/app/NextcloudNativeApp.kt | 5 + .../nextcloudnative/app/SupportDiagnostics.kt | 1 + .../app/JvmSupportIntakeTest.kt | 104 ++++++++++- .../nextcloudnative/app/JvmSupportIntake.kt | 167 ++++++++++++++++-- .../public/screenshots/capture-manifest.json | 4 +- 9 files changed, 295 insertions(+), 27 deletions(-) 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 2d5d9f016..330bf93ee 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -744,12 +744,12 @@ internal class AndroidNextcloudServices( }.getOrNull() }, accountIdOf = NextcloudDocumentIds::accountKey, - )?.also { session -> - registerSessionPrivateValues(session) - val accountIdentity = NextcloudDocumentIds.accountKey(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) - } + publishAccount = { session, accountIdentity -> + session?.let(::registerSessionPrivateValues) + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + }, + ) } override suspend fun saveSession(session: NextcloudSession) { @@ -784,13 +784,14 @@ internal class AndroidNextcloudServices( .apply() }, cancelAll = scheduler::cancelAll, + publishAccount = { accountIdentity -> + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + }, ) if (previousAccountId != null && previousAccountId != replacementAccountId) { nativeMediaPreviewCache.clearAccount(previousAccountId) } - val accountIdentity = NextcloudDocumentIds.accountKey(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) notifyDocumentsRootsChanged() } @@ -827,11 +828,13 @@ internal class AndroidNextcloudServices( .apply() }, cancelAll = scheduler::cancelAll, + clearPublishedAccount = { + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + }, ) accountId?.let(nativeMediaPreviewCache::clearAccount) notifyDocumentsRootsChanged() - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.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 c768e2698..11f2ca30c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt @@ -92,6 +92,7 @@ internal object AndroidSupportIntakeCoordinator { temporaryRoot = File(appContext.noBackupFilesDir, "support-submissions"), environment = androidSupportDiagnosticsEnvironment(), client = client.newBuilder().retryOnConnectionFailure(false).build(), + supportMutationsAllowed = appContext.cloudMutationGate(), ).also { instance = it } } } 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/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index bd2485df3..13736647c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12520,6 +12520,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) }.collectAsState(SupportDiagnosticsSubmissionState.Initializing) val submissionBusy = submissionState is SupportDiagnosticsSubmissionState.Initializing || submissionState is SupportDiagnosticsSubmissionState.Packaging || + submissionState is SupportDiagnosticsSubmissionState.Cancelling || submissionState is SupportDiagnosticsSubmissionState.Uploading val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure || submissionState is SupportDiagnosticsSubmissionState.BlockedByAnotherAccount @@ -12813,6 +12814,10 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) 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) + } is SupportDiagnosticsSubmissionState.Uploading -> { if (current.progress == null) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) 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 2e9fe8a31..53ca792e9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -189,6 +189,7 @@ sealed interface SupportDiagnosticsSubmissionState { data object Idle : SupportDiagnosticsSubmissionState data class BlockedByAnotherAccount(val message: String) : SupportDiagnosticsSubmissionState data object Packaging : SupportDiagnosticsSubmissionState + data object Cancelling : SupportDiagnosticsSubmissionState data class Uploading(val progress: Float?) : SupportDiagnosticsSubmissionState { init { require(progress == null || progress in 0f..1f) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index cae965d60..6690bd83f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -7,6 +7,7 @@ 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 kotlin.io.path.createTempDirectory import kotlin.test.Test @@ -15,8 +16,11 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue 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 @@ -261,6 +265,94 @@ class JvmSupportIntakeTest { } } + @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 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 doesNotAcceptCancellationAfterReceiptCompletion() = runBlocking { testFixture().use { fixture -> @@ -826,7 +918,9 @@ class JvmSupportIntakeTest { } } - private fun testFixture(): Fixture { + private fun testFixture( + supportMutationsAllowed: () -> Boolean = { true }, + ): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") val temporaryRoot = File(root, "submissions") @@ -853,6 +947,7 @@ class JvmSupportIntakeTest { diagnostics = diagnostics, environment = environment, server = server, + supportMutationsAllowed = supportMutationsAllowed, ) } @@ -861,9 +956,10 @@ class JvmSupportIntakeTest { supportCode: String = "OBI-ABCDE-23456", retentionDays: Long = 30, createdAtOffsetDays: Long = 0, + retentionUntil: Instant? = null, ): MockResponse { val createdAt = Instant.now().plus(createdAtOffsetDays, ChronoUnit.DAYS).truncatedTo(ChronoUnit.SECONDS) - val retentionUntil = createdAt.plus(retentionDays, ChronoUnit.DAYS) + val resolvedRetentionUntil = retentionUntil ?: createdAt.plus(retentionDays, ChronoUnit.DAYS) return MockResponse.Builder().code(201).body( """ { @@ -873,7 +969,7 @@ class JvmSupportIntakeTest { "statusUrl": "$statusUrl", "deletionUrl": "$statusUrl", "createdAt": "$createdAt", - "retentionUntil": "$retentionUntil" + "retentionUntil": "$resolvedRetentionUntil" } """.trimIndent(), ).build() @@ -885,6 +981,7 @@ class JvmSupportIntakeTest { val diagnostics: AsyncJvmSupportDiagnostics, val environment: SupportDiagnosticsEnvironment, val server: MockWebServer, + val supportMutationsAllowed: () -> Boolean, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -898,6 +995,7 @@ class JvmSupportIntakeTest { environment = environment, client = OkHttpClient.Builder().retryOnConnectionFailure(false).build(), supportBaseUrl = server.url("/").toString(), + supportMutationsAllowed = supportMutationsAllowed, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 305909cbf..773b866c9 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -23,8 +23,10 @@ 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 @@ -55,6 +57,7 @@ class JvmSupportIntake( private val environment: SupportDiagnosticsEnvironment, client: OkHttpClient, supportBaseUrl: String = DEFAULT_OBIENTE_SUPPORT_URL, + private val supportMutationsAllowed: () -> Boolean = { true }, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -81,6 +84,7 @@ class JvmSupportIntake( private var actualStateAccountIdentity: String? = null private var pending: PendingSubmission? = null private var completedSubmissions: List = emptyList() + private var completedExpiryJob: Job? = null init { scope.launch { @@ -92,6 +96,7 @@ class JvmSupportIntake( synchronized(lock) { pending = restored completedSubmissions = restoredCompleted + scheduleCompletedExpiryLocked() val visibleCompleted = latestCompletedFor(activeAccountIdentity) publishStateLocked( restored?.let { @@ -133,7 +138,11 @@ class JvmSupportIntake( featureState: List, ) = withContext(Dispatchers.IO) { awaitInitialization() - if (!operationActive.compareAndSet(false, true)) 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) { @@ -197,13 +206,26 @@ class JvmSupportIntake( if (!packageSubmission(submission)) return@withContext upload(submission) } finally { - operationActive.set(false) + endOperation() } } suspend fun retry() = withContext(Dispatchers.IO) { awaitInitialization() - if (!operationActive.compareAndSet(false, true)) 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) { @@ -260,6 +282,24 @@ class JvmSupportIntake( } 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) } } @@ -376,6 +416,15 @@ class JvmSupportIntake( 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.outcomeAmbiguous = true if (!persistPendingSafely(submission)) { finishRejected(submission, "The private support submission could not be retained safely on this device.") @@ -405,6 +454,15 @@ class JvmSupportIntake( .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) activeCall.set(call) @@ -536,6 +594,10 @@ class JvmSupportIntake( .header("Accept", "application/json") .delete() .build() + if (!supportMutationsAreAllowed()) { + retainCancellationForRetry(submission, receipt, READ_ONLY_SUPPORT_MESSAGE) + return + } val call = client.newCall(request) activeCall.set(call) try { @@ -621,7 +683,10 @@ class JvmSupportIntake( ) return } - synchronized(lock) { completedSubmissions = completedSubmissions + completedSubmission } + synchronized(lock) { + completedSubmissions = completedSubmissions + completedSubmission + scheduleCompletedExpiryLocked() + } finishTerminal(submission) publishState(submittedStateFor(submission.originAccountIdentity), submission.originAccountIdentity) } @@ -669,7 +734,7 @@ class JvmSupportIntake( } submission.archive?.delete() synchronized(persistenceLock) { - pendingDescriptor().delete() + deletePrivateDescriptorDurably(pendingDescriptor()) } } @@ -680,7 +745,14 @@ class JvmSupportIntake( private fun finishCancelled(submission: PendingSubmission) { finishTerminal(submission) - publishState(SupportDiagnosticsSubmissionState.Cancelled, submission.originAccountIdentity) + publishState( + if (operationActive.get()) { + SupportDiagnosticsSubmissionState.Cancelling + } else { + SupportDiagnosticsSubmissionState.Cancelled + }, + submission.originAccountIdentity, + ) } private fun retainForRetry( @@ -890,6 +962,13 @@ class JvmSupportIntake( } } + private fun deletePrivateDescriptorDurably(descriptor: File) { + val parent = descriptor.parentFile ?: return + if (Files.deleteIfExists(descriptor.toPath())) { + syncDirectoryEntry(parent) + } + } + private fun restorePendingSubmission(): PendingSubmission? = runCatching { val descriptor = pendingDescriptor() if (!descriptor.isFile) return@runCatching null @@ -940,7 +1019,7 @@ class JvmSupportIntake( receipt = persisted.receipt, ) }.getOrElse { - pendingDescriptor().delete() + runCatching { deletePrivateDescriptorDurably(pendingDescriptor()) } null } @@ -964,7 +1043,7 @@ class JvmSupportIntake( require(System.currentTimeMillis() <= Instant.parse(persisted.receipt.retentionUntil).toEpochMilli()) CompletedSubmission(recordId, persisted.originAccountIdentity, persisted.receipt) }.getOrElse { - descriptor.delete() + runCatching { deletePrivateDescriptorDurably(descriptor) } null } @@ -999,7 +1078,12 @@ class JvmSupportIntake( 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( @@ -1034,6 +1118,9 @@ class JvmSupportIntake( next: SupportDiagnosticsSubmissionState, accountIdentity: String? = pending?.originAccountIdentity, ) { + if (pruneExpiredCompletedLocked()) { + scheduleCompletedExpiryLocked() + } actualState = next actualStateAccountIdentity = accountIdentity state.value = if (accountIdentity != null && accountIdentity != activeAccountIdentity) { @@ -1062,13 +1149,17 @@ class JvmSupportIntake( } private fun latestCompletedFor(accountIdentity: String?): CompletedSubmission? = - completedSubmissions.filter { it.originAccountIdentity == accountIdentity } + 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 } + .filter { + it.originAccountIdentity == accountIdentity && it.isRetained(System.currentTimeMillis()) + } .sortedWith( compareByDescending { Instant.parse(it.receipt.createdAt) } .thenByDescending(CompletedSubmission::recordId), @@ -1081,6 +1172,57 @@ class JvmSupportIntake( ) }, ) + + 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) } + return true + } + + private suspend fun deleteCompletedDescriptorsWithRetry(submissions: List) { + var remaining = submissions + while (remaining.isNotEmpty()) { + remaining = synchronized(persistenceLock) { + remaining.filterNot { submission -> + runCatching { + deletePrivateDescriptorDurably(completedDescriptor(submission.recordId)) + }.isSuccess + } + } + if (remaining.isNotEmpty()) delay(SUPPORT_DESCRIPTOR_DELETE_RETRY_MILLIS) + } + } } private fun Long.saturatingAdd(increment: Long): Long = @@ -1196,5 +1338,8 @@ 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 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." diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index e619b5180..51d6d601c 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "613e116d350bcb76abf44d38e86fa95bc459a4bdcbcd9c2324a95cfe912e2158", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "11e2ee7c9afae2ea4eea20512590ffef8b27ecf2d4169f35496b7330a48777a2", "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", @@ -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": "7ff233de81ded3fbc0c06662ba04ec1567caa7091fdcd00f30129c27548a5768", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "55a863899162756d23e0f154168eda3145638513686f1768ef022669b0b7a720", "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", From c47a9626f8cd8e31f482015de95a882749f3f9f9 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 04:49:58 +0200 Subject: [PATCH 15/29] fix(support): harden terminal and account state --- .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +- .../app/DesktopNextcloudServices.kt | 101 ++++++++++-------- .../app/DesktopSessionPublicationGuardTest.kt | 56 ++++++++++ .../app/JvmSupportIntakeTest.kt | 54 ++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 73 ++++++++++--- .../public/screenshots/capture-manifest.json | 2 +- 6 files changed, 231 insertions(+), 61 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSessionPublicationGuardTest.kt 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 13736647c..1561b7a9b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12524,6 +12524,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) submissionState is SupportDiagnosticsSubmissionState.Uploading val submissionPending = submissionState is SupportDiagnosticsSubmissionState.RetryableFailure || submissionState is SupportDiagnosticsSubmissionState.BlockedByAnotherAccount + val submissionUnavailable = submissionState is SupportDiagnosticsSubmissionState.Unsupported LaunchedEffect(submissionBusy, submissionPending) { if (submissionBusy || submissionPending) confirmClear = false @@ -12580,7 +12581,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) }, confirmButton = { TextButton( - enabled = !submissionBusy, + enabled = !submissionBusy && !submissionUnavailable, onClick = { confirmSend = false scope.launch { services.submitSupportDiagnostics(reproductionSteps) } @@ -12743,7 +12744,8 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), ) { Button( - enabled = summary.available && !exporting && !submissionBusy && !submissionPending, + enabled = summary.available && !exporting && !submissionBusy && !submissionPending && + !submissionUnavailable, onClick = { confirmSend = true }, ) { Text("Send to support") 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 0057c5f65..75c347e6e 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, @@ -948,6 +954,7 @@ class DesktopNextcloudServices( ?: 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) }, @@ -3464,51 +3471,55 @@ 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 -> - val accountIdentity = desktopFileCacheAccountId(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) + return sessionPublicationGuard.serialize { + val server = preferences.get(KEY_SERVER, null) ?: return@serialize null + val login = preferences.get(KEY_LOGIN, null) ?: return@serialize null + val password = secretStore.load(desktopSessionSecretReference(server, login)) + ?.decodeToString() + ?.takeIf(String::isNotBlank) + ?: 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) - val accountIdentity = desktopFileCacheAccountId(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() } @@ -3642,10 +3653,12 @@ class DesktopNextcloudServices( ), ) } - preferences.remove(KEY_SERVER) - preferences.remove(KEY_LOGIN) - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.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/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/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 6690bd83f..7614755a3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -1,6 +1,7 @@ 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 @@ -353,6 +354,51 @@ class JvmSupportIntakeTest { } } + @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 reportsUnavailableSubmissionStorageDuringInitialization() = runBlocking { + testFixture().use { fixture -> + fixture.intake.close() + assertTrue(fixture.temporaryRoot.deleteRecursively()) + fixture.temporaryRoot.writeText("unavailable") + + fixture.newIntake().use { unavailable -> + val state = assertIs(unavailable.states().value) + assertTrue(state.reason.contains("storage is unavailable")) + + unavailable.submit("A refresh failed.", "nightly", emptyList()) + + assertIs(unavailable.states().value) + assertEquals(0, fixture.server.requestCount) + } + } + } + @Test fun doesNotAcceptCancellationAfterReceiptCompletion() = runBlocking { testFixture().use { fixture -> @@ -920,6 +966,8 @@ class JvmSupportIntakeTest { private fun testFixture( supportMutationsAllowed: () -> Boolean = { true }, + directorySync: (File) -> Unit = {}, + descriptorCleanupRetryMillis: Long = 60_000L, ): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") @@ -948,6 +996,8 @@ class JvmSupportIntakeTest { environment = environment, server = server, supportMutationsAllowed = supportMutationsAllowed, + directorySync = directorySync, + descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, ) } @@ -982,6 +1032,8 @@ class JvmSupportIntakeTest { val environment: SupportDiagnosticsEnvironment, val server: MockWebServer, val supportMutationsAllowed: () -> Boolean, + val directorySync: (File) -> Unit, + val descriptorCleanupRetryMillis: Long, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -996,6 +1048,8 @@ class JvmSupportIntakeTest { client = OkHttpClient.Builder().retryOnConnectionFailure(false).build(), supportBaseUrl = server.url("/").toString(), supportMutationsAllowed = supportMutationsAllowed, + directorySync = directorySync, + descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 773b866c9..3f07da6e9 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -58,6 +58,8 @@ class JvmSupportIntake( 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, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -85,21 +87,26 @@ class JvmSupportIntake( private var pending: PendingSubmission? = null private var completedSubmissions: List = emptyList() private var completedExpiryJob: Job? = null + private var storageUnavailableMessage: String? = null init { + require(descriptorCleanupRetryMillis > 0L) scope.launch { try { - val storageReady = runCatching { preparePrivateStorage() }.isSuccess - val restored = if (storageReady) restorePendingSubmission() else null - val restoredCompleted = if (storageReady) restoreCompletedSubmissions() else emptyList() - if (storageReady) pruneTemporaryReports(restored?.archive) + val storageFailure = runCatching { preparePrivateStorage() }.exceptionOrNull() + val restored = if (storageFailure == null) restorePendingSubmission() else null + val restoredCompleted = if (storageFailure == null) restoreCompletedSubmissions() else emptyList() + if (storageFailure == null) pruneTemporaryReports(restored?.archive) synchronized(lock) { + storageUnavailableMessage = storageFailure?.let { SUPPORT_STORAGE_UNAVAILABLE_MESSAGE } pending = restored completedSubmissions = restoredCompleted scheduleCompletedExpiryLocked() val visibleCompleted = latestCompletedFor(activeAccountIdentity) publishStateLocked( - restored?.let { + storageFailure?.let { + SupportDiagnosticsSubmissionState.Unsupported(SUPPORT_STORAGE_UNAVAILABLE_MESSAGE) + } ?: restored?.let { SupportDiagnosticsSubmissionState.RetryableFailure( if (restored.cancellationPending) { "Cancellation was interrupted. Retry safely to reconcile and delete the private report." @@ -138,6 +145,10 @@ class JvmSupportIntake( 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 @@ -212,6 +223,10 @@ class JvmSupportIntake( 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( @@ -732,9 +747,33 @@ class JvmSupportIntake( synchronized(lock) { if (pending === submission) pending = null } - submission.archive?.delete() + runCatching { submission.archive?.delete() } + if (!cleanupPendingDescriptorSafely(submission)) { + scope.launch { retryPendingDescriptorCleanup(submission) } + } + } + + private fun cleanupPendingDescriptorSafely(submission: PendingSubmission): Boolean = synchronized(persistenceLock) { - deletePrivateDescriptorDurably(pendingDescriptor()) + runCatching { + val descriptor = pendingDescriptor() + if (descriptor.isFile) { + val persistedIdempotencyKey = runCatching { + json.decodeFromString( + PersistedPendingSubmission.serializer(), + descriptor.readText(Charsets.UTF_8), + ).idempotencyKey + }.getOrNull() + 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 } } @@ -956,17 +995,13 @@ class JvmSupportIntake( } private fun syncDirectoryEntry(directory: File) { - if (Files.getFileAttributeView(directory.toPath(), PosixFileAttributeView::class.java) == null) return - FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> - channel.force(true) - } + directorySync(directory) } private fun deletePrivateDescriptorDurably(descriptor: File) { val parent = descriptor.parentFile ?: return - if (Files.deleteIfExists(descriptor.toPath())) { - syncDirectoryEntry(parent) - } + Files.deleteIfExists(descriptor.toPath()) + syncDirectoryEntry(parent) } private fun restorePendingSubmission(): PendingSubmission? = runCatching { @@ -1225,6 +1260,13 @@ class JvmSupportIntake( } } +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 @@ -1343,3 +1385,6 @@ 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." diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 51d6d601c..a1f1dd392 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "11e2ee7c9afae2ea4eea20512590ffef8b27ecf2d4169f35496b7330a48777a2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "7c8c0f821ae960b6bcd45cb9208e6273611863c477aa5105cc50020bd12cc787", "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", From 07cfa6ad6e6ceffcc5648ffda445ecc87a6bec7e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 05:11:32 +0200 Subject: [PATCH 16/29] fix(support): close final receipt races --- .../app/JvmSupportIntakeTest.kt | 63 +++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 89 +++++++++++++++++-- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 7614755a3..c15280dd9 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -334,6 +334,32 @@ class JvmSupportIntakeTest { } } + @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 expiresCompletedReceiptWhileTheProcessRemainsOpen() = runBlocking { testFixture().use { fixture -> @@ -399,6 +425,39 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -968,6 +1027,7 @@ class JvmSupportIntakeTest { supportMutationsAllowed: () -> Boolean = { true }, directorySync: (File) -> Unit = {}, descriptorCleanupRetryMillis: Long = 60_000L, + beforeCallRegistration: () -> Unit = {}, ): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") @@ -998,6 +1058,7 @@ class JvmSupportIntakeTest { supportMutationsAllowed = supportMutationsAllowed, directorySync = directorySync, descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, + beforeCallRegistration = beforeCallRegistration, ) } @@ -1034,6 +1095,7 @@ class JvmSupportIntakeTest { val supportMutationsAllowed: () -> Boolean, val directorySync: (File) -> Unit, val descriptorCleanupRetryMillis: Long, + val beforeCallRegistration: () -> Unit, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -1050,6 +1112,7 @@ class JvmSupportIntakeTest { supportMutationsAllowed = supportMutationsAllowed, directorySync = directorySync, descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, + beforeCallRegistration = beforeCallRegistration, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 3f07da6e9..28579b155 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -60,6 +60,7 @@ class JvmSupportIntake( private val supportMutationsAllowed: () -> Boolean = { true }, private val directorySync: (File) -> Unit = ::syncPosixDirectoryEntry, private val descriptorCleanupRetryMillis: Long = SUPPORT_DESCRIPTOR_DELETE_RETRY_MILLIS, + private val beforeCallRegistration: () -> Unit = {}, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -370,11 +371,30 @@ class JvmSupportIntake( } override fun close() { - shutdownRequested.set(true) - activeCall.getAndSet(null)?.cancel() + 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 suspend fun packageSubmission(submission: PendingSubmission): Boolean { if (cancellationRequested.get()) { finishCancelled(submission) @@ -480,10 +500,23 @@ class JvmSupportIntake( } publishState(SupportDiagnosticsSubmissionState.Uploading(0f)) val call = client.newCall(request) - activeCall.set(call) + 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( @@ -506,6 +539,7 @@ class JvmSupportIntake( } } } catch (failure: IOException) { + activeCall.compareAndSet(call, null) if (shutdownRequested.get()) { retainForRetry( submission, @@ -533,10 +567,21 @@ class JvmSupportIntake( .get() .build() val call = client.newCall(request) - activeCall.set(call) + 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 + } try { call.execute().use { response -> val responseText = response.readBoundedText() + activeCall.compareAndSet(call, null) when { response.isSuccessful -> finishReceived(submission, decodeReceipt(responseText)) response.code == 404 && cancellationRequested.get() -> finishCancelled(submission) @@ -614,10 +659,17 @@ class JvmSupportIntake( return } val call = client.newCall(request) - activeCall.set(call) + 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) @@ -655,7 +707,17 @@ class JvmSupportIntake( .get() .build() val call = client.newCall(request) - activeCall.set(call) + 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() @@ -682,6 +744,21 @@ class JvmSupportIntake( 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, From d1c587a89d53f290be80fe8d9955352d8c278e4e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 05:41:12 +0200 Subject: [PATCH 17/29] fix(support): preserve cancellation cleanup --- .../app/JvmSupportIntakeTest.kt | 96 +++++++++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 63 ++++++++---- 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index c15280dd9..06902a20a 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -360,6 +360,59 @@ class JvmSupportIntakeTest { } } + @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) + } + 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 expiresCompletedReceiptWhileTheProcessRemainsOpen() = runBlocking { testFixture().use { fixture -> @@ -406,6 +459,41 @@ class JvmSupportIntakeTest { } } + @Test + fun retriesTerminalArchiveDeletionWithoutChangingSubmittedState() = runBlocking { + var archiveDeleteAttempts = 0 + val retryEntered = CountDownLatch(1) + val allowRetry = CountDownLatch(1) + testFixture( + archiveDelete = { 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 reportsUnavailableSubmissionStorageDuringInitialization() = runBlocking { testFixture().use { fixture -> @@ -1028,6 +1116,8 @@ class JvmSupportIntakeTest { directorySync: (File) -> Unit = {}, descriptorCleanupRetryMillis: Long = 60_000L, beforeCallRegistration: () -> Unit = {}, + beforeBundlePackaging: () -> Unit = {}, + archiveDelete: (File) -> Boolean = File::delete, ): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") @@ -1059,6 +1149,8 @@ class JvmSupportIntakeTest { directorySync = directorySync, descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, beforeCallRegistration = beforeCallRegistration, + beforeBundlePackaging = beforeBundlePackaging, + archiveDelete = archiveDelete, ) } @@ -1096,6 +1188,8 @@ class JvmSupportIntakeTest { val directorySync: (File) -> Unit, val descriptorCleanupRetryMillis: Long, val beforeCallRegistration: () -> Unit, + val beforeBundlePackaging: () -> Unit, + val archiveDelete: (File) -> Boolean, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -1113,6 +1207,8 @@ class JvmSupportIntakeTest { directorySync = directorySync, descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, beforeCallRegistration = beforeCallRegistration, + beforeBundlePackaging = beforeBundlePackaging, + archiveDelete = archiveDelete, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 28579b155..4d7ebfe6b 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -61,6 +61,8 @@ class JvmSupportIntake( private val directorySync: (File) -> Unit = ::syncPosixDirectoryEntry, private val descriptorCleanupRetryMillis: Long = SUPPORT_DESCRIPTOR_DELETE_RETRY_MILLIS, private val beforeCallRegistration: () -> Unit = {}, + private val beforeBundlePackaging: () -> Unit = {}, + private val archiveDelete: (File) -> Boolean = File::delete, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -346,18 +348,17 @@ class JvmSupportIntake( } submission.cancellationPending = true submission.outcomeAmbiguous = true - if (!persistPendingSafely(submission)) { + val cancellationPersisted = persistPendingSafely(submission) + val call = activeCall.getAndSet(null) + call?.cancel() + if (!cancellationPersisted) { publishState(SupportDiagnosticsSubmissionState.RetryableFailure( "Cancellation could not be stored safely. Keep the app open and retry to reconcile the private report.", outcomeAmbiguous = true, )) return false } - } - val call = activeCall.getAndSet(null) - if (call != null) { - call.cancel() - return true + if (call != null) return true } if (submission != null) { publishState(SupportDiagnosticsSubmissionState.RetryableFailure( @@ -403,26 +404,35 @@ class JvmSupportIntake( publishState(SupportDiagnosticsSubmissionState.Packaging) val destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip") val prepared = try { + beforeBundlePackaging() diagnostics.writeBundleForSubmission(destination, submission.context) } catch (cancellation: CancellationException) { - retainForRetry( - submission, - "Private report preparation was interrupted. You can retry it safely.", - ambiguous = false, - ) + 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) { - retainForRetry( - submission, - "The private diagnostic report could not be prepared. You can retry safely.", - ambiguous = false, - ) + 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) { - prepared.archive.delete() + deleteArchiveOrRetry(prepared.archive) retainForRetry( submission, "The private report could not be protected on this device. You can retry safely.", @@ -431,7 +441,7 @@ class JvmSupportIntake( return false } if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { - prepared.archive.delete() + deleteArchiveOrRetry(prepared.archive) return false } submission.archive = prepared.archive @@ -824,12 +834,25 @@ class JvmSupportIntake( synchronized(lock) { if (pending === submission) pending = null } - runCatching { submission.archive?.delete() } + deleteArchiveOrRetry(submission.archive) if (!cleanupPendingDescriptorSafely(submission)) { scope.launch { retryPendingDescriptorCleanup(submission) } } } + private fun deleteArchiveOrRetry(archive: File?) { + if (archive == null || deleteArchiveSafely(archive)) return + scope.launch { + while (!shutdownRequested.get()) { + delay(descriptorCleanupRetryMillis) + if (deleteArchiveSafely(archive)) return@launch + } + } + } + + private fun deleteArchiveSafely(archive: File): Boolean = + !archive.exists() || runCatching { archiveDelete(archive) }.getOrDefault(false) || !archive.exists() + private fun cleanupPendingDescriptorSafely(submission: PendingSubmission): Boolean = synchronized(persistenceLock) { runCatching { @@ -938,7 +961,7 @@ class JvmSupportIntake( file.isFile && file.name.matches(SUPPORT_TEMPORARY_FILE_PATTERN) && (file != retainedArchive || file.lastModified() < cutoff) } - .forEach(File::delete) + .forEach(::deleteArchiveOrRetry) temporaryRoot.listFiles().orEmpty() .filter { file -> file.isFile && file.name.matches(SUPPORT_PENDING_TEMPORARY_FILE_PATTERN) } .forEach(File::delete) From 47c633cd7b2ea8602d31f7a7fcd00466f56b7847 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 05:59:27 +0200 Subject: [PATCH 18/29] fix(support): expose preparation state --- .../app/JvmSupportIntakeTest.kt | 108 +++++++++++++++--- .../nextcloudnative/app/JvmSupportIntake.kt | 58 +++++++--- 2 files changed, 129 insertions(+), 37 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 06902a20a..bb399b3e0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -360,6 +360,33 @@ class JvmSupportIntakeTest { } } + @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 cancellationStopsTheActiveCallWhenIntentPersistenceFails() = runBlocking { var directorySyncs = 0 @@ -465,7 +492,7 @@ class JvmSupportIntakeTest { val retryEntered = CountDownLatch(1) val allowRetry = CountDownLatch(1) testFixture( - archiveDelete = { archive -> + privateFileDelete = { archive -> archiveDeleteAttempts += 1 if (archiveDeleteAttempts == 1) { false @@ -495,21 +522,51 @@ class JvmSupportIntakeTest { } @Test - fun reportsUnavailableSubmissionStorageDuringInitialization() = runBlocking { - testFixture().use { fixture -> - fixture.intake.close() - assertTrue(fixture.temporaryRoot.deleteRecursively()) - fixture.temporaryRoot.writeText("unavailable") + 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) + } + } - fixture.newIntake().use { unavailable -> - val state = assertIs(unavailable.states().value) - assertTrue(state.reason.contains("storage is unavailable")) + @Test + fun reportsUnavailableSubmissionStorageDuringInitialization() = runBlocking { + testFixture(submissionStorageBlocked = true).use { fixture -> + val state = assertIs(fixture.intake.states().value) + assertTrue(state.reason.contains("storage is unavailable")) - unavailable.submit("A refresh failed.", "nightly", emptyList()) + fixture.intake.submit("A refresh failed.", "nightly", emptyList()) - assertIs(unavailable.states().value) - assertEquals(0, fixture.server.requestCount) - } + assertIs(fixture.intake.states().value) + assertEquals(0, fixture.server.requestCount) } } @@ -1116,12 +1173,24 @@ class JvmSupportIntakeTest { directorySync: (File) -> Unit = {}, descriptorCleanupRetryMillis: Long = 60_000L, beforeCallRegistration: () -> Unit = {}, + beforeSubmissionPreparation: () -> Unit = {}, beforeBundlePackaging: () -> Unit = {}, - archiveDelete: (File) -> Boolean = File::delete, + privateFileDelete: (File) -> Boolean = File::delete, + submissionStorageBlocked: Boolean = false, + pendingTemporaryBeforeInitialization: Boolean = false, ): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") - val temporaryRoot = File(root, "submissions") + 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") + } val environment = SupportDiagnosticsEnvironment( appVersion = "0.1.0-test", packageVersion = "1", @@ -1149,8 +1218,9 @@ class JvmSupportIntakeTest { directorySync = directorySync, descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, beforeCallRegistration = beforeCallRegistration, + beforeSubmissionPreparation = beforeSubmissionPreparation, beforeBundlePackaging = beforeBundlePackaging, - archiveDelete = archiveDelete, + privateFileDelete = privateFileDelete, ) } @@ -1188,8 +1258,9 @@ class JvmSupportIntakeTest { val directorySync: (File) -> Unit, val descriptorCleanupRetryMillis: Long, val beforeCallRegistration: () -> Unit, + val beforeSubmissionPreparation: () -> Unit, val beforeBundlePackaging: () -> Unit, - val archiveDelete: (File) -> Boolean, + val privateFileDelete: (File) -> Boolean, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -1207,8 +1278,9 @@ class JvmSupportIntakeTest { directorySync = directorySync, descriptorCleanupRetryMillis = descriptorCleanupRetryMillis, beforeCallRegistration = beforeCallRegistration, + beforeSubmissionPreparation = beforeSubmissionPreparation, beforeBundlePackaging = beforeBundlePackaging, - archiveDelete = archiveDelete, + privateFileDelete = privateFileDelete, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 4d7ebfe6b..0e0089e6a 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -61,8 +61,9 @@ class JvmSupportIntake( 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 archiveDelete: (File) -> Boolean = File::delete, + private val privateFileDelete: (File) -> Boolean = File::delete, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -174,7 +175,9 @@ class JvmSupportIntake( return@withContext } cancellationRequested.set(false) + publishState(SupportDiagnosticsSubmissionState.Packaging, originAccountIdentity) val context = try { + beforeSubmissionPreparation() preparePrivateStorage() diagnostics.prepareSubmissionContextForAccountIdentity( reproductionSteps, @@ -185,12 +188,20 @@ class JvmSupportIntake( publishState(SupportDiagnosticsSubmissionState.Cancelled) 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.", )) return@withContext } + if (cancellationRequested.get()) { + publishState(SupportDiagnosticsSubmissionState.Cancelling, originAccountIdentity) + return@withContext + } val submission = PendingSubmission( archive = null, metadata = SupportIntakeMetadata( @@ -326,17 +337,26 @@ class JvmSupportIntake( 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 accepted = synchronized(lock) { + val pendingCancellation: Boolean? = synchronized(lock) { val submission = pending - if (submission == null || !submission.belongsTo(activeAccountIdentity)) { - false - } else { - cancellationRequested.set(true) - true + 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 } } - if (!accepted) return false - return withContext(Dispatchers.IO) { cancelAfterIntentPublished() } + return when (pendingCancellation) { + null -> false + false -> true + true -> withContext(Dispatchers.IO) { cancelAfterIntentPublished() } + } } private fun cancelAfterIntentPublished(): Boolean { @@ -432,7 +452,7 @@ class JvmSupportIntake( try { restrictOwnerOnlyFile(prepared.archive) } catch (_: Throwable) { - deleteArchiveOrRetry(prepared.archive) + deletePrivateFileOrRetry(prepared.archive) retainForRetry( submission, "The private report could not be protected on this device. You can retry safely.", @@ -441,7 +461,7 @@ class JvmSupportIntake( return false } if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { - deleteArchiveOrRetry(prepared.archive) + deletePrivateFileOrRetry(prepared.archive) return false } submission.archive = prepared.archive @@ -834,24 +854,24 @@ class JvmSupportIntake( synchronized(lock) { if (pending === submission) pending = null } - deleteArchiveOrRetry(submission.archive) + deletePrivateFileOrRetry(submission.archive) if (!cleanupPendingDescriptorSafely(submission)) { scope.launch { retryPendingDescriptorCleanup(submission) } } } - private fun deleteArchiveOrRetry(archive: File?) { - if (archive == null || deleteArchiveSafely(archive)) return + private fun deletePrivateFileOrRetry(file: File?) { + if (file == null || deletePrivateFileSafely(file)) return scope.launch { while (!shutdownRequested.get()) { delay(descriptorCleanupRetryMillis) - if (deleteArchiveSafely(archive)) return@launch + if (deletePrivateFileSafely(file)) return@launch } } } - private fun deleteArchiveSafely(archive: File): Boolean = - !archive.exists() || runCatching { archiveDelete(archive) }.getOrDefault(false) || !archive.exists() + private fun deletePrivateFileSafely(file: File): Boolean = + !file.exists() || runCatching { privateFileDelete(file) }.getOrDefault(false) || !file.exists() private fun cleanupPendingDescriptorSafely(submission: PendingSubmission): Boolean = synchronized(persistenceLock) { @@ -961,10 +981,10 @@ class JvmSupportIntake( file.isFile && file.name.matches(SUPPORT_TEMPORARY_FILE_PATTERN) && (file != retainedArchive || file.lastModified() < cutoff) } - .forEach(::deleteArchiveOrRetry) + .forEach(::deletePrivateFileOrRetry) temporaryRoot.listFiles().orEmpty() .filter { file -> file.isFile && file.name.matches(SUPPORT_PENDING_TEMPORARY_FILE_PATTERN) } - .forEach(File::delete) + .forEach(::deletePrivateFileOrRetry) } private fun preparePrivateStorage() { From db08600c7b0f79f0b09e2013bb0f932d55d3e8b0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 06:44:47 +0200 Subject: [PATCH 19/29] fix(support): preserve recovery state --- .../app/JvmSupportIntakeTest.kt | 87 +++++++++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 70 +++++++++++++-- 2 files changed, 149 insertions(+), 8 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index bb399b3e0..4fa8b3e85 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -266,6 +266,29 @@ class JvmSupportIntakeTest { } } + @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 rejectsSupportUploadWhenThePlatformMutationGateIsClosed() = runBlocking { var mutationsAllowed = false @@ -567,7 +590,12 @@ class JvmSupportIntakeTest { assertIs(fixture.intake.states().value) assertEquals(0, fixture.server.requestCount) + + fixture.intake.setActiveAccountIdentity(OTHER_ACCOUNT_IDENTITY) + + assertIs(fixture.intake.states().value) } + Unit } @Test @@ -688,6 +716,33 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -724,6 +779,33 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -1178,6 +1260,7 @@ class JvmSupportIntakeTest { privateFileDelete: (File) -> Boolean = File::delete, submissionStorageBlocked: Boolean = false, pendingTemporaryBeforeInitialization: Boolean = false, + invalidPendingBeforeInitialization: Boolean = false, ): Fixture { val root = createTempDirectory("support-intake-test").toFile() val diagnosticRoot = File(root, "diagnostics") @@ -1191,6 +1274,10 @@ class JvmSupportIntakeTest { require(temporaryRoot.mkdirs()) File(temporaryRoot, ".pending-orphan.tmp").writeText("private context") } + if (invalidPendingBeforeInitialization) { + require(temporaryRoot.isDirectory || temporaryRoot.mkdirs()) + File(temporaryRoot, "pending.json").writeText("not-json") + } val environment = SupportDiagnosticsEnvironment( appVersion = "0.1.0-test", packageVersion = "1", diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 0e0089e6a..c3b8f52ef 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -83,6 +83,7 @@ class JvmSupportIntake( private val cancellationRequested = AtomicBoolean(false) private val shutdownRequested = AtomicBoolean(false) private val operationActive = AtomicBoolean(false) + private val rejectedPendingDescriptorCleanup = AtomicBoolean(false) private val lock = Any() private val persistenceLock = Any() private var activeAccountIdentity: String? = null @@ -102,14 +103,18 @@ class JvmSupportIntake( val restoredCompleted = if (storageFailure == null) restoreCompletedSubmissions() else emptyList() if (storageFailure == null) pruneTemporaryReports(restored?.archive) synchronized(lock) { - storageUnavailableMessage = storageFailure?.let { SUPPORT_STORAGE_UNAVAILABLE_MESSAGE } + storageUnavailableMessage = when { + storageFailure != null -> SUPPORT_STORAGE_UNAVAILABLE_MESSAGE + rejectedPendingDescriptorCleanup.get() -> SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE + else -> null + } pending = restored completedSubmissions = restoredCompleted scheduleCompletedExpiryLocked() val visibleCompleted = latestCompletedFor(activeAccountIdentity) publishStateLocked( - storageFailure?.let { - SupportDiagnosticsSubmissionState.Unsupported(SUPPORT_STORAGE_UNAVAILABLE_MESSAGE) + storageUnavailableMessage?.let { unavailableMessage -> + SupportDiagnosticsSubmissionState.Unsupported(unavailableMessage) } ?: restored?.let { SupportDiagnosticsSubmissionState.RetryableFailure( if (restored.cancellationPending) { @@ -674,10 +679,7 @@ class JvmSupportIntake( submission.cancellationPending = true submission.outcomeAmbiguous = true submission.receipt = receipt - if (!persistPendingSafely(submission)) { - finishSubmitted(submission, receipt) - return - } + persistPendingSafely(submission) val capability = statusUrl.pathSegments.last() val request = Request.Builder() .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) @@ -832,6 +834,7 @@ class JvmSupportIntake( ) 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), @@ -1174,10 +1177,56 @@ class JvmSupportIntake( receipt = persisted.receipt, ) }.getOrElse { - runCatching { deletePrivateDescriptorDurably(pendingDescriptor()) } + 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 = null + publishStateLocked( + latestCompletedFor(activeAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle, + activeAccountIdentity, + ) + } + } + return + } + } + private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) private fun restoreCompletedSubmissions(): List = @@ -1295,6 +1344,9 @@ class JvmSupportIntake( 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) actualStateAccountIdentity == activeAccountIdentity -> state.value = actualState else -> state.value = latestCompletedFor(activeAccountIdentity) @@ -1508,3 +1560,5 @@ private const val READ_ONLY_SUPPORT_MESSAGE = 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." From 2ce9f2e5f3567f54bbfa6209c696856fa9ac857b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 07:19:51 +0200 Subject: [PATCH 20/29] fix(support): protect restoration recovery --- .../nextcloudnative/app/NextcloudNativeApp.kt | 4 +- .../app/JvmSupportIntakeTest.kt | 61 ++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 95 +++++++++++++++++-- .../public/screenshots/capture-manifest.json | 2 +- 4 files changed, 151 insertions(+), 11 deletions(-) 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 1561b7a9b..9cda91b70 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12522,6 +12522,8 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) submissionState is SupportDiagnosticsSubmissionState.Packaging || submissionState is SupportDiagnosticsSubmissionState.Cancelling || 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 @@ -12782,7 +12784,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) } Text(if (exporting) "Preparing..." else "Save a copy") } - if (submissionBusy) { + if (submissionCancellable) { OutlinedButton(onClick = { scope.launch { services.cancelSupportDiagnosticsSubmission() } }) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 4fa8b3e85..ef8571947 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -10,6 +10,7 @@ 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 @@ -580,6 +581,17 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -779,6 +791,45 @@ class JvmSupportIntakeTest { } } + @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 @@ -1258,8 +1309,10 @@ class JvmSupportIntakeTest { beforeSubmissionPreparation: () -> Unit = {}, beforeBundlePackaging: () -> Unit = {}, privateFileDelete: (File) -> Boolean = File::delete, + pendingDescriptorRead: (File) -> String = { descriptor -> descriptor.readText() }, submissionStorageBlocked: Boolean = false, pendingTemporaryBeforeInitialization: Boolean = false, + archiveTemporaryBeforeInitialization: Boolean = false, invalidPendingBeforeInitialization: Boolean = false, ): Fixture { val root = createTempDirectory("support-intake-test").toFile() @@ -1274,6 +1327,11 @@ class JvmSupportIntakeTest { 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") @@ -1308,6 +1366,7 @@ class JvmSupportIntakeTest { beforeSubmissionPreparation = beforeSubmissionPreparation, beforeBundlePackaging = beforeBundlePackaging, privateFileDelete = privateFileDelete, + pendingDescriptorRead = pendingDescriptorRead, ) } @@ -1348,6 +1407,7 @@ class JvmSupportIntakeTest { val beforeSubmissionPreparation: () -> Unit, val beforeBundlePackaging: () -> Unit, val privateFileDelete: (File) -> Boolean, + val pendingDescriptorRead: (File) -> String, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -1368,6 +1428,7 @@ class JvmSupportIntakeTest { beforeSubmissionPreparation = beforeSubmissionPreparation, beforeBundlePackaging = beforeBundlePackaging, privateFileDelete = privateFileDelete, + pendingDescriptorRead = pendingDescriptorRead, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index c3b8f52ef..88da591ff 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -6,8 +6,10 @@ 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 @@ -64,6 +66,9 @@ class JvmSupportIntake( private val beforeSubmissionPreparation: () -> Unit = {}, private val beforeBundlePackaging: () -> Unit = {}, private val privateFileDelete: (File) -> Boolean = File::delete, + private val pendingDescriptorRead: (File) -> String = { descriptor -> + descriptor.readText(Charsets.UTF_8) + }, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -84,6 +89,7 @@ class JvmSupportIntake( private val shutdownRequested = AtomicBoolean(false) private val operationActive = AtomicBoolean(false) private val rejectedPendingDescriptorCleanup = AtomicBoolean(false) + private val pendingDescriptorRestorePending = AtomicBoolean(false) private val lock = Any() private val persistenceLock = Any() private var activeAccountIdentity: String? = null @@ -101,10 +107,13 @@ class JvmSupportIntake( val storageFailure = runCatching { preparePrivateStorage() }.exceptionOrNull() val restored = if (storageFailure == null) restorePendingSubmission() else null val restoredCompleted = if (storageFailure == null) restoreCompletedSubmissions() else emptyList() - if (storageFailure == null) pruneTemporaryReports(restored?.archive) + if (storageFailure == null && !pendingDescriptorRestorePending.get()) { + pruneTemporaryReports(restored?.archive) + } synchronized(lock) { storageUnavailableMessage = when { storageFailure != null -> SUPPORT_STORAGE_UNAVAILABLE_MESSAGE + pendingDescriptorRestorePending.get() -> SUPPORT_PENDING_RESTORE_MESSAGE rejectedPendingDescriptorCleanup.get() -> SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE else -> null } @@ -986,7 +995,12 @@ class JvmSupportIntake( } .forEach(::deletePrivateFileOrRetry) temporaryRoot.listFiles().orEmpty() - .filter { file -> file.isFile && file.name.matches(SUPPORT_PENDING_TEMPORARY_FILE_PATTERN) } + .filter { file -> + file.isFile && ( + file.name.matches(SUPPORT_PENDING_TEMPORARY_FILE_PATTERN) || + file.name.matches(SUPPORT_ARCHIVE_TEMPORARY_FILE_PATTERN) + ) + } .forEach(::deletePrivateFileOrRetry) } @@ -1127,13 +1141,19 @@ class JvmSupportIntake( syncDirectoryEntry(parent) } - private fun restorePendingSubmission(): PendingSubmission? = runCatching { + private fun restorePendingSubmission(scheduleRetry: Boolean = true): PendingSubmission? = try { val descriptor = pendingDescriptor() - if (!descriptor.isFile) return@runCatching null - require(descriptor.length() in 1L..MAX_PENDING_DESCRIPTOR_BYTES) + 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(), - descriptor.readText(Charsets.UTF_8), + pendingDescriptorRead(descriptor), ) require(persisted.archiveName == null || persisted.archiveName.matches(SUPPORT_TEMPORARY_FILE_PATTERN)) require(persisted.idempotencyKey.matches(SUPPORT_IDEMPOTENCY_PATTERN)) @@ -1157,13 +1177,20 @@ class JvmSupportIntake( File(temporaryRoot, archiveName).absoluteFile.normalize().also { candidate -> require(candidate.parentFile == temporaryRoot.absoluteFile.normalize()) if (archiveIsRetained) { - require(candidate.isFile && candidate.length() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + val archiveAttributes = try { + Files.readAttributes(candidate.toPath(), BasicFileAttributes::class.java) + } catch (failure: NoSuchFileException) { + throw IllegalArgumentException("Pending support archive is missing.", failure) + } + require(archiveAttributes.isRegularFile) + require(archiveAttributes.size() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) restrictOwnerOnlyFile(candidate) } else { - candidate.delete() + deletePrivateFileOrRetry(candidate) } } }?.takeIf { archiveIsRetained } + pendingDescriptorRestorePending.set(false) PendingSubmission( archive = archive, metadata = persisted.metadata, @@ -1176,7 +1203,15 @@ class JvmSupportIntake( retryNotBeforeEpochMillis = retryNotBeforeEpochMillis, receipt = persisted.receipt, ) - }.getOrElse { + } 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() } @@ -1227,6 +1262,44 @@ class JvmSupportIntake( } } + 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 = if (rejectedPendingDescriptorCleanup.get()) { + SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE + } else { + null + } + val visibleCompleted = latestCompletedFor(activeAccountIdentity) + publishStateLocked( + storageUnavailableMessage?.let { unavailableMessage -> + SupportDiagnosticsSubmissionState.Unsupported(unavailableMessage) + } ?: restored?.let { + SupportDiagnosticsSubmissionState.RetryableFailure( + if (restored.cancellationPending) { + "Cancellation was interrupted. Retry safely to reconcile and delete the private report." + } else if (restored.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 = restored.outcomeAmbiguous, + ) + } ?: visibleCompleted?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle, + restored?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, + ) + } + return + } + } + private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) private fun restoreCompletedSubmissions(): List = @@ -1533,6 +1606,8 @@ 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") @@ -1553,6 +1628,8 @@ private const val SUPPORT_RECOVERY_MAX_AGE_MILLIS = 30L * 24L * 60L * 60L * 1_00 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_PENDING_RESTORE_MESSAGE = + "Private 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 = diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index a1f1dd392..82d4e07cc 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "7c8c0f821ae960b6bcd45cb9208e6273611863c477aa5105cc50020bd12cc787", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "3f94d5db5986a235c8f8cac9011145cf56308e61d224ae3f2e5980fa83089b21", "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", From 0d2fa63228b6e9590e3da02a3df8a664ff6dfb88 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 08:13:05 +0200 Subject: [PATCH 21/29] fix(support): preserve retained report access --- .../nextcloudnative/app/NextcloudNativeApp.kt | 54 +++++- .../app/SupportDiagnosticsTest.kt | 12 ++ .../app/DesktopNextcloudServices.kt | 15 +- .../app/JvmSupportIntakeTest.kt | 35 ++++ .../nextcloudnative/app/JvmSupportIntake.kt | 154 ++++++++++-------- .../public/screenshots/capture-manifest.json | 2 +- 6 files changed, 203 insertions(+), 69 deletions(-) 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 9cda91b70..9380e7c35 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12515,6 +12515,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) var confirmSend by rememberSaveable { mutableStateOf(false) } var confirmDiscard by rememberSaveable { mutableStateOf(false) } var showPreview by rememberSaveable { mutableStateOf(false) } + var reportPageIndex by rememberSaveable { mutableStateOf(0) } val submissionState by remember(services) { services.supportDiagnosticsSubmissionStates() }.collectAsState(SupportDiagnosticsSubmissionState.Initializing) @@ -12861,6 +12862,12 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) 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." @@ -12870,7 +12877,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) - current.reports.forEach { report -> + reportPage.items.forEach { report -> Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { Text( "Support code: ${report.supportCode}", @@ -12900,6 +12907,27 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) } } } + 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, @@ -12923,6 +12951,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/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 75c347e6e..f14b88634 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3472,12 +3472,21 @@ class DesktopNextcloudServices( override fun loadSession(): NextcloudSession? { return sessionPublicationGuard.serialize { - val server = preferences.get(KEY_SERVER, null) ?: return@serialize null - val login = preferences.get(KEY_LOGIN, null) ?: return@serialize null + 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) - ?: return@serialize null + 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) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index ef8571947..c28b5acc1 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -206,6 +206,37 @@ class JvmSupportIntakeTest { } } + @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 preservesCompletedReceiptsForEachAccountAndReport() = runBlocking { testFixture().use { fixture -> @@ -1310,6 +1341,7 @@ class JvmSupportIntakeTest { beforeBundlePackaging: () -> Unit = {}, privateFileDelete: (File) -> Boolean = File::delete, pendingDescriptorRead: (File) -> String = { descriptor -> descriptor.readText() }, + completedDescriptorRead: (File) -> String = { descriptor -> descriptor.readText() }, submissionStorageBlocked: Boolean = false, pendingTemporaryBeforeInitialization: Boolean = false, archiveTemporaryBeforeInitialization: Boolean = false, @@ -1367,6 +1399,7 @@ class JvmSupportIntakeTest { beforeBundlePackaging = beforeBundlePackaging, privateFileDelete = privateFileDelete, pendingDescriptorRead = pendingDescriptorRead, + completedDescriptorRead = completedDescriptorRead, ) } @@ -1408,6 +1441,7 @@ class JvmSupportIntakeTest { val beforeBundlePackaging: () -> Unit, val privateFileDelete: (File) -> Boolean, val pendingDescriptorRead: (File) -> String, + val completedDescriptorRead: (File) -> String, ) : AutoCloseable { val intake = newIntake() val statusUrl: String get() = server.url("/r/abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678").toString() @@ -1429,6 +1463,7 @@ class JvmSupportIntakeTest { beforeBundlePackaging = beforeBundlePackaging, privateFileDelete = privateFileDelete, pendingDescriptorRead = pendingDescriptorRead, + completedDescriptorRead = completedDescriptorRead, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 88da591ff..6fb205222 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -69,6 +69,9 @@ class JvmSupportIntake( private val pendingDescriptorRead: (File) -> String = { descriptor -> descriptor.readText(Charsets.UTF_8) }, + private val completedDescriptorRead: (File) -> String = { descriptor -> + descriptor.readText(Charsets.UTF_8) + }, ) : AutoCloseable { private val baseUrl = supportBaseUrl.toHttpUrl() private val client = client.newBuilder() @@ -90,6 +93,7 @@ class JvmSupportIntake( 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 @@ -107,38 +111,19 @@ class JvmSupportIntake( 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 = when { - storageFailure != null -> SUPPORT_STORAGE_UNAVAILABLE_MESSAGE - pendingDescriptorRestorePending.get() -> SUPPORT_PENDING_RESTORE_MESSAGE - rejectedPendingDescriptorCleanup.get() -> SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE - else -> null - } + storageUnavailableMessage = storageFailure?.let { SUPPORT_STORAGE_UNAVAILABLE_MESSAGE } + ?: currentRecoveryUnavailableMessage() pending = restored completedSubmissions = restoredCompleted scheduleCompletedExpiryLocked() - val visibleCompleted = latestCompletedFor(activeAccountIdentity) - publishStateLocked( - storageUnavailableMessage?.let { unavailableMessage -> - SupportDiagnosticsSubmissionState.Unsupported(unavailableMessage) - } ?: restored?.let { - SupportDiagnosticsSubmissionState.RetryableFailure( - if (restored.cancellationPending) { - "Cancellation was interrupted. Retry safely to reconcile and delete the private report." - } else if (restored.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 = restored.outcomeAmbiguous, - ) - } ?: visibleCompleted?.let { submittedStateFor(it.originAccountIdentity) } - ?: SupportDiagnosticsSubmissionState.Idle, - restored?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, - ) + publishRecoveredStateLocked() } } finally { initialized.complete(Unit) @@ -1249,13 +1234,8 @@ class JvmSupportIntake( rejectedPendingDescriptorCleanup.set(false) synchronized(lock) { if (storageUnavailableMessage == SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE) { - storageUnavailableMessage = null - publishStateLocked( - latestCompletedFor(activeAccountIdentity) - ?.let { submittedStateFor(it.originAccountIdentity) } - ?: SupportDiagnosticsSubmissionState.Idle, - activeAccountIdentity, - ) + storageUnavailableMessage = currentRecoveryUnavailableMessage() + publishRecoveredStateLocked() } } return @@ -1271,55 +1251,99 @@ class JvmSupportIntake( pruneTemporaryReports(restored?.archive) synchronized(lock) { pending = restored - storageUnavailableMessage = if (rejectedPendingDescriptorCleanup.get()) { - SUPPORT_REJECTED_PENDING_CLEANUP_MESSAGE - } else { - null - } - val visibleCompleted = latestCompletedFor(activeAccountIdentity) - publishStateLocked( - storageUnavailableMessage?.let { unavailableMessage -> - SupportDiagnosticsSubmissionState.Unsupported(unavailableMessage) - } ?: restored?.let { - SupportDiagnosticsSubmissionState.RetryableFailure( - if (restored.cancellationPending) { - "Cancellation was interrupted. Retry safely to reconcile and delete the private report." - } else if (restored.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 = restored.outcomeAmbiguous, - ) - } ?: visibleCompleted?.let { submittedStateFor(it.originAccountIdentity) } - ?: SupportDiagnosticsSubmissionState.Idle, - restored?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, - ) + 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) } + ?: SupportDiagnosticsSubmissionState.Idle, + pendingSubmission?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, + ) + } + private fun pendingDescriptor(): File = File(temporaryRoot, SUPPORT_PENDING_DESCRIPTOR) - private fun restoreCompletedSubmissions(): List = - temporaryRoot.listFiles().orEmpty() - .filter { descriptor -> descriptor.isFile && descriptor.name.matches(SUPPORT_COMPLETED_FILE_PATTERN) } - .mapNotNull(::restoreCompletedSubmission) + 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? = runCatching { - require(descriptor.length() in 1L..MAX_COMPLETED_DESCRIPTOR_BYTES) + 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(), - descriptor.readText(Charsets.UTF_8), + 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) - }.getOrElse { + } catch (_: IOException) { + completedDescriptorRestorePending.set(true) + null + } catch (_: SecurityException) { + completedDescriptorRestorePending.set(true) + null + } catch (_: Throwable) { runCatching { deletePrivateDescriptorDurably(descriptor) } null } @@ -1630,6 +1654,8 @@ 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_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 = diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 82d4e07cc..90673e5ba 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "3f94d5db5986a235c8f8cac9011145cf56308e61d224ae3f2e5980fa83089b21", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "6a60986896c10ca862aecb24c2bffdf5fcb8df5764da2b8641c73a77ac03fd1d", "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", From 42999cdb741a93662712846398fdc036ccd450dd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 08:24:43 +0200 Subject: [PATCH 22/29] fix(support): retry rejected receipt cleanup --- .../app/JvmSupportIntakeTest.kt | 23 +++++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 29 +++++++++++-------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index c28b5acc1..f06dc60c2 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -237,6 +237,24 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -1346,6 +1364,7 @@ class JvmSupportIntakeTest { 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") @@ -1368,6 +1387,10 @@ class JvmSupportIntakeTest { 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", diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 6fb205222..832fe1397 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -1344,10 +1344,19 @@ class JvmSupportIntake( completedDescriptorRestorePending.set(true) null } catch (_: Throwable) { - runCatching { deletePrivateDescriptorDurably(descriptor) } + 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") @@ -1510,21 +1519,17 @@ class JvmSupportIntake( ?.let { submittedStateFor(it.originAccountIdentity) } ?: SupportDiagnosticsSubmissionState.Idle } - scope.launch { deleteCompletedDescriptorsWithRetry(expired) } + scope.launch { + deleteCompletedDescriptorsWithRetry(expired.map { submission -> completedDescriptor(submission.recordId) }) + } return true } - private suspend fun deleteCompletedDescriptorsWithRetry(submissions: List) { - var remaining = submissions + private suspend fun deleteCompletedDescriptorsWithRetry(descriptors: List) { + var remaining = descriptors while (remaining.isNotEmpty()) { - remaining = synchronized(persistenceLock) { - remaining.filterNot { submission -> - runCatching { - deletePrivateDescriptorDurably(completedDescriptor(submission.recordId)) - }.isSuccess - } - } - if (remaining.isNotEmpty()) delay(SUPPORT_DESCRIPTOR_DELETE_RETRY_MILLIS) + remaining = remaining.filterNot(::deleteCompletedDescriptorSafely) + if (remaining.isNotEmpty()) delay(descriptorCleanupRetryMillis) } } } From 5583cd0d232b014ad5b8ec443d80c7da87a77ae4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 08:36:27 +0200 Subject: [PATCH 23/29] fix(support): preserve cross-account busy state --- .../app/JvmSupportIntakeTest.kt | 30 +++++++++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 15 ++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index f06dc60c2..3fbfc263f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -460,6 +460,36 @@ class JvmSupportIntakeTest { } } + @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 diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 832fe1397..b80931658 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -184,7 +184,7 @@ class JvmSupportIntake( originAccountIdentity, ) } catch (cancellation: CancellationException) { - publishState(SupportDiagnosticsSubmissionState.Cancelled) + publishState(SupportDiagnosticsSubmissionState.Cancelled, originAccountIdentity) throw cancellation } catch (failure: Throwable) { if (cancellationRequested.get()) { @@ -194,7 +194,7 @@ class JvmSupportIntake( 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()) { @@ -329,6 +329,7 @@ class JvmSupportIntake( ) } operationActive.set(false) + refreshVisibleStateLocked() } } @@ -1438,6 +1439,8 @@ class JvmSupportIntake( 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 { SupportDiagnosticsSubmissionState.Idle } @@ -1454,6 +1457,9 @@ class JvmSupportIntake( requireNotNull(storageUnavailableMessage), ) pendingSubmission != null -> publishStateLocked(actualState, pendingSubmission.originAccountIdentity) + operationActive.get() && actualStateAccountIdentity != activeAccountIdentity -> { + state.value = blockedByAnotherAccountOperation() + } actualStateAccountIdentity == activeAccountIdentity -> state.value = actualState else -> state.value = latestCompletedFor(activeAccountIdentity) ?.let { submittedStateFor(it.originAccountIdentity) } @@ -1461,6 +1467,11 @@ class JvmSupportIntake( } } + 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 latestCompletedFor(accountIdentity: String?): CompletedSubmission? = completedSubmissions.filter { it.originAccountIdentity == accountIdentity && it.isRetained(System.currentTimeMillis()) From 01885571817a5c776999e6a1733ca5e7f5a06f08 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 09:42:02 +0200 Subject: [PATCH 24/29] fix(support): expose retained report deletion --- .../nextcloudnative/app/NextcloudNativeApp.kt | 64 ++++++- .../nextcloudnative/app/NextcloudPlatform.kt | 6 + .../nextcloudnative/app/SupportDiagnostics.kt | 9 + .../app/DesktopNextcloudServices.kt | 4 + .../app/JvmSupportIntakeTest.kt | 49 +++++ .../nextcloudnative/app/JvmSupportIntake.kt | 167 +++++++++++++++++- .../public/screenshots/capture-manifest.json | 2 +- 7 files changed, 291 insertions(+), 10 deletions(-) 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 9380e7c35..622d6b41f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12516,21 +12516,27 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) 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 + val submissionUnavailable = submissionState is SupportDiagnosticsSubmissionState.Unsupported || + submissionState is SupportDiagnosticsSubmissionState.AccountRequired - LaunchedEffect(submissionBusy, submissionPending) { - if (submissionBusy || submissionPending) confirmClear = false + LaunchedEffect(submissionBusy, submissionPending, submissionUnavailable) { + if (submissionBusy || submissionPending || submissionUnavailable) { + confirmClear = false + confirmSend = false + } } if (confirmClear) { @@ -12621,6 +12627,41 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) ) } + 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, @@ -12809,6 +12850,11 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) 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, @@ -12823,6 +12869,10 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) 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()) @@ -12904,6 +12954,14 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) TextButton(onClick = { services.openExternalUrl(report.statusUrl) }) { Text("Open private status") } + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + onClick = { reportDeletionTarget = report }, + ) { + Text("Delete report") + } } } } 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 34f7cd19a..780c28253 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -534,6 +534,12 @@ interface NextcloudPlatformServices { /** 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 53ca792e9..977f6f477 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -186,10 +186,12 @@ sealed interface 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) @@ -202,6 +204,7 @@ sealed interface SupportDiagnosticsSubmissionState { data class SubmittedReport( val supportCode: String, val statusUrl: String, + val deletionUrl: String, val retentionUntil: String, ) data class Submitted(val reports: List) : SupportDiagnosticsSubmissionState { @@ -216,6 +219,12 @@ sealed interface SupportDiagnosticsSubmissionState { 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, 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 f14b88634..3938a2851 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3391,6 +3391,10 @@ class DesktopNextcloudServices( 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()), diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 3fbfc263f..182d891fb 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -198,6 +198,7 @@ class JvmSupportIntakeTest { 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) @@ -206,6 +207,54 @@ class JvmSupportIntakeTest { } } + @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 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) diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index b80931658..424f012cd 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -168,9 +168,7 @@ class JvmSupportIntake( } val originAccountIdentity = synchronized(lock) { activeAccountIdentity } if (originAccountIdentity == null) { - publishState(SupportDiagnosticsSubmissionState.Rejected( - "Sign in before sending a private support report.", - )) + publishState(SupportDiagnosticsSubmissionState.AccountRequired) return@withContext } cancellationRequested.set(false) @@ -359,6 +357,39 @@ class JvmSupportIntake( } } + 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) { @@ -416,6 +447,121 @@ class JvmSupportIntake( } } + 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 { + val next = synchronized(lock) { + completedSubmissions = completedSubmissions.filterNot { submission -> + submission.recordId == completed.recordId + } + scheduleCompletedExpiryLocked() + latestCompletedFor(completed.originAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle + } + deleteCompletedDescriptorOrRetry(completedDescriptor(completed.recordId)) + publishState(next, completed.originAccountIdentity) + return SupportDiagnosticsDeletionResult.Deleted + } + + private fun failCompletedDeletion( + completed: CompletedSubmission, + message: String, + ): SupportDiagnosticsDeletionResult.Failed { + publishState(submittedStateFor(completed.originAccountIdentity), completed.originAccountIdentity) + return SupportDiagnosticsDeletionResult.Failed(message) + } + private suspend fun packageSubmission(submission: PendingSubmission): Boolean { if (cancellationRequested.get()) { finishCancelled(submission) @@ -1300,7 +1446,7 @@ class JvmSupportIntake( outcomeAmbiguous = submission.outcomeAmbiguous, ) } ?: visibleCompleted?.let { submittedStateFor(it.originAccountIdentity) } - ?: SupportDiagnosticsSubmissionState.Idle, + ?: idleStateForActiveAccountLocked(), pendingSubmission?.originAccountIdentity ?: visibleCompleted?.originAccountIdentity, ) } @@ -1442,7 +1588,7 @@ class JvmSupportIntake( } else if (operationActive.get()) { blockedByAnotherAccountOperation() } else { - SupportDiagnosticsSubmissionState.Idle + idleStateForActiveAccountLocked() } } else { next @@ -1460,10 +1606,11 @@ class JvmSupportIntake( 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) } - ?: SupportDiagnosticsSubmissionState.Idle + ?: idleStateForActiveAccountLocked() } } @@ -1472,6 +1619,13 @@ class JvmSupportIntake( "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()) @@ -1492,6 +1646,7 @@ class JvmSupportIntake( SupportDiagnosticsSubmissionState.SubmittedReport( supportCode = completed.receipt.supportCode, statusUrl = completed.receipt.statusUrl, + deletionUrl = completed.receipt.deletionUrl, retentionUntil = completed.receipt.retentionUntil, ) }, diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 90673e5ba..cd128ef57 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "6a60986896c10ca862aecb24c2bffdf5fcb8df5764da2b8641c73a77ac03fd1d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "a76bca5eab670d6a307683492293a1d6fde6a33b05b73b9d81a66dc8b76772ee", "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", From 241ba2ebf89c431bba885b336c5623bd0332bcba Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:47:16 +0000 Subject: [PATCH 25/29] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index cd128ef57..c46c7f23b 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -369,7 +369,7 @@ "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": "fc1e31f43071ed5c1db8c90dce4a66a6d1e3622f540d60a970a22d937bc4cfa9", + "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": "55a863899162756d23e0f154168eda3145638513686f1768ef022669b0b7a720", + "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", From f5bb6510914ba487cb9808aaa4ed4546e797d8a8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 10:14:33 +0200 Subject: [PATCH 26/29] fix(support): retain ambiguous cancellation recovery --- .../app/JvmSupportIntakeTest.kt | 74 ++++++++- .../nextcloudnative/app/JvmSupportIntake.kt | 143 ++++++++++++------ 2 files changed, 166 insertions(+), 51 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 182d891fb..c8d7cdb9a 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -125,6 +125,39 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -561,7 +594,16 @@ class JvmSupportIntakeTest { 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 } @@ -800,6 +842,13 @@ class JvmSupportIntakeTest { 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()) } @@ -832,9 +881,13 @@ class JvmSupportIntakeTest { } @Test - fun cancellationReconcilesAndDeletesReceiptAcceptedDuringUpload() = runBlocking { - testFixture().use { fixture -> + 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()) @@ -846,10 +899,13 @@ class JvmSupportIntakeTest { submission.join() assertIs(fixture.intake.states().value) - val reconcile = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + 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"], reconcile.headers["Idempotency-Key"]) - assertEquals("GET", reconcile.method) + 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()) @@ -1439,6 +1495,8 @@ class JvmSupportIntakeTest { 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, @@ -1502,6 +1560,8 @@ class JvmSupportIntakeTest { privateFileDelete = privateFileDelete, pendingDescriptorRead = pendingDescriptorRead, completedDescriptorRead = completedDescriptorRead, + cancellationReconcileWindowMillis = cancellationReconcileWindowMillis, + cancellationReconcilePollMillis = cancellationReconcilePollMillis, ) } @@ -1544,6 +1604,8 @@ class JvmSupportIntakeTest { 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() @@ -1566,6 +1628,8 @@ class JvmSupportIntakeTest { privateFileDelete = privateFileDelete, pendingDescriptorRead = pendingDescriptorRead, completedDescriptorRead = completedDescriptorRead, + cancellationReconcileWindowMillis = cancellationReconcileWindowMillis, + cancellationReconcilePollMillis = cancellationReconcilePollMillis, ).also { intake -> intake.setActiveAccountIdentity(TEST_ACCOUNT_IDENTITY) runBlocking { intake.awaitInitialization() } diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 424f012cd..2c7b016a9 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -72,6 +72,8 @@ class JvmSupportIntake( 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() @@ -106,6 +108,8 @@ class JvmSupportIntake( init { require(descriptorCleanupRetryMillis > 0L) + require(cancellationReconcileWindowMillis in 0L..MAX_CANCELLATION_RECONCILE_WINDOW_MILLIS) + require(cancellationReconcilePollMillis > 0L) scope.launch { try { val storageFailure = runCatching { preparePrivateStorage() }.exceptionOrNull() @@ -622,7 +626,7 @@ class JvmSupportIntake( return true } - private fun upload(submission: PendingSubmission) { + private suspend fun upload(submission: PendingSubmission) { if (cancellationRequested.get()) { finishCancelled(submission) return @@ -735,54 +739,94 @@ class JvmSupportIntake( } } - private fun reconcileAfterAmbiguousResult(submission: PendingSubmission, uploadFailure: IOException) { + 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() - val call = client.newCall(request) - if (!registerActiveCall(submission, call, allowCancellationRequested = true)) { - call.cancel() - if (synchronized(lock) { pending === submission }) { + 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, - "The upload result still needs to be reconciled. You can retry it safely.", - ambiguous = true, + 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 - } - try { - call.execute().use { response -> - val responseText = response.readBoundedText() + return + } finally { activeCall.compareAndSet(call, null) - when { - response.isSuccessful -> finishReceived(submission, decodeReceipt(responseText)) - response.code == 404 && cancellationRequested.get() -> finishCancelled(submission) - response.code == 404 -> retainForRetry(submission, "The upload did not complete. You can retry it safely.", false) - else -> retainForRetry( + } + 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 } } - } 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, - ) - } catch (_: IllegalArgumentException) { - retainForRetry(submission, "Obiente Support returned an invalid receipt.", true) - } finally { - activeCall.compareAndSet(call, null) } } @@ -1306,22 +1350,25 @@ class JvmSupportIntake( val archiveAgeMillis = (nowEpochMillis - persisted.createdAtEpochMillis).coerceAtLeast(0L) val archiveIsRetained = archiveAgeMillis <= SUPPORT_TEMPORARY_MAX_AGE_MILLIS val archive = persisted.archiveName?.let { archiveName -> - File(temporaryRoot, archiveName).absoluteFile.normalize().also { candidate -> - require(candidate.parentFile == temporaryRoot.absoluteFile.normalize()) - if (archiveIsRetained) { - val archiveAttributes = try { - Files.readAttributes(candidate.toPath(), BasicFileAttributes::class.java) - } catch (failure: NoSuchFileException) { - throw IllegalArgumentException("Pending support archive is missing.", failure) - } - require(archiveAttributes.isRegularFile) - require(archiveAttributes.size() in 1L..MAX_SUPPORT_ARCHIVE_BYTES) + 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) - } else { - deletePrivateFileOrRetry(candidate) + candidate } } - }?.takeIf { archiveIsRetained } + } pendingDescriptorRestorePending.set(false) PendingSubmission( archive = archive, @@ -1823,6 +1870,10 @@ private const val SUPPORT_RECOVERY_MAX_AGE_MILLIS = 30L * 24L * 60L * 60L * 1_00 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 = From ccd84d31cb9b381ff836c102428f7589daa1ec91 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 11:33:52 +0200 Subject: [PATCH 27/29] fix(support): harden retained report lifecycle --- .../AndroidNextcloudServices.kt | 5 ++ .../nextcloudnative/app/NextcloudNativeApp.kt | 2 +- .../app/JvmSupportIntakeTest.kt | 87 +++++++++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 34 +++++++- .../public/screenshots/capture-manifest.json | 2 +- 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 330bf93ee..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 @@ -635,6 +636,10 @@ internal class AndroidNextcloudServices( 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()), 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 622d6b41f..7a1c04585 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -12582,7 +12582,7 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) "The sanitized report, the description you reviewed, and app release details will be sent to Obiente Support.", ) Text( - "It does not include account credentials, server URLs, filenames, file contents, or a stable device identifier. Private report data is retained for 30 days unless you delete it first.", + "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, ) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index c8d7cdb9a..ea60d6554 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -275,6 +275,52 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -1454,6 +1500,47 @@ class JvmSupportIntakeTest { } } + @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 -> diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 2c7b016a9..1556d326b 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -544,6 +544,12 @@ class JvmSupportIntake( } 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 @@ -553,7 +559,6 @@ class JvmSupportIntake( ?.let { submittedStateFor(it.originAccountIdentity) } ?: SupportDiagnosticsSubmissionState.Idle } - deleteCompletedDescriptorOrRetry(completedDescriptor(completed.recordId)) publishState(next, completed.originAccountIdentity) return SupportDiagnosticsDeletionResult.Deleted } @@ -562,7 +567,12 @@ class JvmSupportIntake( completed: CompletedSubmission, message: String, ): SupportDiagnosticsDeletionResult.Failed { - publishState(submittedStateFor(completed.originAccountIdentity), completed.originAccountIdentity) + val next = synchronized(lock) { + latestCompletedFor(completed.originAccountIdentity) + ?.let { submittedStateFor(it.originAccountIdentity) } + ?: SupportDiagnosticsSubmissionState.Idle + } + publishState(next, completed.originAccountIdentity) return SupportDiagnosticsDeletionResult.Failed(message) } @@ -640,6 +650,7 @@ class JvmSupportIntake( 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.") @@ -1227,6 +1238,7 @@ class JvmSupportIntake( context = submission.context, cancellationPending = submission.cancellationPending, outcomeAmbiguous = submission.outcomeAmbiguous, + latestUploadAttemptAtEpochMillis = submission.latestUploadAttemptAtEpochMillis, retryNotBeforeEpochMillis = submission.retryNotBeforeEpochMillis, receipt = submission.receipt, ), @@ -1335,6 +1347,7 @@ class JvmSupportIntake( 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) @@ -1345,7 +1358,12 @@ class JvmSupportIntake( } val recoveryDeadlineEpochMillis = persisted.receipt ?.let { receipt -> Instant.parse(receipt.retentionUntil).toEpochMilli() } - ?: persisted.createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + ?: 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 @@ -1379,6 +1397,7 @@ class JvmSupportIntake( context = persisted.context, cancellationPending = persisted.cancellationPending, outcomeAmbiguous = persisted.outcomeAmbiguous, + latestUploadAttemptAtEpochMillis = persisted.latestUploadAttemptAtEpochMillis, retryNotBeforeEpochMillis = retryNotBeforeEpochMillis, receipt = persisted.receipt, ) @@ -1565,6 +1584,7 @@ class JvmSupportIntake( val context: PreparedSupportSubmissionContext, var cancellationPending: Boolean = false, var outcomeAmbiguous: Boolean = false, + var latestUploadAttemptAtEpochMillis: Long? = null, var retryNotBeforeEpochMillis: Long? = null, var receipt: SupportIntakeReceipt? = null, ) { @@ -1573,7 +1593,12 @@ class JvmSupportIntake( fun recoveryExpired(nowEpochMillis: Long): Boolean { val deadline = receipt ?.let { value -> runCatching { Instant.parse(value.retentionUntil).toEpochMilli() }.getOrNull() } - ?: createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + ?: if (outcomeAmbiguous) { + (latestUploadAttemptAtEpochMillis ?: createdAtEpochMillis) + .saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + } else { + createdAtEpochMillis.saturatingAdd(SUPPORT_RECOVERY_MAX_AGE_MILLIS) + } return nowEpochMillis > deadline } } @@ -1599,6 +1624,7 @@ class JvmSupportIntake( val context: PreparedSupportSubmissionContext, val cancellationPending: Boolean = false, val outcomeAmbiguous: Boolean = true, + val latestUploadAttemptAtEpochMillis: Long? = null, val retryNotBeforeEpochMillis: Long? = null, val receipt: SupportIntakeReceipt? = null, ) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index c46c7f23b..b00c9abce 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -364,7 +364,7 @@ "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": "a76bca5eab670d6a307683492293a1d6fde6a33b05b73b9d81a66dc8b76772ee", + "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", From fbd134c853594bd1577d9205e6c5e8faafede1cf Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 11:50:12 +0200 Subject: [PATCH 28/29] fix(support): harden cancellation cleanup recovery --- .../app/JvmSupportIntakeTest.kt | 54 +++++++++++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 19 +++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index ea60d6554..29edc8af1 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -654,6 +654,27 @@ class JvmSupportIntakeTest { 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) @@ -726,6 +747,39 @@ class JvmSupportIntakeTest { } } + @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 diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 1556d326b..3b7c9f07c 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -405,15 +405,22 @@ class JvmSupportIntake( submission.outcomeAmbiguous = true val cancellationPersisted = persistPendingSafely(submission) val call = activeCall.getAndSet(null) - call?.cancel() 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) return true + if (call != null) { + publishState( + SupportDiagnosticsSubmissionState.Cancelling, + submission.originAccountIdentity, + ) + call.cancel() + return true + } } if (submission != null) { publishState(SupportDiagnosticsSubmissionState.RetryableFailure( @@ -1077,12 +1084,14 @@ class JvmSupportIntake( runCatching { val descriptor = pendingDescriptor() if (descriptor.isFile) { - val persistedIdempotencyKey = runCatching { + val persistedIdempotencyKey = try { json.decodeFromString( PersistedPendingSubmission.serializer(), - descriptor.readText(Charsets.UTF_8), + pendingDescriptorRead(descriptor), ).idempotencyKey - }.getOrNull() + } catch (_: Throwable) { + return@synchronized false + } if (persistedIdempotencyKey != submission.idempotencyKey) return@synchronized true } deletePrivateDescriptorDurably(descriptor) From 89bc3ded8e3109ab7c7e88efbb63e6c04bd19e80 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 14 Aug 2026 12:10:39 +0200 Subject: [PATCH 29/29] fix(support): protect receipt and archive recovery --- .../app/JvmSupportIntakeTest.kt | 52 ++++++++++++++++++- .../nextcloudnative/app/JvmSupportIntake.kt | 16 +++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt index 29edc8af1..b9fc39f99 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -17,6 +17,7 @@ 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 @@ -467,6 +468,28 @@ class JvmSupportIntakeTest { } } + @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 @@ -701,6 +724,28 @@ class JvmSupportIntakeTest { } } + @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 -> @@ -1633,6 +1678,7 @@ class JvmSupportIntakeTest { 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() }, @@ -1698,6 +1744,7 @@ class JvmSupportIntakeTest { beforeCallRegistration = beforeCallRegistration, beforeSubmissionPreparation = beforeSubmissionPreparation, beforeBundlePackaging = beforeBundlePackaging, + afterBundlePackaging = afterBundlePackaging, privateFileDelete = privateFileDelete, pendingDescriptorRead = pendingDescriptorRead, completedDescriptorRead = completedDescriptorRead, @@ -1712,6 +1759,7 @@ class JvmSupportIntakeTest { 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) @@ -1722,7 +1770,7 @@ class JvmSupportIntakeTest { "supportCode": "$supportCode", "status": "new", "statusUrl": "$statusUrl", - "deletionUrl": "$statusUrl", + "deletionUrl": "$deletionUrl", "createdAt": "$createdAt", "retentionUntil": "$resolvedRetentionUntil" } @@ -1742,6 +1790,7 @@ class JvmSupportIntakeTest { val beforeCallRegistration: () -> Unit, val beforeSubmissionPreparation: () -> Unit, val beforeBundlePackaging: () -> Unit, + val afterBundlePackaging: () -> Unit, val privateFileDelete: (File) -> Boolean, val pendingDescriptorRead: (File) -> String, val completedDescriptorRead: (File) -> String, @@ -1766,6 +1815,7 @@ class JvmSupportIntakeTest { beforeCallRegistration = beforeCallRegistration, beforeSubmissionPreparation = beforeSubmissionPreparation, beforeBundlePackaging = beforeBundlePackaging, + afterBundlePackaging = afterBundlePackaging, privateFileDelete = privateFileDelete, pendingDescriptorRead = pendingDescriptorRead, completedDescriptorRead = completedDescriptorRead, diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index 3b7c9f07c..057543398 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -65,6 +65,7 @@ class JvmSupportIntake( 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) @@ -592,8 +593,11 @@ class JvmSupportIntake( val destination = File(temporaryRoot, "support-${UUID.randomUUID()}.zip") val prepared = try { beforeBundlePackaging() - diagnostics.writeBundleForSubmission(destination, submission.context) + diagnostics.writeBundleForSubmission(destination, submission.context).also { + afterBundlePackaging() + } } catch (cancellation: CancellationException) { + deletePrivateFileOrRetry(destination) if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { finishCancelled(submission) } else { @@ -605,6 +609,7 @@ class JvmSupportIntake( } throw cancellation } catch (_: Throwable) { + deletePrivateFileOrRetry(destination) if (cancellationRequested.get() || synchronized(lock) { pending !== submission }) { finishCancelled(submission) } else { @@ -1053,6 +1058,15 @@ class JvmSupportIntake( 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 }