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
72 changes: 72 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Build

# A debug APK on every push and pull request, kept as an artifact so a build can
# be installed without a local toolchain.
#
# Nothing here overrides the project's Gradle settings. That is deliberate:
# gradle.properties holds only portable configuration, and anything specific to a
# machine (JDK location, an aapt2 override for a non-x86_64 host, heap caps) lives
# in that machine's GRADLE_USER_HOME. A workflow that had to patch the repository's
# build settings before it could build would be a sign the repository was carrying
# someone's local setup.

on:
push:
branches: ['**']
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
# A newer push to the same ref makes the in-flight run redundant.
group: build-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
# AGP 8.5 targets JDK 17; a newer JDK is not a safe substitute here
# because the Kotlin 2.0 compiler's bundled tooling cannot parse it.
java-version: '17'
distribution: 'temurin'

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Unit tests
# Runs before the APK so a failing test fails the job on its own evidence
# rather than being attributed to packaging.
run: ./gradlew --no-daemon testDebugUnitTest

- name: Upload test report
# Wanted precisely when the previous step failed, which is why this is
# not conditional on success.
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-report
path: app/build/reports/tests/testDebugUnitTest
if-no-files-found: ignore
retention-days: 14

- name: Assemble debug APK
run: ./gradlew --no-daemon assembleDebug

- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: processlens-debug-apk
path: app/build/outputs/apk/debug/*.apk
# An empty artifact that silently succeeds would be worse than a failure.
if-no-files-found: error
retention-days: 14
18 changes: 13 additions & 5 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,26 @@ android {
}
}

// R8 and resource shrinking are on for release by default. An unminified,
// unobfuscated release APK is a shipping defect rather than a build-host
// preference, so the default does not bend to the host that happens to be
// building. The on-device ARM host this project is often built on cannot run
// R8 inside its ~1.8 GB of free RAM, so it opts out in GRADLE_USER_HOME with
// `processlens.minify=false`, which keeps the opt-out on that one machine and
// leaves every other build — CI included — minified and shrunk.
val minifyRelease = (project.findProperty("processlens.minify") as String?)
?.toBooleanStrictOrNull() ?: true

buildTypes {
debug {
isMinifyEnabled = false
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
// R8 stays off: full minification is memory-hungry on an on-device ARM
// build host with ~1.8 GB free. Keep-rules are retained so it can be
// switched on when building on a workstation.
isMinifyEnabled = false
isShrinkResources = false
// Resource shrinking requires code shrinking, so the two move together.
isMinifyEnabled = minifyRelease
isShrinkResources = minifyRelease
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
Expand All @@ -36,6 +38,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp

Expand Down Expand Up @@ -198,6 +201,11 @@ fun ScreenList(
* Used only for the genuinely unknown-duration first read. Refresh ticks do not show
* one — a spinner appearing twice a second is worse than a value that updates in
* place, and it hides the fact that the previous reading is still valid.
*
* A spinner is a claim that an answer is on its way, and it expires. Issue #1 was this
* composable left on screen indefinitely by a caller with no deadline, which is not a
* fault of the indicator but of showing it unconditionally: a screen that can reach
* "nothing yet" must bound how long it says so and then switch to [UnresolvedBlock].
*/
@Composable
fun LoadingBlock(modifier: Modifier = Modifier, label: String = "Reading system state") {
Expand All @@ -223,6 +231,89 @@ fun LoadingBlock(modifier: Modifier = Modifier, label: String = "Reading system
}
}

