diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index ef6656099..3d1d1f004 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -121,6 +121,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.SupportDiagnosticsConversationResult import dev.obiente.nextcloudnative.app.SupportDiagnosticsDeletionResult import dev.obiente.nextcloudnative.app.SupportDiagnosticsExportResult import dev.obiente.nextcloudnative.app.SupportDiagnosticsSummary @@ -722,6 +723,17 @@ internal class AndroidNextcloudServices( deletionUrl: String, ): SupportDiagnosticsDeletionResult = supportIntake.deleteCompletedReport(deletionUrl) + override suspend fun refreshSubmittedSupportDiagnosticsReports(): SupportDiagnosticsConversationResult = + supportIntake.refreshCompletedReports() + + override suspend fun sendSubmittedSupportDiagnosticsMessage( + statusUrl: String, + message: String, + ): SupportDiagnosticsConversationResult = supportIntake.sendCompletedReportMessage(statusUrl, message) + + override suspend fun markSubmittedSupportDiagnosticsReportRead(statusUrl: String): Boolean = + supportIntake.markCompletedReportRead(statusUrl) + private fun supportDiagnosticFeatureState(): List = listOf( SupportDiagnosticFieldDraft("distribution", appUpdateSupport().channel.name.lowercase()), diff --git a/changes/unreleased/351-support-conversations.md b/changes/unreleased/351-support-conversations.md new file mode 100644 index 000000000..209522f09 --- /dev/null +++ b/changes/unreleased/351-support-conversations.md @@ -0,0 +1,7 @@ +category: feature +issue: 351 +pull: 404 +platforms: android, desktop +user-facing: yes + +Follow private support report statuses and messages in the app, receive an unread update indicator, and reply to Obiente Support without sharing the report capability. 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 43ca7923b..c8fbb9b73 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1881,6 +1881,21 @@ private fun AuthenticatedApp( val appUpdateResult by remember(services) { services.observeAppUpdateCheckResult() }.collectAsState(null) + val supportSubmissionState by remember(services) { + services.supportDiagnosticsSubmissionStates() + }.collectAsState(SupportDiagnosticsSubmissionState.Initializing) + val submittedSupportReports = (supportSubmissionState as? SupportDiagnosticsSubmissionState.Submitted) + ?.reports + .orEmpty() + val submittedSupportReportCodes = submittedSupportReports + .map(SupportDiagnosticsSubmissionState.SubmittedReport::supportCode) + LaunchedEffect(services, submittedSupportReportCodes) { + if (submittedSupportReportCodes.isEmpty()) return@LaunchedEffect + while (currentCoroutineContext().isActive) { + services.refreshSubmittedSupportDiagnosticsReports() + delay(SUPPORT_CONVERSATION_BACKGROUND_REFRESH_MILLIS) + } + } val pendingEditorNavigationRequests = remember(session) { mutableStateListOf() } @@ -3005,6 +3020,22 @@ private fun AuthenticatedApp( .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)), ) { + val supportUpdates = submittedSupportReports.filter { report -> + report.statusChanged || report.unreadMaintainerMessages > 0 + } + if (supportUpdates.isNotEmpty() && (screen != Screen.Root || destination != NextcloudDestination.Settings)) { + SupportUpdateAvailableBanner( + reports = supportUpdates, + enabled = !groupwareMutationInProgress, + onReview = { + if (!groupwareMutationInProgress) { + leaveAppWorkspace() + screen = Screen.Root + destination = NextcloudDestination.Settings + } + }, + ) + } val availableUpdate = (appUpdateResult as? AppUpdateCheckResult.Available) ?.takeIf { screen != Screen.Root || destination != NextcloudDestination.Settings } availableUpdate?.let { update -> @@ -3072,6 +3103,39 @@ private fun AuthenticatedApp( } } +@Composable +private fun SupportUpdateAvailableBanner( + reports: List, + enabled: Boolean, + onReview: () -> Unit, +) { + val messageCount = reports.sumOf(SupportDiagnosticsSubmissionState.SubmittedReport::unreadMaintainerMessages) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Obiente Support updated your report", style = MaterialTheme.typography.titleSmall) + Text( + when { + messageCount == 1 -> "You have one new private support message." + messageCount > 1 -> "You have $messageCount new private support messages." + reports.size == 1 -> "The status of your private support report changed." + else -> "The status of ${reports.size} private support reports changed." + }, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton(onClick = onReview, enabled = enabled) { Text("View support") } + } + } +} + @Composable private fun AppUpdateAvailableBanner( release: AppUpdateRelease, @@ -14246,6 +14310,8 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) var showPreview by rememberSaveable { mutableStateOf(false) } var reportPageIndex by rememberSaveable { mutableStateOf(0) } var reportDeletionTarget by remember { mutableStateOf(null) } + var reportReplyTarget by rememberSaveable { mutableStateOf(null) } + var reportReplyDraft by rememberSaveable { mutableStateOf("") } val submissionState by remember(services) { services.supportDiagnosticsSubmissionStates() }.collectAsState(SupportDiagnosticsSubmissionState.Initializing) @@ -14260,7 +14326,6 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) submissionState is SupportDiagnosticsSubmissionState.BlockedByAnotherAccount val submissionUnavailable = submissionState is SupportDiagnosticsSubmissionState.Unsupported || submissionState is SupportDiagnosticsSubmissionState.AccountRequired - LaunchedEffect(submissionBusy, submissionPending, submissionUnavailable) { if (submissionBusy || submissionPending || submissionUnavailable) { confirmClear = false @@ -14656,12 +14721,149 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) + OutlinedButton( + enabled = current.reports.none { report -> report.conversationLoading }, + onClick = { + scope.launch { + status = when (val result = services.refreshSubmittedSupportDiagnosticsReports()) { + SupportDiagnosticsConversationResult.Updated -> "Support status refreshed." + is SupportDiagnosticsConversationResult.Failed -> result.message + is SupportDiagnosticsConversationResult.Unsupported -> result.reason + } + } + }, + ) { Text("Refresh support status") } reportPage.items.forEach { report -> Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { Text( "Support code: ${report.supportCode}", style = MaterialTheme.typography.bodyMedium, ) + Text( + "Status: ${supportReportStatusLabel(report.status)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (report.conversationLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + if (report.statusChanged || report.unreadMaintainerMessages > 0) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Text( + buildString { + if (report.statusChanged) append("Support updated this report.") + if (report.statusChanged && report.unreadMaintainerMessages > 0) append(" ") + if (report.unreadMaintainerMessages > 0) { + append(report.unreadMaintainerMessages) + append(if (report.unreadMaintainerMessages == 1) " new message." else " new messages.") + } + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + TextButton( + onClick = { + scope.launch { + status = if ( + services.markSubmittedSupportDiagnosticsReportRead(report.statusUrl) + ) { + "Support update marked as read." + } else { + "The support update could not be marked as read." + } + } + }, + ) { Text("Mark read") } + } + } + } + report.conversationError?.let { message -> + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + report.messages.takeLast(MAX_VISIBLE_SUPPORT_MESSAGES).forEach { message -> + Surface( + color = if (message.author == SupportDiagnosticsMessageAuthor.Maintainer) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (message.author == SupportDiagnosticsMessageAuthor.Maintainer) { + "Obiente Support" + } else { + "You" + }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Text(message.body, style = MaterialTheme.typography.bodyMedium) + } + } + } + if (reportReplyTarget == report.statusUrl) { + OutlinedTextField( + value = reportReplyDraft, + onValueChange = { reportReplyDraft = it.take(MAX_SUPPORT_CONVERSATION_MESSAGE_LENGTH) }, + modifier = Modifier.fillMaxWidth(), + enabled = !report.conversationLoading, + label = { Text("Reply privately") }, + minLines = 2, + maxLines = 6, + supportingText = { + Text("This message is visible only to you and Obiente Support.") + }, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Button( + enabled = reportReplyDraft.isNotBlank() && !report.conversationLoading, + onClick = { + val message = reportReplyDraft + scope.launch { + status = when ( + val result = services.sendSubmittedSupportDiagnosticsMessage( + report.statusUrl, + message, + ) + ) { + SupportDiagnosticsConversationResult.Updated -> { + reportReplyDraft = "" + reportReplyTarget = null + "Reply sent privately." + } + is SupportDiagnosticsConversationResult.Failed -> result.message + is SupportDiagnosticsConversationResult.Unsupported -> result.reason + } + } + }, + ) { Text("Send reply") } + TextButton( + onClick = { + reportReplyDraft = "" + reportReplyTarget = null + }, + ) { Text("Cancel") } + } + } FlowRow( horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), @@ -14683,6 +14885,13 @@ private fun SupportDiagnosticsSettingsCard(services: NextcloudPlatformServices) TextButton(onClick = { services.openExternalUrl(report.statusUrl) }) { Text("Open private status") } + TextButton( + enabled = !report.conversationLoading, + onClick = { + reportReplyTarget = report.statusUrl + reportReplyDraft = "" + }, + ) { Text("Reply in app") } TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error, @@ -14761,6 +14970,19 @@ internal fun supportReportPage( } private const val SUPPORT_REPORT_PAGE_SIZE = 5 +private const val MAX_VISIBLE_SUPPORT_MESSAGES = 20 +private const val MAX_SUPPORT_CONVERSATION_MESSAGE_LENGTH = 8_192 +private const val SUPPORT_CONVERSATION_BACKGROUND_REFRESH_MILLIS = 5L * 60L * 1_000L + +private fun supportReportStatusLabel(status: String): String = when (status) { + "new" -> "Received" + "needs_information" -> "More information requested" + "accepted" -> "Accepted" + "duplicate" -> "Duplicate" + "resolved" -> "Resolved" + "rejected" -> "Closed" + else -> "Updated" +} @Composable internal fun DesktopStartOnLoginSettingsCard( 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 a018b65a9..5c9585b78 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -576,6 +576,23 @@ interface NextcloudPlatformServices { "Deleting submitted support reports is unavailable on this platform.", ) + /** Refreshes private report statuses and conversations using their retained capabilities. */ + suspend fun refreshSubmittedSupportDiagnosticsReports(): SupportDiagnosticsConversationResult = + SupportDiagnosticsConversationResult.Unsupported( + "Private support conversations are unavailable on this platform.", + ) + + /** Sends one reporter reply through the retained private report capability. */ + suspend fun sendSubmittedSupportDiagnosticsMessage( + statusUrl: String, + message: String, + ): SupportDiagnosticsConversationResult = SupportDiagnosticsConversationResult.Unsupported( + "Private support conversations are unavailable on this platform.", + ) + + /** Acknowledges the currently visible status and maintainer messages on this device. */ + suspend fun markSubmittedSupportDiagnosticsReportRead(statusUrl: String): 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 977f6f477..590d8a527 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt @@ -206,6 +206,13 @@ sealed interface SupportDiagnosticsSubmissionState { val statusUrl: String, val deletionUrl: String, val retentionUntil: String, + val status: String, + val updatedAt: String? = null, + val messages: List = emptyList(), + val unreadMaintainerMessages: Int = 0, + val statusChanged: Boolean = false, + val conversationLoading: Boolean = false, + val conversationError: String? = null, ) data class Submitted(val reports: List) : SupportDiagnosticsSubmissionState { init { @@ -219,6 +226,24 @@ sealed interface SupportDiagnosticsSubmissionState { data class Unsupported(val reason: String) : SupportDiagnosticsSubmissionState } +enum class SupportDiagnosticsMessageAuthor { + Maintainer, + Reporter, +} + +data class SupportDiagnosticsMessage( + val id: String, + val author: SupportDiagnosticsMessageAuthor, + val body: String, + val createdAt: String, +) + +sealed interface SupportDiagnosticsConversationResult { + data object Updated : SupportDiagnosticsConversationResult + data class Failed(val message: String) : SupportDiagnosticsConversationResult + data class Unsupported(val reason: String) : SupportDiagnosticsConversationResult +} + sealed interface SupportDiagnosticsDeletionResult { data object Deleted : SupportDiagnosticsDeletionResult data class Failed(val message: String) : SupportDiagnosticsDeletionResult 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 7ba57d8f9..5954ed64d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3499,6 +3499,17 @@ class DesktopNextcloudServices( deletionUrl: String, ): SupportDiagnosticsDeletionResult = supportIntake.deleteCompletedReport(deletionUrl) + override suspend fun refreshSubmittedSupportDiagnosticsReports(): SupportDiagnosticsConversationResult = + supportIntake.refreshCompletedReports() + + override suspend fun sendSubmittedSupportDiagnosticsMessage( + statusUrl: String, + message: String, + ): SupportDiagnosticsConversationResult = supportIntake.sendCompletedReportMessage(statusUrl, message) + + override suspend fun markSubmittedSupportDiagnosticsReportRead(statusUrl: String): Boolean = + supportIntake.markCompletedReportRead(statusUrl) + 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 a2da6f739..71c115dbc 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt @@ -31,6 +31,81 @@ import mockwebserver3.SocketEffect import okhttp3.OkHttpClient class JvmSupportIntakeTest { + @Test + fun refreshesPrivateConversationAndPersistsReadPosition() = runBlocking { + testFixture().use { fixture -> + val maintainerMessageId = UUID.randomUUID().toString() + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("The updater failed.", "nightly", emptyList()) + fixture.server.enqueue( + privateStatusResponse( + status = "needs_information", + messages = listOf(maintainerMessageId to "Which installation stage failed?"), + ), + ) + + assertEquals(SupportDiagnosticsConversationResult.Updated, fixture.intake.refreshCompletedReports()) + + val refreshed = assertIs(fixture.intake.states().value) + .reports.single() + assertEquals("needs_information", refreshed.status) + assertTrue(refreshed.statusChanged) + assertEquals(1, refreshed.unreadMaintainerMessages) + assertEquals("Which installation stage failed?", refreshed.messages.single().body) + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val refreshRequest = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("GET", refreshRequest.method) + assertTrue(refreshRequest.url.encodedPath.startsWith("/api/v1/reports/")) + + assertTrue(fixture.intake.markCompletedReportRead(fixture.statusUrl)) + fixture.intake.close() + fixture.newIntake().use { restored -> + fixture.server.enqueue( + privateStatusResponse( + status = "needs_information", + messages = listOf(maintainerMessageId to "Which installation stage failed?"), + ), + ) + assertEquals(SupportDiagnosticsConversationResult.Updated, restored.refreshCompletedReports()) + val afterRestart = assertIs(restored.states().value) + .reports.single() + assertFalse(afterRestart.statusChanged) + assertEquals(0, afterRestart.unreadMaintainerMessages) + } + } + } + + @Test + fun sendsReporterReplyThroughPrivateCapabilityWithoutExposingItInStateErrors() = runBlocking { + testFixture().use { fixture -> + fixture.server.enqueue(receiptResponse(fixture.statusUrl)) + fixture.intake.submit("The updater failed.", "nightly", emptyList()) + fixture.server.enqueue( + privateStatusResponse( + status = "needs_information", + messages = emptyList(), + reporterMessage = "It failed after the download completed.", + ), + ) + + assertEquals( + SupportDiagnosticsConversationResult.Updated, + fixture.intake.sendCompletedReportMessage( + fixture.statusUrl, + "It failed after the download completed.", + ), + ) + + requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + val reply = requireNotNull(fixture.server.takeRequest(2, TimeUnit.SECONDS)) + assertEquals("POST", reply.method) + assertTrue(reply.url.encodedPath.matches(Regex("/api/v1/reports/[A-Za-z0-9_-]{43}/messages"))) + assertTrue(reply.body?.utf8().orEmpty().contains("It failed after the download completed.")) + val submitted = assertIs(fixture.intake.states().value) + assertEquals(SupportDiagnosticsMessageAuthor.Reporter, submitted.reports.single().messages.single().author) + } + } + @Test fun submitsSanitizedBundleAndRemovesTemporaryArchive() = runBlocking { testFixture().use { fixture -> @@ -2685,6 +2760,39 @@ class JvmSupportIntakeTest { """{"contractVersion":1,"code":"submission_cancelled","message":"Submission cancelled."}""", ).build() + private fun privateStatusResponse( + status: String, + messages: List>, + reporterMessage: String? = null, + ): MockResponse { + val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val encodedMessages = buildList { + messages.forEach { (id, body) -> + add("""{"id":"$id","author":"maintainer","body":"$body","createdAt":"$now"}""") + } + reporterMessage?.let { body -> + add( + """{"id":"${UUID.randomUUID()}","author":"reporter","body":"$body","createdAt":"$now"}""", + ) + } + }.joinToString(",") + return MockResponse.Builder().code(if (reporterMessage == null) 200 else 201).body( + """ + { + "contractVersion": 1, + "supportCode": "OBI-ABCDE-23456", + "productId": "nextcloud-native", + "requestType": "bug", + "status": "$status", + "createdAt": "$now", + "updatedAt": "$now", + "retentionUntil": "${now.plus(30, ChronoUnit.DAYS)}", + "messages": [$encodedMessages] + } + """.trimIndent(), + ).build() + } + private data class Fixture( val root: File, val temporaryRoot: File, 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 5209bab53..9c788f4e7 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -14,6 +14,7 @@ import java.nio.file.attribute.PosixFileAttributeView import java.nio.file.attribute.PosixFilePermission import java.security.SecureRandom import java.time.Duration +import java.time.DateTimeException import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeFormatter @@ -468,6 +469,244 @@ class JvmSupportIntake( } } + suspend fun refreshCompletedReports(): SupportDiagnosticsConversationResult = withContext(Dispatchers.IO) { + awaitInitialization() + if (!beginOperation()) { + return@withContext SupportDiagnosticsConversationResult.Failed( + "Another private support operation is still in progress.", + ) + } + try { + val accountIdentity = synchronized(lock) { activeAccountIdentity } + ?: return@withContext SupportDiagnosticsConversationResult.Failed( + "Sign in to refresh private support reports.", + ) + val reports = synchronized(lock) { + completedSubmissions.filter { completed -> + completed.originAccountIdentity == accountIdentity && completed.isRetained(currentTimeMillis()) + }.onEach { completed -> + completed.conversationLoading = true + completed.conversationError = null + } + } + if (reports.isEmpty()) { + return@withContext SupportDiagnosticsConversationResult.Failed( + "No submitted support reports are available on this device.", + ) + } + publishState(submittedStateFor(accountIdentity), accountIdentity) + var failure: String? = null + reports.forEach { completed -> + when (val result = refreshCompletedReport(completed)) { + SupportDiagnosticsConversationResult.Updated -> Unit + is SupportDiagnosticsConversationResult.Failed -> if (failure == null) failure = result.message + is SupportDiagnosticsConversationResult.Unsupported -> if (failure == null) failure = result.reason + } + publishState(submittedStateFor(accountIdentity), accountIdentity) + } + failure?.let { message -> SupportDiagnosticsConversationResult.Failed(message) } + ?: SupportDiagnosticsConversationResult.Updated + } finally { + endOperation() + } + } + + suspend fun sendCompletedReportMessage( + statusUrl: String, + message: String, + ): SupportDiagnosticsConversationResult = withContext(Dispatchers.IO) { + awaitInitialization() + if (!beginOperation()) { + return@withContext SupportDiagnosticsConversationResult.Failed( + "Another private support operation is still in progress.", + ) + } + try { + if (!supportMutationsAreAllowed()) { + return@withContext SupportDiagnosticsConversationResult.Unsupported(READ_ONLY_SUPPORT_MESSAGE) + } + val normalizedMessage = message.trim() + if ( + normalizedMessage.isEmpty() || + normalizedMessage.toByteArray(StandardCharsets.UTF_8).size > MAX_SUPPORT_CONVERSATION_MESSAGE_BYTES + ) { + return@withContext SupportDiagnosticsConversationResult.Failed( + "Enter a message no longer than 8 KiB.", + ) + } + val completed = synchronized(lock) { + completedSubmissions.firstOrNull { submission -> + submission.originAccountIdentity == activeAccountIdentity && + submission.receipt.statusUrl == statusUrl && + submission.isRetained(currentTimeMillis()) + }?.also { submission -> + submission.conversationLoading = true + submission.conversationError = null + } + } ?: return@withContext SupportDiagnosticsConversationResult.Failed( + "This submitted support report is no longer available on this device.", + ) + publishState(submittedStateFor(completed.originAccountIdentity), completed.originAccountIdentity) + val capability = runCatching { capabilityFor(completed) }.getOrElse { + return@withContext failConversation(completed, "The private report capability is invalid.") + } + val requestBody = json.encodeToString( + SupportConversationMessageInput.serializer(), + SupportConversationMessageInput(normalizedMessage), + ).toRequestBody(SUPPORT_METADATA_MEDIA_TYPE) + val request = Request.Builder() + .url( + baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability) + .addPathSegment("messages").build(), + ) + .header("Accept", "application/json") + .post(requestBody) + .build() + val result = executeConversationRequest(completed, request, expectedStatusCode = 201) + publishState(submittedStateFor(completed.originAccountIdentity), completed.originAccountIdentity) + result + } finally { + endOperation() + } + } + + suspend fun markCompletedReportRead(statusUrl: String): Boolean = withContext(Dispatchers.IO) { + awaitInitialization() + if (!beginOperation()) return@withContext false + try { + val completed = synchronized(lock) { + completedSubmissions.firstOrNull { submission -> + submission.originAccountIdentity == activeAccountIdentity && + submission.receipt.statusUrl == statusUrl + } + } ?: return@withContext false + val conversation = synchronized(lock) { completed.conversation } ?: return@withContext false + val previousReadState = synchronized(lock) { + val previous = completed.acknowledgedStatus to completed.lastReadMaintainerMessageId + completed.acknowledgedStatus = conversation.status + completed.lastReadMaintainerMessageId = conversation.messages + .lastOrNull { message -> message.author == "maintainer" } + ?.id + previous + } + if (!persistCompletedSafely(completed)) { + synchronized(lock) { + completed.acknowledgedStatus = previousReadState.first + completed.lastReadMaintainerMessageId = previousReadState.second + } + return@withContext false + } + publishState(submittedStateFor(completed.originAccountIdentity), completed.originAccountIdentity) + true + } finally { + endOperation() + } + } + + private fun refreshCompletedReport( + completed: CompletedSubmission, + ): SupportDiagnosticsConversationResult { + val capability = runCatching { capabilityFor(completed) }.getOrElse { + return failConversation(completed, "The private report capability is invalid.") + } + val request = Request.Builder() + .url(baseUrl.newBuilder().addPathSegments("api/v1/reports").addPathSegment(capability).build()) + .header("Accept", "application/json") + .get() + .build() + return executeConversationRequest(completed, request, expectedStatusCode = 200) + } + + private fun executeConversationRequest( + completed: CompletedSubmission, + request: Request, + expectedStatusCode: Int, + ): SupportDiagnosticsConversationResult { + val call = client.newCall(request) + if (!registerActiveCall(call)) { + call.cancel() + return failConversation(completed, "Another private support operation is still in progress.") + } + return try { + call.execute().use { response -> + val body = response.readBoundedText() + if (response.code != expectedStatusCode) { + return@use failConversation( + completed, + if (response.code == 404 || response.code == 410) { + "This private support report is no longer available." + } else { + "The private support conversation could not be refreshed. Try again." + }, + ) + } + val conversation = decodeConversation(body, completed) + synchronized(lock) { + completed.conversation = conversation + completed.conversationLoading = false + completed.conversationError = null + if (request.method == "POST") { + completed.acknowledgedStatus = conversation.status + completed.lastReadMaintainerMessageId = conversation.messages + .lastOrNull { message -> message.author == "maintainer" } + ?.id + } + } + if (request.method == "POST" && !persistCompletedSafely(completed)) { + return@use failConversation( + completed, + "Your reply was sent, but its read state could not be stored on this device.", + ) + } + SupportDiagnosticsConversationResult.Updated + } + } catch (_: IOException) { + failConversation(completed, "Could not reach Obiente Support. Check your connection and try again.") + } catch (_: SerializationException) { + failConversation(completed, "Obiente Support returned an invalid private conversation.") + } catch (_: DateTimeException) { + failConversation(completed, "Obiente Support returned an invalid private conversation.") + } catch (_: IllegalArgumentException) { + failConversation(completed, "Obiente Support returned an invalid private conversation.") + } finally { + activeCall.compareAndSet(call, null) + } + } + + private fun decodeConversation(body: String, completed: CompletedSubmission): SupportPrivateStatus { + val conversation = json.decodeFromString(SupportPrivateStatus.serializer(), body) + require(conversation.contractVersion == SUPPORT_INTAKE_CONTRACT_VERSION) + require(conversation.supportCode == completed.receipt.supportCode) + require(conversation.productId == SUPPORT_INTAKE_PRODUCT_ID) + require(conversation.status in SUPPORT_REPORT_STATUSES) + require(conversation.messages.size <= MAX_SUPPORT_CONVERSATION_MESSAGES) + Instant.parse(conversation.createdAt) + Instant.parse(conversation.updatedAt) + Instant.parse(conversation.retentionUntil) + conversation.messages.forEach { message -> + require(message.id.matches(SUPPORT_COMPLETED_RECORD_ID_PATTERN)) + require(message.author == "maintainer" || message.author == "reporter") + require(message.body.isNotBlank()) + require(message.body.toByteArray(StandardCharsets.UTF_8).size <= MAX_SUPPORT_CONVERSATION_MESSAGE_BYTES) + Instant.parse(message.createdAt) + } + return conversation + } + + private fun capabilityFor(completed: CompletedSubmission): String = + validateReceipt(completed.receipt).pathSegments.last() + + private fun failConversation( + completed: CompletedSubmission, + message: String, + ): SupportDiagnosticsConversationResult.Failed { + synchronized(lock) { + completed.conversationLoading = false + completed.conversationError = message + } + return SupportDiagnosticsConversationResult.Failed(message) + } + private fun cancelAfterIntentPublished(callAtIntent: Call?): Boolean { var localCancellationCommitted = false val submission = synchronized(lock) { @@ -1540,7 +1779,12 @@ class JvmSupportIntake( descriptor, json.encodeToString( PersistedCompletedSubmission.serializer(), - PersistedCompletedSubmission(submission.originAccountIdentity, submission.receipt), + PersistedCompletedSubmission( + originAccountIdentity = submission.originAccountIdentity, + receipt = submission.receipt, + acknowledgedStatus = submission.acknowledgedStatus, + lastReadMaintainerMessageId = submission.lastReadMaintainerMessageId, + ), ).encodeToByteArray(), ".completed-", ) @@ -1822,7 +2066,18 @@ class JvmSupportIntake( 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) + require(persisted.acknowledgedStatus in SUPPORT_REPORT_STATUSES) + require( + persisted.lastReadMaintainerMessageId == null || + persisted.lastReadMaintainerMessageId.matches(SUPPORT_COMPLETED_RECORD_ID_PATTERN), + ) + CompletedSubmission( + recordId = recordId, + originAccountIdentity = persisted.originAccountIdentity, + receipt = persisted.receipt, + acknowledgedStatus = persisted.acknowledgedStatus, + lastReadMaintainerMessageId = persisted.lastReadMaintainerMessageId, + ) } catch (_: IOException) { completedDescriptorRestorePending.set(true) null @@ -1881,6 +2136,11 @@ class JvmSupportIntake( val recordId: String, val originAccountIdentity: String, val receipt: SupportIntakeReceipt, + var acknowledgedStatus: String = receipt.status, + var lastReadMaintainerMessageId: String? = null, + var conversation: SupportPrivateStatus? = null, + var conversationLoading: Boolean = false, + var conversationError: String? = null, ) { val retentionUntilEpochMillis: Long get() = Instant.parse(receipt.retentionUntil).toEpochMilli() @@ -1918,6 +2178,32 @@ class JvmSupportIntake( private data class PersistedCompletedSubmission( val originAccountIdentity: String, val receipt: SupportIntakeReceipt, + val acknowledgedStatus: String = receipt.status, + val lastReadMaintainerMessageId: String? = null, + ) + + @Serializable + private data class SupportConversationMessageInput(val body: String) + + @Serializable + private data class SupportPrivateStatus( + val contractVersion: Int, + val supportCode: String, + val productId: String, + val requestType: String, + val status: String, + val createdAt: String, + val updatedAt: String, + val retentionUntil: String, + val messages: List, + ) + + @Serializable + private data class SupportPrivateMessage( + val id: String, + val author: String, + val body: String, + val createdAt: String, ) private fun publishState( @@ -2001,11 +2287,43 @@ class JvmSupportIntake( .thenByDescending(CompletedSubmission::recordId), ) .map { completed -> + val conversation = completed.conversation + val maintainerMessages = conversation?.messages.orEmpty() + .filter { message -> message.author == "maintainer" } + val lastReadIndex = completed.lastReadMaintainerMessageId?.let { readId -> + maintainerMessages.indexOfFirst { message -> message.id == readId } + } ?: -1 SupportDiagnosticsSubmissionState.SubmittedReport( supportCode = completed.receipt.supportCode, statusUrl = completed.receipt.statusUrl, deletionUrl = completed.receipt.deletionUrl, retentionUntil = completed.receipt.retentionUntil, + status = conversation?.status ?: completed.receipt.status, + updatedAt = conversation?.updatedAt, + messages = conversation?.messages.orEmpty().map { message -> + SupportDiagnosticsMessage( + id = message.id, + author = if (message.author == "maintainer") { + SupportDiagnosticsMessageAuthor.Maintainer + } else { + SupportDiagnosticsMessageAuthor.Reporter + }, + body = message.body, + createdAt = message.createdAt, + ) + }, + unreadMaintainerMessages = if (maintainerMessages.isEmpty()) { + 0 + } else if (lastReadIndex < 0) { + maintainerMessages.size + } else { + maintainerMessages.lastIndex - lastReadIndex + }, + statusChanged = conversation?.status?.let { status -> + status != completed.acknowledgedStatus + } ?: false, + conversationLoading = completed.conversationLoading, + conversationError = completed.conversationError, ) }, ) @@ -2171,6 +2489,8 @@ private val TERMINAL_DELETION_STATUS_CODES = setOf(200, 204) private const val SUPPORT_SUBMISSION_CANCELLED_CODE = "submission_cancelled" private const val MAX_SUPPORT_INTAKE_MESSAGE_LENGTH = 240 private const val MAX_SUPPORT_INTAKE_RESPONSE_BYTES = 64 * 1024 +private const val MAX_SUPPORT_CONVERSATION_MESSAGE_BYTES = 8 * 1024 +private const val MAX_SUPPORT_CONVERSATION_MESSAGES = 1_000 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 @@ -2195,3 +2515,11 @@ private const val SUPPORT_STORAGE_UNAVAILABLE_MESSAGE = "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." +private val SUPPORT_REPORT_STATUSES = setOf( + "new", + "needs_information", + "accepted", + "duplicate", + "resolved", + "rejected", +) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 31c82345e..95e3bad07 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -377,12 +377,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "4ea7b9a1979db26b71944ade4b1f8eafff7e10c85b79479cf79f3da8b473dfbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "0917961c491ddecdc125e5d59f5ebf07654e0a1d745b64c460402d9140d296c0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "99abe0f4e5721e0b604b4ba51b936482d9a447f51e4d3ba9882c3da46f4ea6e5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "14d43a632afa7c5bf970d90d1387285624182be00569b57c0955670de017cc6c", "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": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "6131de64fefeda3272e46b2739857db16d43f8feb6d4f261b12539a020b5fdac", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "0609bae46105254abf506047f2abf8aeadaff22065295d577cc48df5e0423ff0", "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", @@ -417,7 +417,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": "70395d62c76898cf627096c8b801fe0c87d7417612490d95f8e61ea23ccf3ae9", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportDiagnostics.kt": "3062824940da8fe8f052ffc75cc135b417c10d382c0a5d0ce231baefd79974d0", "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",