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