Skip to content

Commit 5f3267c

Browse files
committed
Diagnostics: Stop reporting an empty capture as a withheld one
A contribution report with no rows printed "(no settings approved for inclusion)" regardless of cause. In issue #23 the matrix was empty -- nothing differed across the captured modes -- but the wording reads as a contributor redacting every row, so the only way to tell the two apart was the absence of an optional withheld_rows line. Report schema 2 states what was measured: scanned_namespaces, captured_mode_count, and changed_rows. An empty matrix now says so in its own words and names the providers that were actually compared; all-withheld keeps the inclusion wording. The wizard also let two paths reach that report unchallenged. A single capture could advance to review, where a diff has nothing to diff against and the matrix is empty by construction -- now blocked at two observations in the ViewModel, with the UI mirroring the guard. A genuinely empty two-mode result stays deliverable, because "this ROM keeps the mode elsewhere" is a real finding, but the review step explains the likely causes, offers a restart, and relabels the primary action to "Continue anyway". A backend only reports Failure when the call threw; empty or unparsable output parses to an empty map instead, and three empty namespaces would diff to "nothing changed" and look like that same real finding. A merged snapshot with no keys at all is now a capture failure.
1 parent 17a86b6 commit 5f3267c

10 files changed

Lines changed: 339 additions & 17 deletions

File tree

