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