Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6f312e2
feat: send diagnostic reports to support
veryCrunchy Aug 13, 2026
fd62795
chore(website): refresh marketing captures
obiente-automations[bot] Aug 13, 2026
cfc193a
fix: harden support report recovery
veryCrunchy Aug 13, 2026
af76a88
Merge remote-tracking branch 'origin/main' into feat/support-intake-i…
veryCrunchy Aug 13, 2026
2814d5a
Merge remote-tracking branch 'origin/main' into feat/support-intake-i…
veryCrunchy Aug 13, 2026
ffbebe9
chore(website): refresh marketing captures
obiente-automations[bot] Aug 13, 2026
efd2e80
fix(support): preserve retryable diagnostic submissions
veryCrunchy Aug 13, 2026
1fe254a
chore(website): refresh marketing captures
obiente-automations[bot] Aug 13, 2026
964ef47
fix(support): preserve confirmed report recovery
veryCrunchy Aug 13, 2026
cefea00
fix(support): coordinate durable cancellation
veryCrunchy Aug 13, 2026
5c1abbd
fix(support): scope pending report lifecycle
veryCrunchy Aug 13, 2026
b366021
chore: merge main into support intake
veryCrunchy Aug 13, 2026
5e4b5b6
chore(website): refresh marketing captures
obiente-automations[bot] Aug 13, 2026
8f2ad7c
fix(support): preserve account-scoped report recovery
veryCrunchy Aug 14, 2026
a45733b
fix(support): retain durable receipt capabilities
veryCrunchy Aug 14, 2026
3b36920
fix(support): expose all retained receipts
veryCrunchy Aug 14, 2026
19f25c2
fix(support): close durable review races
veryCrunchy Aug 14, 2026
c47a962
fix(support): harden terminal and account state
veryCrunchy Aug 14, 2026
07cfa6a
fix(support): close final receipt races
veryCrunchy Aug 14, 2026
d1c587a
fix(support): preserve cancellation cleanup
veryCrunchy Aug 14, 2026
47c633c
fix(support): expose preparation state
veryCrunchy Aug 14, 2026
db08600
fix(support): preserve recovery state
veryCrunchy Aug 14, 2026
2ce9f2e
fix(support): protect restoration recovery
veryCrunchy Aug 14, 2026
0d2fa63
fix(support): preserve retained report access
veryCrunchy Aug 14, 2026
42999cd
fix(support): retry rejected receipt cleanup
veryCrunchy Aug 14, 2026
5583cd0
fix(support): preserve cross-account busy state
veryCrunchy Aug 14, 2026
0188557
fix(support): expose retained report deletion
veryCrunchy Aug 14, 2026
241ba2e
chore(website): refresh marketing captures
obiente-automations[bot] Aug 14, 2026
f5bb651
fix(support): retain ambiguous cancellation recovery
veryCrunchy Aug 14, 2026
ccd84d3
fix(support): harden retained report lifecycle
veryCrunchy Aug 14, 2026
fbd134c
fix(support): harden cancellation cleanup recovery
veryCrunchy Aug 14, 2026
89bc3de
fix(support): protect receipt and archive recovery
veryCrunchy Aug 14, 2026
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 @@ -30,17 +30,20 @@ internal class AndroidFileSyncSessionSchedulingGuard {
fun <Session> 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
}
Expand All @@ -49,6 +52,7 @@ internal class AndroidFileSyncSessionSchedulingGuard {
replacementAccountId: String,
persist: () -> Unit,
cancelAll: () -> Unit,
publishAccount: (String) -> Unit = {},
) {
synchronized(monitor) {
val accountChanged = accountId != replacementAccountId
Expand All @@ -57,6 +61,7 @@ internal class AndroidFileSyncSessionSchedulingGuard {
try {
persist()
accountId = replacementAccountId
publishAccount(replacementAccountId)
} finally {
if (accountChanged) cancelAll()
}
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -416,6 +417,11 @@ internal class AndroidNextcloudServices(
activity = activity,
diagnostics = supportDiagnostics,
)
private val supportIntake = AndroidSupportIntakeCoordinator.get(
context = appContext,
diagnostics = supportDiagnostics,
client = httpClient,
)

init {
supportDiagnostics.registerPrivateValue(System.getProperty("user.home"))
Expand Down Expand Up @@ -615,14 +621,33 @@ internal class AndroidNextcloudServices(
reproductionSteps: String,
): SupportDiagnosticsExportResult = supportBundleExporter.export(
reproductionSteps = reproductionSteps,
featureState = listOf(
featureState = supportDiagnosticFeatureState(),
)

override fun supportDiagnosticsSubmissionStates() = supportIntake.states()

override suspend fun submitSupportDiagnostics(reproductionSteps: String) = supportIntake.submit(
Comment thread
veryCrunchy marked this conversation as resolved.
reproductionSteps = reproductionSteps,
channel = appUpdateSupport().channel.name.lowercase(),
featureState = supportDiagnosticFeatureState(),
)

override suspend fun retrySupportDiagnosticsSubmission() = supportIntake.retry()

override suspend fun cancelSupportDiagnosticsSubmission(): Boolean = supportIntake.cancel()
Comment thread
veryCrunchy marked this conversation as resolved.

override suspend fun deleteSubmittedSupportDiagnosticsReport(
deletionUrl: String,
): SupportDiagnosticsDeletionResult = supportIntake.deleteCompletedReport(deletionUrl)

private fun supportDiagnosticFeatureState(): List<SupportDiagnosticFieldDraft> =
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()

Expand Down Expand Up @@ -724,10 +749,12 @@ internal class AndroidNextcloudServices(
}.getOrNull()
},
accountIdOf = NextcloudDocumentIds::accountKey,
)?.also { session ->
registerSessionPrivateValues(session)
supportDiagnostics.setActiveAccountIdentity(NextcloudDocumentIds.accountKey(session))
}
publishAccount = { session, accountIdentity ->
session?.let(::registerSessionPrivateValues)
supportDiagnostics.setActiveAccountIdentity(accountIdentity)
supportIntake.setActiveAccountIdentity(accountIdentity)
},
)
}

override suspend fun saveSession(session: NextcloudSession) {
Expand Down Expand Up @@ -762,11 +789,14 @@ internal class AndroidNextcloudServices(
.apply()
},
cancelAll = scheduler::cancelAll,
publishAccount = { accountIdentity ->
supportDiagnostics.setActiveAccountIdentity(accountIdentity)
supportIntake.setActiveAccountIdentity(accountIdentity)
},
)
if (previousAccountId != null && previousAccountId != replacementAccountId) {
nativeMediaPreviewCache.clearAccount(previousAccountId)
}
supportDiagnostics.setActiveAccountIdentity(NextcloudDocumentIds.accountKey(session))
notifyDocumentsRootsChanged()
}

