Skip to content

Provisioning (MDM setup) by QR code - compatible with 16-qpr2#48

Open
MoChahadeh wants to merge 8 commits into
GrapheneOS:16-qpr2from
MoChahadeh:16-qpr2
Open

Provisioning (MDM setup) by QR code - compatible with 16-qpr2#48
MoChahadeh wants to merge 8 commits into
GrapheneOS:16-qpr2from
MoChahadeh:16-qpr2

Conversation

@MoChahadeh

Copy link
Copy Markdown

Summary

Port the Headwind MDM QR-provisioning patch (upstream PR
GrapheneOS#40,
still open against the Android-15 base) onto GrapheneOS SetupWizard2's
16-qpr2 Android-16 branch. The Android-15 fork at
MoChahadeh/grapheneos-setup-wizard
was the source of truth for new files. The full execution plan is in
MDM_PORT_PLAN.md on this branch.

The wizard becomes an enrollment entry point: tapping the Welcome screen 6
times reveals a hidden ZXing-based QR scanner, and a Headwind/MobileIron-
formatted QR provisions the device as device-owner via the standard
ACTION_PROVISION_MANAGED_DEVICE_FROM_TRUSTED_SOURCE intent — no
GrapheneOS-specific bypasses.

Three commits, one per phase:

  1. Phase 1 — ZXing/Jackson prebuilts and Soong wiring
    (libs/Android.bp + 5 prebuilt jars/aar; +5 static_libs entries)
  2. Phase 2 — new activities, actions, ViewModel, layout, drawables, strings
    (with bug fixes folded in as the files were copied — see below)
  3. Phase 3 — surgical edits to WelcomeActivity / WelcomeActions /
    AndroidManifest.xml / activity_welcome.xml for the tap-trigger
    integration. Build-portability adjustments also live here (no
    androidx.activity:activity-ktx, no kotlinx-coroutines).

Bug fixes vs. the original Headwind patch

These were applied as the files were copied from the source fork — the diff
isn't a verbatim port

  • Explicit + mutable PendingIntent in AppInstaller — drop
    FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT, set intent.setPackage(packageName)
    so the install-callback intent is explicit. Action namespaced to
    app.grapheneos.setupwizard.INSTALL_COMPLETE.
  • HTTPS validation on the APK download URL before the connection opens.
    The QR payload is trusted but the URL must travel over TLS — otherwise
    an on-path attacker can swap the APK before the post-download checksum
    check.
  • NPE fix: return after the null-QR-content error branch in
    MdmInstallActions.handleEntry (the original fork posted the error then
    fell through to a qrContent!! dereference).
  • BroadcastReceiver lifecycle: register/unregister moved into
    MdmInstallActivity.onStart/onStop with Context.RECEIVER_NOT_EXPORTED.
    The receiver lives on the ViewModel so the activity can pick it up after
    recreation.
  • Cancel background work on activity finish: replaced the
    MdmInstallActions singleton Executor with a per-ViewModel single-
    thread Executor + tracked Future list, all cancelled in
    MdmInstallViewModel.onCleared(). (viewModelScope was the plan's first
    recommendation but kotlinx-coroutines is not currently linked into
    SetupWizard2; the plan also offered the Executor + Future path.)
  • ActivityResultContract everywhere: ProvisionActivity now extends
    androidx.activity.ComponentActivity with two
    registerForActivityResult launchers (one provisioning, one
    finalization). MdmInstallActivity keeps using the existing
    SetupWizardActivity launcher pattern. The deprecated 3-arg
    onActivityResult is gone.
  • Dead code removed: commented PO factory-reset block deleted;
    disableSelfAndFinish() now actually implemented — disables
    WelcomeActivity component (not the whole package, so the factory-reset
    fallback path still works) on successful hand-off.
  • Recursion bound in jsonToPersistableBundle (depth ≤ 8) so a
    malicious deeply-nested QR payload can't exhaust the stack.
  • Tap threshold as a resource: R.integer.qr_provision_tap_threshold
    rather than a hardcoded 6.
  • MdmInstallData singleton replaced with a regular
    class MdmInstallViewModel : ViewModel() scoped to the activity. State
    fields (adminComponentName, downloadLocation, etc.) and the
    BroadcastReceiver moved onto the ViewModel.
  • Stale-launcher guard removed from WelcomeActions.initQrProvisioning
    — the fork's if (barcodeLauncher != null) return would skip
    re-registration on activity recreation and leave a launcher bound to a
    destroyed activity.

