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 @@ -83,6 +83,38 @@ data class ChargingState(
writeRequiresShizuku -> access?.shizuku?.ready == true
else -> access?.canControl == true
}

/**
* True when an adapter — live or lab — recognized this device's family, i.e. Amply knows *what* it is looking
* at even when it cannot control it. Derived from [adapterId] rather than carried separately: [AdapterRegistry]
* returns an adapter exactly when some probe matched, and its catch-all is the only null-adapter selection, so a
* mirrored flag could only ever drift.
*/
val adapterMatched: Boolean get() = adapterId != null

/**
* Whether the unprivileged device metadata alone gives a maintainer somewhere to start, which is what makes it
* worth a public device-support issue. Two independent sources, because neither covers the other:
*
* - a matched adapter, including a lab one. Those match on manufacturer or ROM marker, so "Samsung, One UI
* unreadable" still says the feature exists and names the skin whose key mapping to check.
* - a ROM marker or a protection key/provider the probes found directly. [AdapterRegistry]'s family matchers are
* manufacturer lists and property checks, so a rebranded Oplus device or a LineageOS derivative that ships the
* settings provider without the Lineage property lands in the catch-all while still carrying a real lead.
*
* False means every probe came back empty: the report can only state that none of the known families matched,
* which no maintainer can act on. Those devices are pointed at the contribution wizard, which can discover a key
* Amply does not know yet, or at email, where a dead end costs one reply instead of a public issue.
*/
val hasSupportLead: Boolean
get() = adapterMatched ||
device.hasProtectBattery ||
device.hasLineageSettingsProvider ||
device.hasChargingOptimization ||
device.oneUiVersion != null ||
device.hyperOsVersion != null ||
device.oplusRomVersion != null ||
device.lineageOsVersion != null
}

@Singleton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ fun DashboardScreen(
UnsupportedDeviceCard(
manufacturer = state.charging.device.manufacturer
.ifBlank { stringResource(R.string.dashboard_manufacturer_fallback) },
hasSupportLead = state.charging.hasSupportLead,
reportPreview = state.deviceReport?.let(::formatReport),
onOpenWizard = onOpenContribution,
onPrepareReport = onPrepareSupportReport,
Expand Down Expand Up @@ -1431,6 +1432,9 @@ private fun DashboardScreenUnsupportedPreview() = PreviewWrapper {
charging = ChargingState(
device = DeviceInfo("Samsung", "SM-S911B", 34, "preview", hasChargingOptimization = false),
adapterName = "Diagnostics only".toCaString(),
// A Samsung on an unverified One UI: the lab adapter matched, so the metadata-only report
// names a family to check and the card offers it alongside the wizard.
adapterId = "samsung-lab",
controlEnabled = false,
contributionWanted = true,
observation = ChargeObservation.Unsupported("This device is not a supported Pixel".toCaString()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ import eu.darken.amply.common.compose.PreviewWrapper
fun UnsupportedDeviceCard(
modifier: Modifier = Modifier,
manufacturer: String,
/**
* Whether the device metadata alone gives a maintainer somewhere to start (see
* [eu.darken.amply.charging.core.ChargingState.hasSupportLead]). Only then is the metadata-only GitHub
* path offered — it is the one contribution route that needs no Shizuku, but a report naming no family,
* ROM marker or key is a public issue nobody can act on. Without a lead the card offers the wizard,
* which can still discover a key Amply does not know, and email.
*/
hasSupportLead: Boolean,
reportPreview: String?,
onOpenWizard: () -> Unit,
onPrepareReport: () -> Unit,
Expand Down Expand Up @@ -86,15 +94,18 @@ fun UnsupportedDeviceCard(
Modifier.padding(start = 8.dp),
)
}
// Secondary: send just the non-privileged device metadata (no Shizuku needed).
OutlinedButton(
onClick = {
onPrepareReport()
showDialog = true
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.setup_unsupported_request_action))
// Secondary: send just the non-privileged device metadata (no Shizuku needed). Offered only where
// that metadata identifies something — see [hasSupportLead].
if (hasSupportLead) {
OutlinedButton(
onClick = {
onPrepareReport()
showDialog = true
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.setup_unsupported_request_action))
}
}

HorizontalDivider(Modifier.padding(vertical = 4.dp))
Expand All @@ -119,7 +130,9 @@ fun UnsupportedDeviceCard(
}
}

if (showDialog) {
// Also gated, not just its launcher: a lead that disappears under an open dialog must take the
// confirmation with it rather than leaving an Open-GitHub button behind.
if (showDialog && hasSupportLead) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text(stringResource(R.string.setup_unsupported_dialog_title)) },
Expand Down Expand Up @@ -174,6 +187,7 @@ private fun UnsupportedDeviceCardPreview() = PreviewWrapper {
UnsupportedDeviceCard(
modifier = Modifier.padding(16.dp),
manufacturer = "Samsung",
hasSupportLead = true,
reportPreview = PREVIEW_REPORT,
onOpenWizard = {},
onPrepareReport = {},
Expand All @@ -183,3 +197,21 @@ private fun UnsupportedDeviceCardPreview() = PreviewWrapper {
onHelp = {},
)
}

/** A device whose metadata carries no lead at all: the metadata-only GitHub path is not offered. */
@AmplyPreview
@Composable
private fun UnsupportedDeviceCardNoLeadPreview() = PreviewWrapper {
UnsupportedDeviceCard(
modifier = Modifier.padding(16.dp),
manufacturer = "BLU",
hasSupportLead = false,
reportPreview = null,
onOpenWizard = {},
onPrepareReport = {},
onCopyReport = {},
onOpenIssue = {},
onEmail = {},
onHelp = {},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package eu.darken.amply.charging.core

import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test

/**
* The predicate deciding whether unprivileged device metadata is worth a public device-support issue. It must stay
* wider than "an adapter matched": the family matchers are manufacturer lists and property checks, so a device can
* carry a real lead and still land in the registry's catch-all.
*/
class ChargingStateSupportLeadTest {

private fun device(
manufacturer: String = "BLU",
hasChargingOptimization: Boolean = false,
oneUiVersion: Int? = null,
hyperOsVersion: Int? = null,
oplusRomVersion: Int? = null,
lineageOsVersion: String? = null,
hasProtectBattery: Boolean = false,
hasLineageSettingsProvider: Boolean = false,
) = DeviceInfo(
manufacturer = manufacturer,
model = "B1660V",
sdk = 35,
fingerprint = "test",
hasChargingOptimization = hasChargingOptimization,
oneUiVersion = oneUiVersion,
hyperOsVersion = hyperOsVersion,
oplusRomVersion = oplusRomVersion,
lineageOsVersion = lineageOsVersion,
hasProtectBattery = hasProtectBattery,
hasLineageSettingsProvider = hasLineageSettingsProvider,
)

@Test
fun `a device whose every probe came back empty has no lead`() {
// The BLU B1660V of issue #42: stock Android, no marker, no key, no adapter.
ChargingState(device = device(), adapterId = null).hasSupportLead shouldBe false
}

@Test
fun `a matched adapter is a lead even when no marker was readable`() {
// Samsung whose One UI version won't parse: the lab adapter still names the skin to check.
ChargingState(device = device(manufacturer = "samsung"), adapterId = "samsung-lab")
.hasSupportLead shouldBe true
}

@Test
fun `adapterMatched follows the selected adapter`() {
ChargingState(adapterId = "xiaomi-lab").adapterMatched shouldBe true
ChargingState(adapterId = null).adapterMatched shouldBe false
}

@Test
fun `a ROM marker is a lead even when no adapter matched`() {
// Rebranded Oplus-family hardware: the ROM property is read globally, the matcher is a
// manufacturer list, so this combination reaches the registry's catch-all.
ChargingState(device = device(oplusRomVersion = 16), adapterId = null).hasSupportLead shouldBe true
ChargingState(device = device(oneUiVersion = 9), adapterId = null).hasSupportLead shouldBe true
ChargingState(device = device(hyperOsVersion = 3), adapterId = null).hasSupportLead shouldBe true
ChargingState(device = device(lineageOsVersion = "23.0"), adapterId = null).hasSupportLead shouldBe true
}

@Test
fun `a present protection key or provider is a lead even when no adapter matched`() {
// A LineageOS derivative that ships the settings provider without the Lineage build property.
ChargingState(device = device(hasLineageSettingsProvider = true), adapterId = null)
.hasSupportLead shouldBe true
ChargingState(device = device(hasProtectBattery = true), adapterId = null).hasSupportLead shouldBe true
ChargingState(device = device(hasChargingOptimization = true), adapterId = null).hasSupportLead shouldBe true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package eu.darken.amply.main.ui.setup

import android.app.Application
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import eu.darken.amply.R
import io.kotest.matchers.shouldBe
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.GraphicsMode

/**
* Guards which contribution paths an unsupported device is offered. The metadata-only GitHub path is worth a public
* issue only where the metadata names something to chase; for a device whose every probe came back empty it would
* name no family, marker or key at all.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UnsupportedDeviceCardTest {

@get:Rule
val compose = createComposeRule()

private fun string(res: Int): String =
ApplicationProvider.getApplicationContext<Application>().getString(res)

private fun render(
hasSupportLead: Boolean,
reportPreview: String? = "manufacturer=samsung\nadapter=samsung-lab",
onPrepareReport: () -> Unit = {},
) {
compose.setContent {
Column(Modifier.verticalScroll(rememberScrollState())) {
UnsupportedDeviceCard(
manufacturer = "Samsung",
hasSupportLead = hasSupportLead,
reportPreview = reportPreview,
onOpenWizard = {},
onPrepareReport = onPrepareReport,
onCopyReport = {},
onOpenIssue = {},
onEmail = {},
onHelp = {},
)
}
}
}

@Test
fun `a device with a lead is offered the metadata-only report`() {
render(hasSupportLead = true)

compose.onNodeWithText(string(R.string.setup_unsupported_request_action)).assertExists()
}

@Test
fun `a device without a lead is not offered the metadata-only report`() {
render(hasSupportLead = false)

compose.onNodeWithText(string(R.string.setup_unsupported_request_action)).assertDoesNotExist()
}

@Test
fun `the wizard and email paths stay available without a lead`() {
render(hasSupportLead = false)

compose.onNodeWithText(string(R.string.setup_unsupported_wizard_action)).assertExists()
compose.onNodeWithText(string(R.string.setup_unsupported_email_action)).assertExists()
}

@Test
fun `opening the metadata path requests the report and confirms first`() {
var prepared = false
render(hasSupportLead = true, onPrepareReport = { prepared = true })

compose.onNodeWithText(string(R.string.setup_unsupported_request_action)).performClick()

prepared shouldBe true
// Confirmation, not a direct hand-off: nothing reaches GitHub until the user sees the exact report.
compose.onNodeWithText(string(R.string.setup_unsupported_dialog_title)).assertExists()
compose.onNodeWithText(string(R.string.setup_unsupported_dialog_open)).assertIsEnabled()
}

@Test
fun `the confirmation cannot be accepted until the report snapshot has arrived`() {
// Collection is async: until it lands, what GitHub would receive isn't the previewed text yet.
render(hasSupportLead = true, reportPreview = null)

compose.onNodeWithText(string(R.string.setup_unsupported_request_action)).performClick()

compose.onNodeWithText(string(R.string.setup_unsupported_dialog_open)).assertIsNotEnabled()
compose.onNodeWithText(string(R.string.setup_unsupported_dialog_copy)).assertIsNotEnabled()
}
}