Expand Down Expand Up @@ -803,10 +833,13 @@ internal class AndroidNextcloudServices(
.apply()
},
cancelAll = scheduler::cancelAll,
clearPublishedAccount = {
supportDiagnostics.setActiveAccountIdentity(null)
supportIntake.setActiveAccountIdentity(null)
},
)
accountId?.let(nativeMediaPreviewCache::clearAccount)
notifyDocumentsRootsChanged()
supportDiagnostics.setActiveAccountIdentity(null)
} catch (failure: Throwable) {
recordSupportDiagnostic(
SupportDiagnosticEventDraft(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -62,18 +64,48 @@ internal object AndroidSupportDiagnostics {
val appContext = context.applicationContext ?: context
instance ?: AsyncJvmSupportDiagnostics(
root = File(appContext.filesDir, "support-diagnostics"),
environment = SupportDiagnosticsEnvironment(
appVersion = BuildConfig.VERSION_NAME,
packageVersion = BuildConfig.VERSION_CODE.toString(),
platform = "Android",
operatingSystemVersion = android.os.Build.VERSION.RELEASE.orEmpty(),
architecture = android.os.Build.SUPPORTED_ABIS.firstOrNull().orEmpty(),
),
environment = androidSupportDiagnosticsEnvironment(),
workerName = "nextcloud-support-diagnostics",
).also { instance = it }
}
}

/**
* Owns the one durable support-submission state machine for this Android process.
*
* Activities, workers, and providers each create their own service facade, but they all operate on
* the same no-backup directory. Sharing the coordinator prevents a replacement facade from
* restoring or mutating that directory while an earlier facade is still packaging or uploading.
*/
internal object AndroidSupportIntakeCoordinator {
@Volatile
private var instance: JvmSupportIntake? = null

fun get(
context: Context,
diagnostics: AsyncJvmSupportDiagnostics,
client: OkHttpClient,
): JvmSupportIntake = instance ?: synchronized(this) {
val appContext = context.applicationContext ?: context
instance ?: JvmSupportIntake(
diagnostics = diagnostics,
temporaryRoot = File(appContext.noBackupFilesDir, "support-submissions"),
environment = androidSupportDiagnosticsEnvironment(),
client = client.newBuilder().retryOnConnectionFailure(false).build(),
supportMutationsAllowed = appContext.cloudMutationGate(),
).also { instance = it }
}
}

internal fun androidSupportDiagnosticsEnvironment(): SupportDiagnosticsEnvironment =
SupportDiagnosticsEnvironment(
appVersion = BuildConfig.VERSION_NAME,
packageVersion = BuildConfig.VERSION_CODE.toString(),
platform = "Android",
operatingSystemVersion = android.os.Build.VERSION.RELEASE.orEmpty(),
architecture = android.os.Build.SUPPORTED_ABIS.firstOrNull().orEmpty(),
)

internal class AndroidSupportBundleExporter(
private val context: Context,
private val activity: Activity?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ class AndroidFileSyncEngineInvariantTest {
events += "restore-old-authority"
"account-old"
},
publishAccount = { _, accountId -> events += "publish-${accountId ?: "none"}" },
)
}
loadThread.start()
Expand All @@ -396,6 +397,7 @@ class AndroidFileSyncEngineInvariantTest {
guard.clearSession(
persist = { events += "clear-session" },
cancelAll = { events += "cancel-all" },
clearPublishedAccount = { events += "publish-none" },
)
}
clearThread.start()
Expand All @@ -410,7 +412,9 @@ class AndroidFileSyncEngineInvariantTest {
listOf(
"read-old-session",
"restore-old-authority",
"publish-account-old",
"clear-session",
"publish-none",
"cancel-all",
),
events,
Expand All @@ -437,6 +441,7 @@ class AndroidFileSyncEngineInvariantTest {
events += "restore-old-authority"
"account-old"
},
publishAccount = { _, accountId -> events += "publish-$accountId" },
)
}
loadThread.start()
Expand All @@ -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()
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/351-direct-support-intake.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down
Loading
Loading