Compatibility / no-drift notes

  • UpdaterSecurityPreviewActivity (new in 16-qpr2) is preserved
    untouched.
  • Android.bp change is only a static_libs: append; the
    prebuilt_etc { etc_permissions_app.grapheneos.setupwizard } block and
    the required: entry are untouched.
  • The four new permissions are added below the upstream
    OPEN_SECURITY_PREVIEW_SETTINGS line; existing ones are preserved.

Static checks completed in the porting environment

  • xmllint clean on all 7 modified/new XML files (manifest, layouts,
    drawables, strings, integers).
  • Manifest permissions delta is exactly the four new perms.
  • All 36 R.string/R.plurals/R.integer/R.layout/R.drawable/R.id
    references in new code resolve to actual resources.
  • { / } brace counts match in all 9 new/edited Kotlin files.
  • ZXing classes (ScanContract, ScanOptions, CaptureActivity) and
    Jackson classes (ObjectMapper, JsonNode) verified present in the
    shipped jars/aar.
  • kotlinc / m / aapt were not available in the porting environment,
    so type-checking and APK build are deferred to reviewer / CI.

MoChahadeh and others added 8 commits April 30, 2026 21:05
Add the third-party libraries the MDM QR-provisioning patch depends on:
- zxing-android-embedded 4.3.0 (camera UI + decoder)
- zxing-core 3.4.1
- jackson-core, jackson-databind, jackson-annotations 2.18.2

Each is declared as a Soong import in libs/Android.bp and pulled into the
SetupWizard2 app via static_libs in the root Android.bp. Prebuilt jars/aars
are copied verbatim from the Android-15 fork at MoChahadeh/grapheneos-setup-wizard.
Port the new files from MoChahadeh/grapheneos-setup-wizard for the QR-based
MDM enrollment flow. Bug fixes from Phase 4 are folded in as the files are
copied so the diff against the original Headwind patch is minimised:

- AppInstaller: drop FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT and make the install-
  callback intent explicit via setPackage(context.packageName); keep
  FLAG_MUTABLE which PackageInstaller requires. Use a private namespaced
  action ("app.grapheneos.setupwizard.INSTALL_COMPLETE") instead of a bare
  "INSTALL_COMPLETE" string. Single 64KiB IO buffer constant shared with the
  download path.

- MdmInstallActions: add the missing return after the qrContent==null error
  branch (fixes NPE on the next line). Reject non-HTTPS download URLs before
  opening the connection. Bound recursion in jsonToPersistableBundle to a
  depth of 8 to prevent stack exhaustion via a malicious payload. Drive
  download/install from viewModelScope+Dispatchers.IO instead of a singleton
  Executor so background work is cancelled when the activity is finished.
  All state is now held on MdmInstallViewModel rather than the action object.

- ProvisionActions: drop the deprecated startActivityForResult callers;
  helpers now take ActivityResultLaunchers wired in by ProvisionActivity.
  Remove the dead commented-out PO factory-reset block. Implement
  disableSelfAndFinish() so it actually disables WelcomeActivity (component-
  level, not the whole package) after a successful hand-off, preventing the
  wizard from re-entering setup. Use the modern getParcelableExtra(Class)
  overloads.

- MdmInstallViewModel: rewrite the fork's `object MdmInstallData : ViewModel()`
  singleton as a regular class scoped to the activity via `by viewModels()`.
  Holds the BroadcastReceiver as a property so MdmInstallActivity can
  register/unregister it on the lifecycle.

