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 9b4bc75..fa0cf12 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -15,12 +15,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/etc/permissions/app.grapheneos.setupwizard.xml b/etc/permissions/app.grapheneos.setupwizard.xml
index 8123af3..4712bb3 100644
--- a/etc/permissions/app.grapheneos.setupwizard.xml
+++ b/etc/permissions/app.grapheneos.setupwizard.xml
@@ -7,7 +7,11 @@
-
+
+
+
+
+
diff --git a/java/app/grapheneos/setupwizard/action/AppInstaller.kt b/java/app/grapheneos/setupwizard/action/AppInstaller.kt
new file mode 100644
index 0000000..da80565
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/action/AppInstaller.kt
@@ -0,0 +1,95 @@
+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 = "INSTALL_COMPLETE"
+ const val PACKAGE_NAME = "PACKAGE_NAME"
+
+ 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 `in` = FileInputStream(file)
+ val packageInstaller = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(
+ PackageInstaller.SessionParams.MODE_FULL_INSTALL
+ )
+ params.setAppPackageName(packageName)
+ // set params
+ val sessionId = packageInstaller.createSession(params)
+ val session = packageInstaller.openSession(sessionId)
+ val out = session.openWrite("COSU", 0, -1)
+ val buffer = ByteArray(65536)
+ var c: Int
+ while (`in`.read(buffer).also { c = it } != -1) {
+ out.write(buffer, 0, c)
+ }
+ session.fsync(out)
+ `in`.close()
+ out.close()
+ session.commit(
+ createIntentSender(
+ context,
+ sessionId,
+ packageName
+ )
+ )
+ Log.i(TAG, "Installation session committed")
+ return null
+ } catch (e: java.lang.Exception) {
+ Log.w(TAG, "PackageInstaller error: " + e.message)
+ e.printStackTrace()
+ return e.message
+ }
+ }
+
+
+ private fun createIntentSender(context: Context?, sessionId: Int, packageName: String?): IntentSender {
+ val intent: Intent = Intent(ACTION_INSTALL_COMPLETE)
+ if (packageName != null) {
+ intent.putExtra(PACKAGE_NAME, packageName)
+ }
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ sessionId,
+ intent,
+ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT
+ )
+ return pendingIntent.intentSender
+ }
+
+ fun getPackageInstallerStatusMessage(status: Int): String {
+ when (status) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> return "PENDING_USER_ACTION"
+ PackageInstaller.STATUS_SUCCESS -> return "SUCCESS"
+ PackageInstaller.STATUS_FAILURE -> return "FAILURE_UNKNOWN"
+ PackageInstaller.STATUS_FAILURE_BLOCKED -> return "BLOCKED"
+ PackageInstaller.STATUS_FAILURE_ABORTED -> return "ABORTED"
+ PackageInstaller.STATUS_FAILURE_INVALID -> return "INVALID"
+ PackageInstaller.STATUS_FAILURE_CONFLICT -> return "CONFLICT"
+ PackageInstaller.STATUS_FAILURE_STORAGE -> return "STORAGE"
+ PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> return "INCOMPATIBLE"
+ }
+ return "UNKNOWN"
+ }
+}
\ No newline at end of file
diff --git a/java/app/grapheneos/setupwizard/action/MdmInstallActions.kt b/java/app/grapheneos/setupwizard/action/MdmInstallActions.kt
new file mode 100644
index 0000000..d02e3a0
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/action/MdmInstallActions.kt
@@ -0,0 +1,356 @@
+package app.grapheneos.setupwizard.action
+
+import android.app.Activity
+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.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.os.PersistableBundle
+import android.util.Base64
+import androidx.appcompat.app.AlertDialog
+import app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.data.MdmInstallData
+import app.grapheneos.setupwizard.view.activity.ProvisionActivity
+import app.grapheneos.setupwizard.view.activity.SetupWizardActivity
+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
+import java.util.concurrent.Executors
+
+
+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 var adminComponentName: String? = null
+ private var downloadLocation: String? = null
+ private var packageChecksum: String? = null
+ private var skipEncryption: Boolean = false
+ private var systemAppsEnabled: Boolean = false
+ private var extrasBundle: PersistableBundle? = null
+
+ private val executor = Executors.newSingleThreadExecutor()
+ private const val CONNECTION_TIMEOUT_MS = 10000;
+ private const val ADMIN_APK_FILE_NAME = "deviceadmin.apk"
+ private var calculatedPackageChecksum: String? = null
+
+ fun handleEntry(context: Activity) {
+ SystemBarHelper.setBackButtonVisible(context.window, false)
+ val qrContent = context.intent.getStringExtra(EXTRA_QR_CONTENTS)
+ if (qrContent == null) {
+ MdmInstallData.error.postValue(context.getString(R.string.qr_parse_failed))
+ }
+ if (!parseProvisioningQr(context, qrContent!!)) {
+ return
+ }
+ setupWiFi(context)
+ }
+
+ fun handleError(context: Activity, message: String) {
+ try {
+ context.unregisterReceiver(appInstallReceiver)
+ } catch (e: Exception) {
+ }
+ AlertDialog.Builder(context)
+ .setTitle(R.string.error_title)
+ .setMessage(message)
+ .setPositiveButton(R.string.button_ok) { dialog, _ ->
+ dialog.dismiss()
+ MdmInstallData.error.postValue(null)
+ context.finish()
+ }
+ .setCancelable(false)
+ .create()
+ .show()
+ }
+
+ private fun parseProvisioningQr(context: Activity, qrContent: String): Boolean {
+ val objectMapper = ObjectMapper()
+ lateinit var jsonNode: JsonNode
+ try {
+ jsonNode = objectMapper.readTree(qrContent)
+ } catch(e: Exception) {
+ MdmInstallData.error.postValue(context.getString(R.string.qr_parse_failed))
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_ADMIN_COMPONENT_NAME) && jsonNode[EXTRA_ADMIN_COMPONENT_NAME].isTextual) {
+ adminComponentName = jsonNode[EXTRA_ADMIN_COMPONENT_NAME].asText()
+ } else {
+ MdmInstallData.error.postValue(context.getString(R.string.qr_missing_parameter) + EXTRA_ADMIN_COMPONENT_NAME)
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_DOWNLOAD_LOCATION) && jsonNode[EXTRA_DOWNLOAD_LOCATION].isTextual) {
+ downloadLocation = jsonNode[EXTRA_DOWNLOAD_LOCATION].asText()
+ } else {
+ MdmInstallData.error.postValue(context.getString(R.string.qr_missing_parameter) + EXTRA_DOWNLOAD_LOCATION)
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_PACKAGE_CHECKSUM) && jsonNode[EXTRA_PACKAGE_CHECKSUM].isTextual) {
+ packageChecksum = jsonNode[EXTRA_PACKAGE_CHECKSUM].asText()
+ } else {
+ MdmInstallData.error.postValue(context.getString(R.string.qr_missing_parameter) + EXTRA_PACKAGE_CHECKSUM)
+ return false
+ }
+
+ if (jsonNode.has(EXTRA_SKIP_ENCRYPTION) && jsonNode[EXTRA_SKIP_ENCRYPTION].isBoolean) {
+ skipEncryption = jsonNode[EXTRA_SKIP_ENCRYPTION].asBoolean()
+ }
+
+ if (jsonNode.has(EXTRA_SYSTEM_APPS_ENABLED) && jsonNode[EXTRA_SYSTEM_APPS_ENABLED].isBoolean) {
+ systemAppsEnabled = jsonNode[EXTRA_SYSTEM_APPS_ENABLED].asBoolean()
+ }
+
+ if (jsonNode.has(EXTRA_EXTRAS_BUNDLE) && jsonNode[EXTRA_EXTRAS_BUNDLE].isObject) {
+ extrasBundle = jsonToPersistableBundle(jsonNode[EXTRA_EXTRAS_BUNDLE])
+ }
+
+ return true
+ }
+
+ /**
+ * There is an option to set the WiFi parameters (PROVISIONING_WIFI_SSID, PROVISIONING_WIFI_PASSWORD,
+ * PROVISIONING_WIFI_SECURITY_TYPE, etc) in the QR code, which requires SetupWizard to set up
+ * WiFi automatically. Unfortunately the automatic WiFi configuration doesn't work because it
+ * requires elevated permissions, either Device / Profile owner, or a system user (android.uid.system).
+ *
+ * Another approach to treat WiFi and other provisioning parameters could be to send all them directly
+ * to DevicePolicyManager immediately after scanning a QR code. However if they're sent in
+ * REQUEST_CODE_STEP1, WiFi connection is not established. There could be another request code
+ * which should be sent to set up WiFi (like pre-provisioning), this needs to be investigated.
+ *
+ * By now, only manual setup of the WiFi connection is supported.
+ *
+ */
+ private fun setupWiFi(context: Activity) {
+ WifiActions.launchSetup(context as SetupWizardActivity)
+ }
+
+ private fun onWifiSetupComplete(context: Activity) {
+ downloadAdminApp(context)
+ }
+
+ private fun downloadAdminApp(context: Activity) {
+ MdmInstallData.message.postValue(context.getString(R.string.downloading_admin_app))
+ executor.execute {
+ if (downloadAdminAppSync(context)) {
+ MdmInstallData.progressVisible.postValue(false)
+ if (!calculatedPackageChecksum.equals(packageChecksum, ignoreCase = true)) {
+ MdmInstallData.error.postValue(context.getString(R.string.checksum_failed))
+ return@execute
+ }
+ installAdminApp(context)
+ }
+ }
+ }
+
+ private fun downloadAdminAppSync(context: Activity) : Boolean {
+ var tempFile = File(context.filesDir, ADMIN_APK_FILE_NAME)
+ if (tempFile.exists()) {
+ tempFile.delete()
+ }
+ try {
+ try {
+ tempFile.createNewFile()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ MdmInstallData.error.postValue("Failed to create " + tempFile.absolutePath)
+ return false
+ }
+ val url = URL(downloadLocation)
+ 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 java.lang.Exception("Bad server response for " + downloadLocation + ": " + connection.responseCode)
+ }
+ val lengthOfFile = connection.contentLength
+ notifyDownloadStart(lengthOfFile)
+ val digest = MessageDigest.getInstance("SHA-256")
+ val dis = DataInputStream(connection.inputStream)
+ val buffer = ByteArray(1024)
+ var length: Int
+ var total: Long = 0
+ val fos = FileOutputStream(tempFile)
+ while (dis.read(buffer).also { length = it } > 0) {
+ digest.update(buffer, 0, length);
+ total += length.toLong()
+ notifyDownloadProgress(context, total.toInt(), lengthOfFile)
+ fos.write(buffer, 0, length)
+ }
+ fos.flush()
+ fos.close()
+ dis.close()
+ calculatedPackageChecksum = Base64.encodeToString(digest.digest(), Base64.NO_WRAP or Base64.URL_SAFE)
+ } catch (e: java.lang.Exception) {
+ e.printStackTrace()
+ tempFile.delete()
+ MdmInstallData.error.postValue(context.getString(R.string.download_failed) + e.message)
+ return false
+ }
+ return true
+ }
+
+ private fun notifyDownloadStart(total: Int) {
+ if (total == -1) {
+ // We don't know the content length
+ MdmInstallData.spinnerVisible.postValue(true)
+ MdmInstallData.progressVisible.postValue(false)
+ } else {
+ MdmInstallData.spinnerVisible.postValue(false)
+ MdmInstallData.progressVisible.postValue(true)
+ }
+ }
+
+ private fun notifyDownloadProgress(context: Activity, downloaded: Int, total: Int) {
+ if (total != -1) {
+ val downloadedMb: Float = downloaded / 1048576.0f
+ val totalMb: Float = total / 1048576.0f
+ val progress = context.getString(R.string.download_progress, downloadedMb, totalMb)
+ MdmInstallData.downloadProgressLegend.postValue(progress)
+ MdmInstallData.downloadProgress.postValue((downloadedMb * 100 / totalMb).toInt())
+ }
+ }
+
+ val appInstallReceiver = object: BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val status = intent!!.getIntExtra(PackageInstaller.EXTRA_STATUS, 0)
+ when (status) {
+ PackageInstaller.STATUS_SUCCESS -> {
+ MdmInstallData.spinnerVisible.postValue(false)
+ MdmInstallData.message.postValue(context?.getString(R.string.install_successful))
+ MdmInstallData.complete.postValue(true)
+ }
+ else -> {
+ // Installation failure
+ val extraMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ val statusMessage = AppInstaller.getPackageInstallerStatusMessage(status)
+ var errorText = context?.getString(R.string.install_failed) + statusMessage
+ if (extraMessage != null && extraMessage.length > 0) {
+ errorText += ", extra: $extraMessage"
+ }
+ MdmInstallData.error.postValue(errorText)
+ }
+ }
+ }
+ }
+
+ private fun installAdminApp(context: Activity) {
+ MdmInstallData.spinnerVisible.postValue(true)
+ MdmInstallData.message.postValue(context.getString(R.string.installing_admin_app))
+ context.registerReceiver(
+ appInstallReceiver,
+ IntentFilter(AppInstaller.ACTION_INSTALL_COMPLETE),
+ Context.RECEIVER_EXPORTED
+ )
+ executor.execute {
+ val error = AppInstaller.silentInstallApplication(context, File(context.filesDir, ADMIN_APK_FILE_NAME))
+ if (error != null) {
+ context.unregisterReceiver(appInstallReceiver)
+ MdmInstallData.error.postValue(context.getString(R.string.install_failed) + error)
+ }
+ // Install completion is caught in the BroadcastReceiver
+ }
+ }
+
+ @Suppress("deprecation")
+ fun provisionDeviceOwner(context: Activity) {
+ try {
+ context.unregisterReceiver(appInstallReceiver)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ if (!context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_DEVICE_ADMIN)) {
+ handleError(context, "Cannot set up device owner because device does not have the "
+ + PackageManager.FEATURE_DEVICE_ADMIN + " feature")
+ return
+ }
+ val dpm: DevicePolicyManager? = context.getSystemService(DevicePolicyManager::class.java)
+ if (dpm == null) {
+ handleError(context, "Cannot set up device owner because DevicePolicyManager can't be initialized")
+ return
+ }
+ if (!dpm.isProvisioningAllowed(DevicePolicyManager.ACTION_PROVISION_MANAGED_DEVICE)) {
+ handleError(context, "DeviceOwner provisioning is not allowed, most like device is already provisioned")
+ return
+ }
+
+ val intent = Intent(context, ProvisionActivity::class.java)
+
+ val adminComponentNameParts = adminComponentName!!.split("/")
+ if (adminComponentNameParts.size != 2) {
+ handleError(context, "Wrong component name format: " + adminComponentName)
+ return
+ }
+ intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME,
+ ComponentName(adminComponentNameParts[0], adminComponentNameParts[1]))
+ intent.putExtra(EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM, packageChecksum)
+ if (systemAppsEnabled) {
+ intent.putExtra(EXTRA_PROVISIONING_LEAVE_ALL_SYSTEM_APPS_ENABLED, true)
+ }
+ if (skipEncryption) {
+ intent.putExtra(EXTRA_PROVISIONING_SKIP_ENCRYPTION, true)
+ }
+ if (extrasBundle != null) {
+ intent.putExtra(EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE, extrasBundle)
+ }
+ context.startActivity(intent)
+ }
+
+ private fun jsonToPersistableBundle(jsonNode: JsonNode): PersistableBundle {
+ val bundle = PersistableBundle()
+
+ 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)) // Recursively handle nested objects
+ value.isArray -> {
+ // Handle array of supported primitive types
+ val arrayElements = value.map { it.asText() }.toTypedArray()
+ bundle.putStringArray(key, arrayElements) // Using StringArray as PersistableBundle doesn't support generic arrays
+ }
+ }
+ }
+ return bundle
+ }
+
+ fun handleActivityResult(activity: Activity, resultCode: Int) {
+ if (resultCode == Activity.RESULT_CANCELED) {
+ handleError(activity, activity.getString(R.string.wifi_failed))
+ } else {
+ onWifiSetupComplete(activity)
+ }
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/action/ProvisionActions.kt b/java/app/grapheneos/setupwizard/action/ProvisionActions.kt
new file mode 100644
index 0000000..771fe34
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/action/ProvisionActions.kt
@@ -0,0 +1,155 @@
+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 app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.view.activity.FinishActivity
+
+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
+ private const val RESULT_CODE_DEVICE_OWNER_SET = 123
+
+ const val REQUEST_CODE_STEP1 = 42
+ const val REQUEST_CODE_STEP2_PO = 43
+ const val REQUEST_CODE_STEP2_DO = 44
+
+ @Suppress("deprecation")
+ fun provisionDeviceOwner(context: Activity) {
+ 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,
+ context.intent.getParcelableExtra(DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME) as ComponentName?
+ )
+ provisionIntent.putExtra(
+ DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM,
+ context.intent.getStringExtra(DevicePolicyManager.EXTRA_PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM)
+ )
+ val systemAppsEnabled = context.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 = context.intent.getBooleanExtra(DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, false)
+ if (skipEncryption) {
+ provisionIntent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_SKIP_ENCRYPTION, true)
+ }
+ val extrasBundle: PersistableBundle? = context.intent.getParcelableExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE)
+ if (extrasBundle != null) {
+ provisionIntent.putExtra(DevicePolicyManager.EXTRA_PROVISIONING_ADMIN_EXTRAS_BUNDLE, extrasBundle)
+ }
+ context.startActivityForResult(provisionIntent, REQUEST_CODE_STEP1)
+ }
+
+ private fun disableSelfAndFinish(context: Activity) {
+ // remove this activity from the package manager.
+ val pm: PackageManager = context.getPackageManager()
+ val name = context.packageName
+ Log.i(TAG, "Disabling itself ($name)")
+ pm.setApplicationEnabledSetting(
+ name, PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ // terminate the activity.
+ context.finish()
+ }
+
+ fun handleProvisioningStep1Result(context: Activity, resultCode: Int) {
+ val requestCodeStep2: Int
+ when (resultCode) {
+ RESULT_CODE_PROFILE_OWNER_SET -> requestCodeStep2 = REQUEST_CODE_STEP2_PO /*{
+ factoryReset(context, "profile owner is not supported")
+ return
+ }*/
+ RESULT_CODE_DEVICE_OWNER_SET -> requestCodeStep2 = REQUEST_CODE_STEP2_DO
+ else -> {
+ factoryReset(context, "invalid response from the provisioning engine: "
+ + resultCodeToString(resultCode))
+ return
+ }
+ }
+ val intent = Intent(PROVISION_FINALIZATION_INSIDE_SUW)
+ .addCategory(Intent.CATEGORY_DEFAULT)
+ Log.i(TAG, "Finalizing DPC with $intent")
+ context.startActivityForResult(intent, requestCodeStep2)
+ }
+
+ fun handleProvisioningStep2Result(context: Activity, requestCode: Int, resultCode: Int) {
+ // Must set state before launching the intent that finalize the DPC, because the DPC
+ // implementation might not remove the back button
+ setProvisioningState(context)
+ val doMode = requestCode == REQUEST_CODE_STEP2_DO
+ if (resultCode != Activity.RESULT_OK) {
+ factoryReset(context, "invalid response from the provisioning engine: "
+ + resultCodeToString(resultCode))
+ return
+ }
+ Log.i(TAG, (if (doMode) "Device owner" else "Profile owner") + " mode provisioned!")
+ //disableSelfAndFinish(context)
+ // Let user know the setup is completed and finalize self properly
+ SetupWizard.startActivity(context, FinishActivity::class.java)
+ }
+
+ fun resultCodeToString(resultCode: Int): String {
+ val result = StringBuilder()
+ when (resultCode) {
+ Activity.RESULT_OK -> result.append("RESULT_OK")
+ Activity.RESULT_CANCELED -> result.append("RESULT_CANCELED")
+ Activity.RESULT_FIRST_USER -> result.append("RESULT_FIRST_USER")
+ RESULT_CODE_PROFILE_OWNER_SET -> result.append("RESULT_CODE_PROFILE_OWNER_SET")
+ RESULT_CODE_DEVICE_OWNER_SET -> result.append("RESULT_CODE_DEVICE_OWNER_SET")
+ else -> result.append("UNKNOWN_CODE")
+ }
+ return result.append('(').append(resultCode).append(')').toString()
+ }
+
+ private fun factoryReset(context: Activity, reason: String) {
+ AlertDialog.Builder(context)
+ .setMessage("Device provisioning failed (" + reason
+ + ") and device must be factory reset"
+ )
+ .setPositiveButton(context.getString(R.string.button_reset)) { _: DialogInterface?, _: Int ->
+ sendFactoryResetIntent(context, reason)
+ }
+ .setOnDismissListener { _: DialogInterface? ->
+ sendFactoryResetIntent(context, reason)
+ }
+ .show()
+ }
+
+ private fun sendFactoryResetIntent(context: Activity, 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)
+ context.sendBroadcast(intent)
+
+ // Just in case the factory reset request fails...
+ setProvisioningState(context)
+ disableSelfAndFinish(context)
+ }
+
+ private fun setProvisioningState(context: Activity) {
+ Log.i(TAG, "Setting provisioning state")
+ // Add a persistent setting to allow other apps to know the device has been provisioned.
+ Settings.Global.putInt(context.getContentResolver(), Settings.Global.DEVICE_PROVISIONED, 1)
+ Settings.Secure.putInt(context.getContentResolver(), Settings.Secure.USER_SETUP_COMPLETE, 1)
+ }
+
+}
diff --git a/java/app/grapheneos/setupwizard/action/WelcomeActions.kt b/java/app/grapheneos/setupwizard/action/WelcomeActions.kt
index bccfa44..722b2fd 100644
--- a/java/app/grapheneos/setupwizard/action/WelcomeActions.kt
+++ b/java/app/grapheneos/setupwizard/action/WelcomeActions.kt
@@ -2,7 +2,6 @@ package app.grapheneos.setupwizard.action
import android.app.Activity
import android.app.AlertDialog
-import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.os.PowerManager
@@ -11,15 +10,21 @@ import android.telecom.TelecomManager
import android.telephony.TelephonyManager
import android.util.Log
import android.widget.ArrayAdapter
+import android.widget.Toast
+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.OemUnlockActivity
+import app.grapheneos.setupwizard.view.activity.MdmInstallActivity
import com.android.internal.app.LocalePicker
import com.android.internal.app.LocalePicker.LocaleInfo
import com.google.android.setupcompat.util.SystemBarHelper
+import androidx.appcompat.app.AppCompatActivity
+import com.journeyapps.barcodescanner.ScanContract
+import com.journeyapps.barcodescanner.ScanOptions
import java.util.Locale
object WelcomeActions {
@@ -27,6 +32,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 +45,7 @@ object WelcomeActions {
SetupWizard.setStatusBarHidden(true)
SystemBarHelper.setBackButtonVisible(context.window, false)
if (APPLY_SIM_LANGUAGE_ON_ENTRY) applySimLocale()
+ initQrProvisioning(context as AppCompatActivity)
}
fun showLanguagePicker(activity: Activity) {
@@ -145,4 +153,49 @@ object WelcomeActions {
WelcomeData.oemUnlocked.value = getOemLockManager()?.isDeviceOemUnlocked ?: false
}
+
+ fun handleConsecutiveTap(welcomeTapCounter: Int, activity: AppCompatActivity) {
+ qrToast?.cancel()
+ if (welcomeTapCounter >= 6) {
+ startQrProvisioning();
+ } else {
+ if (welcomeTapCounter < 3) {
+ return
+ }
+ val tapsRemaining = 6 - welcomeTapCounter
+ val msg = activity.resources.getQuantityString(
+ R.plurals.qr_provision_toast,
+ tapsRemaining,
+ Integer.valueOf(tapsRemaining)
+ )
+ qrToast = Toast.makeText(activity, msg, Toast.LENGTH_LONG)
+ qrToast!!.show()
+ }
+
+ }
+
+ private fun initQrProvisioning(activity: AppCompatActivity) {
+ 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()
+ options.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ options.setBeepEnabled(false)
+ barcodeLauncher?.launch(options)
+ }
+
+ fun launchQrProvisioning(activity: AppCompatActivity, 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/MdmInstallData.kt b/java/app/grapheneos/setupwizard/data/MdmInstallData.kt
new file mode 100644
index 0000000..8da7c4a
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/data/MdmInstallData.kt
@@ -0,0 +1,19 @@
+package app.grapheneos.setupwizard.data
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import app.grapheneos.setupwizard.action.MdmInstallActions
+
+object MdmInstallData : ViewModel() {
+ val spinnerVisible = MutableLiveData()
+ val progressVisible = MutableLiveData()
+ val message = MutableLiveData()
+ val downloadProgress = MutableLiveData()
+ val downloadProgressLegend = MutableLiveData()
+ val error = MutableLiveData()
+ val complete = MutableLiveData()
+
+ init {
+ MdmInstallActions
+ }
+}
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..3bb00c4
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/view/activity/MdmInstallActivity.kt
@@ -0,0 +1,89 @@
+package app.grapheneos.setupwizard.view.activity
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.widget.ProgressBar
+import android.widget.TextView
+import app.grapheneos.setupwizard.R
+import app.grapheneos.setupwizard.action.DateTimeActions
+import app.grapheneos.setupwizard.action.MdmInstallActions
+import app.grapheneos.setupwizard.data.MdmInstallData
+
+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"
+ }
+
+ private lateinit var spinner: ProgressBar
+ private lateinit var message: TextView
+ private lateinit var linearProgress: ProgressBar
+ private lateinit var progressLegend: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ MdmInstallActions.handleEntry(this)
+ }
+
+ 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
+
+ MdmInstallData.message.observe(this) {
+ this.message.text = it
+ }
+ MdmInstallData.spinnerVisible.observe(this) {
+ this.spinner.visibility = if (it) View.VISIBLE else View.GONE
+ }
+ MdmInstallData.progressVisible.observe(this) {
+ val visibility = if (it) View.VISIBLE else View.GONE
+ this.linearProgress.visibility = visibility
+ this.progressLegend.visibility = visibility
+ }
+ MdmInstallData.downloadProgress.observe(this) {
+ this.linearProgress.progress = it
+ }
+ MdmInstallData.downloadProgressLegend.observe(this) {
+ this.progressLegend.text = it
+ }
+ MdmInstallData.error.observe(this) {
+ if (it != null) {
+ MdmInstallActions.handleError(this, it)
+ }
+ }
+ MdmInstallData.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..f3704b0
--- /dev/null
+++ b/java/app/grapheneos/setupwizard/view/activity/ProvisionActivity.kt
@@ -0,0 +1,34 @@
+package app.grapheneos.setupwizard.view.activity
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import app.grapheneos.setupwizard.action.ProvisionActions
+
+class ProvisionActivity : Activity() {
+ private val TAG = "ProvisionActivity"
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ ProvisionActions.provisionDeviceOwner(this)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ Log.d(
+ TAG, "onActivityResult(): request=" + requestCode + ", result="
+ + ProvisionActions.resultCodeToString(resultCode) + ", data=" + data
+ )
+ when (requestCode) {
+ ProvisionActions.REQUEST_CODE_STEP1 -> ProvisionActions.handleProvisioningStep1Result(this, resultCode)
+
+ ProvisionActions.REQUEST_CODE_STEP2_PO, ProvisionActions.REQUEST_CODE_STEP2_DO ->
+ ProvisionActions.handleProvisioningStep2Result(this, requestCode, resultCode)
+ else -> showErrorMessage("onActivityResult(): invalid request code $requestCode")
+ }
+ }
+
+ private fun showErrorMessage(message: String) {
+ Log.e(TAG, "Error: $message")
+ }
+}
diff --git a/java/app/grapheneos/setupwizard/view/activity/WelcomeActivity.kt b/java/app/grapheneos/setupwizard/view/activity/WelcomeActivity.kt
index 921abd3..e4e2b65 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(
+ this.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 isTouchEventHandled = super.dispatchTouchEvent(ev)
+ if (ev.action == MotionEvent.ACTION_UP) {
+ this.consecutiveTapsGestureDetector?.onTouchEvent(ev)
+ }
+ return isTouchEventHandled
+ }
+
+ private val onConsecutiveTapsListener: ConsecutiveTapsGestureDetector.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..9dcdad9 100644
--- a/res/layout/activity_welcome.xml
+++ b/res/layout/activity_welcome.xml
@@ -10,6 +10,7 @@
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