diff --git a/README.md b/README.md index 2db91f7..c3ac92e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Custota is installed via a Magisk/KernelSU module so that it can run as a system 1. Follow the instructions in the [OTA server](#ota-server) section to set up a webserver and generate the metadata files for the OTA zips. + Alternatively, Custota supports installing OTAs from a local directory instead of downloading them from an OTA server. When using a local directory, the expected directory structure is exactly the same as how it would be with a server. + 2. If you're installing OTA updates signed with a custom key, follow the instructions in the [Custom Verification Key](#custom-verification-key) section. 3. Download the latest version from the [releases page](https://github.com/chenxiaolong/Custota/releases). To verify the digital signature, see the [verifying digital signatures](#verifying-digital-signatures) section. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ffc1625..4644bf8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,6 +24,12 @@ # only goal of minification. -dontobfuscate +# We construct TreeDocumentFile via reflection in DocumentFileExtensions +# to speed up SAF performance when doing path lookups. +-keepclassmembers class androidx.documentfile.provider.TreeDocumentFile { + (androidx.documentfile.provider.DocumentFile, android.content.Context, android.net.Uri); +} + # Keep classes generated from AIDL -keep class android.os.IUpdateEngine* { *; diff --git a/app/src/main/cpp/custota_selinux/src/main.cpp b/app/src/main/cpp/custota_selinux/src/main.cpp index c4ddf9a..1dc1f1d 100644 --- a/app/src/main/cpp/custota_selinux/src/main.cpp +++ b/app/src/main/cpp/custota_selinux/src/main.cpp @@ -839,6 +839,18 @@ static bool apply_patches( // allow custota_app oem_lock_service:service_manager find; ff(add_rule(pdb, target_type, "oem_lock_service", "service_manager", "find", errors)); + // Now, allow update_engine to access the file descriptor we pass to it via + // binder for a file opened from local storage. + + // allow update_engine mediaprovider_app:fd use; + ff(add_rule(pdb, "update_engine", "mediaprovider_app", "fd", "use", errors)); + + // allow update_engine fuse:file getattr; + // allow update_engine fuse:file read; + for (auto const &perm : {"getattr", "read"}) { + ff(add_rule(pdb, "update_engine", "fuse", "file", perm, errors)); + } + if (strip_no_audit) { ff(raw_strip_no_audit(pdb) != SELinuxResult::Error); } diff --git a/app/src/main/java/com/chiller3/custota/MainApplication.kt b/app/src/main/java/com/chiller3/custota/MainApplication.kt index d4e51f7..14ac737 100644 --- a/app/src/main/java/com/chiller3/custota/MainApplication.kt +++ b/app/src/main/java/com/chiller3/custota/MainApplication.kt @@ -37,6 +37,8 @@ class MainApplication : Application() { // Enable Material You colors DynamicColors.applyToActivitiesIfAvailable(this) + Preferences(this).migrate() + Notifications(this).updateChannels() UpdaterJob.schedulePeriodic(this, false) diff --git a/app/src/main/java/com/chiller3/custota/Preferences.kt b/app/src/main/java/com/chiller3/custota/Preferences.kt index c2df45b..e1ae486 100644 --- a/app/src/main/java/com/chiller3/custota/Preferences.kt +++ b/app/src/main/java/com/chiller3/custota/Preferences.kt @@ -5,18 +5,23 @@ package com.chiller3.custota +import android.content.ContentResolver import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log import androidx.core.content.edit import androidx.preference.PreferenceManager -import java.net.URL -class Preferences(context: Context) { +class Preferences(private val context: Context) { companion object { + private val TAG = Preferences::class.java.simpleName + const val CATEGORY_CERTIFICATES = "certificates" const val CATEGORY_DEBUG = "debug" const val PREF_CHECK_FOR_UPDATES = "check_for_updates" - const val PREF_OTA_SERVER_URL = "ota_server_url" + const val PREF_OTA_SOURCE = "ota_source" const val PREF_AUTOMATIC_CHECK = "automatic_check" const val PREF_AUTOMATIC_INSTALL = "automatic_install" const val PREF_UNMETERED_ONLY = "unmetered_only" @@ -34,6 +39,9 @@ class Preferences(context: Context) { // Not associated with a UI preference private const val PREF_DEBUG_MODE = "debug_mode" + + // Legacy preferences + private const val PREF_OTA_SERVER_URL = "ota_server_url" } private val prefs = PreferenceManager.getDefaultSharedPreferences(context) @@ -42,14 +50,41 @@ class Preferences(context: Context) { get() = prefs.getBoolean(PREF_DEBUG_MODE, false) set(enabled) = prefs.edit { putBoolean(PREF_DEBUG_MODE, enabled) } - /** Base URL to fetch OTA updates. */ - var otaServerUrl: URL? - get() = prefs.getString(PREF_OTA_SERVER_URL, null)?.let { URL(it) } - set(url) = prefs.edit { - if (url == null) { - remove(PREF_OTA_SERVER_URL) - } else { - putString(PREF_OTA_SERVER_URL, url.toString()) + /** Base URI to fetch OTA updates. This is either an HTTP/HTTPS URL or a SAF URI. */ + var otaSource: Uri? + get() = prefs.getString(PREF_OTA_SOURCE, null)?.let { Uri.parse(it) } + set(uri) { + val oldUri = otaSource + if (oldUri == uri) { + // URI is the same as before or both are null + return + } + + prefs.edit { + if (uri != null) { + if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + // Persist permissions for the new URI first + context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + putString(PREF_OTA_SOURCE, uri.toString()) + } else { + remove(PREF_OTA_SOURCE) + } + } + + // Release persisted permissions on the old directory only after the new URI is set to + // guarantee atomicity + if (oldUri != null && oldUri.scheme == ContentResolver.SCHEME_CONTENT) { + // It's not documented, but this can throw an exception when trying to release a + // previously persisted URI that's associated with an app that's no longer installed + try { + context.contentResolver.releasePersistableUriPermission( + oldUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { + Log.w(TAG, "Error when releasing persisted URI permission for: $oldUri", e) + } } } @@ -82,4 +117,12 @@ class Preferences(context: Context) { var allowReinstall: Boolean get() = prefs.getBoolean(PREF_ALLOW_REINSTALL, false) set(enabled) = prefs.edit { putBoolean(PREF_ALLOW_REINSTALL, enabled) } + + /** Migrate legacy preferences to current preferences. */ + fun migrate() { + if (prefs.contains(PREF_OTA_SERVER_URL)) { + otaSource = prefs.getString(PREF_OTA_SERVER_URL, null)?.let { Uri.parse(it) } + prefs.edit { remove(PREF_OTA_SERVER_URL) } + } + } } diff --git a/app/src/main/java/com/chiller3/custota/dialog/OtaServerUrlDialogFragment.kt b/app/src/main/java/com/chiller3/custota/dialog/OtaServerUrlDialogFragment.kt deleted file mode 100644 index b9427f6..0000000 --- a/app/src/main/java/com/chiller3/custota/dialog/OtaServerUrlDialogFragment.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.chiller3.custota.dialog - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.text.InputType -import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import com.chiller3.custota.Preferences -import com.chiller3.custota.R -import com.chiller3.custota.databinding.DialogTextInputBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.net.MalformedURLException -import java.net.URL - -class OtaServerUrlDialogFragment : DialogFragment() { - companion object { - val TAG: String = OtaServerUrlDialogFragment::class.java.simpleName - - const val RESULT_SUCCESS = "success" - } - - private lateinit var prefs: Preferences - private lateinit var binding: DialogTextInputBinding - private var url: URL? = null - // Allow the user to clear the URL - private var isEmpty = false - private var success: Boolean = false - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() - prefs = Preferences(context) - - binding = DialogTextInputBinding.inflate(layoutInflater) - - binding.message.setText(R.string.dialog_ota_server_url_message) - - binding.text.hint = getString(R.string.dialog_ota_server_url_hint) - binding.text.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - binding.text.addTextChangedListener { - url = null - - try { - if (it.isNullOrEmpty()) { - isEmpty = true - } else { - isEmpty = false - - val newUrl = URL(it.toString()) - if (newUrl.protocol == "http" || newUrl.protocol == "https") { - url = newUrl - binding.textLayout.error = null - // Don't keep the layout space for the error message reserved - binding.textLayout.isErrorEnabled = false - } else { - binding.textLayout.error = getString( - R.string.dialog_ota_server_url_error_bad_protocol) - } - } - } catch (e: MalformedURLException) { - binding.textLayout.error = getString(R.string.dialog_ota_server_url_error_malformed) - } - - refreshOkButtonEnabledState() - } - if (savedInstanceState == null) { - val oldUrl = prefs.otaServerUrl?.toString() - binding.text.setText(oldUrl) - isEmpty = oldUrl == null - } - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.dialog_ota_server_url_title) - .setView(binding.root) - .setPositiveButton(R.string.dialog_action_ok) { _, _ -> - prefs.otaServerUrl = url - success = true - } - .setNegativeButton(R.string.dialog_action_cancel, null) - .create() - .apply { - setCanceledOnTouchOutside(false) - } - } - - override fun onStart() { - super.onStart() - refreshOkButtonEnabledState() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - - setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) - } - - private fun refreshOkButtonEnabledState() { - (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = - isEmpty || url != null - } -} diff --git a/app/src/main/java/com/chiller3/custota/dialog/OtaSourceDialogFragment.kt b/app/src/main/java/com/chiller3/custota/dialog/OtaSourceDialogFragment.kt new file mode 100644 index 0000000..58aac35 --- /dev/null +++ b/app/src/main/java/com/chiller3/custota/dialog/OtaSourceDialogFragment.kt @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.custota.dialog + +import android.app.Dialog +import android.content.ContentResolver +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import android.text.InputType +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import com.chiller3.custota.Preferences +import com.chiller3.custota.R +import com.chiller3.custota.databinding.DialogOtaSourceBinding +import com.chiller3.custota.extension.formattedString +import com.chiller3.custota.settings.OpenPersistentDocumentTree +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.net.MalformedURLException +import java.net.URL + +class OtaSourceDialogFragment : DialogFragment() { + companion object { + val TAG: String = OtaSourceDialogFragment::class.java.simpleName + + const val RESULT_SUCCESS = "success" + private const val STATE_IS_LOCAL = "is_local" + private const val STATE_URI_LOCAL = "uri_local" + } + + private lateinit var prefs: Preferences + private lateinit var binding: DialogOtaSourceBinding + private var uriRemote: Uri? = null + private var uriLocal: Uri? = null + private var isLocal = false + private var success: Boolean = false + + private val requestSafDirectory = + registerForActivityResult(OpenPersistentDocumentTree()) { uri -> + uriLocal = uri + + refreshModeState() + refreshOkButtonEnabledState() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + prefs = Preferences(context) + + binding = DialogOtaSourceBinding.inflate(layoutInflater) + + binding.url.hint = getString(R.string.dialog_ota_source_server_url_hint) + binding.url.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + binding.url.addTextChangedListener { + uriRemote = null + + try { + if (it.isNullOrEmpty()) { + // Avoid showing error initially + binding.urlLayout.error = null + binding.urlLayout.isErrorEnabled = false + } else { + // The URL round trip is used for validation because Uri allows any input + val newUri = Uri.parse(URL(it.toString()).toString()) + + if (newUri.scheme == "http" || newUri.scheme == "https") { + uriRemote = newUri + binding.urlLayout.error = null + // Don't keep the layout space for the error message reserved + binding.urlLayout.isErrorEnabled = false + } else { + binding.urlLayout.error = + getString(R.string.dialog_ota_source_server_url_error_bad_protocol) + } + } + } catch (e: MalformedURLException) { + binding.urlLayout.error = + getString(R.string.dialog_ota_source_server_url_error_malformed) + } + + refreshModeState() + refreshOkButtonEnabledState() + } + + binding.changeDirectory.setOnClickListener { + requestSafDirectory.launch(null) + } + + if (savedInstanceState == null) { + val oldUri = prefs.otaSource + + isLocal = oldUri?.scheme == ContentResolver.SCHEME_CONTENT + + if (isLocal) { + uriLocal = oldUri + } else { + // The text change listener will set uriRemote + binding.url.setText(oldUri?.toString()) + } + } else { + isLocal = savedInstanceState.getBoolean(STATE_IS_LOCAL) + uriLocal = savedInstanceState.getParcelable(STATE_URI_LOCAL, Uri::class.java) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.dialog_ota_source_title) + .setView(binding.root) + .setPositiveButton(R.string.dialog_action_ok) { _, _ -> + val uri = if (isLocal) { uriLocal } else { uriRemote } + prefs.otaSource = uri + success = true + } + .setNegativeButton(R.string.dialog_action_cancel, null) + .setNeutralButton("", null) + .create() + .apply { + setCanceledOnTouchOutside(false) + } + } + + override fun onStart() { + super.onStart() + + // This is set separately to prevent the builtin dismiss callback from being called + (dialog as AlertDialog?)!!.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener { + isLocal = !isLocal + refreshModeState() + refreshOkButtonEnabledState() + } + + refreshModeState() + refreshOkButtonEnabledState() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + + setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(STATE_IS_LOCAL, isLocal) + outState.putParcelable(STATE_URI_LOCAL, uriLocal) + } + + private fun refreshModeState() { + (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_NEUTRAL)?.text = if (isLocal) { + getString(R.string.dialog_ota_source_use_server_url) + } else { + getString(R.string.dialog_ota_source_use_local_path) + } + + binding.message.setText(if (isLocal) { + R.string.dialog_ota_source_local_path_message + } else { + R.string.dialog_ota_source_server_url_message + }) + + binding.urlLayout.isVisible = !isLocal + binding.changeDirectory.isVisible = isLocal + if (uriLocal != null) { + binding.changeDirectory.text = uriLocal?.formattedString + } else { + binding.changeDirectory.setText(R.string.dialog_ota_source_local_path_select_directory) + } + } + + private fun refreshOkButtonEnabledState() { + val uri = if (isLocal) { uriLocal } else { uriRemote } + + (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = uri != null + } +} diff --git a/app/src/main/java/com/chiller3/custota/extension/DocumentFileExtensions.kt b/app/src/main/java/com/chiller3/custota/extension/DocumentFileExtensions.kt new file mode 100644 index 0000000..77a78e4 --- /dev/null +++ b/app/src/main/java/com/chiller3/custota/extension/DocumentFileExtensions.kt @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + * Based on BCR code. + */ + +package com.chiller3.custota.extension + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.util.Log +import androidx.documentfile.provider.DocumentFile + +private const val TAG = "DocumentFileExtensions" + +/** Get the internal [Context] for a DocumentsProvider-backed file. */ +private val DocumentFile.context: Context? + get() = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> { + javaClass.getDeclaredField("mContext").apply { + isAccessible = true + }.get(this) as Context + } + else -> null + } + +private val DocumentFile.isTree: Boolean + get() = uri.scheme == ContentResolver.SCHEME_CONTENT && DocumentsContract.isTreeUri(uri) + +private fun DocumentFile.iterChildrenWithColumns(extraColumns: Array) = iterator { + require(isTree) { "Not a tree URI" } + + val file = this@iterChildrenWithColumns + + // These reflection calls access private fields, but everything is part of the + // androidx.documentfile:documentfile dependency and we control the version of that. + val constructor = file.javaClass.getDeclaredConstructor( + DocumentFile::class.java, + Context::class.java, + Uri::class.java, + ).apply { + isAccessible = true + } + + context!!.contentResolver.query( + DocumentsContract.buildChildDocumentsUriUsingTree( + uri, + DocumentsContract.getDocumentId(uri), + ), + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + extraColumns, + null, null, null, + )?.use { + while (it.moveToNext()) { + val child: DocumentFile = constructor.newInstance( + file, + context, + DocumentsContract.buildDocumentUriUsingTree(uri, it.getString(0)), + ) + + yield(Pair(child, it)) + } + } +} + +/** + * Like [DocumentFile.findFile], but faster for tree URIs. + * + * [DocumentFile.findFile] performs a query for the document ID list and then performs separate + * queries for each document to get the name. This is extremely slow on some devices and is + * unnecessary because [DocumentsContract.Document.COLUMN_DOCUMENT_ID] and + * [DocumentsContract.Document.COLUMN_DISPLAY_NAME] can be queried at the same time. + */ +fun DocumentFile.findFileFast(displayName: String): DocumentFile? { + if (!isTree) { + return findFile(displayName) + } + + try { + return iterChildrenWithColumns(arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)) + .asSequence() + .find { it.second.getString(1) == displayName } + ?.first + } catch (e: Exception) { + Log.w(TAG, "Failed to query tree URI", e) + } + + return null +} + +/** Like [DocumentFile.findFileFast], but accepts nested paths. */ +fun DocumentFile.findNestedFile(path: List): DocumentFile? { + var file = this + for (segment in path) { + file = file.findFileFast(segment) ?: return null + } + return file +} diff --git a/app/src/main/java/com/chiller3/custota/extension/UriExtensions.kt b/app/src/main/java/com/chiller3/custota/extension/UriExtensions.kt new file mode 100644 index 0000000..bd6203d --- /dev/null +++ b/app/src/main/java/com/chiller3/custota/extension/UriExtensions.kt @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + * Based on BCR code. + */ + +package com.chiller3.custota.extension + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import android.telecom.PhoneAccount + +private const val DOCUMENTSUI_AUTHORITY = "com.android.externalstorage.documents" + +val Uri.formattedString: String + get() = when (scheme) { + ContentResolver.SCHEME_FILE -> path!! + ContentResolver.SCHEME_CONTENT -> { + val prefix = when (authority) { + DOCUMENTSUI_AUTHORITY -> "" + // Include the authority to reduce ambiguity when this isn't a SAF URI provided by + // Android's local filesystem document provider + else -> "[$authority] " + } + val segments = pathSegments + + // If this looks like a SAF tree/document URI, then try and show the document ID. This + // cannot be implemented in a way that prevents all false positives. + if (segments.size == 4 && segments[0] == "tree" && segments[2] == "document") { + prefix + segments[3] + } else if (segments.size == 2 && segments[0] == "tree") { + prefix + segments[1] + } else { + toString() + } + } + else -> toString() + } diff --git a/app/src/main/java/com/chiller3/custota/settings/OpenPersistentDocumentTree.kt b/app/src/main/java/com/chiller3/custota/settings/OpenPersistentDocumentTree.kt new file mode 100644 index 0000000..165de1c --- /dev/null +++ b/app/src/main/java/com/chiller3/custota/settings/OpenPersistentDocumentTree.kt @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + * Based on BCR code. + */ + +package com.chiller3.custota.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts + +/** + * A small wrapper around [ActivityResultContracts.OpenDocumentTree] that requests read-persistable + * URIs when opening directories. + */ +class OpenPersistentDocumentTree : ActivityResultContracts.OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent { + val intent = super.createIntent(context, input) + + intent.addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + + return intent + } +} diff --git a/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt b/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt index da44855..7f59d05 100644 --- a/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt +++ b/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt @@ -27,7 +27,8 @@ import com.chiller3.custota.BuildConfig import com.chiller3.custota.Permissions import com.chiller3.custota.Preferences import com.chiller3.custota.R -import com.chiller3.custota.dialog.OtaServerUrlDialogFragment +import com.chiller3.custota.dialog.OtaSourceDialogFragment +import com.chiller3.custota.extension.formattedString import com.chiller3.custota.updater.OtaPaths import com.chiller3.custota.updater.UpdaterJob import com.chiller3.custota.updater.UpdaterThread @@ -51,7 +52,7 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic private lateinit var categoryCertificates: PreferenceCategory private lateinit var categoryDebug: PreferenceCategory private lateinit var prefCheckForUpdates: Preference - private lateinit var prefOtaServerUrl: Preference + private lateinit var prefOtaSource: LongClickablePreference private lateinit var prefAndroidVersion: Preference private lateinit var prefFingerprint: Preference private lateinit var prefBootSlot: Preference @@ -85,8 +86,9 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic prefCheckForUpdates = findPreference(Preferences.PREF_CHECK_FOR_UPDATES)!! prefCheckForUpdates.onPreferenceClickListener = this - prefOtaServerUrl = findPreference(Preferences.PREF_OTA_SERVER_URL)!! - prefOtaServerUrl.onPreferenceClickListener = this + prefOtaSource = findPreference(Preferences.PREF_OTA_SOURCE)!! + prefOtaSource.onPreferenceClickListener = this + prefOtaSource.onPreferenceLongClickListener = this prefAndroidVersion = findPreference(Preferences.PREF_ANDROID_VERSION)!! prefAndroidVersion.summary = Build.VERSION.RELEASE @@ -115,7 +117,7 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic prefRevertCompleted.onPreferenceClickListener = this refreshCheckForUpdates() - refreshOtaServerUrl() + refreshOtaSource() refreshVersion() refreshDebugPrefs() @@ -154,12 +156,12 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic } private fun refreshCheckForUpdates() { - prefCheckForUpdates.isEnabled = prefs.otaServerUrl != null + prefCheckForUpdates.isEnabled = prefs.otaSource != null } - private fun refreshOtaServerUrl() { - prefOtaServerUrl.summary = prefs.otaServerUrl?.toString() - ?: getString(R.string.pref_ota_server_url_desc_none) + private fun refreshOtaSource() { + prefOtaSource.summary = prefs.otaSource?.formattedString + ?: getString(R.string.pref_ota_source_none) } private fun refreshVersion() { @@ -223,9 +225,9 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic performAction() return true } - prefOtaServerUrl -> { - OtaServerUrlDialogFragment().show(parentFragmentManager.beginTransaction(), - OtaServerUrlDialogFragment.TAG) + prefOtaSource -> { + OtaSourceDialogFragment().show(parentFragmentManager.beginTransaction(), + OtaSourceDialogFragment.TAG) return true } prefVersion -> { @@ -257,6 +259,10 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic override fun onPreferenceLongClick(preference: Preference): Boolean { when (preference) { + prefOtaSource -> { + prefs.otaSource = null + return true + } prefVersion -> { prefs.isDebugMode = !prefs.isDebugMode refreshVersion() @@ -270,9 +276,9 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { - Preferences.PREF_OTA_SERVER_URL -> { + Preferences.PREF_OTA_SOURCE -> { refreshCheckForUpdates() - refreshOtaServerUrl() + refreshOtaSource() } Preferences.PREF_UNMETERED_ONLY, Preferences.PREF_BATTERY_NOT_LOW -> { UpdaterJob.schedulePeriodic(requireContext(), true) diff --git a/app/src/main/java/com/chiller3/custota/updater/PartialFdInputStream.kt b/app/src/main/java/com/chiller3/custota/updater/PartialFdInputStream.kt new file mode 100644 index 0000000..6497845 --- /dev/null +++ b/app/src/main/java/com/chiller3/custota/updater/PartialFdInputStream.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.chiller3.custota.updater + +import android.os.ParcelFileDescriptor +import android.system.Os +import android.system.OsConstants +import java.io.InputStream +import java.lang.Long.min + +/** + * Present a view of a file offset range as an input stream. + * + * This will seek to the specified offset and takes ownership of the file descriptor. + */ +class PartialFdInputStream( + private val pfd: ParcelFileDescriptor, + offset: Long, + private val size: Long, +) : InputStream() { + private var pos = 0L + + init { + Os.lseek(pfd.fileDescriptor, offset, OsConstants.SEEK_SET) + } + + override fun close() { + pfd.close() + } + + override fun read(): Int { + val buf = ByteArray(1) + if (read(buf, 0, 1) != 1) { + return -1 + } + + return buf[0].toInt() + } + + override fun read(b: ByteArray?, off: Int, len: Int): Int { + val toRead = min(len.toLong(), size - pos).toInt() + val n = Os.read(pfd.fileDescriptor, b, off, toRead) + pos += n + return n + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/custota/updater/UpdaterService.kt b/app/src/main/java/com/chiller3/custota/updater/UpdaterService.kt index 94280bd..856a5c1 100644 --- a/app/src/main/java/com/chiller3/custota/updater/UpdaterService.kt +++ b/app/src/main/java/com/chiller3/custota/updater/UpdaterService.kt @@ -57,7 +57,7 @@ class UpdaterService : Service(), UpdaterThread.UpdaterThreadListener { // returns. updateForegroundNotification(false) - if (prefs.otaServerUrl != null) { + if (prefs.otaSource != null) { startUpdate(intent) } else { Log.w(TAG, "Not starting thread because no URL is configured") diff --git a/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt b/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt index a2f5da3..05c358a 100644 --- a/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt +++ b/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt @@ -8,8 +8,10 @@ package com.chiller3.custota.updater import android.annotation.SuppressLint +import android.content.ContentResolver import android.content.Context import android.net.Network +import android.net.Uri import android.os.Build import android.os.IUpdateEngine import android.os.IUpdateEngineCallback @@ -17,8 +19,10 @@ import android.os.Parcelable import android.os.PowerManager import android.ota.OtaPackageMetadata.OtaMetadata import android.util.Log +import androidx.documentfile.provider.DocumentFile import com.chiller3.custota.BuildConfig import com.chiller3.custota.Preferences +import com.chiller3.custota.extension.findNestedFile import com.chiller3.custota.extension.toSingleLineString import com.chiller3.custota.wrapper.ServiceManagerProxy import kotlinx.parcelize.Parcelize @@ -115,10 +119,6 @@ class UpdaterThread( } init { - if (action != Action.REVERT && network == null) { - throw IllegalStateException("Network is required for non-revert actions") - } - updateEngine.bind(engineCallback) engineIsBound = true } @@ -159,8 +159,55 @@ class UpdaterThread( updateEngine.cancel() } + /** + * Compute [str] relative to [base]. + * + * For local SAF URIs, [str] must be a (potentially nested) child of [base] or an absolute + * HTTP(S) URI. For HTTP(S) URIs, [str] can be a relative path, absolute path, or absolute + * HTTP(S) URI. + * + * For HTTP(S) URIs, if [forceBaseAsDir] is true, then [base] is treated as a directory even if + * it doesn't end in a trailing slash. + */ + private fun resolveUri(base: Uri, str: String, forceBaseAsDir: Boolean): Uri { + if (base.scheme == ContentResolver.SCHEME_CONTENT) { + val strUriRaw = Uri.parse(str) + if (strUriRaw.scheme == "http" || strUriRaw.scheme == "https") { + // Allow local update info to redirect to an absolute URL since that has been the + // documented behavior + return strUriRaw + } + + val file = DocumentFile.fromTreeUri(context, base) + ?: throw IOException("Failed to open: $base") + // This is safe because SAF does not allow '..' + val components = str.split('/') + + val child = file.findNestedFile(components) + ?: throw IOException("Failed to find $str inside $base") + + return child.uri + } else { + var raw = base.toString() + if (forceBaseAsDir && !raw.endsWith('/')) { + raw += '/' + } + + val resolved = Uri.parse(URI(raw).resolve(str).toString()) + if (resolved.scheme != "http" && resolved.scheme != "https") { + throw IllegalStateException("$str resolves to unsupported protocol") + } + + return resolved + } + } + private fun openUrl(url: URL): HttpURLConnection { - val c = network!!.openConnection(url) as HttpURLConnection + if (network == null) { + throw IllegalStateException("Network is required, but no network object available") + } + + val c = network.openConnection(url) as HttpURLConnection c.connectTimeout = TIMEOUT_MS c.readTimeout = TIMEOUT_MS c.setRequestProperty("User-Agent", USER_AGENT) @@ -170,11 +217,16 @@ class UpdaterThread( return c } - /** Download and parse update info JSON file. */ - private fun downloadUpdateInfo(url: URL): UpdateInfo { - val updateInfo: UpdateInfo = openUrl(url).inputStream.use { - Json.decodeFromStream(it) + /** Fetch and parse update info JSON file. */ + private fun fetchUpdateInfo(uri: Uri): UpdateInfo { + val stream = if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + context.contentResolver.openInputStream(uri) + ?: throw IOException("Failed to open: $uri") + } else { + openUrl(URL(uri.toString())).inputStream } + + val updateInfo: UpdateInfo = stream.use { Json.decodeFromStream(it) } Log.d(TAG, "Update info: $updateInfo") if (updateInfo.version != 2) { @@ -185,31 +237,40 @@ class UpdaterThread( } /** - * Download a property file entry from the OTA zip. The server must support byte ranges. If the - * server returns too few or too many bytes, then the download will fail. + * Fetch a property file entry from the OTA zip. For HTTP and HTTPS, the server must support + * byte ranges. If the server returns too few or too many bytes, then the download will fail. * * @param output Not closed by this function */ - private fun downloadPropertyFile(url: URL, pf: PropertyFile, output: OutputStream) { - val connection = openUrl(url) - connection.setRequestProperty("Range", "bytes=${pf.offset}-${pf.offset + pf.size - 1}") - connection.connect() + private fun fetchPropertyFile(uri: Uri, pf: PropertyFile, output: OutputStream) { + val stream = if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + val pfd = context.contentResolver.openFileDescriptor(uri, "r") + ?: throw IOException("Failed to open: $uri") - if (connection.responseCode / 100 != 2) { - throw IOException("Got ${connection.responseCode} (${connection.responseMessage}) for $url") - } + PartialFdInputStream(pfd, pf.offset, pf.size) + } else { + val connection = openUrl(URL(uri.toString())) + connection.setRequestProperty("Range", "bytes=${pf.offset}-${pf.offset + pf.size - 1}") + connection.connect() - if (connection.getHeaderField("Accept-Ranges") != "bytes") { - throw IOException("Server does not support byte ranges") - } + if (connection.responseCode / 100 != 2) { + throw IOException("Got ${connection.responseCode} (${connection.responseMessage}) for $uri") + } + + if (connection.getHeaderField("Accept-Ranges") != "bytes") { + throw IOException("Server does not support byte ranges") + } - if (connection.contentLengthLong != pf.size) { - throw IOException("Expected ${pf.size} bytes, but Content-Length is ${connection.contentLengthLong}") + if (connection.contentLengthLong != pf.size) { + throw IOException("Expected ${pf.size} bytes, but Content-Length is ${connection.contentLengthLong}") + } + + connection.inputStream } val md = MessageDigest.getInstance("SHA-256") - connection.inputStream.use { input -> + stream.use { input -> val buf = ByteArray(16384) var downloaded = 0L @@ -289,17 +350,24 @@ class UpdaterThread( return result } - /** Download and parse key/value pairs file. */ - private fun downloadKeyValueFile(url: URL, pf: PropertyFile): Map { + /** Fetch and parse key/value pairs file. */ + private fun fetchKeyValueFile(uri: Uri, pf: PropertyFile): Map { val outputStream = ByteArrayOutputStream() - downloadPropertyFile(url, pf, outputStream) + fetchPropertyFile(uri, pf, outputStream) return parseKeyValuePairs(outputStream.toString(Charsets.UTF_8)) } - /** Download and verify signature of the csig file. */ - private fun downloadAndCheckCsig(csigUrl: URL): CsigInfo { - val csigRaw = openUrl(csigUrl).inputStream.use { it.readBytes() } + /** Fetch and verify signature of the csig file. */ + private fun downloadAndCheckCsig(uri: Uri): CsigInfo { + val stream = if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + context.contentResolver.openInputStream(uri) + ?: throw IOException("Failed to open: $uri") + } else { + openUrl(URL(uri.toString())).inputStream + } + + val csigRaw = stream.use { it.readBytes() } val csigCms = CMSSignedData(csigRaw) // Verify the signature against the same OTA certs as what's used for the payload. @@ -324,14 +392,14 @@ class UpdaterThread( return csigInfo } - /** Download the OTA metadata and validate that the update is valid for the current system. */ - private fun downloadAndCheckMetadata( - url: URL, + /** Fetch the OTA metadata and validate that the update is valid for the current system. */ + private fun fetchAndCheckMetadata( + uri: Uri, pf: PropertyFile, csigInfo: CsigInfo, ): OtaMetadata { val outputStream = ByteArrayOutputStream() - downloadPropertyFile(url, pf, outputStream) + fetchPropertyFile(uri, pf, outputStream) val metadata = OtaMetadata.newBuilder().mergeFrom(outputStream.toByteArray()).build() Log.d(TAG, "OTA metadata: $metadata") @@ -382,18 +450,18 @@ class UpdaterThread( } /** - * Download the payload metadata (protobuf in headers) and verify that the payload is valid for + * Fetch the payload metadata (protobuf in headers) and verify that the payload is valid for * this device. * * At a minimum, update_engine checks that the list of partitions in the OTA match the device. */ @SuppressLint("SetWorldReadable") - private fun downloadAndCheckPayloadMetadata(url: URL, pf: PropertyFile) { + private fun fetchAndCheckPayloadMetadata(uri: Uri, pf: PropertyFile) { val file = File(OtaPaths.OTA_PACKAGE_DIR, OtaPaths.PAYLOAD_METADATA_NAME) try { file.outputStream().use { out -> - downloadPropertyFile(url, pf, out) + fetchPropertyFile(uri, pf, out) } file.setReadable(true, false) @@ -409,12 +477,12 @@ class UpdaterThread( * Returns the path to the written file. */ @SuppressLint("SetWorldReadable") - private fun downloadCareMap(url: URL, pf: PropertyFile): File { + private fun downloadCareMap(uri: Uri, pf: PropertyFile): File { val file = File(OtaPaths.OTA_PACKAGE_DIR, OtaPaths.CARE_MAP_NAME) try { file.outputStream().use { out -> - downloadPropertyFile(url, pf, out) + fetchPropertyFile(uri, pf, out) } file.setReadable(true, false) } catch (e: Exception) { @@ -427,25 +495,25 @@ class UpdaterThread( /** Synchronously check for updates. */ private fun checkForUpdates(): CheckUpdateResult { - val baseUrl = prefs.otaServerUrl ?: throw IllegalStateException("No URL configured") - val updateInfoUrl = resolveUrl(baseUrl, "${Build.DEVICE}.json", true) - Log.d(TAG, "Update info URL: $updateInfoUrl") + val baseUri = prefs.otaSource ?: throw IllegalStateException("No URI configured") + val updateInfoUri = resolveUri(baseUri, "${Build.DEVICE}.json", true) + Log.d(TAG, "Update info URI: $updateInfoUri") val updateInfo = try { - downloadUpdateInfo(updateInfoUrl) + fetchUpdateInfo(updateInfoUri) } catch (e: Exception) { throw IOException("Failed to download update info", e) } - val otaUrl = resolveUrl(updateInfoUrl, updateInfo.full.locationOta, false) - Log.d(TAG, "OTA URL: $otaUrl") - val csigUrl = resolveUrl(updateInfoUrl, updateInfo.full.locationCsig, false) - Log.d(TAG, "csig URL: $csigUrl") + val otaUri = resolveUri(updateInfoUri, updateInfo.full.locationOta, false) + Log.d(TAG, "OTA URI: $otaUri") + val csigUri = resolveUri(updateInfoUri, updateInfo.full.locationCsig, false) + Log.d(TAG, "csig URI: $csigUri") - val csigInfo = downloadAndCheckCsig(csigUrl) + val csigInfo = downloadAndCheckCsig(csigUri) val pfMetadata = csigInfo.getOrThrow(OtaPaths.METADATA_NAME) - val metadata = downloadAndCheckMetadata(otaUrl, pfMetadata, csigInfo) + val metadata = fetchAndCheckMetadata(otaUri, pfMetadata, csigInfo) if (metadata.postcondition.buildCount != 1) { throw ValidationException("Metadata postcondition lists multiple fingerprints") @@ -465,13 +533,13 @@ class UpdaterThread( return CheckUpdateResult( updateAvailable, fingerprint, - otaUrl, + otaUri, csigInfo, ) } /** Asynchronously trigger the update_engine payload application. */ - private fun startInstallation(otaUrl: URL, csigInfo: CsigInfo) { + private fun startInstallation(otaUri: Uri, csigInfo: CsigInfo) { val pfPayload = csigInfo.getOrThrow(OtaPaths.PAYLOAD_NAME) val pfPayloadMetadata = csigInfo.getOrThrow(OtaPaths.PAYLOAD_METADATA_NAME) val pfPayloadProperties = csigInfo.getOrThrow(OtaPaths.PAYLOAD_PROPERTIES_NAME) @@ -479,19 +547,19 @@ class UpdaterThread( Log.i(TAG, "Downloading payload metadata and checking compatibility") - downloadAndCheckPayloadMetadata(otaUrl, pfPayloadMetadata) + fetchAndCheckPayloadMetadata(otaUri, pfPayloadMetadata) Log.i(TAG, "Downloading dm-verity care map file") if (pfCareMap != null) { - downloadCareMap(otaUrl, pfCareMap) + downloadCareMap(otaUri, pfCareMap) } else { Log.w(TAG, "OTA package does not have a dm-verity care map") } Log.i(TAG, "Downloading payload properties file") - val payloadProperties = downloadKeyValueFile(otaUrl, pfPayloadProperties) + val payloadProperties = fetchKeyValueFile(otaUri, pfPayloadProperties) Log.i(TAG, "Passing payload information to update_engine") @@ -509,12 +577,28 @@ class UpdaterThread( } } - updateEngine.applyPayload( - otaUrl.toString(), - pfPayload.offset, - pfPayload.size, - engineProperties.map { "${it.key}=${it.value}" }.toTypedArray(), - ) + val enginePropertiesArray = engineProperties.map { "${it.key}=${it.value}" }.toTypedArray() + + if (otaUri.scheme == ContentResolver.SCHEME_CONTENT) { + val pfd = context.contentResolver.openFileDescriptor(otaUri, "r") + ?: throw IOException("Failed to open: $otaUri") + + pfd.use { + updateEngine.applyPayloadFd( + it, + pfPayload.offset, + pfPayload.size, + enginePropertiesArray, + ) + } + } else { + updateEngine.applyPayload( + otaUri.toString(), + pfPayload.offset, + pfPayload.size, + engineProperties.map { "${it.key}=${it.value}" }.toTypedArray(), + ) + } } private fun startLogcat() { @@ -606,7 +690,7 @@ class UpdaterThread( } startInstallation( - checkUpdateResult.otaUrl, + checkUpdateResult.otaUri, checkUpdateResult.csigInfo, ) } else { @@ -651,7 +735,7 @@ class UpdaterThread( private data class CheckUpdateResult( val updateAvailable: Boolean, val fingerprint: String, - val otaUrl: URL, + val otaUri: Uri, val csigInfo: CsigInfo, ) @@ -753,14 +837,5 @@ class UpdaterThread( private val USER_AGENT_UPDATE_ENGINE = "$USER_AGENT update_engine/${Build.VERSION.SDK_INT}" private const val TIMEOUT_MS = 30_000 - - private fun resolveUrl(base: URL, str: String, forceBaseAsDir: Boolean): URL { - var raw = base.toString() - if (forceBaseAsDir && !raw.endsWith('/')) { - raw += '/' - } - - return URI(raw).resolve(str).toURL() - } } } \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_text_input.xml b/app/src/main/res/layout/dialog_ota_source.xml similarity index 81% rename from app/src/main/res/layout/dialog_text_input.xml rename to app/src/main/res/layout/dialog_ota_source.xml index ec0f2e9..b43d1b9 100644 --- a/app/src/main/res/layout/dialog_text_input.xml +++ b/app/src/main/res/layout/dialog_ota_source.xml @@ -23,23 +23,23 @@ android:layout_height="wrap_content" /> - + style="?attr/materialButtonOutlinedStyle" /> diff --git a/app/src/main/res/values-vi/values.xml b/app/src/main/res/values-vi/values.xml index de87de8..4603b56 100644 --- a/app/src/main/res/values-vi/values.xml +++ b/app/src/main/res/values-vi/values.xml @@ -15,8 +15,6 @@ Kiểm tra cập nhật OTA Đặt lịch kiểm tra cập nhật OTA. - Đường dẫn OTA server - Đường dẫn chưa được thiết lập. Chứng chỉ %1$s Chủ đề: %1$s Số sê-ri: %1$s @@ -63,10 +61,10 @@ Lựa chọn này chỉ có thể xảy ra sau khi cài đặt cập nhật hoàn thành, nhưng trước khi khởi động lại máy. Chỉ nên sử dụng cho mục đích gỡ lỗi. - @string/pref_ota_server_url_name - Thêm đường dẫn gốc để tải về metadata cho nhật OTA(ngoại trừ tên file). - Chỉ đường dẫn http:// và https:// được hỗ trợ. - Đương dẫn không hợp lệ. + @string/pref_ota_source_name + Thêm đường dẫn gốc để tải về metadata cho nhật OTA(ngoại trừ tên file). + Chỉ đường dẫn http:// và https:// được hỗ trợ. + Đương dẫn không hợp lệ. OK Hủy diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 055bf3b..b6287f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,8 +15,8 @@ Check for updates Schedule a check for new OTA updates. - OTA server URL - No URL set. + OTA installation source + No installation source set. Certificate %1$s Subject: %1$s Serial: %1$s @@ -63,10 +63,14 @@ This is only possible after an update completes, but before rebooting. This is meant for debugging purposes only. - @string/pref_ota_server_url_name - Enter the base URL for fetching OTA update metadata (excluding the filename). - Only http:// and https:// URLs are supported. - Not a valid URL. + @string/pref_ota_source_name + Enter the base URL for fetching OTA update metadata (excluding the filename). + Only http:// and https:// URLs are supported. + Not a valid URL. + Select the directory containing the OTA update metadata. + Select directory + Use server URL + Use local path OK Cancel diff --git a/app/src/main/res/values/strings_notranslate.xml b/app/src/main/res/values/strings_notranslate.xml index 7db94c1..34afff0 100644 --- a/app/src/main/res/values/strings_notranslate.xml +++ b/app/src/main/res/values/strings_notranslate.xml @@ -6,5 +6,5 @@ Custota - https://hostname[:port]/path + https://hostname[:port]/path diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml index 2d75d41..89b2bc3 100644 --- a/app/src/main/res/xml/preferences_root.xml +++ b/app/src/main/res/xml/preferences_root.xml @@ -15,10 +15,10 @@ app:summary="@string/pref_check_for_updates_desc" app:iconSpaceReserved="false" /> -