- MdmInstallActivity: bind to the activity-scoped ViewModel. Move
  registerReceiver/unregisterReceiver into onStart/onStop with
  Context.RECEIVER_NOT_EXPORTED (the install-status broadcast is private to
  this app's package).

- ProvisionActivity: extend ComponentActivity instead of bare Activity, and
  register two ActivityResultLauncher fields - one for the provisioning
  intent, one for the finalization intent - so the deprecated 3-arg
  onActivityResult is no longer needed.

- ConsecutiveTapsGestureDetector, baseline_provisioning{,_glif}.xml,
  activity_mdm_install.xml: copied verbatim.

- integers.xml: new resource file holding qr_provision_tap_threshold so the
  literal "6" lives in resources rather than code.

- strings.xml: append the patch's MDM-related keys without disturbing any
  upstream entries.
Surgical edits on top of the Android-16 base:

- AndroidManifest.xml: append the four new permissions (INTERNET,
  DISPATCH_PROVISIONING_MESSAGE, INSTALL_PACKAGES, MASTER_CLEAR), add
  android:hardwareAccelerated="true" to <application> (required by ZXing's
  camera SurfaceView), and register MdmInstallActivity, ProvisionActivity,
  and com.journeyapps.barcodescanner.CaptureActivity. Preserves the
  upstream-16 UpdaterSecurityPreviewActivity entry untouched.

- res/layout/activity_welcome.xml: add android:id="@+id/root_layout" to
  the inner LinearLayout so ConsecutiveTapsGestureDetector can hit-test
  against the content area rather than the GlifLayout root.

- WelcomeActivity: wire ConsecutiveTapsGestureDetector to root_layout,
  forward ACTION_UP events from dispatchTouchEvent, reset the counter on
  onResume, and route consecutive-tap callbacks to WelcomeActions.

- WelcomeActions: cast to androidx.activity.ComponentActivity (already in
  the classpath via SetupWizardActivity's AppCompatActivity ancestor) and
  register a ScanContract launcher in handleEntry. The launcher is re-
  registered on each call so a stale launcher bound to a destroyed
  activity is not retained across recreation. Tap threshold is read from
  R.integer.qr_provision_tap_threshold rather than a hardcoded 6.

Build-portability fixes also applied in this commit:

- MdmInstallActivity: switch `by viewModels()` to a lazy ViewModelProvider
  lookup so the build only depends on androidx.lifecycle:viewmodel (already
  used by upstream OemUnlockData/SecurityData/etc.) rather than
  androidx.activity:activity-ktx.

- MdmInstallViewModel: drive background work via a per-ViewModel single-
  thread Executor with tracked Futures cancelled in onCleared(), instead
  of viewModelScope+Dispatchers.IO. This avoids pulling in
  kotlinx.coroutines, which is not currently linked into SetupWizard2.
  Same lifecycle correctness: when the activity is finished, in-flight
  download/install threads are interrupted.
…roid16-KnkXr

Port Headwind MDM QR-provisioning patch onto Android-16 (16-qpr2)
The MDM-provisioning port (PR #1) added these three signature|privileged
permissions to AndroidManifest.xml but missed updating the matching privapp
allowlist at etc/permissions/app.grapheneos.setupwizard.xml. For a privileged
system_ext app, the platform certificate plus manifest declaration are not
sufficient — the permission also has to appear in the privapp allowlist that
prebuilt_etc installs to /system_ext/etc/permissions/, otherwise system_server
denies it at boot.

Symptom on a real GrapheneOS build: the QR scan and download steps work, but
PackageInstaller.Session.commit() returns STATUS_PENDING_USER_ACTION instead of
silently installing, because checkSelfPermission(INSTALL_PACKAGES) returns
PERMISSION_DENIED. The MdmInstallActivity reports "Failed to install the MDM
Application! PENDING_USER_ACTION" and the flow dead-ends.

INSTALL_PACKAGES fixes the immediate install failure.
DISPATCH_PROVISIONING_MESSAGE is needed by the next stage
(ACTION_PROVISION_MANAGED_DEVICE_FROM_TRUSTED_SOURCE).
MASTER_CLEAR is needed by ProvisionActions's factory-reset fallback path.

INTERNET is normal protection level and does not require allowlisting.
…owlist

Allowlist INSTALL_PACKAGES + 2 more in privapp permissions
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants