diff --git a/Android.bp b/Android.bp
index ddb8c32..2b15fd6 100644
--- a/Android.bp
+++ b/Android.bp
@@ -15,6 +15,11 @@ android_app {
"androidx.core_core",
"setupcompat",
"setupdesign",
+ "setupwizard2-jackson-core",
+ "setupwizard2-jackson-databind",
+ "setupwizard2-jackson-annotations",
+ "setupwizard2-zxing-android",
+ "setupwizard2-zxing-core",
],
required: ["etc_permissions_app.grapheneos.setupwizard"],
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9f988f1..7bcee17 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -19,12 +19,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/permissions/app.grapheneos.setupwizard.xml b/etc/permissions/app.grapheneos.setupwizard.xml
index da9eb49..9b05119 100644
--- a/etc/permissions/app.grapheneos.setupwizard.xml
+++ b/etc/permissions/app.grapheneos.setupwizard.xml
@@ -3,7 +3,10 @@
+
+
+
diff --git a/java/app/grapheneos/setupwizard/action/AppInstaller.kt b/java/app/grapheneos/setupwizard/action/AppInstaller.kt
new file mode 100644
index 0000000..b387ce5
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/action/AppInstaller.kt
@@ -0,0 +1,97 @@
+package app.grapheneos.setupwizard.action
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageInfo
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.util.Log
+import java.io.File
+import java.io.FileInputStream
+
+object AppInstaller {
+ private const val TAG = "AppInstaller"
+
+ const val ACTION_INSTALL_COMPLETE = "app.grapheneos.setupwizard.INSTALL_COMPLETE"
+ const val PACKAGE_NAME = "PACKAGE_NAME"
+ const val IO_BUFFER_SIZE = 64 * 1024
+
+ fun silentInstallApplication(
+ context: Context,
+ file: File
+ ): String? {
+ try {
+ val packageManager: PackageManager = context.packageManager
+ val packageInfo: PackageInfo = packageManager.getPackageArchiveInfo(file.path, 0)
+ ?: throw Exception("Failed to parse the admin app package")
+
+ val packageName = packageInfo.packageName
+
+ Log.i(TAG, "Installing $packageName")
+ val input = FileInputStream(file)
+ val packageInstaller = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(
+ PackageInstaller.SessionParams.MODE_FULL_INSTALL
+ )
+ params.setAppPackageName(packageName)
+ val sessionId = packageInstaller.createSession(params)
+ val session = packageInstaller.openSession(sessionId)
+ val out = session.openWrite("COSU", 0, -1)
+ val buffer = ByteArray(IO_BUFFER_SIZE)
+ var c: Int
+ while (input.read(buffer).also { c = it } != -1) {
+ out.write(buffer, 0, c)
+ }
+ session.fsync(out)
+ input.close()
+ out.close()
+ session.commit(
+ createIntentSender(
+ context,
+ sessionId,
+ packageName
+ )
+ )
+ Log.i(TAG, "Installation session committed")
+ return null
+ } catch (e: Exception) {
+ Log.w(TAG, "PackageInstaller error: ${e.message}", e)
+ return e.message
+ }
+ }
+
+ private fun createIntentSender(context: Context, sessionId: Int, packageName: String?): IntentSender {
+ val intent = Intent(ACTION_INSTALL_COMPLETE).apply {
+ // Make the broadcast explicit so PendingIntent's explicit-target requirement
+ // is satisfied without needing FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT.
+ setPackage(context.packageName)
+ if (packageName != null) {
+ putExtra(PACKAGE_NAME, packageName)
+ }
+ }
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ sessionId,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ )
+ return pendingIntent.intentSender
+ }
+
+ fun getPackageInstallerStatusMessage(status: Int): String {
+ return when (status) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> "PENDING_USER_ACTION"
+ PackageInstaller.STATUS_SUCCESS -> "SUCCESS"
+ PackageInstaller.STATUS_FAILURE -> "FAILURE_UNKNOWN"
+ PackageInstaller.STATUS_FAILURE_BLOCKED -> "BLOCKED"
+ PackageInstaller.STATUS_FAILURE_ABORTED -> "ABORTED"
+ PackageInstaller.STATUS_FAILURE_INVALID -> "INVALID"
+ PackageInstaller.STATUS_FAILURE_CONFLICT -> "CONFLICT"
+ PackageInstaller.STATUS_FAILURE_STORAGE -> "STORAGE"
+ PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "INCOMPATIBLE"
+ else -> "UNKNOWN"
+ }
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/action/MdmInstallActions.kt b/java/app/grapheneos/setupwizard/action/MdmInstallActions.kt
new file mode 100644
index 0000000..ccd3fd8
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/action/MdmInstallActions.kt
@@ -0,0 +1,365 @@
+package app.grapheneos.setupwizard.action
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.app.admin.DevicePolicyManager
+import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE
+import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME
+import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM
+import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_LEAVE_ALL_SYSTEM_APPS_ENABLED
+import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.PersistableBundle
+import android.util.Base64
+import app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.data.MdmInstallViewModel
+import app.grapheneos.setupwizard.view.activity.MdmInstallActivity
+import app.grapheneos.setupwizard.view.activity.ProvisionActivity
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.google.android.setupcompat.util.SystemBarHelper
+import java.io.DataInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.MessageDigest
+
+object MdmInstallActions {
+ private const val TAG = "MdmInstallActions"
+
+ const val EXTRA_QR_CONTENTS = "EXTRA_QR_CONTENTS"
+
+ private const val EXTRA_ADMIN_COMPONENT_NAME =
+ "android.app.extra.PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME"
+ private const val EXTRA_DOWNLOAD_LOCATION =
+ "android.app.extra.PROVISIONING_DEVICE_ADMIN_PACKAGE_DOWNLOAD_LOCATION"
+ private const val EXTRA_PACKAGE_CHECKSUM =
+ "android.app.extra.PROVISIONING_DEVICE_ADMIN_PACKAGE_CHECKSUM"
+ private const val EXTRA_SKIP_ENCRYPTION =
+ "android.app.extra.PROVISIONING_SKIP_ENCRYPTION"
+ private const val EXTRA_SYSTEM_APPS_ENABLED =
+ "android.app.extra.PROVISIONING_LEAVE_ALL_SYSTEM_APPS_ENABLED"
+ private const val EXTRA_EXTRAS_BUNDLE =
+ "android.app.extra.PROVISIONING_ADMIN_EXTRAS_BUNDLE"
+
+ private const val CONNECTION_TIMEOUT_MS = 10_000
+ private const val ADMIN_APK_FILE_NAME = "deviceadmin.apk"
+
+ // Bound recursion in jsonToPersistableBundle to prevent stack exhaustion via
+ // a maliciously deeply-nested QR payload.
+ private const val MAX_JSON_DEPTH = 8
+
+ fun handleEntry(
+ activity: MdmInstallActivity,
+ viewModel: MdmInstallViewModel,
+ intent: Intent
+ ) {
+ SystemBarHelper.setBackButtonVisible(activity.window, false)
+ val qrContent = intent.getStringExtra(EXTRA_QR_CONTENTS)
+ if (qrContent == null) {
+ viewModel.error.postValue(activity.getString(R.string.qr_parse_failed))
+ return
+ }
+ if (!parseProvisioningQr(activity, viewModel, qrContent)) {
+ return
+ }
+ setupWiFi(activity)
+ }
+
+ fun handleError(activity: MdmInstallActivity, message: String) {
+ AlertDialog.Builder(activity)
+ .setTitle(R.string.error_title)
+ .setMessage(message)
+ .setPositiveButton(R.string.button_ok) { dialog, _ ->
+ dialog.dismiss()
+ activity.viewModel.error.postValue(null)
+ activity.finish()
+ }
+ .setCancelable(false)
+ .create()
+ .show()
+ }
+
+ fun handleActivityResult(activity: MdmInstallActivity, resultCode: Int) {
+ if (resultCode == Activity.RESULT_CANCELED) {
+ handleError(activity, activity.getString(R.string.wifi_failed))
+ } else {
+ onWifiSetupComplete(activity)
+ }
+ }
+
+ private fun parseProvisioningQr(
+ activity: MdmInstallActivity,
+ viewModel: MdmInstallViewModel,
+ qrContent: String
+ ): Boolean {
+ val objectMapper = ObjectMapper()
+ val jsonNode: JsonNode = try {
+ objectMapper.readTree(qrContent)
+ } catch (e: Exception) {
+ viewModel.error.postValue(activity.getString(R.string.qr_parse_failed))
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_ADMIN_COMPONENT_NAME) && jsonNode[EXTRA_ADMIN_COMPONENT_NAME].isTextual) {
+ viewModel.adminComponentName = jsonNode[EXTRA_ADMIN_COMPONENT_NAME].asText()
+ } else {
+ viewModel.error.postValue(
+ activity.getString(R.string.qr_missing_parameter) + EXTRA_ADMIN_COMPONENT_NAME
+ )
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_DOWNLOAD_LOCATION) && jsonNode[EXTRA_DOWNLOAD_LOCATION].isTextual) {
+ viewModel.downloadLocation = jsonNode[EXTRA_DOWNLOAD_LOCATION].asText()
+ } else {
+ viewModel.error.postValue(
+ activity.getString(R.string.qr_missing_parameter) + EXTRA_DOWNLOAD_LOCATION
+ )
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_PACKAGE_CHECKSUM) && jsonNode[EXTRA_PACKAGE_CHECKSUM].isTextual) {
+ viewModel.packageChecksum = jsonNode[EXTRA_PACKAGE_CHECKSUM].asText()
+ } else {
+ viewModel.error.postValue(
+ activity.getString(R.string.qr_missing_parameter) + EXTRA_PACKAGE_CHECKSUM
+ )
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_SKIP_ENCRYPTION) && jsonNode[EXTRA_SKIP_ENCRYPTION].isBoolean) {
+ viewModel.skipEncryption = jsonNode[EXTRA_SKIP_ENCRYPTION].asBoolean()
+ }
+
+ if (jsonNode.has(EXTRA_SYSTEM_APPS_ENABLED) && jsonNode[EXTRA_SYSTEM_APPS_ENABLED].isBoolean) {
+ viewModel.systemAppsEnabled = jsonNode[EXTRA_SYSTEM_APPS_ENABLED].asBoolean()
+ }
+
+ if (jsonNode.has(EXTRA_EXTRAS_BUNDLE) && jsonNode[EXTRA_EXTRAS_BUNDLE].isObject) {
+ viewModel.extrasBundle = jsonToPersistableBundle(jsonNode[EXTRA_EXTRAS_BUNDLE], 0)
+ }
+
+ return true
+ }
+
+ /**
+ * The QR payload may carry WiFi parameters (PROVISIONING_WIFI_SSID, etc.), but configuring
+ * WiFi automatically requires elevated permissions (Device / Profile owner or
+ * android.uid.system) which the wizard does not have at this point. Manual WiFi setup is the
+ * only supported path.
+ */
+ private fun setupWiFi(activity: MdmInstallActivity) {
+ WifiActions.launchSetup(activity)
+ }
+
+ private fun onWifiSetupComplete(activity: MdmInstallActivity) {
+ downloadAdminApp(activity, activity.viewModel)
+ }
+
+ private fun downloadAdminApp(activity: MdmInstallActivity, viewModel: MdmInstallViewModel) {
+ viewModel.message.postValue(activity.getString(R.string.downloading_admin_app))
+ viewModel.runOnIo {
+ if (downloadAdminAppSync(activity, viewModel)) {
+ viewModel.progressVisible.postValue(false)
+ if (!viewModel.calculatedPackageChecksum.equals(viewModel.packageChecksum, ignoreCase = true)) {
+ viewModel.error.postValue(activity.getString(R.string.checksum_failed))
+ return@runOnIo
+ }
+ installAdminApp(activity, viewModel)
+ }
+ }
+ }
+
+ private fun downloadAdminAppSync(
+ activity: MdmInstallActivity,
+ viewModel: MdmInstallViewModel
+ ): Boolean {
+ val downloadLocation = viewModel.downloadLocation
+ if (downloadLocation == null) {
+ viewModel.error.postValue(activity.getString(R.string.download_failed))
+ return false
+ }
+
+ val url: URL = try {
+ URL(downloadLocation)
+ } catch (e: Exception) {
+ viewModel.error.postValue(activity.getString(R.string.download_failed) + e.message)
+ return false
+ }
+
+ // Refuse non-HTTPS download URLs. The QR payload comes from a trusted MDM enrollment
+ // source, but the URL itself must travel over a TLS-protected channel - otherwise an
+ // attacker on path could swap the APK and the checksum check happens after we've already
+ // written attacker-controlled bytes to disk.
+ if (!url.protocol.equals("https", ignoreCase = true)) {
+ viewModel.error.postValue(
+ activity.getString(R.string.download_failed) + "non-HTTPS URL refused"
+ )
+ return false
+ }
+
+ val tempFile = File(activity.filesDir, ADMIN_APK_FILE_NAME)
+ if (tempFile.exists()) {
+ tempFile.delete()
+ }
+ try {
+ try {
+ tempFile.createNewFile()
+ } catch (e: Exception) {
+ viewModel.error.postValue("Failed to create " + tempFile.absolutePath)
+ return false
+ }
+ val connection = url.openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.setRequestProperty("Accept-Encoding", "identity")
+ connection.connectTimeout = CONNECTION_TIMEOUT_MS
+ connection.readTimeout = CONNECTION_TIMEOUT_MS
+ connection.connect()
+ if (connection.responseCode != 200) {
+ throw Exception("Bad server response for $downloadLocation: ${connection.responseCode}")
+ }
+ val lengthOfFile = connection.contentLength
+ notifyDownloadStart(viewModel, lengthOfFile)
+ val digest = MessageDigest.getInstance("SHA-256")
+ DataInputStream(connection.inputStream).use { dis ->
+ FileOutputStream(tempFile).use { fos ->
+ val buffer = ByteArray(AppInstaller.IO_BUFFER_SIZE)
+ var length: Int
+ var total: Long = 0
+ while (dis.read(buffer).also { length = it } > 0) {
+ digest.update(buffer, 0, length)
+ total += length.toLong()
+ notifyDownloadProgress(activity, viewModel, total.toInt(), lengthOfFile)
+ fos.write(buffer, 0, length)
+ }
+ fos.flush()
+ }
+ }
+ viewModel.calculatedPackageChecksum =
+ Base64.encodeToString(digest.digest(), Base64.NO_WRAP or Base64.URL_SAFE)
+ } catch (e: Exception) {
+ tempFile.delete()
+ viewModel.error.postValue(activity.getString(R.string.download_failed) + e.message)
+ return false
+ }
+ return true
+ }
+
+ private fun notifyDownloadStart(viewModel: MdmInstallViewModel, total: Int) {
+ if (total == -1) {
+ viewModel.spinnerVisible.postValue(true)
+ viewModel.progressVisible.postValue(false)
+ } else {
+ viewModel.spinnerVisible.postValue(false)
+ viewModel.progressVisible.postValue(true)
+ }
+ }
+
+ private fun notifyDownloadProgress(
+ activity: MdmInstallActivity,
+ viewModel: MdmInstallViewModel,
+ downloaded: Int,
+ total: Int
+ ) {
+ if (total != -1) {
+ val downloadedMb: Float = downloaded / 1048576.0f
+ val totalMb: Float = total / 1048576.0f
+ val progress = activity.getString(R.string.download_progress, downloadedMb, totalMb)
+ viewModel.downloadProgressLegend.postValue(progress)
+ viewModel.downloadProgress.postValue((downloadedMb * 100 / totalMb).toInt())
+ }
+ }
+
+ private fun installAdminApp(activity: MdmInstallActivity, viewModel: MdmInstallViewModel) {
+ viewModel.spinnerVisible.postValue(true)
+ viewModel.message.postValue(activity.getString(R.string.installing_admin_app))
+ viewModel.runOnIo {
+ val error = AppInstaller.silentInstallApplication(
+ activity,
+ File(activity.filesDir, ADMIN_APK_FILE_NAME)
+ )
+ if (error != null) {
+ viewModel.error.postValue(activity.getString(R.string.install_failed) + error)
+ }
+ // Successful install completion is delivered via the BroadcastReceiver registered
+ // by MdmInstallActivity in onStart/onStop.
+ }
+ }
+
+ fun provisionDeviceOwner(activity: MdmInstallActivity) {
+ val viewModel = activity.viewModel
+ if (!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_DEVICE_ADMIN)) {
+ handleError(
+ activity,
+ "Cannot set up device owner because device does not have the "
+ + PackageManager.FEATURE_DEVICE_ADMIN + " feature"
+ )
+ return
+ }
+ val dpm: DevicePolicyManager? = activity.getSystemService(DevicePolicyManager::class.java)
+ if (dpm == null) {
+ handleError(activity, "Cannot set up device owner because DevicePolicyManager can't be initialized")
+ return
+ }
+ if (!dpm.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_DEVICE)) {
+ handleError(activity, "DeviceOwner provisioning is not allowed; the device may already be provisioned")
+ return
+ }
+
+ val adminComponentNameRaw = viewModel.adminComponentName
+ if (adminComponentNameRaw == null) {
+ handleError(activity, "Missing admin component name")
+ return
+ }
+ val adminComponentNameParts = adminComponentNameRaw.split("/")
+ if (adminComponentNameParts.size != 2) {
+ handleError(activity, "Wrong component name format: $adminComponentNameRaw")
+ return
+ }
+
+ val intent = Intent(activity, ProvisionActivity::class.java)
+ intent.putExtra(
+ EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME,
+ ComponentName(adminComponentNameParts[0], adminComponentNameParts[1])
+ )
+ intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM, viewModel.packageChecksum)
+ if (viewModel.systemAppsEnabled) {
+ intent.putExtra(EXTRA_PROVISIONING_LEAVE_ALL_SYSTEM_APPS_ENABLED, true)
+ }
+ if (viewModel.skipEncryption) {
+ intent.putExtra(EXTRA_PROVISIONING_SKIP_ENCRYPTION, true)
+ }
+ viewModel.extrasBundle?.let {
+ intent.putExtra(EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE, it)
+ }
+ activity.startActivity(intent)
+ }
+
+ private fun jsonToPersistableBundle(jsonNode: JsonNode, depth: Int): PersistableBundle {
+ val bundle = PersistableBundle()
+ if (depth > MAX_JSON_DEPTH) return bundle
+
+ jsonNode.fields().forEach { (key, value) ->
+ when {
+ value.isTextual -> bundle.putString(key, value.asText())
+ value.isInt -> bundle.putInt(key, value.asInt())
+ value.isLong -> bundle.putLong(key, value.asLong())
+ value.isBoolean -> bundle.putBoolean(key, value.asBoolean())
+ value.isDouble -> bundle.putDouble(key, value.asDouble())
+ value.isObject -> bundle.putPersistableBundle(
+ key,
+ jsonToPersistableBundle(value, depth + 1)
+ )
+ value.isArray -> {
+ val arrayElements = value.map { it.asText() }.toTypedArray()
+ bundle.putStringArray(key, arrayElements)
+ }
+ }
+ }
+ return bundle
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/action/ProvisionActions.kt b/java/app/grapheneos/setupwizard/action/ProvisionActions.kt
new file mode 100644
index 0000000..1afac6b
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/action/ProvisionActions.kt
@@ -0,0 +1,165 @@
+package app.grapheneos.setupwizard.action
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.app.admin.DevicePolicyManager
+import android.app.admin.DevicePolicyManager.ACTION_PROVISION_MANAGED_DEVICE_FROM_TRUSTED_SOURCE
+import android.app.admin.DevicePolicyManager.EXTRA_PROVISIONING_TRIGGER
+import android.content.ComponentName
+import android.content.DialogInterface
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.PersistableBundle
+import android.provider.Settings
+import android.util.Log
+import androidx.activity.result.ActivityResultLauncher
+import app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.view.activity.FinishActivity
+import app.grapheneos.setupwizard.view.activity.ProvisionActivity
+import app.grapheneos.setupwizard.view.activity.WelcomeActivity
+
+object ProvisionActions {
+ private const val TAG = "ProvisionActions"
+ private const val PROVISIONING_TRIGGER_QR_CODE = 2
+
+ // Copied from ManagedProvisioning app, as they're hidden.
+ private const val PROVISION_FINALIZATION_INSIDE_SUW =
+ "android.app.action.PROVISION_FINALIZATION_INSIDE_SUW"
+ private const val RESULT_CODE_PROFILE_OWNER_SET = 122
+ const val RESULT_CODE_DEVICE_OWNER_SET = 123
+
+ fun provisionDeviceOwner(
+ activity: ProvisionActivity,
+ launcher: ActivityResultLauncher
+ ) {
+ val provisionIntent = Intent(ACTION_PROVISION_MANAGED_DEVICE_FROM_TRUSTED_SOURCE)
+ provisionIntent.putExtra(EXTRA_PROVISIONING_TRIGGER, PROVISIONING_TRIGGER_QR_CODE)
+ provisionIntent.putExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME,
+ activity.intent.getParcelableExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME,
+ ComponentName::class.java
+ )
+ )
+ provisionIntent.putExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM,
+ activity.intent.getStringExtra(DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM)
+ )
+ val systemAppsEnabled = activity.intent.getBooleanExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_LEAVE_ALL_SYSTEM_APPS_ENABLED, false
+ )
+ if (systemAppsEnabled) {
+ provisionIntent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_LEAVE_ALL_SYSTEM_APPS_ENABLED, true)
+ }
+ val skipEncryption = activity.intent.getBooleanExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, false
+ )
+ if (skipEncryption) {
+ provisionIntent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, true)
+ }
+ val extrasBundle: PersistableBundle? = activity.intent.getParcelableExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE,
+ PersistableBundle::class.java
+ )
+ if (extrasBundle != null) {
+ provisionIntent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE, extrasBundle)
+ }
+ launcher.launch(provisionIntent)
+ }
+
+ fun handleProvisioningStep1Result(
+ activity: ProvisionActivity,
+ resultCode: Int,
+ finalizationLauncher: ActivityResultLauncher
+ ) {
+ when (resultCode) {
+ RESULT_CODE_PROFILE_OWNER_SET, RESULT_CODE_DEVICE_OWNER_SET -> {
+ val intent = Intent(PROVISION_FINALIZATION_INSIDE_SUW)
+ .addCategory(Intent.CATEGORY_DEFAULT)
+ Log.i(TAG, "Finalizing DPC with $intent")
+ finalizationLauncher.launch(intent)
+ }
+ else -> {
+ factoryReset(
+ activity,
+ "invalid response from the provisioning engine: ${resultCodeToString(resultCode)}"
+ )
+ }
+ }
+ }
+
+ fun handleProvisioningStep2Result(activity: ProvisionActivity, resultCode: Int) {
+ // Set provisioning state before launching FinishActivity. The DPC may not remove the back
+ // button on its own, so the wizard needs to mark the device as provisioned first.
+ setProvisioningState(activity)
+ if (resultCode != Activity.RESULT_OK) {
+ factoryReset(
+ activity,
+ "invalid response from the provisioning engine: ${resultCodeToString(resultCode)}"
+ )
+ return
+ }
+ Log.i(TAG, "Device owner mode provisioned")
+ SetupWizard.startActivity(activity, FinishActivity::class.java)
+ disableSelfAndFinish(activity)
+ }
+
+ fun resultCodeToString(resultCode: Int): String {
+ val name = when (resultCode) {
+ Activity.RESULT_OK -> "RESULT_OK"
+ Activity.RESULT_CANCELED -> "RESULT_CANCELED"
+ Activity.RESULT_FIRST_USER -> "RESULT_FIRST_USER"
+ RESULT_CODE_PROFILE_OWNER_SET -> "RESULT_CODE_PROFILE_OWNER_SET"
+ RESULT_CODE_DEVICE_OWNER_SET -> "RESULT_CODE_DEVICE_OWNER_SET"
+ else -> "UNKNOWN_CODE"
+ }
+ return "$name($resultCode)"
+ }
+
+ private fun factoryReset(activity: ProvisionActivity, reason: String) {
+ AlertDialog.Builder(activity)
+ .setMessage("Device provisioning failed ($reason) and device must be factory reset")
+ .setPositiveButton(activity.getString(R.string.button_reset)) { _: DialogInterface?, _: Int ->
+ sendFactoryResetIntent(activity, reason)
+ }
+ .setOnDismissListener {
+ sendFactoryResetIntent(activity, reason)
+ }
+ .show()
+ }
+
+ private fun sendFactoryResetIntent(activity: ProvisionActivity, reason: String) {
+ Log.e(TAG, "Factory resetting: $reason")
+ val intent = Intent(Intent.ACTION_FACTORY_RESET)
+ intent.setPackage("android")
+ intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+ intent.putExtra(Intent.EXTRA_REASON, reason)
+ activity.sendBroadcast(intent)
+
+ // Just in case the factory reset request fails, mark the device as provisioned and shut
+ // ourselves down so the user is not stuck in the wizard.
+ setProvisioningState(activity)
+ disableSelfAndFinish(activity)
+ }
+
+ private fun setProvisioningState(activity: Activity) {
+ Log.i(TAG, "Setting provisioning state")
+ Settings.Global.putInt(activity.contentResolver, Settings.Global.DEVICE_PROVISIONED, 1)
+ Settings.Secure.putInt(activity.contentResolver, Settings.Secure.USER_SETUP_COMPLETE, 1)
+ }
+
+ // Disable the wizard's WelcomeActivity component so the launcher does not re-enter setup
+ // after the MDM admin has taken over. We only disable the entry-point activity (not the
+ // entire app) so the package is still around if the factory-reset fallback path runs.
+ private fun disableSelfAndFinish(activity: Activity) {
+ val pm: PackageManager = activity.packageManager
+ val component = ComponentName(activity.packageName, WelcomeActivity::class.java.name)
+ Log.i(TAG, "Disabling component $component")
+ pm.setComponentEnabledSetting(
+ component,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ activity.finish()
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/action/WelcomeActions.kt b/java/app/grapheneos/setupwizard/action/WelcomeActions.kt
index bccfa44..45d4c36 100644
--- a/java/app/grapheneos/setupwizard/action/WelcomeActions.kt
+++ b/java/app/grapheneos/setupwizard/action/WelcomeActions.kt
@@ -11,15 +11,21 @@ import android.telecom.TelecomManager
import android.telephony.TelephonyManager
import android.util.Log
import android.widget.ArrayAdapter
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
import app.grapheneos.setupwizard.APPLY_SIM_LANGUAGE_ON_ENTRY
import app.grapheneos.setupwizard.R
import app.grapheneos.setupwizard.appContext
import app.grapheneos.setupwizard.data.WelcomeData
import app.grapheneos.setupwizard.utils.DebugFlags
+import app.grapheneos.setupwizard.view.activity.MdmInstallActivity
import app.grapheneos.setupwizard.view.activity.OemUnlockActivity
import com.android.internal.app.LocalePicker
import com.android.internal.app.LocalePicker.LocaleInfo
import com.google.android.setupcompat.util.SystemBarHelper
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
import java.util.Locale
object WelcomeActions {
@@ -27,6 +33,8 @@ object WelcomeActions {
private const val ACTION_ACCESSIBILITY = "android.settings.ACCESSIBILITY_SETTINGS_FOR_SUW"
private const val REBOOT_REASON_BOOTLOADER = "bootloader"
private var simLocaleApplied = false
+ private var qrToast: Toast? = null
+ private var barcodeLauncher: ActivityResultLauncher? = null
init {
refreshCurrentLocale()
@@ -38,6 +46,7 @@ object WelcomeActions {
SetupWizard.setStatusBarHidden(true)
SystemBarHelper.setBackButtonVisible(context.window, false)
if (APPLY_SIM_LANGUAGE_ON_ENTRY) applySimLocale()
+ initQrProvisioning(context as ComponentActivity)
}
fun showLanguagePicker(activity: Activity) {
@@ -145,4 +154,48 @@ object WelcomeActions {
WelcomeData.oemUnlocked.value = getOemLockManager()?.isDeviceOemUnlocked ?: false
}
+
+ fun handleConsecutiveTap(welcomeTapCounter: Int, activity: ComponentActivity) {
+ qrToast?.cancel()
+ val tapThreshold = activity.resources.getInteger(R.integer.qr_provision_tap_threshold)
+ val toastFloor = (tapThreshold - 3).coerceAtLeast(1)
+ if (welcomeTapCounter >= tapThreshold) {
+ startQrProvisioning()
+ return
+ }
+ if (welcomeTapCounter < toastFloor) return
+ val tapsRemaining = tapThreshold - welcomeTapCounter
+ val msg = activity.resources.getQuantityString(
+ R.plurals.qr_provision_toast,
+ tapsRemaining,
+ tapsRemaining
+ )
+ qrToast = Toast.makeText(activity, msg, Toast.LENGTH_LONG).also { it.show() }
+ }
+
+ private fun initQrProvisioning(activity: ComponentActivity) {
+ // Always re-register for the new activity instance. Holding a launcher in a singleton
+ // across activity recreation would leave it bound to a destroyed activity and crash
+ // when launched.
+ barcodeLauncher = activity.registerForActivityResult(ScanContract()) { result ->
+ if (result.contents == null) {
+ Toast.makeText(activity, R.string.qr_provisioning_cancelled, Toast.LENGTH_LONG).show()
+ } else {
+ launchQrProvisioning(activity, result.contents)
+ }
+ }
+ }
+
+ fun startQrProvisioning() {
+ val options = ScanOptions()
+ .setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ .setBeepEnabled(false)
+ barcodeLauncher?.launch(options)
+ }
+
+ fun launchQrProvisioning(activity: ComponentActivity, contents: String) {
+ val intent = Intent(activity, MdmInstallActivity::class.java)
+ intent.putExtra(MdmInstallActions.EXTRA_QR_CONTENTS, contents)
+ SetupWizard.startActivity(activity, intent)
+ }
}
diff --git a/java/app/grapheneos/setupwizard/android/ConsecutiveTapsGestureDetector.kt b/java/app/grapheneos/setupwizard/android/ConsecutiveTapsGestureDetector.kt
new file mode 100644
index 0000000..6beb67b
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/android/ConsecutiveTapsGestureDetector.kt
@@ -0,0 +1,62 @@
+package app.grapheneos.setupwizard.android
+
+import android.graphics.Rect
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+
+
+class ConsecutiveTapsGestureDetector(
+ private val mListener: OnConsecutiveTapsListener,
+ private val mView: View
+) {
+ private val mConsecutiveTapTouchSlopSquare: Int
+ private var mConsecutiveTapsCounter = 0
+ private var mPreviousTapEvent: MotionEvent? = null
+
+ interface OnConsecutiveTapsListener {
+ fun onConsecutiveTaps(welcomeTapCounter: Int)
+ }
+
+ init {
+ val doubleTapSlop: Int = ViewConfiguration.get(mView.context).getScaledDoubleTapSlop()
+ mConsecutiveTapTouchSlopSquare = doubleTapSlop * doubleTapSlop
+ }
+
+ fun onTouchEvent(ev: MotionEvent) {
+ if (ev.action != MotionEvent.ACTION_UP) {
+ return
+ }
+ val viewRect = Rect()
+ val leftTop = IntArray(2)
+ mView.getLocationOnScreen(leftTop)
+ viewRect.set(leftTop[0], leftTop[1], leftTop[0] + mView.width, leftTop[1] + mView.height)
+ if (viewRect.contains(ev.x.toInt(), ev.y.toInt())) {
+ if (isConsecutiveTap(ev)) {
+ mConsecutiveTapsCounter++
+ } else {
+ mConsecutiveTapsCounter = 1
+ }
+ mListener.onConsecutiveTaps(mConsecutiveTapsCounter)
+ } else {
+ mConsecutiveTapsCounter = 0
+ }
+ if (mPreviousTapEvent != null) {
+ mPreviousTapEvent!!.recycle()
+ }
+ mPreviousTapEvent = MotionEvent.obtain(ev)
+ }
+
+ fun resetCounter() {
+ mConsecutiveTapsCounter = 0
+ }
+
+ private fun isConsecutiveTap(currentTapEvent: MotionEvent): Boolean {
+ if (mPreviousTapEvent == null) {
+ return false
+ }
+ val deltaX = (mPreviousTapEvent!!.x - currentTapEvent.x).toDouble()
+ val deltaY = (mPreviousTapEvent!!.y - currentTapEvent.y).toDouble()
+ return deltaX * deltaX + deltaY * deltaY <= mConsecutiveTapTouchSlopSquare.toDouble()
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/data/MdmInstallViewModel.kt b/java/app/grapheneos/setupwizard/data/MdmInstallViewModel.kt
new file mode 100644
index 0000000..b33b696
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/data/MdmInstallViewModel.kt
@@ -0,0 +1,87 @@
+package app.grapheneos.setupwizard.data
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.PersistableBundle
+import android.util.Log
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.action.AppInstaller
+import app.grapheneos.setupwizard.action.MdmInstallActions
+import app.grapheneos.setupwizard.view.activity.MdmInstallActivity
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+
+class MdmInstallViewModel : ViewModel() {
+ companion object {
+ private const val TAG = "MdmInstallViewModel"
+ }
+
+ val spinnerVisible = MutableLiveData()
+ val progressVisible = MutableLiveData()
+ val message = MutableLiveData()
+ val downloadProgress = MutableLiveData()
+ val downloadProgressLegend = MutableLiveData()
+ val error = MutableLiveData()
+ val complete = MutableLiveData()
+
+ var adminComponentName: String? = null
+ var downloadLocation: String? = null
+ var packageChecksum: String? = null
+ var skipEncryption: Boolean = false
+ var systemAppsEnabled: Boolean = false
+ var extrasBundle: PersistableBundle? = null
+ var calculatedPackageChecksum: String? = null
+
+ private var started: Boolean = false
+
+ // Per-ViewModel executor + tracked futures, so background work is interrupted when the
+ // activity is finished (onCleared runs).
+ private val executor = Executors.newSingleThreadExecutor()
+ private val pendingFutures = CopyOnWriteArrayList>()
+
+ val appInstallReceiver: BroadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val status = intent!!.getIntExtra(PackageInstaller.EXTRA_STATUS, 0)
+ when (status) {
+ PackageInstaller.STATUS_SUCCESS -> {
+ spinnerVisible.postValue(false)
+ message.postValue(context?.getString(R.string.install_successful))
+ complete.postValue(true)
+ }
+ else -> {
+ val extraMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ val statusMessage = AppInstaller.getPackageInstallerStatusMessage(status)
+ var errorText = context?.getString(R.string.install_failed) + statusMessage
+ if (!extraMessage.isNullOrEmpty()) {
+ errorText += ", extra: $extraMessage"
+ }
+ error.postValue(errorText)
+ }
+ }
+ }
+ }
+
+ fun start(activity: MdmInstallActivity, intent: Intent) {
+ if (started) return
+ started = true
+ MdmInstallActions.handleEntry(activity, this, intent)
+ }
+
+ fun runOnIo(block: () -> Unit) {
+ val future = executor.submit(block)
+ pendingFutures.add(future)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ Log.d(TAG, "onCleared: cancelling ${pendingFutures.size} pending futures")
+ pendingFutures.forEach { it.cancel(true) }
+ pendingFutures.clear()
+ executor.shutdownNow()
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/view/activity/MdmInstallActivity.kt b/java/app/grapheneos/setupwizard/view/activity/MdmInstallActivity.kt
new file mode 100644
index 0000000..3cf8af1
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/view/activity/MdmInstallActivity.kt
@@ -0,0 +1,122 @@
+package app.grapheneos.setupwizard.view.activity
+
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.view.View
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.lifecycle.ViewModelProvider
+import app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.action.AppInstaller
+import app.grapheneos.setupwizard.action.DateTimeActions
+import app.grapheneos.setupwizard.action.MdmInstallActions
+import app.grapheneos.setupwizard.data.MdmInstallViewModel
+
+class MdmInstallActivity : SetupWizardActivity(
+ R.layout.activity_mdm_install,
+ R.drawable.baseline_provisioning_glif,
+ R.string.provisioning_title,
+ R.string.provisioning_desc,
+) {
+ companion object {
+ private const val TAG = "MdmInstallActivity"
+ }
+
+ val viewModel: MdmInstallViewModel by lazy {
+ ViewModelProvider(this)[MdmInstallViewModel::class.java]
+ }
+
+ private lateinit var spinner: ProgressBar
+ private lateinit var message: TextView
+ private lateinit var linearProgress: ProgressBar
+ private lateinit var progressLegend: TextView
+
+ private var receiverRegistered = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.start(this, intent)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!receiverRegistered) {
+ // RECEIVER_NOT_EXPORTED: the install-status broadcast is sent from system_server to
+ // our own private action (with setPackage(this.packageName)), so no other app needs
+ // to reach this receiver. Not exporting eliminates the spoofing surface.
+ registerReceiver(
+ viewModel.appInstallReceiver,
+ IntentFilter(AppInstaller.ACTION_INSTALL_COMPLETE),
+ Context.RECEIVER_NOT_EXPORTED
+ )
+ receiverRegistered = true
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (receiverRegistered) {
+ unregisterReceiver(viewModel.appInstallReceiver)
+ receiverRegistered = false
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ DateTimeActions.handleEntry()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ DateTimeActions.handleExit()
+ }
+
+ override fun onActivityResult(resultCode: Int, data: Intent?) {
+ super.onActivityResult(resultCode, data)
+ MdmInstallActions.handleActivityResult(this, resultCode)
+ }
+
+ override fun bindViews() {
+ spinner = requireViewById(R.id.spinning_progress)
+ message = requireViewById(R.id.text_message)
+ linearProgress = requireViewById(R.id.linear_progress)
+ progressLegend = requireViewById(R.id.progress_legend)
+ secondaryButton.visibility = View.GONE
+ primaryButton.visibility = View.GONE
+
+ viewModel.message.observe(this) {
+ this.message.text = it
+ }
+ viewModel.spinnerVisible.observe(this) {
+ this.spinner.visibility = if (it) View.VISIBLE else View.GONE
+ }
+ viewModel.progressVisible.observe(this) {
+ val visibility = if (it) View.VISIBLE else View.GONE
+ this.linearProgress.visibility = visibility
+ this.progressLegend.visibility = visibility
+ }
+ viewModel.downloadProgress.observe(this) {
+ this.linearProgress.progress = it
+ }
+ viewModel.downloadProgressLegend.observe(this) {
+ this.progressLegend.text = it
+ }
+ viewModel.error.observe(this) {
+ if (it != null) {
+ MdmInstallActions.handleError(this, it)
+ }
+ }
+ viewModel.complete.observe(this) {
+ primaryButton.setText(this, R.string.next)
+ primaryButton.visibility = View.VISIBLE
+ }
+ }
+
+ override fun setupActions() {
+ primaryButton.setOnClickListener {
+ MdmInstallActions.provisionDeviceOwner(this)
+ }
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/view/activity/ProvisionActivity.kt b/java/app/grapheneos/setupwizard/view/activity/ProvisionActivity.kt
new file mode 100644
index 0000000..9c9c0f4
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/view/activity/ProvisionActivity.kt
@@ -0,0 +1,33 @@
+package app.grapheneos.setupwizard.view.activity
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import app.grapheneos.setupwizard.action.ProvisionActions
+
+class ProvisionActivity : ComponentActivity() {
+ companion object {
+ private const val TAG = "ProvisionActivity"
+ }
+
+ private lateinit var finalizationLauncher: ActivityResultLauncher
+ private lateinit var provisioningLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Register the finalization launcher first so that the provisioning launcher can refer
+ // to it from its callback.
+ finalizationLauncher = registerForActivityResult(StartActivityForResult()) { result ->
+ Log.d(TAG, "finalization result: ${ProvisionActions.resultCodeToString(result.resultCode)}")
+ ProvisionActions.handleProvisioningStep2Result(this, result.resultCode)
+ }
+ provisioningLauncher = registerForActivityResult(StartActivityForResult()) { result ->
+ Log.d(TAG, "provisioning result: ${ProvisionActions.resultCodeToString(result.resultCode)}")
+ ProvisionActions.handleProvisioningStep1Result(this, result.resultCode, finalizationLauncher)
+ }
+ ProvisionActions.provisionDeviceOwner(this, provisioningLauncher)
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/view/activity/WelcomeActivity.kt b/java/app/grapheneos/setupwizard/view/activity/WelcomeActivity.kt
index 921abd3..a56938d 100644
--- a/java/app/grapheneos/setupwizard/view/activity/WelcomeActivity.kt
+++ b/java/app/grapheneos/setupwizard/view/activity/WelcomeActivity.kt
@@ -3,6 +3,7 @@ package app.grapheneos.setupwizard.view.activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
+import android.view.MotionEvent
import android.view.View
import android.widget.Button
import android.widget.TextView
@@ -16,6 +17,7 @@ import app.grapheneos.setupwizard.R
import app.grapheneos.setupwizard.action.FinishActions
import app.grapheneos.setupwizard.action.SetupWizard
import app.grapheneos.setupwizard.action.WelcomeActions
+import app.grapheneos.setupwizard.android.ConsecutiveTapsGestureDetector
import app.grapheneos.setupwizard.data.WelcomeData
import app.grapheneos.setupwizard.utils.DebugFlags
@@ -29,6 +31,7 @@ class WelcomeActivity : SetupWizardActivity(R.layout.activity_welcome) {
private lateinit var language: TextView
private lateinit var accessibility: View
private lateinit var letsSetupText: TextView
+ private var consecutiveTapsGestureDetector: ConsecutiveTapsGestureDetector? = null
override fun onCreate(savedInstanceState: Bundle?) {
if (WizardManagerHelper.isUserSetupComplete(this)
@@ -41,6 +44,11 @@ class WelcomeActivity : SetupWizardActivity(R.layout.activity_welcome) {
super.onCreate(savedInstanceState)
}
+ override fun onResume() {
+ super.onResume()
+ consecutiveTapsGestureDetector?.resetCounter()
+ }
+
@MainThread
override fun bindViews() {
oemUnlockedContainer = requireViewById(R.id.oem_unlocked_container)
@@ -60,6 +68,10 @@ class WelcomeActivity : SetupWizardActivity(R.layout.activity_welcome) {
Log.d(TAG, "oemUnlocked: $it")
oemUnlockedContainer.visibility = if (it) View.VISIBLE else View.GONE
}
+ consecutiveTapsGestureDetector = ConsecutiveTapsGestureDetector(
+ onConsecutiveTapsListener,
+ requireViewById(R.id.root_layout)
+ )
}
@MainThread
@@ -73,4 +85,19 @@ class WelcomeActivity : SetupWizardActivity(R.layout.activity_welcome) {
}
primaryButton.setOnClickListener { WelcomeActions.next(this) }
}
+
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
+ val handled = super.dispatchTouchEvent(ev)
+ if (ev.action == MotionEvent.ACTION_UP) {
+ consecutiveTapsGestureDetector?.onTouchEvent(ev)
+ }
+ return handled
+ }
+
+ private val onConsecutiveTapsListener =
+ object : ConsecutiveTapsGestureDetector.OnConsecutiveTapsListener {
+ override fun onConsecutiveTaps(welcomeTapCounter: Int) {
+ WelcomeActions.handleConsecutiveTap(welcomeTapCounter, this@WelcomeActivity)
+ }
+ }
}
diff --git a/libs/Android.bp b/libs/Android.bp
new file mode 100644
index 0000000..b07bee8
--- /dev/null
+++ b/libs/Android.bp
@@ -0,0 +1,31 @@
+android_library_import {
+ name: "setupwizard2-zxing-android",
+ aars: ["zxing-android-embedded-4.3.0.aar"],
+ sdk_version: "current",
+}
+
+java_import {
+ name: "setupwizard2-zxing-core",
+ jars: ["zxing-core-3.4.1.jar"],
+ sdk_version: "current",
+}
+
+java_import {
+ name: "setupwizard2-jackson-core",
+ jars: ["jackson-core-2.18.2.jar"],
+ sdk_version: "current",
+}
+
+java_import {
+ name: "setupwizard2-jackson-databind",
+ jars: ["jackson-databind-2.18.2.jar"],
+ sdk_version: "current",
+}
+
+java_import {
+ name: "setupwizard2-jackson-annotations",
+ jars: ["jackson-annotations-2.18.2.jar"],
+ sdk_version: "current",
+}
+
+
diff --git a/libs/jackson-annotations-2.18.2.jar b/libs/jackson-annotations-2.18.2.jar
new file mode 100644
index 0000000..746fa63
Binary files /dev/null and b/libs/jackson-annotations-2.18.2.jar differ
diff --git a/libs/jackson-core-2.18.2.jar b/libs/jackson-core-2.18.2.jar
new file mode 100644
index 0000000..12b2a46
Binary files /dev/null and b/libs/jackson-core-2.18.2.jar differ
diff --git a/libs/jackson-databind-2.18.2.jar b/libs/jackson-databind-2.18.2.jar
new file mode 100644
index 0000000..2b360b4
Binary files /dev/null and b/libs/jackson-databind-2.18.2.jar differ
diff --git a/libs/zxing-android-embedded-4.3.0.aar b/libs/zxing-android-embedded-4.3.0.aar
new file mode 100644
index 0000000..04d731a
Binary files /dev/null and b/libs/zxing-android-embedded-4.3.0.aar differ
diff --git a/libs/zxing-core-3.4.1.jar b/libs/zxing-core-3.4.1.jar
new file mode 100644
index 0000000..11f6788
Binary files /dev/null and b/libs/zxing-core-3.4.1.jar differ
diff --git a/res/drawable/baseline_provisioning.xml b/res/drawable/baseline_provisioning.xml
new file mode 100644
index 0000000..284a0cb
--- /dev/null
+++ b/res/drawable/baseline_provisioning.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/res/drawable/baseline_provisioning_glif.xml b/res/drawable/baseline_provisioning_glif.xml
new file mode 100644
index 0000000..c36c8f7
--- /dev/null
+++ b/res/drawable/baseline_provisioning_glif.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/res/layout/activity_mdm_install.xml b/res/layout/activity_mdm_install.xml
new file mode 100644
index 0000000..b13e1d3
--- /dev/null
+++ b/res/layout/activity_mdm_install.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/layout/activity_welcome.xml b/res/layout/activity_welcome.xml
index 00bbfa7..1fb5d0b 100644
--- a/res/layout/activity_welcome.xml
+++ b/res/layout/activity_welcome.xml
@@ -8,6 +8,7 @@
android:layout_height="match_parent">
diff --git a/res/values/integers.xml b/res/values/integers.xml
new file mode 100644
index 0000000..41f21e3
--- /dev/null
+++ b/res/values/integers.xml
@@ -0,0 +1,4 @@
+
+
+ 6
+
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 1aaad57..ee54d3a 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -63,4 +63,29 @@
I understand the security risks of not locking my bootloader.
Disable OEM unlocking
It\'s recommended to disable OEM unlocking to improve device security
+
+
+
+ - %1$s more tap to start QR code setup
+ - %1$s more taps to start QR code setup
+
+ QR provisioning cancelled
+ Device provisioning
+ Set up a work device by installing the mobile device management (MDM) application
+ Powered by Headwind MDM
+ Provisioning the device…
+ Failed to parse the QR code contents!
+ QR code parameter missing:
+ Setting up the WiFi connection…
+ Failed to set up the WiFi connection!
+ Downloading the MDM application…
+ %1$.1f of %2$.1f Mb
+ Failed to download the MDM application!
+ Failed to validate the MDM application - checksum is incorrect!
+ Installing the MDM application…
+ Failed to install the MDM application!
+ MDM application is installed!
+ Error
+ OK
+ Reset