app/src/main/java/eu/darken/amply/diagnostics/core/ContributionModels.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ data class ReviewedRow(
5353
* The complete, immutable, export-safe contribution report. The pure formatter accepts **only** this type and has no
5454
* access to raw snapshots, so privacy cannot regress through a later formatting change. Redacted rows are represented
5555
* solely by [withheldRowCount] — their namespace, key, and values are absent entirely.
56+
*
57+
* [changedRowCount] and [scannedNamespaces] describe the *measurement*, not its contents: without them a report with no
58+
* rows is ambiguous between "nothing differed across the modes" and "the contributor withheld everything", and a reader
59+
* cannot tell which providers were even looked at. Both are counts/namespace names only — never key or value bearing.
5660
*/
5761
data class ReviewedContributionReport(
5862
val schema: Int,
@@ -70,6 +74,8 @@ data class ReviewedContributionReport(
7074
val modeLabels: List<String>,
7175
val rows: List<ReviewedRow>,
7276
val withheldRowCount: Int,
77+
val changedRowCount: Int,
78+
val scannedNamespaces: List<String>,
7379
val effects: List<ModeEffect>,
7480
val notes: String,
7581
)

app/src/main/java/eu/darken/amply/diagnostics/core/ContributionReport.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package eu.darken.amply.diagnostics.core
22

33
import eu.darken.amply.charging.core.DeviceInfo
4+
import eu.darken.amply.charging.core.access.STANDARD_SETTINGS_NAMESPACES
5+
import eu.darken.amply.charging.core.access.SettingNamespace
46
import eu.darken.amply.main.core.DeviceSupportReporter
57
import eu.darken.amply.main.core.sanitizeReportValue
68
import java.net.URLEncoder
79

8-
internal const val CONTRIBUTION_SCHEMA = 1
10+
/** 2: added `scanned_namespaces`, `captured_mode_count`, `changed_rows`, and a distinct empty-matrix wording. */
11+
internal const val CONTRIBUTION_SCHEMA = 2
912

1013
/** Conservative cap for a GitHub issue prefill URL; browsers/GitHub start truncating well above this. */
1114
internal const val MAX_ISSUE_URL_BYTES = 6_000
@@ -46,6 +49,7 @@ internal fun buildReviewedReport(
4649
effects: List<ModeEffect>,
4750
notes: String,
4851
createdAtEpochMs: Long,
52+
scannedNamespaces: List<SettingNamespace> = STANDARD_SETTINGS_NAMESPACES,
4953
schema: Int = CONTRIBUTION_SCHEMA,
5054
): ReviewedContributionReport {
5155
val matrix = deriveMatrix(session.observations)
@@ -72,6 +76,8 @@ internal fun buildReviewedReport(
7276
)
7377
},
7478
withheldRowCount = matrix.size - exportRows.size,
79+
changedRowCount = matrix.size,
80+
scannedNamespaces = scannedNamespaces.map { it.commandName },
7581
effects = effects.map { ModeEffect(sanitizeReportValue(it.modeLabel), sanitizeReportValue(it.effect)) },
7682
notes = sanitizeReportValue(notes, MAX_NOTE),
7783
)
@@ -92,10 +98,23 @@ internal fun formatContributionReport(report: ReviewedContributionReport): Strin
9298
appendLine("rom_version=${report.romVersion.ifBlank { "unspecified" }}")
9399
appendLine("feature_name=${report.featureName.ifBlank { "unspecified" }}")
94100
appendLine("modes=${report.modeLabels.joinToString(" | ")}")
101+
appendLine("captured_mode_count=${report.modeLabels.size}")
102+
appendLine("scanned_namespaces=${report.scannedNamespaces.joinToString(",")}")
103+
appendLine("changed_rows=${report.changedRowCount}")
95104
appendLine()
96105
appendLine("# changed settings (value per mode, in the order above)")
97106
if (report.rows.isEmpty()) {
98-
appendLine("(no settings approved for inclusion)")
107+
// Two very different outcomes used to share one line. An empty matrix is a *measurement* result (nothing moved
108+
// in the scanned providers); an all-withheld matrix is a *contributor* choice. Reading the first as the second
109+
// sends a maintainer chasing a privacy decision that never happened.
110+
if (report.changedRowCount == 0) {
111+
appendLine(
112+
"(no settings changed across the captured modes — " +
113+
"nothing differed in ${report.scannedNamespaces.joinToString(", ")})",
114+
)
115+
} else {
116+
appendLine("(no settings approved for inclusion)")
117+
}
99118
} else {
100119
report.rows.forEach { row ->
101120
appendLine("${row.namespace}/${row.key} = ${row.valuesByMode.joinToString(" | ") { it ?: "<absent>" }}")

app/src/main/java/eu/darken/amply/diagnostics/core/ContributionRepository.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package eu.darken.amply.diagnostics.core
22

33
import android.content.Context
44
import dagger.hilt.android.qualifiers.ApplicationContext
5+
import eu.darken.amply.R
56
import eu.darken.amply.charging.core.DeviceInfo
67
import eu.darken.amply.charging.core.access.BackendStatus
78
import eu.darken.amply.charging.core.access.NamespaceSnapshot
89
import eu.darken.amply.charging.core.access.STANDARD_SETTINGS_NAMESPACES
910
import eu.darken.amply.charging.core.access.SettingsSnapshotSource
1011
import eu.darken.amply.charging.core.adapter.AdapterRegistry
1112
import eu.darken.amply.common.ca.CaString
13+
import eu.darken.amply.common.ca.toCaString
1214
import javax.inject.Inject
1315
import javax.inject.Singleton
1416

@@ -50,6 +52,10 @@ class DefaultContributionRepository @Inject constructor(
5052
is NamespaceSnapshot.Failure -> return CaptureResult.Failure(result.reason)
5153
}
5254
}
55+
// A backend only reports Failure when the call *threw*. A command that returns empty or unparsable output
56+
// instead parses to an empty map, and three empty namespaces would look exactly like a valid "nothing changed"
57+
// capture. No real Android device has zero settings across all three, so treat it as a failed read.
58+
if (merged.isEmpty()) return CaptureResult.Failure(R.string.contribution_capture_empty_error.toCaString())
5359
return CaptureResult.Success(merged)
5460
}
5561

app/src/main/java/eu/darken/amply/diagnostics/ui/ContributionWizardScreen.kt

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package eu.darken.amply.diagnostics.ui
22

3+
import androidx.annotation.StringRes
34
import androidx.compose.foundation.layout.Arrangement
45
import androidx.compose.foundation.layout.ExperimentalLayoutApi
56
import androidx.compose.foundation.layout.FlowRow
@@ -39,6 +40,7 @@ import eu.darken.amply.R
3940
import eu.darken.amply.charging.core.access.BackendStatus
4041
import eu.darken.amply.common.compose.AmplyCard
4142
import eu.darken.amply.common.compose.AmplyCardDefaults
43+
import eu.darken.amply.common.compose.AmplyCardTone
4244
import eu.darken.amply.common.compose.AmplyCodeBlock
4345
import eu.darken.amply.common.compose.AmplyPreview
4446
import eu.darken.amply.common.compose.PreviewWrapper
@@ -94,10 +96,18 @@ fun ContributionWizardScreen(
9496
showNext = state.step != WizardStep.DELIVER,
9597
nextEnabled = when (state.step) {
9698
WizardStep.INTRO -> state.shizukuReady
97-
// Not while a capture is in flight — Review must reflect a settled session.
98-
WizardStep.CAPTURE -> state.modes.isNotEmpty() && !state.busy
99+
// Not while a capture is in flight — Review must reflect a settled session. Below two modes there
100+
// is nothing to diff, so Review could only ever be empty.
101+
WizardStep.CAPTURE -> state.modes.size >= ContributionWizardViewModel.MIN_MODES && !state.busy
99102
else -> true
100103
},
104+
// An empty matrix is still deliverable — "the ROM stores this elsewhere" is a real finding — but the
105+
// label has to stop reading like a normal happy-path Next.
106+
nextLabel = if (state.step == WizardStep.REVIEW && state.review.isEmpty()) {
107+
R.string.contribution_next_empty
108+
} else {
109+
R.string.contribution_next
110+
},
101111
onBack = onBack,
102112
onNext = onNext,
103113
)
@@ -130,7 +140,7 @@ fun ContributionWizardScreen(
130140
onUndoLast,
131141
onRestart,
132142
)
133-
WizardStep.REVIEW -> reviewStep(state, onRevealRow, onToggleInclude)
143+
WizardStep.REVIEW -> reviewStep(state, onRevealRow, onToggleInclude, onRestart)
134144
WizardStep.DELIVER -> deliverStep(state, onOpenIssue, onCopyReport, onEmail)
135145
}
136146
}
@@ -142,6 +152,7 @@ private fun WizardBottomBar(
142152
showBack: Boolean,
143153
showNext: Boolean,
144154
nextEnabled: Boolean,
155+
@StringRes nextLabel: Int,
145156
onBack: () -> Unit,
146157
onNext: () -> Unit,
147158
) {
@@ -161,7 +172,7 @@ private fun WizardBottomBar(
161172
Spacer(Modifier.weight(1f))
162173
if (showNext) {
163174
Button(onClick = onNext, enabled = nextEnabled) {
164-
Text(stringResource(R.string.contribution_next))
175+
Text(stringResource(nextLabel))
165176
}
166177
}
167178
}
@@ -401,18 +412,46 @@ private fun LazyListScope.reviewStep(
401412
state: ContributionUiState,
402413
onRevealRow: (SettingId) -> Unit,
403414
onToggleInclude: (SettingId) -> Unit,
415+
onRestart: () -> Unit,
404416
) {
405417
item { SectionTitle(stringResource(R.string.contribution_review_title)) }
406-
item { BodyText(stringResource(R.string.contribution_review_body)) }
407418
if (state.review.isEmpty()) {
408-
item { BodyText(stringResource(R.string.contribution_review_empty)) }
419+
item { EmptyReviewCard(onRestart) }
409420
} else {
421+
item { BodyText(stringResource(R.string.contribution_review_body)) }
410422
items(state.review, key = { it.id.display }) { row ->
411423
ReviewRowCard(row, onRevealRow, onToggleInclude)
412424
}
413425
}
414426
}
415427

428+
/**
429+
* Shown when the captured modes produced no differences at all. Most reports that land here are a capture mishap rather
430+
* than a finding, so the card names the likely causes — but it deliberately does not block delivery: "this ROM keeps the
431+
* mode somewhere else" is exactly the kind of result that should still reach a maintainer.
432+
*/
433+
@Composable
434+
private fun EmptyReviewCard(onRestart: () -> Unit) {
435+
AmplyCard(
436+
tone = AmplyCardTone.TertiaryContainer,
437+
verticalArrangement = Arrangement.spacedBy(AmplyCardDefaults.ItemSpacing),
438+
) {
439+
Text(
440+
stringResource(R.string.contribution_review_empty_title),
441+
style = MaterialTheme.typography.titleMedium,
442+
)
443+
Text(stringResource(R.string.contribution_review_empty_body))
444+
Text(stringResource(R.string.contribution_review_empty_causes))
445+
Text(
446+
stringResource(R.string.contribution_review_empty_hint),
447+
style = MaterialTheme.typography.bodySmall,
448+
)
449+
TextButton(onClick = onRestart) {
450+
Text(stringResource(R.string.contribution_restart))
451+
}
452+
}
453+
}
454+
416455
@Composable
417456
private fun ReviewRowCard(
418457
row: ReviewRowUi,
@@ -505,8 +544,8 @@ private fun LazyListScope.deliverStep(
505544
@AmplyPreview
506545
@Composable
507546
private fun ContributionWizardScreenPreview() = PreviewWrapper {
508-
ContributionWizardScreen(
509-
state = ContributionUiState(
547+
PreviewScreen(
548+
ContributionUiState(
510549
step = WizardStep.CAPTURE,
511550
shizuku = BackendStatus(available = true, granted = true, detail = "Shizuku connected".toCaString()),
512551
featureName = "Protect battery",
@@ -526,6 +565,31 @@ private fun ContributionWizardScreenPreview() = PreviewWrapper {
526565
),
527566
),
528567
),
568+
)
569+
}
570+
571+
@AmplyPreview
572+
@Composable
573+
private fun ContributionWizardEmptyReviewPreview() = PreviewWrapper {
574+
PreviewScreen(
575+
ContributionUiState(
576+
step = WizardStep.REVIEW,
577+
shizuku = BackendStatus(available = true, granted = true, detail = "Shizuku connected".toCaString()),
578+
featureName = "Charging protection",
579+
romVersion = "HyperOS 3",
580+
modes = listOf(
581+
ModeSummary(label = "Intelligent charging", effect = "no_change", changedFromPrevious = null),
582+
ModeSummary(label = "Charge fully", effect = "no_change", changedFromPrevious = 0),
583+
),
584+
review = emptyList(),
585+
),
586+
)
587+
}
588+
589+
@Composable
590+
private fun PreviewScreen(state: ContributionUiState) {
591+
ContributionWizardScreen(
592+
state = state,
529593
onExit = {},
530594
onRefreshStatus = {},
531595
onOpenShizuku = {},

app/src/main/java/eu/darken/amply/diagnostics/ui/ContributionWizardViewModel.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,12 @@ class ContributionWizardViewModel @Inject constructor(
205205
when (mutableState.value.step) {
206206
WizardStep.INTRO -> if (mutableState.value.shizukuReady) transitionTo(WizardStep.DETAILS)
207207
WizardStep.DETAILS -> transitionTo(WizardStep.CAPTURE)
208-
// Never advance while a capture is in flight — Review must reflect a settled session.
209-
WizardStep.CAPTURE -> if (rawSession.observations.isNotEmpty() && captureJob?.isActive != true) buildReview()
208+
// Never advance while a capture is in flight — Review must reflect a settled session. Two observations are
209+
// the authoritative minimum: a diff needs something to diff against, so a single capture can only ever
210+
// derive an empty matrix and would produce a report with no discovery data at all.
211+
WizardStep.CAPTURE -> if (rawSession.observations.size >= MIN_MODES && captureJob?.isActive != true) {
212+
buildReview()
213+
}
210214
WizardStep.REVIEW -> buildDelivery()
211215
WizardStep.DELIVER -> Unit
212216
}
@@ -287,7 +291,9 @@ class ContributionWizardViewModel @Inject constructor(
287291
private fun diffCount(before: Map<SettingId, String>, after: Map<SettingId, String>): Int =
288292
(before.keys + after.keys).count { before[it] != after[it] }
289293

290-
private companion object {
291-
val TAG = logTag("Contribution", "VM")
294+
companion object {
295+
/** A comparison needs at least two modes; one capture derives an empty matrix by construction. */
296+
const val MIN_MODES = 2
297+
private val TAG = logTag("Contribution", "VM")
292298
}
293299
}

app/src/main/res/values/strings.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@
310310
<string name="contribution_label_duplicate">You already captured a mode with that name.</string>
311311
<string name="contribution_status_no_change">That mode changed no settings — recorded anyway.</string>
312312
<string name="contribution_status_shizuku_required">Shizuku access is required to capture.</string>
313+
<string name="contribution_capture_empty_error">Nothing could be read from the settings providers. Check that Shizuku is still running, then try again.</string>
313314
<string name="contribution_undo_last">Undo last</string>
314315
<string name="contribution_restart">Start over</string>
315316
<string name="contribution_modes_captured">%1$d mode(s) captured</string>
@@ -327,7 +328,10 @@
327328
<string name="contribution_review_hidden">Hidden</string>
328329
<string name="contribution_review_reveal">Reveal</string>
329330
<string name="contribution_review_include">Include in the public report</string>
330-
<string name="contribution_review_empty">No settings changed across the captured modes.</string>
331+
<string name="contribution_review_empty_title">No differences found</string>
332+
<string name="contribution_review_empty_body">Nothing changed in the secure, global, or system settings between the modes you captured. That usually means one of:</string>
333+
<string name="contribution_review_empty_causes">• The mode wasn\'t actually switched in the system settings between captures.\n• It was switched, but captured before the system applied it. Wait until the manufacturer\'s screen shows the new mode, then capture.\n• This ROM keeps the mode somewhere Amply can\'t read.</string>
334+
<string name="contribution_review_empty_hint">Only the last one is worth sending. If you\'re not sure, start over and capture each mode again.</string>
331335

332336
<!-- Step: deliver -->
333337
<string name="contribution_deliver_title">Send it</string>
@@ -340,6 +344,7 @@
340344

341345
<!-- Navigation -->
342346
<string name="contribution_next">Next</string>
347+
<string name="contribution_next_empty">Continue anyway</string>
343348
<string name="contribution_back">Back</string>
344349

345350
<!-- Dashboard (static UI) -->

app/src/test/java/eu/darken/amply/diagnostics/core/ContributionReportTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,42 @@ class ContributionReportTest {
9595
built.withheldRowCount shouldBe 0
9696
}
9797

98+
@Test
99+
fun `an empty matrix reads as nothing changed, not as a contributor redaction`() {
100+
val id = secure("charge_optimization_mode")
101+
val built = report(RawWizardSession(listOf(obs("a", id to "1"), obs("b", id to "1"))))
102+
built.changedRowCount shouldBe 0
103+
built.withheldRowCount shouldBe 0
104+
val text = formatContributionReport(built)
105+
text shouldContain "no settings changed across the captured modes"
106+
text shouldNotContain "approved for inclusion"
107+
text shouldContain "changed_rows=0"
108+
}
109+
110+
@Test
111+
fun `a fully withheld matrix keeps the inclusion wording and leaks no key`() {
112+
val id = secure("lock_screen_owner_info")
113+
val built = report(RawWizardSession(listOf(obs("a", id to "SECRET-A"), obs("b", id to "SECRET-B"))))
114+
built.changedRowCount shouldBe 1
115+
built.withheldRowCount shouldBe 1
116+
val text = formatContributionReport(built)
117+
text shouldContain "(no settings approved for inclusion)"
118+
text shouldContain "withheld_rows=1"
119+
text shouldNotContain "lock_screen_owner_info"
120+
text shouldNotContain "SECRET"
121+
}
122+
123+
@Test
124+
fun `report states what was measured`() {
125+
val text = formatContributionReport(
126+
report(RawWizardSession(listOf(obs("off", global("protect_battery") to "0"), obs("max", global("protect_battery") to "1")))),
127+
)
128+
text shouldContain "contribution_schema=2"
129+
text shouldContain "captured_mode_count=2"
130+
text shouldContain "scanned_namespaces=secure,global,system"
131+
text shouldContain "changed_rows=1"
132+
}
133+
98134
@Test
99135
fun `multiline values are collapsed to one line`() {
100136
val id = secure("charge_optimization_mode")

app/src/test/java/eu/darken/amply/diagnostics/core/DefaultContributionRepositoryTest.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@ import org.robolectric.annotation.Config
3232
@Config(sdk = [34])
3333
class DefaultContributionRepositoryTest {
3434

35-
private class RecordingSource : SettingsSnapshotSource {
35+
private class RecordingSource(private val values: Map<String, String> = mapOf("some_key" to "1")) :
36+
SettingsSnapshotSource {
3637
val requested = mutableListOf<SettingNamespace>()
3738
override suspend fun status() = BackendStatus(available = true, granted = true, detail = "test".toCaString())
3839
override suspend fun snapshot(namespace: SettingNamespace): NamespaceSnapshot {
3940
requested += namespace
40-
return NamespaceSnapshot.Success(emptyMap())
41+
return NamespaceSnapshot.Success(values)
4142
}
4243
}
4344

@@ -74,4 +75,14 @@ class DefaultContributionRepositoryTest {
7475
)
7576
source.requested shouldNotContain SettingNamespace.LINEAGE_SYSTEM
7677
}
78+
79+
@Test
80+
fun `a capture that reads nothing at all fails instead of passing as an empty snapshot`() = runTest {
81+
// A backend only reports Failure when the call threw; empty/unparsable output parses to an empty map. Three
82+
// empty namespaces would otherwise diff to "nothing changed" and look like a valid unsupported-ROM finding.
83+
val source = RecordingSource(values = emptyMap())
84+
val repo = DefaultContributionRepository(ApplicationProvider.getApplicationContext(), source, registry)
85+
86+
repo.captureSnapshot().shouldBeInstanceOf<CaptureResult.Failure>()
87+
}
7788
}

0 commit comments

Comments
 (0)