diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index bdb4792ea..66e68b038 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -14,6 +14,10 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + + env: + GITHUB_TOKEN: ${{ github.token }} + packages: read steps: - uses: actions/checkout@v4 - name: Set up JDK 17 @@ -37,7 +41,7 @@ jobs: cp -f google-services.json.example app/google-services.json - name: Run unit tests (all modules) run: | - ./gradlew --no-daemon --stacktrace testDebugUnitTest + ./gradlew --no-daemon --stacktrace testDebugUnitTest :app:testStandardDebugUnitTest :app:testTraceboxDebugUnitTest - name: Upload test reports if: always() uses: actions/upload-artifact@v4 @@ -54,6 +58,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: read steps: - uses: actions/checkout@v4 - name: Set up JDK 17 @@ -75,14 +80,14 @@ jobs: chmod +x gradlew echo "MAPS_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" > local.properties cp -f google-services.json.example app/google-services.json - - name: Run debug lint - run: ./gradlew lintDebug --no-daemon --stacktrace --console=plain + - name: Run debug lint for both diagnostics flavors + run: ./gradlew :app:lintStandardDebug :app:lintTraceboxDebug --no-daemon --stacktrace --console=plain - name: Run release lint - run: ./gradlew :app:lintRelease :app:checkReleaseLintReport --no-daemon --stacktrace --console=plain - - name: Build minified release - run: ./gradlew :app:assembleRelease --no-daemon --stacktrace --console=plain - - name: Build diagnostic release (nominify) - run: ./gradlew assembleRelease_nominify --no-daemon --stacktrace --console=plain + run: ./gradlew :app:lintStandardRelease :app:lintTraceboxRelease :app:checkReleaseLintReport --no-daemon --stacktrace --console=plain + - name: Build minified releases + run: ./gradlew :app:assembleStandardRelease :app:assembleTraceboxRelease --no-daemon --stacktrace --console=plain + - name: Build diagnostic releases (nominify) + run: ./gradlew :app:assembleStandardRelease_nominify :app:assembleTraceboxRelease_nominify --no-daemon --stacktrace --console=plain - name: Check Room schema drift run: ./gradlew checkRoomSchemaDrift --no-daemon --stacktrace --console=plain - name: Upload build reports diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a9ba0465..78cefd7a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,18 @@ android { resourceConfigurations.addAll(listOf("en", "cs-rCZ")) } + flavorDimensions += "diagnostics" + productFlavors { + create("standard") { + dimension = "diagnostics" + buildConfigField("boolean", "TRACEBOX_AVAILABLE", "false") + } + create("tracebox") { + dimension = "diagnostics" + buildConfigField("boolean", "TRACEBOX_AVAILABLE", "true") + } + } + compileOptions { isCoreLibraryDesugaringEnabled = true } @@ -166,45 +178,48 @@ android { includeInBundle = true } } -val releaseLintReport = layout.buildDirectory.file("reports/lint-results-release.xml") +val releaseLintReports = listOf( + layout.buildDirectory.file("reports/lint-results-standardRelease.xml"), + layout.buildDirectory.file("reports/lint-results-traceboxRelease.xml"), +) tasks.register("checkReleaseLintReport") { group = "verification" - description = "Fails when app release lint reports unbaselined fatal/error issues." - dependsOn("lintReportRelease") - mustRunAfter("lintRelease") - inputs.file(releaseLintReport) + description = "Fails when either app release flavor has unbaselined fatal/error lint issues." + dependsOn("lintStandardRelease", "lintTraceboxRelease") + inputs.files(releaseLintReports) doLast { - val report = releaseLintReport.get().asFile - if (!report.isFile) { - throw GradleException("Release lint report was not generated: ${report.absolutePath}") - } - - val documentBuilderFactory = DocumentBuilderFactory.newInstance().apply { - setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) - isExpandEntityReferences = false - } - val document = documentBuilderFactory.newDocumentBuilder().parse(report) - val issues = document.getElementsByTagName("issue") val blockingIssues = buildList { - for (index in 0 until issues.length) { - val issue = issues.item(index) as? Element ?: continue - val severity = issue.getAttribute("severity") - if (severity != "Fatal" && severity != "Error") continue - - val id = issue.getAttribute("id") - val message = issue.getAttribute("message") - val locations = issue.getElementsByTagName("location") - val location = if (locations.length > 0) { - val element = locations.item(0) as Element - val file = element.getAttribute("file") - val line = element.getAttribute("line") - if (line.isBlank()) file else "$file:$line" - } else { - "no location" + releaseLintReports.forEach { reportProvider -> + val report = reportProvider.get().asFile + if (!report.isFile) { + throw GradleException("Release lint report was not generated: ${report.absolutePath}") + } + val documentBuilderFactory = DocumentBuilderFactory.newInstance().apply { + setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) + isExpandEntityReferences = false + } + val document = documentBuilderFactory.newDocumentBuilder().parse(report) + val issues = document.getElementsByTagName("issue") + for (index in 0 until issues.length) { + val issue = issues.item(index) as? Element ?: continue + val severity = issue.getAttribute("severity") + if (severity != "Fatal" && severity != "Error") continue + + val id = issue.getAttribute("id") + val message = issue.getAttribute("message") + val locations = issue.getElementsByTagName("location") + val location = if (locations.length > 0) { + val element = locations.item(0) as Element + val file = element.getAttribute("file") + val line = element.getAttribute("line") + if (line.isBlank()) file else "$file:$line" + } else { + "no location" + } + add("${report.name}: [$severity][$id] $message ($location)") } - add("[$severity][$id] $message ($location)") } } @@ -265,6 +280,10 @@ dependencies { implementation(libs.androidx.work.runtime.ktx) androidTestImplementation(libs.androidx.work.testing) + // Published immutable Tracebox foundation artifact. The source branch and its workflow build + // every AAR from pinned source; this variant never resolves Maven Local. + add("traceboxImplementation", libs.tracebox) + implementation(libs.hilt.navigation.compose) implementation(libs.hilt.work) @@ -354,18 +373,17 @@ tasks.configureEach { } } -// Fix for KSP running before R class generation -// Ensure KSP waits for resource processing to complete +// Fix for KSP running before R class generation. Flavored variants use names such as +// kspStandardDebugKotlin and kspTraceboxDebugKotlin, so derive the matching resource task +// instead of assuming an unflavored debug/release task exists. afterEvaluate { - tasks.named("kspDebugKotlin") { - dependsOn("processDebugResources") - } - tasks.named("kspReleaseKotlin") { - dependsOn("processReleaseResources") + tasks.matching { task -> + task.name.startsWith("ksp") && task.name.endsWith("Kotlin") + }.configureEach { + val variantName = name.removePrefix("ksp").removeSuffix("Kotlin") + tasks.findByName("process${variantName}Resources")?.let { resourceTask -> + dependsOn(resourceTask) + } } - tasks.findByName("kspDevKotlin")?.dependsOn("processDevResources") } - - - diff --git a/app/src/main/java/com/adsamcik/tracker/app/Application.kt b/app/src/main/java/com/adsamcik/tracker/app/Application.kt index 1343f96fe..7b865711c 100644 --- a/app/src/main/java/com/adsamcik/tracker/app/Application.kt +++ b/app/src/main/java/com/adsamcik/tracker/app/Application.kt @@ -2,6 +2,7 @@ package com.adsamcik.tracker.app import android.app.ActivityManager import android.app.ApplicationExitInfo +import android.content.Context import android.os.Build import android.database.sqlite.SQLiteException import androidx.annotation.MainThread @@ -122,6 +123,13 @@ class Application : AndroidApplication(), Configuration.Provider { private val deferredStartupStarted = AtomicBoolean(false) private val maintenanceStartupStarted = AtomicBoolean(false) + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + // The flavor implementation is a no-op in standard builds. Tracebox itself initializes + // disabled and performs durable work off the main thread. + com.adsamcik.tracker.app.tracebox.TraceboxStartup.install(this) + } override val workManagerConfiguration: Configuration get() = Configuration.Builder() diff --git a/app/src/main/java/com/adsamcik/tracker/app/settings/SettingsRoute.kt b/app/src/main/java/com/adsamcik/tracker/app/settings/SettingsRoute.kt index 5014b5bb4..b43d0559d 100644 --- a/app/src/main/java/com/adsamcik/tracker/app/settings/SettingsRoute.kt +++ b/app/src/main/java/com/adsamcik/tracker/app/settings/SettingsRoute.kt @@ -37,6 +37,7 @@ import com.adsamcik.tracker.app.settings.map.MapSettingsScreen import com.adsamcik.tracker.app.settings.root.RootSettingsScreen import com.adsamcik.tracker.app.settings.tracking.TrackingSettingsScreen +import com.adsamcik.tracker.app.settings.tracebox.TraceboxSettingsScreen import com.adsamcik.tracker.shared.utils.style.compose.ridgelineSettle // Contract: Entry route for settings; manages hierarchical navigation & hosts category screens. @@ -118,6 +119,7 @@ fun SettingsRoute( LaunchedEffect(Unit) { currentScreen = SettingsScreen.Root } } SettingsScreen.Debug -> DebugSettingsScreen(onNavigateToDebug) + SettingsScreen.Tracebox -> TraceboxSettingsScreen() } } } @@ -153,6 +155,9 @@ sealed class SettingsScreen { data object Debug : SettingsScreen() { @Composable override fun title() = stringResource(R.string.settings_debug_title) } + data object Tracebox : SettingsScreen() { + @Composable override fun title() = stringResource(R.string.settings_tracebox_title) + } internal fun saveKey(): String = when (this) { Root -> "root" @@ -163,6 +168,7 @@ sealed class SettingsScreen { Game -> "game" Statistics -> "statistics" Debug -> "debug" + Tracebox -> "tracebox" } internal companion object { @@ -175,6 +181,7 @@ sealed class SettingsScreen { "game" -> Game "statistics" -> Statistics "debug" -> Debug + "tracebox" -> Tracebox else -> null } } diff --git a/app/src/main/java/com/adsamcik/tracker/app/settings/components/SettingsComponents.kt b/app/src/main/java/com/adsamcik/tracker/app/settings/components/SettingsComponents.kt index b8b38a8a6..edced6ace 100644 --- a/app/src/main/java/com/adsamcik/tracker/app/settings/components/SettingsComponents.kt +++ b/app/src/main/java/com/adsamcik/tracker/app/settings/components/SettingsComponents.kt @@ -50,13 +50,14 @@ fun SettingsItem( modifier: Modifier = Modifier, subtitle: String? = null, icon: ImageVector? = null, + enabled: Boolean = true, onClick: () -> Unit ) { Row( modifier = modifier .fillMaxWidth() .heightIn(min = 56.dp) - .clickable(role = Role.Button, onClick = onClick) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .padding(horizontal = 16.dp, vertical = 14.dp) .semantics(mergeDescendants = true) { contentDescription = if (subtitle != null) "$title: $subtitle" else title diff --git a/app/src/main/java/com/adsamcik/tracker/app/settings/root/RootSettingsScreen.kt b/app/src/main/java/com/adsamcik/tracker/app/settings/root/RootSettingsScreen.kt index f5a4051cb..386a40215 100644 --- a/app/src/main/java/com/adsamcik/tracker/app/settings/root/RootSettingsScreen.kt +++ b/app/src/main/java/com/adsamcik/tracker/app/settings/root/RootSettingsScreen.kt @@ -130,6 +130,12 @@ internal fun RootSettingsContent( icon = Icons.Default.Folder, onClick = { onNavigate(SettingsScreen.Data) } ) + SettingsItem( + title = stringResource(R.string.settings_tracebox_title), + subtitle = stringResource(R.string.settings_tracebox_root_subtitle), + icon = Icons.Default.BugReport, + onClick = { onNavigate(SettingsScreen.Tracebox) }, + ) SettingsItem( title = stringResource(com.adsamcik.tracker.activity.R.string.settings_activity_title), icon = Icons.AutoMirrored.Filled.DirectionsRun, diff --git a/app/src/main/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsScreen.kt b/app/src/main/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsScreen.kt new file mode 100644 index 000000000..e4cafdd5f --- /dev/null +++ b/app/src/main/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsScreen.kt @@ -0,0 +1,217 @@ +package com.adsamcik.tracker.app.settings.tracebox + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.adsamcik.tracker.R +import com.adsamcik.tracker.app.settings.components.SettingsGroupCard +import com.adsamcik.tracker.app.settings.components.SettingsItem +import com.adsamcik.tracker.app.settings.components.SwitchSettingsItem +import com.adsamcik.tracker.app.tracebox.TraceboxAvailability +import com.adsamcik.tracker.app.tracebox.TraceboxExportState +import com.adsamcik.tracker.app.tracebox.TraceboxUiState + +@Composable +fun TraceboxSettingsScreen( + viewModel: TraceboxSettingsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val approvalLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + viewModel.acceptApprovalResult(result.data) + } + val saveLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + result.data?.data?.let(viewModel::save) + } + TraceboxSettingsContent( + state = state, + onEnabledChange = viewModel::setEnabled, + onPrepare = viewModel::prepareStandardPackage, + onReview = { + viewModel.approvalIntent()?.let(approvalLauncher::launch) + }, + onShare = { + viewModel.shareIntent()?.let(context::startActivity) + }, + onSave = { + viewModel.createSaveIntent()?.let(saveLauncher::launch) + }, + onCancel = viewModel::cancelPackage, + onDelete = viewModel::deleteAllTraceboxData, + ) +} + +@Composable +internal fun TraceboxSettingsContent( + state: TraceboxUiState, + onEnabledChange: (Boolean) -> Unit, + onPrepare: () -> Unit, + onReview: () -> Unit, + onShare: () -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + onDelete: () -> Unit, +) { + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val available = state.availability != TraceboxAvailability.UNAVAILABLE + val canPrepare = state.enabled && + state.availability in setOf(TraceboxAvailability.READY, TraceboxAvailability.DEGRADED) + val canReview = state.exportState == TraceboxExportState.PREVIEW_READY + val canExport = state.exportState in setOf(TraceboxExportState.APPROVED, TraceboxExportState.SAVED) + + LazyColumn( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + contentPadding = PaddingValues(bottom = 16.dp + bottomPadding), + ) { + item { + SettingsGroupCard( + title = stringResource(R.string.settings_tracebox_title), + modifier = Modifier.padding(top = 12.dp), + ) { + SwitchSettingsItem( + title = stringResource(R.string.settings_tracebox_enable_title), + subtitle = stringResource( + when { + !available -> R.string.settings_tracebox_unavailable + state.enabled -> R.string.settings_tracebox_enabled + else -> R.string.settings_tracebox_disabled + }, + ), + icon = Icons.Default.BugReport, + checked = state.enabled, + enabled = available, + onCheckedChange = onEnabledChange, + ) + } + } + item { + TraceboxExplanation( + stringResource(R.string.settings_tracebox_privacy_title), + stringResource(R.string.settings_tracebox_privacy_body), + ) + } + item { + TraceboxExplanation( + stringResource(R.string.settings_tracebox_capture_title), + stringResource(R.string.settings_tracebox_capture_body), + ) + } + item { + SettingsGroupCard( + title = stringResource(R.string.settings_tracebox_export_title), + modifier = Modifier.padding(top = 12.dp), + ) { + SettingsItem( + title = stringResource(R.string.settings_tracebox_prepare_title), + subtitle = stringResource(R.string.settings_tracebox_prepare_body), + icon = Icons.Default.FileDownload, + enabled = canPrepare, + onClick = onPrepare, + ) + if (state.preview != null) { + SettingsItem( + title = stringResource(R.string.settings_tracebox_review_title), + subtitle = stringResource( + R.string.settings_tracebox_review_body, + state.preview.includedValues, + state.preview.includedBytes, + state.preview.sourceProcesses, + ), + icon = Icons.Default.BugReport, + enabled = canReview, + onClick = onReview, + ) + Text( + text = stringResource( + R.string.settings_tracebox_digest, + state.preview.digestSha256.joinToString("") { "%02x".format(it) }, + ), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + SettingsItem( + title = stringResource(R.string.settings_tracebox_share_title), + subtitle = stringResource(R.string.settings_tracebox_share_body), + icon = Icons.Default.Share, + enabled = canExport, + onClick = onShare, + ) + SettingsItem( + title = stringResource(R.string.settings_tracebox_save_title), + subtitle = stringResource(R.string.settings_tracebox_save_body), + icon = Icons.Default.FileDownload, + enabled = canExport, + onClick = onSave, + ) + SettingsItem( + title = stringResource(R.string.settings_tracebox_cancel_title), + subtitle = stringResource(R.string.settings_tracebox_cancel_body), + icon = Icons.Default.Delete, + enabled = state.exportState != TraceboxExportState.IDLE, + onClick = onCancel, + ) + } + } + item { + SettingsGroupCard( + title = stringResource(R.string.settings_tracebox_delete_group_title), + modifier = Modifier.padding(top = 12.dp), + ) { + SettingsItem( + title = stringResource(R.string.settings_tracebox_delete_title), + subtitle = stringResource(R.string.settings_tracebox_delete_body), + icon = Icons.Default.Delete, + enabled = available, + onClick = onDelete, + ) + } + } + } +} + +@Composable +private fun TraceboxExplanation( + title: String, + body: String, +) { + SettingsGroupCard( + title = title, + modifier = Modifier.padding(top = 12.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsViewModel.kt b/app/src/main/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsViewModel.kt new file mode 100644 index 000000000..d0c4b78a9 --- /dev/null +++ b/app/src/main/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsViewModel.kt @@ -0,0 +1,27 @@ +package com.adsamcik.tracker.app.settings.tracebox + +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.ViewModel +import com.adsamcik.tracker.app.tracebox.TraceboxController +import com.adsamcik.tracker.app.tracebox.TraceboxUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +class TraceboxSettingsViewModel @Inject constructor( + private val controller: TraceboxController, +) : ViewModel() { + val state: StateFlow = controller.state + + fun setEnabled(enabled: Boolean) = controller.setEnabled(enabled) + fun prepareStandardPackage() = controller.prepareStandardPackage() + fun approvalIntent(): Intent? = controller.approvalIntent() + fun acceptApprovalResult(result: Intent?) = controller.acceptApprovalResult(result) + fun shareIntent(): Intent? = controller.shareIntent() + fun createSaveIntent(): Intent? = controller.createSaveIntent() + fun save(destination: Uri) = controller.save(destination) + fun cancelPackage() = controller.cancelPackage() + fun deleteAllTraceboxData() = controller.deleteAllTraceboxData() +} diff --git a/app/src/main/java/com/adsamcik/tracker/app/tracebox/TraceboxController.kt b/app/src/main/java/com/adsamcik/tracker/app/tracebox/TraceboxController.kt new file mode 100644 index 000000000..b3637ce46 --- /dev/null +++ b/app/src/main/java/com/adsamcik/tracker/app/tracebox/TraceboxController.kt @@ -0,0 +1,50 @@ +package com.adsamcik.tracker.app.tracebox + +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.flow.StateFlow + +enum class TraceboxAvailability { UNAVAILABLE, INITIALIZING, DISABLED, READY, DEGRADED, DELETING } +enum class TraceboxExportState { IDLE, PREVIEW_READY, APPROVED, SAVED, CANCELLED, FAILED } +enum class TraceboxOperationResult { NONE, PROFILE_UPDATED, DATA_DELETED, SAVE_COMPLETE, SAVE_PARTIAL, FAILED } + +data class TraceboxPreview( + val includedValues: Int, + val includedBytes: Long, + val sourceProcesses: Int, + val rawArtifactsExcluded: Boolean, + val digestSha256: ByteArray, +) { + init { + require(includedValues >= 0) + require(includedBytes >= 0) + require(sourceProcesses >= 0) + require(digestSha256.size == 32) + } +} + +data class TraceboxUiState( + val availability: TraceboxAvailability, + val enabled: Boolean, + val exportState: TraceboxExportState = TraceboxExportState.IDLE, + val preview: TraceboxPreview? = null, + val result: TraceboxOperationResult = TraceboxOperationResult.NONE, +) + +/** + * App-facing bounded control surface. It never accepts Tracker location, sensor, account, + * identifier, user-entered text, or generic record values. + */ +interface TraceboxController { + val state: StateFlow + + fun setEnabled(enabled: Boolean) + fun prepareStandardPackage() + fun approvalIntent(): Intent? + fun acceptApprovalResult(result: Intent?) + fun shareIntent(): Intent? + fun createSaveIntent(): Intent? + fun save(destination: Uri) + fun cancelPackage() + fun deleteAllTraceboxData() +} diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index 02d2821fa..6acfa656c 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -125,4 +125,29 @@ Hry používají živou polohu jen při aktivitě a pokud je dostupná, využijí polohu Trackeru. Pozastavení uvolní používání polohy hrou. V tomto zařízení se ukládá jen skóre a body/XP; nezpracované herní polohy se neukládají. Načítání nastavení her… Nastavení her se nepodařilo načíst. Zkuste to prosím znovu. + Diagnostika Tracebox + Volitelná místní diagnostika pádů a ANR + Povolit diagnostiku Tracebox + Toto standardní sestavení Tracebox neobsahuje. + Vypnuto. Nové diagnostické záznamy Tracebox se nepořizují. + Zapnuto. Diagnostika zůstane v tomto zařízení, dokud výslovně nevyexportujete schválený balíček. + Soukromí a kontrola + Tracebox je ve výchozím stavu vypnutý. Tracker do Traceboxu neposílá polohu, Wi-Fi, mobilní síť, senzory, účet, uživatelský text ani identifikátory Trackeru. Standardní balíčky nezahrnují nezpracované artefakty pádů. + Co zaznamenává + Je-li zapnutý, může Tracebox shromažďovat omezenou strukturální diagnostiku selhání včetně důkazů pádů a ANR. Data neodesílá ani do Trackeru nepřidává síťovou funkci. + Místní export + Připravit standardní balíček + Vytvoří deterministická místní data k revizi. Nezpracované artefakty pádů jsou vyloučeny. + Zkontrolovat a schválit přesný balíček + %1$d hodnot, %2$d bajtů, z %3$d procesů. Schválení je vázáno na přesně tato data. + SHA-256: %1$s + Sdílet schválený balíček + Otevře Android Sharesheet pro přesně schválená data. Doručení nelze prokázat. + Uložit schválený balíček + Vyberte umístění pomocí systémového výběru souboru. Poskytovatel cíle může soubor uchovávat. + Zrušit balíček + Odstraní dočasně uložená data Traceboxu pro aktuální balíček. + Odstranit data Traceboxu + Odstranit veškerou diagnostiku Tracebox + Vypne Tracebox a odstraní diagnostiku vlastněnou knihovnou i místní dočasná data. Historie ukončení vlastněná Androidem zůstane beze změny. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e39cd126..8cac06587 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,31 @@ Version: %1$s (%2$d) Crash reports stay on-device unless you choose to export them manually. Local crash reports + Tracebox diagnostics + Optional local crash and ANR diagnostics + Enable Tracebox diagnostics + This standard build does not include Tracebox. + Off. No new Tracebox diagnostics are recorded. + On. Diagnostics stay on this device until you explicitly export an approved package. + Privacy and control + Tracebox is disabled by default. Tracker sends no location, Wi-Fi, cell, sensor, account, user-entered text, or Tracker identifiers to Tracebox. Standard packages exclude raw crash artifacts. + What it records + When enabled, Tracebox can collect bounded structural failure diagnostics, including crash and ANR evidence. It does not upload data or add a network capability to Tracker. + Local export + Prepare Standard package + Create deterministic local bytes for review. Raw crash artifacts are excluded. + Review and approve exact package + %1$d values, %2$d bytes, from %3$d process(es). Approval is bound to these exact bytes. + SHA-256: %1$s + Share approved package + Open Android Sharesheet for the exact approved bytes. Delivery cannot be proven. + Save approved package + Choose a destination with Android’s file picker. The destination provider may retain the file. + Cancel package + Delete Tracebox-owned staging for the current package. + Delete Tracebox data + Delete all Tracebox diagnostics + Disable Tracebox and remove library-owned diagnostics and local staging. Android-owned exit history is unaffected. helloWorld Hello world! diff --git a/app/src/standard/java/com/adsamcik/tracker/app/tracebox/DisabledTraceboxController.kt b/app/src/standard/java/com/adsamcik/tracker/app/tracebox/DisabledTraceboxController.kt new file mode 100644 index 000000000..a95187510 --- /dev/null +++ b/app/src/standard/java/com/adsamcik/tracker/app/tracebox/DisabledTraceboxController.kt @@ -0,0 +1,37 @@ +package com.adsamcik.tracker.app.tracebox + +import android.content.Intent +import android.net.Uri +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DisabledTraceboxController @Inject constructor() : TraceboxController { + private val mutableState = MutableStateFlow( + TraceboxUiState(TraceboxAvailability.UNAVAILABLE, enabled = false), + ) + override val state: StateFlow = mutableState + + override fun setEnabled(enabled: Boolean) = Unit + override fun prepareStandardPackage() = Unit + override fun approvalIntent(): Intent? = null + override fun acceptApprovalResult(result: Intent?) = Unit + override fun shareIntent(): Intent? = null + override fun createSaveIntent(): Intent? = null + override fun save(destination: Uri) = Unit + override fun cancelPackage() = Unit + override fun deleteAllTraceboxData() = Unit +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class StandardTraceboxModule { + @Binds + abstract fun bindTraceboxController(controller: DisabledTraceboxController): TraceboxController +} diff --git a/app/src/standard/java/com/adsamcik/tracker/app/tracebox/TraceboxStartup.kt b/app/src/standard/java/com/adsamcik/tracker/app/tracebox/TraceboxStartup.kt new file mode 100644 index 000000000..b87f979b5 --- /dev/null +++ b/app/src/standard/java/com/adsamcik/tracker/app/tracebox/TraceboxStartup.kt @@ -0,0 +1,8 @@ +package com.adsamcik.tracker.app.tracebox + +import android.app.Application + +/** Standard Tracker builds deliberately contain no Tracebox runtime. */ +object TraceboxStartup { + fun install(application: Application) = Unit +} diff --git a/app/src/test/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsViewModelTest.kt b/app/src/test/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsViewModelTest.kt new file mode 100644 index 000000000..d3627b4f9 --- /dev/null +++ b/app/src/test/java/com/adsamcik/tracker/app/settings/tracebox/TraceboxSettingsViewModelTest.kt @@ -0,0 +1,45 @@ +package com.adsamcik.tracker.app.settings.tracebox + +import android.content.Intent +import android.net.Uri +import com.adsamcik.tracker.app.tracebox.TraceboxAvailability +import com.adsamcik.tracker.app.tracebox.TraceboxController +import com.adsamcik.tracker.app.tracebox.TraceboxUiState +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.jupiter.api.Test + +class TraceboxSettingsViewModelTest { + @Test + fun `delegates explicit enablement to the bounded controller`() { + val controller = FakeTraceboxController() + val viewModel = TraceboxSettingsViewModel(controller) + + viewModel.setEnabled(true) + + controller.enabled shouldBe true + viewModel.state.value.availability shouldBe TraceboxAvailability.DISABLED + } + + private class FakeTraceboxController : TraceboxController { + private val mutableState = MutableStateFlow( + TraceboxUiState(TraceboxAvailability.DISABLED, enabled = false), + ) + var enabled = false + override val state: StateFlow = mutableState + + override fun setEnabled(enabled: Boolean) { + this.enabled = enabled + } + + override fun prepareStandardPackage() = Unit + override fun approvalIntent(): Intent? = null + override fun acceptApprovalResult(result: Intent?) = Unit + override fun shareIntent(): Intent? = null + override fun createSaveIntent(): Intent? = null + override fun save(destination: Uri) = Unit + override fun cancelPackage() = Unit + override fun deleteAllTraceboxData() = Unit + } +} diff --git a/app/src/tracebox/java/com/adsamcik/tracker/app/tracebox/TraceboxStartup.kt b/app/src/tracebox/java/com/adsamcik/tracker/app/tracebox/TraceboxStartup.kt new file mode 100644 index 000000000..74c323c95 --- /dev/null +++ b/app/src/tracebox/java/com/adsamcik/tracker/app/tracebox/TraceboxStartup.kt @@ -0,0 +1,35 @@ +package com.adsamcik.tracker.app.tracebox + +import android.app.Application +import dev.tracebox.Tracebox +import dev.tracebox.TraceboxConfiguration +import dev.tracebox.api.TraceboxHandle + +/** + * Installed from `Application.attachBaseContext()` in every Tracker process. The runtime remains + * fail-closed until the user enables it in Settings; the persisted choice is only written after a + * successful explicit profile update. + */ +object TraceboxStartup { + @Volatile + private var handle: TraceboxHandle? = null + + fun install(application: Application) { + handle = Tracebox.install( + application, + TraceboxConfiguration.Builder() + .setProcessRole(TraceboxConfiguration.DEFAULT_PROCESS_ROLE) + .setPersistRequestedProfile(true) + .build(), + ) + } + + fun requireHandle(application: Application): TraceboxHandle = + handle ?: Tracebox.install( + application, + TraceboxConfiguration.Builder() + .setProcessRole(TraceboxConfiguration.DEFAULT_PROCESS_ROLE) + .setPersistRequestedProfile(true) + .build(), + ).also { handle = it } +} diff --git a/app/src/tracebox/java/com/adsamcik/tracker/app/tracebox/TrackerTraceboxController.kt b/app/src/tracebox/java/com/adsamcik/tracker/app/tracebox/TrackerTraceboxController.kt new file mode 100644 index 000000000..e91d303be --- /dev/null +++ b/app/src/tracebox/java/com/adsamcik/tracker/app/tracebox/TrackerTraceboxController.kt @@ -0,0 +1,230 @@ +package com.adsamcik.tracker.app.tracebox + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.SystemClock +import com.adsamcik.tracker.shared.base.di.ApplicationScope +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.tracebox.api.ApprovalToken +import dev.tracebox.api.DeleteReport +import dev.tracebox.api.DeleteRequest +import dev.tracebox.api.DiagnosticPackage +import dev.tracebox.api.DiagnosticsProfile +import dev.tracebox.api.PackagePreparationResult +import dev.tracebox.api.PackageRequest +import dev.tracebox.api.PackageResult +import dev.tracebox.api.PolicyUpdateResult +import dev.tracebox.api.SavePackageResult +import dev.tracebox.api.TraceboxHealth +import dev.tracebox.api.generated.GeneratedDiagnostics +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tracker sends Tracebox only one application-owned structural breadcrumb on an explicit opt-in. + * It never forwards location, Wi-Fi, cell, sensor, account, user-entered text, or app identifiers. + */ +@Singleton +class TrackerTraceboxController @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope private val applicationScope: CoroutineScope, +) : TraceboxController { + private val handle = TraceboxStartup.requireHandle(context.applicationContext as Application) + private val mutableState = MutableStateFlow( + TraceboxUiState(TraceboxAvailability.INITIALIZING, enabled = false), + ) + private var preview: dev.tracebox.api.PackagePreview? = null + private var approvedPackage: DiagnosticPackage? = null + + override val state: StateFlow = mutableState + + init { + applicationScope.launch { + combine(handle.health, handle.readiness) { health, _ -> health }.collect { health -> + mutableState.update { current -> + current.copy( + availability = health.toTrackerAvailability(), + enabled = when (health) { + TraceboxHealth.DISABLED, TraceboxHealth.INITIALIZING, TraceboxHealth.CLOSED -> false + TraceboxHealth.READY, TraceboxHealth.DEGRADED, TraceboxHealth.DELETING -> current.enabled + }, + ) + } + } + } + } + + override fun setEnabled(enabled: Boolean) { + applicationScope.launch(Dispatchers.IO) { + val update = handle.updateProfile( + if (enabled) DiagnosticsProfile.STANDARD_DIAGNOSTICS else DiagnosticsProfile.DISABLED, + ) + if (update == PolicyUpdateResult.SUCCESS && enabled) { + GeneratedDiagnostics.breadcrumb( + handle.diagnostics, + code = TRACKER_OPT_IN_BREADCRUMB, + monotonic_time_ns = SystemClock.elapsedRealtimeNanos().toULong(), + ) + } + if (!enabled && update == PolicyUpdateResult.SUCCESS) { + handle.delete(DeleteRequest.ALL_TRACEBOX_DATA) + } + mutableState.update { current -> + current.copy( + enabled = enabled && update == PolicyUpdateResult.SUCCESS, + exportState = TraceboxExportState.IDLE, + preview = null, + result = if (update == PolicyUpdateResult.SUCCESS) { + TraceboxOperationResult.PROFILE_UPDATED + } else { + TraceboxOperationResult.FAILED + }, + ) + } + } + } + + override fun prepareStandardPackage() { + applicationScope.launch(Dispatchers.IO) { + when (val prepared = handle.packages.prepare(PackageRequest.STANDARD)) { + is PackagePreparationResult.Ready -> { + preview = prepared.preview + val disclosure = prepared.preview.disclosure + mutableState.update { + it.copy( + exportState = TraceboxExportState.PREVIEW_READY, + preview = TraceboxPreview( + disclosure.includedValueCount, + disclosure.includedBytes, + disclosure.sourceProcessCount, + disclosure.rawArtifactCount == 0, + disclosure.plaintextDigestSha256.copyOf(), + ), + result = TraceboxOperationResult.NONE, + ) + } + } + + PackagePreparationResult.NotReady, PackagePreparationResult.Rejected -> { + mutableState.update { it.copy(exportState = TraceboxExportState.FAILED, result = TraceboxOperationResult.FAILED) } + } + } + } + } + + override fun approvalIntent(): Intent? = + preview?.let { handle.packages.approvalIntent(context, it) } + + override fun acceptApprovalResult(result: Intent?) { + val approval = ApprovalToken.fromActivityResult(result) ?: run { + mutableState.update { it.copy(exportState = TraceboxExportState.CANCELLED) } + return + } + when (val created = handle.packages.create(PackageRequest.STANDARD, approval)) { + is PackageResult.Created -> { + approvedPackage = created.diagnosticPackage + mutableState.update { it.copy(exportState = TraceboxExportState.APPROVED) } + } + + PackageResult.NotReady, PackageResult.Rejected -> { + mutableState.update { it.copy(exportState = TraceboxExportState.FAILED, result = TraceboxOperationResult.FAILED) } + } + } + } + + override fun shareIntent(): Intent? = approvedPackage?.shareIntent(context) + + override fun createSaveIntent(): Intent? = approvedPackage?.createSaveIntent() + + override fun save(destination: Uri) { + val packageToSave = approvedPackage ?: return + applicationScope.launch(Dispatchers.IO) { + val result = packageToSave.save(context, destination) + mutableState.update { + when (result) { + is SavePackageResult.Complete -> it.copy( + exportState = TraceboxExportState.SAVED, + result = TraceboxOperationResult.SAVE_COMPLETE, + ) + + is SavePackageResult.PartialCopyWarning -> it.copy( + exportState = TraceboxExportState.CANCELLED, + result = TraceboxOperationResult.SAVE_PARTIAL, + ) + + is SavePackageResult.Failed -> it.copy( + exportState = TraceboxExportState.FAILED, + result = TraceboxOperationResult.FAILED, + ) + } + } + } + } + + override fun cancelPackage() { + applicationScope.launch(Dispatchers.IO) { + approvedPackage?.deleteStaging() + approvedPackage = null + preview = null + mutableState.update { + it.copy( + exportState = TraceboxExportState.CANCELLED, + preview = null, + ) + } + } + } + + override fun deleteAllTraceboxData() { + applicationScope.launch(Dispatchers.IO) { + val report = handle.delete(DeleteRequest.ALL_TRACEBOX_DATA) + approvedPackage = null + preview = null + mutableState.update { + it.copy( + enabled = false, + exportState = TraceboxExportState.IDLE, + preview = null, + result = if (report == DeleteReport.COMPLETE) { + TraceboxOperationResult.DATA_DELETED + } else { + TraceboxOperationResult.FAILED + }, + ) + } + } + } + + private fun TraceboxHealth.toTrackerAvailability(): TraceboxAvailability = when (this) { + TraceboxHealth.INITIALIZING -> TraceboxAvailability.INITIALIZING + TraceboxHealth.DISABLED -> TraceboxAvailability.DISABLED + TraceboxHealth.READY -> TraceboxAvailability.READY + TraceboxHealth.DEGRADED -> TraceboxAvailability.DEGRADED + TraceboxHealth.DELETING -> TraceboxAvailability.DELETING + TraceboxHealth.CLOSED -> TraceboxAvailability.DEGRADED + } + + private companion object { + const val TRACKER_OPT_IN_BREADCRUMB = 0x5452434bU + } +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class TraceboxRuntimeModule { + @Binds + abstract fun bindTraceboxController(controller: TrackerTraceboxController): TraceboxController +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 55d884d43..2e5696d0c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ kotlin = "2.4.0" ksp = "2.3.8" agp = "9.2.1" +tracebox = "0.1.0-foundation.1" # Tools benmanes = "0.54.0" @@ -106,6 +107,7 @@ kotest = "6.2.2" # Gradle plugin artifacts for convention plugins android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +tracebox = { group = "io.github.tracebox", name = "tracebox", version.ref = "tracebox" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6c440a779..60d2e6900 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,21 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS repositories { + maven { + name = "TraceboxGitHubPackages" + url = uri("https://maven.pkg.github.com/adsamcik/Tracebox") + credentials { + username = providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")) + .orNull + password = providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + .orNull + } + content { + includeGroup("io.github.tracebox") + } + } google() maven("https://jitpack.io") mavenCentral() @@ -33,4 +48,3 @@ include(":feature:map:api", ":feature:map", ":feature:statistics:api", ":feature -