Skip to content
Open
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
15 changes: 15 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,21 @@ jobs:
- name: Stop gradle daemon
run: ./app/gradlew --stop

host-tests:
name: Host tests (python)
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Check out
uses: actions/checkout@v6

- name: GrapheneOS contract suites
run: python3 -m unittest tests.test_grapheneos_bindings tests.test_grapheneos_zygote_contract -v

- name: Zygisk exec-spawn suite
working-directory: native/src/core/zygisk
run: python3 -m unittest discover -p 'test_*.py' -v

avd-test:
name: Test API ${{ matrix.version }} (x86_64)
runs-on: ubuntu-24.04
Expand Down
90 changes: 90 additions & 0 deletions .github/workflows/grapheneos_zygote_contract_monitor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: GrapheneOS Zygote Contract Monitor

on:
workflow_dispatch:
schedule:
# Twice a month (1st and 15th) at 04:17 UTC.
- cron: "17 4 1,15 * *"

permissions:
contents: read
issues: write

concurrency:
group: grapheneos-zygote-contract-monitor
cancel-in-progress: false

jobs:
monitor:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Check GrapheneOS zygote contract
id: monitor
run: |
set +e
python3 scripts/grapheneos_zygote_contract.py \
--out-json grapheneos_zygote_contract_current.json \
--report grapheneos_zygote_contract_report.md
status=$?
set -e
if [ "$status" = "0" ]; then
echo "drift=false" >> "$GITHUB_OUTPUT"
elif [ "$status" = "2" ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
else
exit "$status"
fi

- name: Upload contract report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: grapheneos-zygote-contract
if-no-files-found: warn
path: |
grapheneos_zygote_contract_current.json
grapheneos_zygote_contract_report.md

- name: Run contract regression suites
if: always()
run: python3 -m unittest tests.test_grapheneos_bindings tests.test_grapheneos_zygote_contract -v

- name: Run zygisk behavior suite
if: always()
working-directory: native/src/core/zygisk
run: python3 -m unittest discover -p 'test_*.py' -v

- name: Sync drift issue
if: github.repository == 'pixincreate/Magisk'
env:
GH_TOKEN: ${{ github.token }}
DRIFT: ${{ steps.monitor.outputs.drift }}
MARKER: "<!-- grapheneos-zygote-contract-monitor:v1 -->"
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
issue="$(gh issue list --state all --limit 1000 --json number,body,title,state,author --jq "map(select(.title == \"GrapheneOS zygote semantic contract drift\" and .author.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$MARKER\")))) | sort_by(.number) | last | if . then [.number, .state] | @tsv else \"\" end")"
issue_number="${issue%%$'\t'*}"
issue_state="${issue#*$'\t'}"
if [ "$DRIFT" = "true" ]; then
{
cat grapheneos_zygote_contract_report.md
printf '\nWorkflow run: %s\n' "$RUN_URL"
} > issue_body.md
if [ -n "$issue_number" ]; then
if [ "$issue_state" = "CLOSED" ]; then
gh issue reopen "$issue_number"
fi
gh issue edit "$issue_number" --title "GrapheneOS zygote semantic contract drift" --body-file issue_body.md
else
gh issue create --title "GrapheneOS zygote semantic contract drift" --body-file issue_body.md
fi
elif [ -n "$issue_number" ] && [ "$issue_state" = "OPEN" ]; then
gh issue comment "$issue_number" --body "GrapheneOS zygote contract matches the committed baseline again. Closing from $RUN_URL."
gh issue close "$issue_number" --reason completed
fi
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.topjohnwu.magisk.test

import android.os.ParcelFileDescriptor.AutoCloseInputStream
import androidx.annotation.Keep
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* Regression tests for the bootloader-lock UI guard introduced in bf2e846
* (hide direct install / uninstall when the bootloader is locked).
*
* Runs against the real app UI with UiAutomator. The lock state comes from
* ro.boot.vbmeta.device_state, toggled with resetprop through Magisk's su.
* Because Info.isBootloaderLocked caches the value per process, every state
* change is followed by an app restart.
*/
@Keep
@RunWith(AndroidJUnit4::class)
class BootloaderLockUiTest {

companion object {
private const val APP_PKG = "com.topjohnwu.magisk"
private const val LOCK_STATE_PROP = "ro.boot.vbmeta.device_state"

private const val UNINSTALL = "Uninstall Magisk"
private const val DIRECT_INSTALL = "Direct install (Recommended)"
private const val PATCH_FILE = "Select and patch a file"

// The home card action label depends on environment state
// (Reinstall/Install/Update), so accept all of them as "home ready".
private val ACTION_LABELS = arrayOf("Reinstall", "Install", "Update")

private const val TIMEOUT_MS = 15_000L
private const val GRACE_MS = 3_000L
}

private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
private val uiAutomation get() = InstrumentationRegistry.getInstrumentation().uiAutomation

@After
fun tearDown() {
setLockStateAndRestart("unlocked")
}

private fun shell(cmd: String): String {
val pfd = uiAutomation.executeShellCommand(cmd)
return AutoCloseInputStream(pfd).reader().use { it.readText() }
}

private fun setLockStateAndRestart(state: String) {
shell("su -c 'resetprop $LOCK_STATE_PROP $state'")
shell("am force-stop $APP_PKG")
shell("monkey -p $APP_PKG -c android.intent.category.LAUNCHER 1")
assertNotNull(
"Magisk home never became ready after restart (state=$state)",
awaitAnyOf(*ACTION_LABELS)
)
}

private fun awaitAnyOf(vararg texts: String): String? {
val deadline = System.currentTimeMillis() + TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
texts.firstOrNull { device.findObject(By.text(it)) != null }?.let { return it }
Thread.sleep(250)
}
return null
}

/** True if [text] shows up within [timeout]; used both for presence and short-grace absence checks. */
private fun waitText(text: String, timeout: Long = GRACE_MS): Boolean {
val deadline = System.currentTimeMillis() + timeout
while (System.currentTimeMillis() < deadline) {
if (device.findObject(By.text(text)) != null) return true
Thread.sleep(250)
}
return false
}

private fun openInstallSheet() {
val action = awaitAnyOf(*ACTION_LABELS)
assertNotNull("No install action button found on home screen", action)
val button = action?.let { device.findObject(By.text(it)) }
assertNotNull("Action button disappeared before it could be tapped", button)
button!!.click()
// Positive control: this row exists regardless of lock state,
// so reaching it proves the sheet actually opened.
assertTrue(
"Install sheet did not open ($PATCH_FILE never appeared)",
waitText(PATCH_FILE, TIMEOUT_MS)
)
}

@Test
fun testBootloaderLockTogglesInstallSurfaces() {
// --- Locked: destructive surfaces hidden ---
setLockStateAndRestart("locked")

assertTrue(
"$UNINSTALL must be hidden when the bootloader is locked",
!waitText(UNINSTALL)
)

openInstallSheet()
assertTrue(
"$DIRECT_INSTALL must be hidden when the bootloader is locked",
!waitText(DIRECT_INSTALL)
)
device.pressBack()

// --- Unlocked: destructive surfaces restored ---
setLockStateAndRestart("unlocked")

assertTrue(
"$UNINSTALL must be visible when the bootloader is unlocked",
waitText(UNINSTALL, TIMEOUT_MS)
)

openInstallSheet()
assertTrue(
"$DIRECT_INSTALL must be visible when the bootloader is unlocked",
waitText(DIRECT_INSTALL, TIMEOUT_MS)
)
device.pressBack()
}
}
Loading
Loading