/**
* What a screen shows when a bounded wait ran out (Sections 42, 48).
*
* The counterpart to [LoadingBlock], and the state this app did not have. Issue #1
* presented as a hang, but the reason it survived to a release is that the symptom was
* indistinguishable from slowness: the header rendered, the title rendered, the one
* thing below them turned forever, and nothing in the UI was capable of saying "this is
* not coming". So every screen that can wait for a first reading now has somewhere to
* land — what was being waited for, why the waiting stopped, and a control that does
* something about it.
*
* [onRetry] has no default and there is no overload without it. A screen that can reach
* this state with no way out is the exact defect this exists to prevent, so the type
* system refuses to let a caller build one.
*
* [detail] goes through [ExpandableDetail] rather than into [explanation], keeping the
* Section 48 split intact: prose a user can act on first, the mechanical cause behind a
* disclosure for the reader who wants it.
*/
@Composable
fun UnresolvedBlock(
title: String,
explanation: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
retryLabel: String = "Try again",
isRetrying: Boolean = false,
detail: String? = null,
icon: ImageVector = Icons.Outlined.Info,
) {
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 120.dp)
.padding(vertical = 12.dp)
.semantics { contentDescription = title },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(Dimens.iconLarge),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(10.dp))
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Text(
explanation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
if (isRetrying) {
// The control stays put and goes busy rather than being swapped for a bare
// spinner. Replacing it is what makes a retry that is working look like a
// retry that did nothing, which is the complaint this whole state answers.
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = ProcessLensTheme.accent.base,
)
Spacer(Modifier.width(8.dp))
ActionText(retryLabel, onClick = onRetry, enabled = false)
}
} else {
ActionText(retryLabel, onClick = onRetry, icon = Icons.Outlined.Refresh)
}
if (!detail.isNullOrBlank()) {
Spacer(Modifier.height(8.dp))
ExpandableDetail(summary = "Technical details", detail = detail)
}
}
}

/** Fixed-height spacer used to separate sections without a divider. */
@Composable
fun SectionGap(modifier: Modifier = Modifier) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ class CapabilityDetector @Inject constructor(
*/
suspend fun detect(): SystemCapabilities = withContext(io) {
val api = Build.VERSION.SDK_INT
// A capability refresh is the one question "what can this app read *now*",
// and it is the only caller that ever asks. A denial is terminal only for
// the privileges that earned it, so any remembered refusal from a lower
// access level is dropped before the probes below re-attempt every read —
// otherwise a freshly granted Shizuku or root session would keep answering
// the normal-app denial it cached minutes earlier (see
// [ProcFsReader.invalidateRestrictions]).
procFs.invalidateRestrictions()
val shizukuState = shizuku.state()
val rootState = root.state()
val access = when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class CompositeSystemObserver @Inject constructor(
private val standard: StandardAndroidObserver,
private val shizuku: ShizukuShell,
private val root: RootShell,
private val procFs: ProcFsReader,
private val packages: PackageInspector,
private val cpuSamplerFactory: CpuSamplerFactory,
private val samplingPolicy: SamplingPolicy,
Expand Down Expand Up @@ -80,15 +81,31 @@ class CompositeSystemObserver @Inject constructor(
suspend fun applySettings(settings: UserSettings) = routeLock.withLock {
samplingPolicy.apply(settings)
val changed = allowShizuku != settings.shizukuEnabled || allowRoot != settings.rootEnabled
val rootDisabled = allowRoot && !settings.rootEnabled
allowShizuku = settings.shizukuEnabled
allowRoot = settings.rootEnabled
if (changed) active = null
if (changed) {
active = null
// A proven grant survives `invalidate`, by design (defect 4). But the
// user turning root support *off* is the one case where that proof must
// not be carried forward: re-enabling it later has to consult their
// superuser manager afresh rather than silently elevating on a grant
// recorded under the old setting.
if (rootDisabled) root.forgetGrant()
// The access level may now differ, so any refusal remembered under the
// old route is stale — the next capability refresh re-probes everything.
procFs.invalidateRestrictions()
}
}

/** Forces re-resolution — after a permission grant, or a manual refresh. */
suspend fun invalidate() = routeLock.withLock {
active = null
root.invalidate()
// A manual refresh is exactly the moment to re-ask what the current access
// level can read: a denial cached before the user granted Shizuku or root
// would otherwise outlive the grant that lifts it.
procFs.invalidateRestrictions()
}

/**
Expand Down
Loading
Loading