Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SupportDiagnosticFieldDraft> =
listOf(
SupportDiagnosticFieldDraft("distribution", appUpdateSupport().channel.name.lowercase()),
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/351-support-conversations.md
Original file line number Diff line number Diff line change
@@ -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.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SupportDiagnosticsMessage> = emptyList(),
val unreadMaintainerMessages: Int = 0,
val statusChanged: Boolean = false,
val conversationLoading: Boolean = false,
val conversationError: String? = null,
)
data class Submitted(val reports: List<SubmittedReport>) : SupportDiagnosticsSubmissionState {
init {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SupportDiagnosticFieldDraft> =
listOf(
SupportDiagnosticFieldDraft("distribution", appUpdateSupport().channel.name.lowercase()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SupportDiagnosticsSubmissionState.Submitted>(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<SupportDiagnosticsSubmissionState.Submitted>(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<SupportDiagnosticsSubmissionState.Submitted>(fixture.intake.states().value)
assertEquals(SupportDiagnosticsMessageAuthor.Reporter, submitted.reports.single().messages.single().author)
}
}

@Test
fun submitsSanitizedBundleAndRemovesTemporaryArchive() = runBlocking {
testFixture().use { fixture ->
Expand Down Expand Up @@ -2685,6 +2760,39 @@ class JvmSupportIntakeTest {
"""{"contractVersion":1,"code":"submission_cancelled","message":"Submission cancelled."}""",
).build()

private fun privateStatusResponse(
status: String,
messages: List<Pair<String, String>>,
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,
Expand Down
Loading
Loading