From de9dfc6a401d34b45d18cbc0bf9d0d3f7b4cc73e Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:45:09 +0300 Subject: [PATCH 1/4] Validate portable config schemas and report ignored import fields --- AGENTS.md | 6 + README.md | 8 + app/build.gradle.kts | 7 + .../java/net/megaproxy487/ProfilesScreen.kt | 4 + .../megaproxy487/data/ConfigImportNotice.kt | 44 ++ .../net/megaproxy487/data/ConfigTransfer.kt | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../net/megaproxy487/ProfileTransferUiTest.kt | 24 + .../net/megaproxy487/data/ConfigSchemaTest.kt | 104 +++ .../data/ConfigStoreIntegrationTest.kt | 18 + config-schema/LICENSE | 21 + config-schema/android-v8.json | 24 + config-schema/android-v8.schema.json | 409 +++++++++++ config-schema/browser-v8.json | 56 ++ config-schema/megaproxy-v8.schema.json | 640 ++++++++++++++++++ config-schema/schema-lock.json | 11 + docs/en/fastlane.md | 11 + docs/reviews/config-schema.md | 39 ++ docs/ru/fastlane.md | 11 + fastlane/Fastfile | 5 + fastlane/README.md | 1 + scripts/renew_config_schema.py | 82 +++ scripts/tests/test_renew_config_schema.py | 63 ++ 24 files changed, 1594 insertions(+) create mode 100644 app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt create mode 100644 app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt create mode 100644 config-schema/LICENSE create mode 100644 config-schema/android-v8.json create mode 100644 config-schema/android-v8.schema.json create mode 100644 config-schema/browser-v8.json create mode 100644 config-schema/megaproxy-v8.schema.json create mode 100644 config-schema/schema-lock.json create mode 100644 docs/reviews/config-schema.md create mode 100644 scripts/renew_config_schema.py create mode 100644 scripts/tests/test_renew_config_schema.py diff --git a/AGENTS.md b/AGENTS.md index 688f491..781d93f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,12 @@ branch names, credentials, signing material, or other secrets. HTTP origin, using disposable credentials and the production dialers. Keep it separate from ordinary local Go tests and Android TUN/JNI coverage; never use a public or personal proxy. +- Vendor MegaProxyConfig schemas/examples with commit and SHA-256 in `config-schema/`. Update only + through `bundle exec fastlane android renew_config_schema [ref:FULL_SHA]`; CI never fetches upstream. + JVM checks validate real exports against both schemas. Keep permissive legacy imports; report + ignored browser settings and undocumented keys once each in the existing import result, without + field values. Unsupported fields are discarded and do not round-trip through Android. + ## Releases and distribution - Release builds and signing are separate from PR CI. Release artifacts are created only through diff --git a/README.md b/README.md index c9f4114..9f2b024 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,14 @@ private-key fields preserve credentials already stored on the device, while expl clear them. After import, MegaProxy offers an unselected list of local profiles absent from the file so the user can optionally remove specific obsolete profiles. +The portable contract is maintained in [MegaProxyConfig](https://github.com/andre487/MegaProxyConfig). +Pinned schemas and examples live in `config-schema/`; update them explicitly with +`bundle exec fastlane android renew_config_schema` (or `ref:FULL_SHA`). JVM checks validate real +exports against both the Android baseline and shared schema. Import results report ignored browser +settings and fields unknown to the pinned specification once each, without displaying values. +Neither category is retained or included in later exports. See the +[compatibility audit](docs/reviews/config-schema.md) for the canonical-format and legacy-import distinction. + ### HTTPS with Jump Select **HTTPS with Jump** to use two HTTPS CONNECT proxies in sequence: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea60c30..7a71b3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,6 +38,12 @@ android { compileSdk = 36 buildToolsVersion = "36.0.0" + sourceSets.getByName("main").resources.apply { + srcDir(rootProject.file("config-schema")) + include("*.schema.json") + } + sourceSets.getByName("test").resources.srcDir(rootProject.file("config-schema")) + defaultConfig { applicationId = "net.megaproxy487" minSdk = 26 @@ -144,6 +150,7 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") testImplementation("org.json:json:20250107") + testImplementation("com.networknt:json-schema-validator:1.5.9") implementation(files("libs/megaproxy.aar")) } diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 136c66b..47b2275 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -335,6 +335,10 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil .takeIf { missingPasswords > 0 }, activity.uiText(R.string.config_import_always_on_reconnect) .takeIf { ProxyVpnService.isAlwaysOnMode && ProxyVpnService.isRunning }, + activity.uiText(R.string.config_import_browser_fields) + .takeIf { configuration.notice.browserFields }, + activity.uiText(R.string.config_import_unknown_fields) + .takeIf { configuration.notice.unknownFields }, ).joinToString(" ") if (result.missing.isEmpty()) { transferMessage = summary diff --git a/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt b/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt new file mode 100644 index 0000000..4ec4aa2 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt @@ -0,0 +1,44 @@ +package net.megaproxy487.data + +import org.json.JSONArray +import org.json.JSONObject + +data class ConfigImportNotice( + val browserFields: Boolean = false, + val unknownFields: Boolean = false, +) + +/** Field recognition only: canonical schema validation belongs to export tests, not legacy imports. */ +internal object ConfigImportNotices { + private val schema by lazy { + JSONObject(checkNotNull(javaClass.getResourceAsStream("/megaproxy-v8.schema.json")) + .bufferedReader().use { it.readText() }) + } + + fun inspect(root: JSONObject): ConfigImportNotice { + var unknown = false + fun walk(value: Any?, definition: JSONObject) { + val reference = definition.optString("\$ref") + val resolved = if (reference.isEmpty()) definition + else schema.getJSONObject("\$defs").getJSONObject(reference.substringAfterLast('/')) + when (value) { + is JSONObject -> { + val properties = resolved.optJSONObject("properties") + value.keys().forEach { key -> + val child = properties?.optJSONObject(key) + if (child == null) unknown = true else walk(value.opt(key), child) + } + } + is JSONArray -> resolved.optJSONObject("items")?.let { item -> + for (index in 0 until value.length()) walk(value.opt(index), item) + } + } + } + walk(root, schema) + val profiles = root.optJSONArray("profiles") + val browser = root.has("browser") || profiles?.let { array -> + (0 until array.length()).any { array.optJSONObject(it)?.has("browser") == true } + } == true + return ConfigImportNotice(browserFields = browser, unknownFields = unknown) + } +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index 97b35ec..827feca 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -31,6 +31,7 @@ data class PortableConfiguration( val globalConnectionSettings: GlobalConnectionSettings? = null, val skippedProfiles: Int = 0, val secretPresence: Map = emptyMap(), + val notice: ConfigImportNotice = ConfigImportNotice(), ) data class ProfileSecretPresence( @@ -121,6 +122,7 @@ object ConfigTransfer { it.copy(config = it.config.copy(allowIpv6 = legacyIpv6)) } else decodedProfiles return PortableConfiguration( + notice = ConfigImportNotices.inspect(root), profiles = profiles, activeProfileId = root.optString("activeProfileId").ifBlank { null }, alwaysOnProfileId = root.optString("alwaysOnProfileId").ifBlank { null }, diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fd41532..d85f17e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -51,6 +51,8 @@ Пропущено некорректных профилей: %1$d. Профилей без пароля: %1$d. Переподключите VPN, чтобы применить импортированный профиль постоянного VPN. + Конфигурация содержит настройки для браузеров. На Android они игнорируются. + Конфигурация содержит поля, неизвестные спецификации MegaProxy. Они игнорируются. Удалено профилей: %1$d. Удалить профили, отсутствующие в конфигурации? Эти локальные профили отсутствуют в импортированном файле. Изначально ни один профиль не выбран — отметьте только те, которые действительно нужно удалить. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d8f1a6c..973d475 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,8 @@ Skipped %1$d invalid profile(s). %1$d profile(s) have no password. Reconnect the VPN to apply the imported Always-on profile selection. + The configuration contains browser settings. They are ignored on Android. + The configuration contains fields unknown to the MegaProxy specification. They are ignored. Removed %1$d profile(s). Remove profiles missing from the configuration? These local profiles were not present in the imported file. None are selected by default; select only profiles you really want to remove. diff --git a/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt b/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt index dc94ecc..19d7cef 100644 --- a/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt +++ b/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt @@ -75,6 +75,30 @@ class ProfileTransferUiTest : MainUiTestBase() { assertEquals(1, store.profiles().size) } + @Test fun jsonImportReportsIgnoredFieldsInItsExistingResultAndDiscardsThem() { + val uri = Uri.parse("content://ui-test/portable.json") + val root = org.json.JSONObject(net.megaproxy487.data.ConfigTransfer.exportJson(store, true)) + .put("browser", org.json.JSONObject().put("theme", "dark")) + .put("future", "unknown-secret") + shadowOf(activity.contentResolver).registerInputStream(uri, ByteArrayInputStream(root.toString().toByteArray())) + screen() + icon(R.string.import_action).performClick() + documentResult(uri) + compose.waitUntil(10_000) { + compose.onAllNodes(hasText(text(R.string.config_import_browser_fields), substring = true)).fetchSemanticsNodes().isNotEmpty() + } + saved() + val summary = compose.onNode(hasText(text(R.string.config_import_browser_fields), substring = true)) + summary.assertTextContains(text(R.string.config_import_unknown_fields), substring = true) + assertFalse(summary.fetchSemanticsNode().config[androidx.compose.ui.semantics.SemanticsProperties.Text].joinToString().contains("unknown-secret")) + node(R.string.ok).performClick() + assertEquals(1, store.profiles().size) + val exported = net.megaproxy487.data.ConfigTransfer.exportJson(store, true) + assertFalse(exported.contains("browser")) + assertFalse(exported.contains("future")) + assertFalse(exported.contains("unknown-secret")) + } + @Test fun defaultJsonExportOmitsSecrets() { val uri = Uri.parse("content://ui-test/export.json") val output = ByteArrayOutputStream() diff --git a/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt new file mode 100644 index 0000000..44f26e1 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt @@ -0,0 +1,104 @@ +package net.megaproxy487.data + +import com.fasterxml.jackson.databind.ObjectMapper +import com.networknt.schema.JsonSchemaFactory +import com.networknt.schema.SpecVersion +import net.megaproxy487.model.* +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import java.security.MessageDigest + +internal object ConfigSchemas { + fun bytes(name: String) = checkNotNull(javaClass.getResourceAsStream("/$name")).use { it.readBytes() } + private val mapper = ObjectMapper() + private val schemas = listOf("android-v8.schema.json", "megaproxy-v8.schema.json").map { + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(mapper.readTree(bytes(it))) + } + fun assertValid(text: String) { + schemas.forEach { schema -> + val errors = schema.validate(mapper.readTree(text)) + assertTrue(errors.joinToString("\n"), errors.isEmpty()) + } + } + fun rejects(text: String) = schemas.all { it.validate(mapper.readTree(text)).isNotEmpty() } +} + +class ConfigSchemaTest { + @Test fun vendoredFilesMatchPinnedCommitAndChecksums() { + val lock = JSONObject(String(ConfigSchemas.bytes("schema-lock.json"))) + assertEquals("https://github.com/andre487/MegaProxyConfig", lock.getString("repository")) + assertTrue(lock.getString("commit").matches(Regex("[a-f0-9]{40}"))) + val files = lock.getJSONObject("files") + assertEquals(setOf("android-v8.schema.json", "megaproxy-v8.schema.json", "android-v8.json", "browser-v8.json", "LICENSE"), files.keySet()) + files.keys().forEach { name -> + val hash = MessageDigest.getInstance("SHA-256").digest(ConfigSchemas.bytes(name)) + .joinToString("") { "%02x".format(it) } + assertEquals(name, files.getString(name), hash) + } + } + + @Test fun upstreamExamplesValidateAndImportWithExpectedPlatformNotice() { + for (name in listOf("android-v8.json", "browser-v8.json")) { + val text = String(ConfigSchemas.bytes(name)) + ConfigSchemas.assertValid(text) + val imported = ConfigTransfer.importJson(text) + assertEquals(ConfigImportNotice(browserFields = name == "browser-v8.json"), imported.notice) + assertFalse(imported.profiles.isEmpty()) + } + } + + @Test fun schemaTracksEveryExportedEnumAndVersion() { + for (name in listOf("android-v8.schema.json", "megaproxy-v8.schema.json")) { + val schema = JSONObject(String(ConfigSchemas.bytes(name))) + assertEquals(ConfigTransfer.SCHEMA_VERSION, schema.getJSONObject("properties").getJSONObject("version").getInt("const")) + val defs = schema.getJSONObject("\$defs") + fun check(definition: String, field: String, values: Set) { + val array = defs.getJSONObject(definition).getJSONObject("properties").getJSONObject(field).getJSONArray("enum") + assertEquals("$name: $definition.$field", values, (0 until array.length()).map { array.getString(it) }.toSet()) + } + check("proxy", "type", ProxyType.entries.map { it.name }.toSet()) + check("proxy", "sshProfile", SshProfile.entries.map { it.name }.toSet()) + check("ssh", "fingerprint", SshProfile.entries.map { it.name }.toSet()) + check("ssh", "authMode", SshAuthMode.entries.map { it.name }.toSet()) + check("tls", "fingerprint", TlsProfile.entries.map { it.name }.toSet()) + check("dns", "provider", DnsProvider.entries.map { it.name }.toSet()) + check("failover", "mode", FailoverMode.entries.map { it.name }.toSet()) + } + } + + @Test fun schemasRejectMalformedCanonicalConfiguration() { + val valid = String(ConfigSchemas.bytes("android-v8.json")) + for (change in listOf<(JSONObject) -> Unit>( + { it.put("version", 9) }, + { it.getJSONArray("profiles").getJSONObject(0).remove("id") }, + { it.getJSONArray("profiles").getJSONObject(0).getJSONObject("proxy").put("port", 65536) }, + { it.getJSONArray("profiles").getJSONObject(0).getJSONObject("proxy").put("type", "HTTP") }, + { it.getJSONArray("profiles").getJSONObject(0).getJSONObject("proxy").put("type", "SSH_JUMP").remove("jump") }, + )) { + assertTrue(ConfigSchemas.rejects(JSONObject(valid).also(change).toString())) + } + } + + @Test fun noticesDistinguishBrowserSettingsAndUnknownKeysAtEveryObjectDepth() { + val valid = String(ConfigSchemas.bytes("android-v8.json")) + for (change in listOf<(JSONObject) -> Unit>( + { it.put("future", true) }, + { it.getJSONArray("profiles").getJSONObject(0).put("future", true) }, + { it.getJSONArray("profiles").getJSONObject(0).getJSONObject("proxy").put("future", "secret") }, + { it.put("tls", JSONObject().put("future", true)) }, + { it.getJSONArray("profiles").getJSONObject(0).getJSONObject("proxy").put("jump", JSONObject().put("host", "jump.example").put("port", 22).put("future", true)) }, + )) { + val imported = ConfigTransfer.importJson(JSONObject(valid).also(change).toString()) + assertEquals(ConfigImportNotice(unknownFields = true), imported.notice) + } + val root = JSONObject(valid).put("browser", JSONObject().put("theme", "dark")) + assertEquals(ConfigImportNotice(browserFields = true), ConfigTransfer.importJson(root.toString()).notice) + root.getJSONObject("browser").put("routing", JSONObject().put("assignments", org.json.JSONArray().put( + JSONObject().put("domain", "example.com").put("profileId", "one").put("future", "secret")))) + assertEquals(ConfigImportNotice(true, true), ConfigTransfer.importJson(root.toString()).notice) + root.remove("browser") + root.getJSONArray("profiles").getJSONObject(0).put("browser", JSONObject().put("knockHost", "knock.example")) + assertEquals(ConfigImportNotice(browserFields = true), ConfigTransfer.importJson(root.toString()).notice) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt index 7dd991f..b7c6068 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt @@ -69,6 +69,24 @@ class ConfigStoreIntegrationTest { assertEquals(original, store.profile(original.id)) } + @Test fun actualExportsValidateForEveryTransportAndSecretChoice() { + val original = secretProfile() + for (type in ProxyType.entries) { + store.saveProfile(original.copy(config = original.config.copy(type = type, port = type.defaultPort, jumpPort = type.defaultPort))) + for (passwords in listOf(false, true)) for (keys in listOf(false, true)) { + val text = ConfigTransfer.exportJson(store, passwords, keys) + ConfigSchemas.assertValid(text) + val imported = ConfigTransfer.importJson(text) + assertEquals(ConfigImportNotice(), imported.notice) + assertEquals(type, imported.profiles.single().config.type) + assertEquals(passwords, text.contains("destination-secret")) + assertEquals(keys, text.contains("destination-private-key")) + assertEquals(passwords && type.hasJump, text.contains("jump-secret")) + assertEquals(keys && type.hasJump, text.contains("jump-private-key")) + } + } + } + @Test fun importWithoutSecretsPreservesAllExistingCredentials() { val original = secretProfile() val updated = original.copy(name = "Updated") diff --git a/config-schema/LICENSE b/config-schema/LICENSE new file mode 100644 index 0000000..957378b --- /dev/null +++ b/config-schema/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrey Prokopyuk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/config-schema/android-v8.json b/config-schema/android-v8.json new file mode 100644 index 0000000..cdd9cdd --- /dev/null +++ b/config-schema/android-v8.json @@ -0,0 +1,24 @@ +{ + "schema": "net.megaproxy487.config", + "version": 8, + "passwordsIncluded": false, + "privateKeysIncluded": false, + "activeProfileId": null, + "routing": { + "bypassLocalNetworks": true + }, + "profiles": [ + { + "id": "example-proxy", + "name": "Example", + "color": 5, + "countryCode": "", + "proxy": { + "type": "HTTPS", + "host": "proxy.example.com", + "port": 443, + "username": "demo" + } + } + ] +} diff --git a/config-schema/android-v8.schema.json b/config-schema/android-v8.schema.json new file mode 100644 index 0000000..98eece8 --- /dev/null +++ b/config-schema/android-v8.schema.json @@ -0,0 +1,409 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/andre487/MegaProxyConfig/main/schemas/android-v8.schema.json", + "title": "AndroidMegaProxy version 8 baseline", + "type": "object", + "properties": { + "schema": { + "type": "string", + "enum": [ + "net.megaproxy487.config", + "dev.megaproxy.config" + ] + }, + "version": { + "const": 8 + }, + "passwordsIncluded": { + "type": "boolean" + }, + "privateKeysIncluded": { + "type": "boolean" + }, + "activeProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "alwaysOnProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "diagnosticLogLimitMb": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "ssh": { + "$ref": "#/$defs/ssh" + }, + "failover": { + "$ref": "#/$defs/failover" + }, + "routing": { + "$ref": "#/$defs/routing" + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/$defs/profile" + }, + "maxItems": 1000, + "minItems": 1 + } + }, + "required": [ + "schema", + "version", + "profiles" + ], + "additionalProperties": true, + "$defs": { + "profile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "color": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "countryCode": { + "type": "string", + "maxLength": 2, + "pattern": "^([A-Z]{2})?$" + }, + "proxy": { + "$ref": "#/$defs/proxy" + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "dns": { + "$ref": "#/$defs/dns" + }, + "routing": { + "$ref": "#/$defs/routing" + } + }, + "required": [ + "id", + "proxy" + ], + "additionalProperties": true + }, + "proxy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "HTTPS", + "HTTPS_JUMP", + "SSH", + "SSH_JUMP" + ] + }, + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + }, + "sshProfile": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "jump": { + "$ref": "#/$defs/jump" + } + }, + "required": [ + "type", + "host", + "port" + ], + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "HTTPS_JUMP", + "SSH_JUMP" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "jump" + ], + "properties": { + "jump": { + "$ref": "#/$defs/jump" + } + } + } + } + ] + }, + "jump": { + "type": "object", + "properties": { + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "sameAuthentication": { + "type": "boolean", + "default": true + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + } + }, + "required": [ + "host", + "port" + ], + "additionalProperties": true + }, + "tls": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "CHROME_ANDROID", + "FIREFOX_ANDROID", + "EDGE_ANDROID", + "RANDOMIZED", + "SAMSUNG_INTERNET", + "YANDEX_BROWSER", + "CUSTOM" + ] + }, + "customJa3": { + "type": "string", + "maxLength": 8192 + } + }, + "required": [], + "additionalProperties": true + }, + "routing": { + "type": "object", + "properties": { + "bypassLocalNetworks": { + "type": "boolean", + "default": true + }, + "routeAllApps": { + "type": "boolean" + }, + "selectedPackages": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "pattern": "^[A-Za-z0-9_.]+$" + }, + "maxItems": 1000 + }, + "allowIpv6": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": true + }, + "dns": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "CLOUDFLARE", + "GOOGLE", + "QUAD9", + "YANDEX", + "YANDEX_SAFE", + "YANDEX_FAMILY", + "CUSTOM" + ] + }, + "customDohUrl": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [], + "additionalProperties": true + }, + "ssh": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "authMode": { + "type": "string", + "enum": [ + "AUTO", + "PASSWORD_ONLY", + "KEY_ONLY" + ] + }, + "keepaliveSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 3600 + }, + "maxChannels": { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + "rotationMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 1440 + }, + "rotationMb": { + "type": "integer", + "minimum": 0, + "maximum": 10240 + } + }, + "required": [], + "additionalProperties": true + }, + "failover": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "DISABLED", + "SELECTED", + "ALL" + ] + }, + "profileIds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "maxItems": 1000, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": true + } + } +} diff --git a/config-schema/browser-v8.json b/config-schema/browser-v8.json new file mode 100644 index 0000000..fdae849 --- /dev/null +++ b/config-schema/browser-v8.json @@ -0,0 +1,56 @@ +{ + "schema": "net.megaproxy487.config", + "version": 8, + "passwordsIncluded": false, + "privateKeysIncluded": false, + "activeProfileId": null, + "routing": { + "bypassLocalNetworks": true + }, + "profiles": [ + { + "id": "example-proxy", + "name": "Example", + "color": 5, + "countryCode": "", + "proxy": { + "type": "HTTPS", + "host": "proxy.example.com", + "port": 443, + "username": "demo" + }, + "browser": { + "knockHost": "knock.example.com", + "bypass": [ + "intranet.example.com" + ], + "authMode": "auto" + } + } + ], + "browser": { + "theme": "system", + "language": "auto", + "routing": { + "enabled": true, + "mode": "domains", + "domains": [ + "example.com", + "*.example.org" + ], + "sites": [ + "site.example.com" + ], + "subscriptions": { + "domainSources": [ + "youtube" + ], + "siteSources": [ + "discord" + ], + "autoUpdate": true, + "throughProxy": false + } + } + } +} diff --git a/config-schema/megaproxy-v8.schema.json b/config-schema/megaproxy-v8.schema.json new file mode 100644 index 0000000..cd07eb9 --- /dev/null +++ b/config-schema/megaproxy-v8.schema.json @@ -0,0 +1,640 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/andre487/MegaProxyConfig/main/schemas/megaproxy-v8.schema.json", + "title": "MegaProxy portable configuration, version 8", + "type": "object", + "properties": { + "schema": { + "type": "string", + "enum": [ + "net.megaproxy487.config", + "dev.megaproxy.config" + ] + }, + "version": { + "const": 8 + }, + "passwordsIncluded": { + "type": "boolean" + }, + "privateKeysIncluded": { + "type": "boolean" + }, + "activeProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "alwaysOnProfileId": { + "anyOf": [ + { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "diagnosticLogLimitMb": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "ssh": { + "$ref": "#/$defs/ssh" + }, + "failover": { + "$ref": "#/$defs/failover" + }, + "routing": { + "$ref": "#/$defs/routing" + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/$defs/profile" + }, + "maxItems": 1000, + "minItems": 1 + }, + "browser": { + "type": "object", + "properties": { + "theme": { + "type": "string", + "enum": [ + "system", + "light", + "dark" + ] + }, + "language": { + "type": "string", + "enum": [ + "auto", + "en", + "ru" + ] + }, + "routing": { + "$ref": "#/$defs/browserRouting" + }, + "webRTC": { + "type": "string", + "enum": [ + "browser", + "default", + "default_public_and_private_interfaces", + "default_public_interface_only", + "disable_non_proxied_udp", + "proxy_only", + "disabled" + ] + } + }, + "required": [], + "additionalProperties": true + } + }, + "required": [ + "schema", + "version", + "profiles" + ], + "additionalProperties": true, + "$defs": { + "profile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "color": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "countryCode": { + "type": "string", + "maxLength": 2, + "pattern": "^([A-Z]{2})?$" + }, + "proxy": { + "$ref": "#/$defs/proxy" + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "dns": { + "$ref": "#/$defs/dns" + }, + "routing": { + "$ref": "#/$defs/routing" + }, + "browser": { + "$ref": "#/$defs/browserProfile" + } + }, + "required": [ + "id", + "proxy" + ], + "additionalProperties": true + }, + "proxy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "HTTPS", + "HTTPS_JUMP", + "SSH", + "SSH_JUMP" + ] + }, + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + }, + "sshProfile": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "jump": { + "$ref": "#/$defs/jump" + } + }, + "required": [ + "type", + "host", + "port" + ], + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "HTTPS_JUMP", + "SSH_JUMP" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "jump" + ], + "properties": { + "jump": { + "$ref": "#/$defs/jump" + } + } + } + } + ] + }, + "jump": { + "type": "object", + "properties": { + "host": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "sameAuthentication": { + "type": "boolean", + "default": true + }, + "username": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^:\\r\\n]*$" + }, + "password": { + "type": "string", + "maxLength": 16384, + "pattern": "^[^\\r\\n]*$" + }, + "privateKey": { + "type": "string", + "maxLength": 65536 + }, + "trustedHostKey": { + "type": "string", + "maxLength": 256 + }, + "acceptAnyHostKey": { + "type": "boolean" + }, + "allowInvalidProxyCertificate": { + "type": "boolean" + } + }, + "required": [ + "host", + "port" + ], + "additionalProperties": true + }, + "tls": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "CHROME_ANDROID", + "FIREFOX_ANDROID", + "EDGE_ANDROID", + "RANDOMIZED", + "SAMSUNG_INTERNET", + "YANDEX_BROWSER", + "CUSTOM" + ] + }, + "customJa3": { + "type": "string", + "maxLength": 8192 + } + }, + "required": [], + "additionalProperties": true + }, + "routing": { + "type": "object", + "properties": { + "bypassLocalNetworks": { + "type": "boolean", + "default": true + }, + "routeAllApps": { + "type": "boolean" + }, + "selectedPackages": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "pattern": "^[A-Za-z0-9_.]+$" + }, + "maxItems": 1000 + }, + "allowIpv6": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": true + }, + "dns": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "CLOUDFLARE", + "GOOGLE", + "QUAD9", + "YANDEX", + "YANDEX_SAFE", + "YANDEX_FAMILY", + "CUSTOM" + ] + }, + "customDohUrl": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [], + "additionalProperties": true + }, + "ssh": { + "type": "object", + "properties": { + "fingerprint": { + "type": "string", + "enum": [ + "DEFAULT", + "OPENSSH_TERMUX", + "CONNECTBOT", + "JUICESSH", + "TERMIUS_ANDROID" + ] + }, + "authMode": { + "type": "string", + "enum": [ + "AUTO", + "PASSWORD_ONLY", + "KEY_ONLY" + ] + }, + "keepaliveSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 3600 + }, + "maxChannels": { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + "rotationMinutes": { + "type": "integer", + "minimum": 0, + "maximum": 1440 + }, + "rotationMb": { + "type": "integer", + "minimum": 0, + "maximum": 10240 + } + }, + "required": [], + "additionalProperties": true + }, + "failover": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "DISABLED", + "SELECTED", + "ALL" + ] + }, + "profileIds": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "maxItems": 1000, + "uniqueItems": true + } + }, + "required": [], + "additionalProperties": true + }, + "browserProfile": { + "type": "object", + "properties": { + "knockHost": { + "anyOf": [ + { + "const": "" + }, + { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + } + ] + }, + "bypass": { + "type": "array", + "items": { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "maxItems": 1000, + "uniqueItems": true + }, + "authMode": { + "type": "string", + "enum": [ + "auto", + "challenge" + ] + } + }, + "required": [], + "additionalProperties": true + }, + "browserRouting": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "domains", + "tabs" + ] + }, + "domains": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^(?:[^\\s*/:@?#\\\\]+|[a-zA-Z0-9*](?:[a-zA-Z0-9.*-]*[a-zA-Z0-9*])?|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "maxItems": 1000, + "uniqueItems": true + }, + "sites": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^(?:[^\\s*/:@?#\\\\]+|[a-zA-Z0-9*](?:[a-zA-Z0-9.*-]*[a-zA-Z0-9*])?|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + }, + "maxItems": 1000, + "uniqueItems": true + }, + "subscriptions": { + "$ref": "#/$defs/browserSubscriptions" + }, + "assignments": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "required": [ + "domain", + "profileId" + ], + "properties": { + "domain": { + "type": "string", + "minLength": 1, + "maxLength": 253, + "pattern": "^[^\\s*/:@?#\\\\]+$" + }, + "profileId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "includeSubdomains": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": true + } + } + }, + "required": [], + "additionalProperties": true + }, + "browserSubscriptions": { + "type": "object", + "properties": { + "domainSources": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "russia_inside", + "russia_outside", + "ukraine_inside", + "geoblock", + "block", + "porn", + "news", + "anime", + "hodca", + "youtube", + "discord", + "meta", + "twitter", + "telegram", + "tiktok", + "hdrezka", + "google_ai", + "google_play", + "roblox", + "cloudflare", + "cloudfront", + "digitalocean", + "hetzner", + "ovh" + ] + }, + "maxItems": 24, + "uniqueItems": true + }, + "siteSources": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "russia_inside", + "russia_outside", + "ukraine_inside", + "geoblock", + "block", + "porn", + "news", + "anime", + "hodca", + "youtube", + "discord", + "meta", + "twitter", + "telegram", + "tiktok", + "hdrezka", + "google_ai", + "google_play", + "roblox", + "cloudflare", + "cloudfront", + "digitalocean", + "hetzner", + "ovh" + ] + }, + "maxItems": 24, + "uniqueItems": true + }, + "autoUpdate": { + "type": "boolean" + }, + "throughProxy": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": true + } + } +} diff --git a/config-schema/schema-lock.json b/config-schema/schema-lock.json new file mode 100644 index 0000000..8cfcc41 --- /dev/null +++ b/config-schema/schema-lock.json @@ -0,0 +1,11 @@ +{ + "repository": "https://github.com/andre487/MegaProxyConfig", + "commit": "9427cf235d6c112cd97ae82dc34d5d9b02aa42b7", + "files": { + "android-v8.schema.json": "65d2718871c89b930142122301cad194cb4856662d416493aa1e734c2c80b221", + "megaproxy-v8.schema.json": "a5c83d0ffa44642250cae9c97816c99ae0781ad4f63dc30992b18af344a7a14b", + "android-v8.json": "398ac24bb1200f8ab9390f21d7c7a5d44a81c5ffe43f0c21924e18231c6e7d90", + "browser-v8.json": "aebbedbddd47edff5adea3b6e3e4cdda25a8be29fe7052f05b420927d77cd9ed", + "LICENSE": "6c13885cba42ec32ee2f3720283ee61256643bd4a12fa736b4b6d9823e6e4ccc" + } +} diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 75dace2..94a2dd1 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -37,6 +37,7 @@ That command lists the lanes available in the checked-out version of the project | Command | Result | | --- | --- | +| `bundle exec fastlane android renew_config_schema` | Downloads and pins the current MegaProxyConfig schemas and examples; use `ref:FULL_SHA` for a reviewed revision. | | `bundle exec fastlane android python_format` | Formats Python scripts with pinned Black and isort. | | `bundle exec fastlane android python_tests` | Runs Python unit tests. | | `bundle exec fastlane android python_checks` | Checks Python formatting/import order and runs unit tests. | @@ -285,3 +286,13 @@ JUnit XML, instrumentation output and Logcat are saved under `test-results/andro separate seven-day artifacts and links them in each job summary. Pure logic, most Compose interactions and the full transport failure matrix remain in JVM/Robolectric/Go tests. These emulators do not certify OEM behavior, physical network handover or all Always-on modes. + +## Portable configuration schemas + +`renew_config_schema` uses authenticated `gh` and Python's standard library to resolve one +MegaProxyConfig commit, download both draft 2020-12 schemas, examples and LICENSE, and record +SHA-256 checksums in `config-schema/schema-lock.json`. Review and commit the whole directory. +CI reads only these committed files; it never fetches a moving upstream branch. +`android_checks` validates real exports against both schemas and checks examples, enums and +checksums. The JVM validator is a test dependency only. Import remains compatible with versions +1–8; it uses the bundled shared schema to recognize fields, without rejecting permissive legacy input. diff --git a/docs/reviews/config-schema.md b/docs/reviews/config-schema.md new file mode 100644 index 0000000..5fc5d34 --- /dev/null +++ b/docs/reviews/config-schema.md @@ -0,0 +1,39 @@ +# Configuration schema compatibility audit — 2026-10-06 + +Compared Android's `ConfigTransfer`, model enums and merge flow on current `main` +with MegaProxyConfig commit `9427cf235d6c112cd97ae82dc34d5d9b02aa42b7` +and BrowserMegaProxy's schema renewal pattern. + +The Android v8 baseline is current for exported keys, proxy transports, TLS/SSH/DNS +presets, authentication modes, failover modes and secret inclusion flags. No Android +schema additions or version change are needed. Tests now validate actual ConfigStore +exports for all four transports and all password/private-key inclusion combinations +against both upstream schemas; enum and checksum checks detect contract drift. + +The schemas describe canonical v8 documents, not every input accepted by Android. +Import intentionally accepts versions 1–8, omitted legacy fields, default ports and +future enum values, and clamps some numeric settings. Strict schema validation must +not replace that parser. IPv6 proxy literals are schema-valid but are rejected by +Android's existing decoder; the upstream documentation already records this limit. +Schema validation also cannot enforce unique profile IDs or ID-reference integrity; +consumer merge checks remain responsible. Blank unfinished local profiles can be +exported today but are not canonical usable profiles (their empty host fails the +schema and import). The export matrix covers configured profiles, not unfinished drafts. + +Browser settings are optional shared-schema additions and are discarded by Android. +The upstream import-warning prose currently combines unsupported and undocumented +fields into one general message and forbids naming platforms. This change follows +the requested Android behavior instead: the existing import result includes at most +one browser-settings notice and one undocumented-fields notice. Neither reveals key +names or values, adds another confirmation, nor preserves discarded fields. + +The upstream browser schema is behind BrowserMegaProxy's local overrides for dynamic +subscription source IDs and `browser.routing.strategy`. Android does not apply these +settings: documented browser fields get the browser notice, and a `strategy` key +not yet present upstream also gets the undocumented-fields notice. Android vendors +the exact upstream files without silently adopting another consumer's local overrides. + +Renew with `bundle exec fastlane android renew_config_schema [ref:FULL_SHA]`, review +the schema/example changes and lock file together, then run `android_checks`. +Tests use committed files offline. The draft 2020-12 validator is a JVM test dependency; +the app uses only org.json and the bundled shared schema for field recognition. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index c415a9f..f980b89 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -38,6 +38,7 @@ bundle exec fastlane lanes | Команда | Результат | | --- | --- | +| `bundle exec fastlane android renew_config_schema` | Загружает и фиксирует схемы и примеры MegaProxyConfig; `ref:FULL_SHA` выбирает проверенную ревизию. | | `bundle exec fastlane android python_format` | Форматирует Python зафиксированными Black и isort. | | `bundle exec fastlane android python_tests` | Запускает Python unit-тесты. | | `bundle exec fastlane android python_checks` | Проверяет форматирование, порядок импортов и Python-тесты. | @@ -290,3 +291,13 @@ JUnit XML, instrumentation output и Logcat сохраняются в `test-resu отдельные артефакты на семь дней со ссылками в job summary. Чистая логика, большинство Compose взаимодействий и полная матрица сетевых ошибок остаются в JVM/Robolectric/Go-тестах. Эти эмуляторы не подтверждают поведение OEM, физическое переключение сетей и все Always-on режимы. + +## Схемы переносимой конфигурации + +`renew_config_schema` использует авторизованный `gh` и стандартную библиотеку Python: определяет +один commit MegaProxyConfig, загружает обе схемы draft 2020-12, примеры и LICENSE, сохраняет +SHA-256 в `config-schema/schema-lock.json`. Проверяйте diff и коммитьте каталог целиком. +CI читает только зафиксированные файлы и не скачивает актуальную upstream-ветку. +`android_checks` проверяет реальные экспорты по обеим схемам, примеры, перечисления и контрольные +суммы. JVM-валидатор подключён только в тестах. Импорт сохраняет совместимость с версиями 1–8: +встроенная общая схема служит для распознавания полей, без строгой проверки старого формата. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index eb7ad6a..5d2992f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,6 +7,11 @@ ruby_version("3.4.0") project_root = File.expand_path("..", __dir__) platform :android do + desc "Explicitly update pinned MegaProxyConfig schemas and examples" + lane :renew_config_schema do |options| + sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "scripts/renew_config_schema.py"), "--ref", options.fetch(:ref, "main").to_s) + end + desc "Run the native Go tests with the race detector" lane :native_tests do report = File.join(project_root, "test-results/native/unit.xml") diff --git a/fastlane/README.md b/fastlane/README.md index 1835d7f..20aac08 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -5,6 +5,7 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub | Command | Purpose | | --- | --- | +| `bundle exec fastlane android renew_config_schema` | Explicitly update pinned config schemas/examples; optional `ref:FULL_SHA` | | `bundle exec fastlane android python_format` | Format Python with Black and isort | | `bundle exec fastlane android python_tests` | Run Python unit tests | | `bundle exec fastlane android python_checks` | Check Python style and run unit tests | diff --git a/scripts/renew_config_schema.py b/scripts/renew_config_schema.py new file mode 100644 index 0000000..47bcf8d --- /dev/null +++ b/scripts/renew_config_schema.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Vendor reviewed MegaProxyConfig schemas from an immutable GitHub revision.""" + +import argparse +import hashlib +import json +import re +import subprocess +from pathlib import Path + +REPOSITORY = "andre487/MegaProxyConfig" +FILES = ( + "schemas/android-v8.schema.json", + "schemas/megaproxy-v8.schema.json", + "examples/android-v8.json", + "examples/browser-v8.json", + "LICENSE", +) +DIRECTORY = Path(__file__).resolve().parents[1] / "config-schema" + + +def github(path, raw=False): + args = ["gh", "api", f"repos/{REPOSITORY}/{path}"] + if raw: + args += ["-H", "Accept: application/vnd.github.raw+json"] + return subprocess.run(args, check=True, capture_output=True, timeout=60).stdout + + +def renew(ref="main", directory=DIRECTORY): + commit = ( + ref + if re.fullmatch(r"[a-f0-9]{40}", ref) + else json.loads(github(f"commits/{ref}"))["sha"] + ) + if not re.fullmatch(r"[a-f0-9]{40}", commit): + raise ValueError("GitHub returned an invalid commit") + contents = { + Path(path).name: github(f"contents/{path}?ref={commit}", raw=True) + for path in FILES + } + # Fetch and inspect everything before replacing any committed file. + for name, content in contents.items(): + if name.endswith(".json"): + document = json.loads(content) + if name.endswith(".schema.json"): + if ( + document.get("$schema") + != "https://json-schema.org/draft/2020-12/schema" + or document.get("properties", {}).get("version", {}).get("const") + != 8 + ): + raise ValueError( + "Unexpected schema version; update consumers explicitly" + ) + lock = { + "repository": f"https://github.com/{REPOSITORY}", + "commit": commit, + "files": { + name: hashlib.sha256(content).hexdigest() + for name, content in contents.items() + }, + } + contents["schema-lock.json"] = (json.dumps(lock, indent=2) + "\n").encode() + directory.mkdir(parents=True, exist_ok=True) + for name, content in contents.items(): + temporary = directory / f"{name}.tmp" + temporary.write_bytes(content) + temporary.replace(directory / name) + print(f"Updated MegaProxyConfig to {commit}. Review and commit config-schema/.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ref", default="main") + args = parser.parse_args() + if not re.fullmatch(r"[A-Za-z0-9._/-]+", args.ref): + parser.error("Expected a commit or branch name") + renew(args.ref) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_renew_config_schema.py b/scripts/tests/test_renew_config_schema.py new file mode 100644 index 0000000..5343cbf --- /dev/null +++ b/scripts/tests/test_renew_config_schema.py @@ -0,0 +1,63 @@ +import hashlib +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).parents[1])) +import renew_config_schema as m + + +class RenewConfigSchemaTest(unittest.TestCase): + def test_pins_one_commit_and_records_exact_bytes(self): + calls = [] + + def github(path, raw=False): + calls.append(path) + if not raw: + return json.dumps({"sha": "a" * 40}).encode() + if ".schema.json" in path: + return json.dumps( + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {"version": {"const": 8}}, + } + ).encode() + return b"{}" if ".json" in path else b"license\n" + + with tempfile.TemporaryDirectory() as root, patch.object(m, "github", github): + directory = Path(root) + m.renew("main", directory) + lock = json.loads((directory / "schema-lock.json").read_text()) + self.assertEqual("a" * 40, lock["commit"]) + for name, checksum in lock["files"].items(): + self.assertEqual( + hashlib.sha256((directory / name).read_bytes()).hexdigest(), + checksum, + ) + self.assertEqual("commits/main", calls[0]) + self.assertTrue( + all(path.endswith("?ref=" + "a" * 40) for path in calls[1:]) + ) + + def test_failure_or_new_version_does_not_replace_existing_files(self): + for response in ( + RuntimeError("download failed"), + b'{"properties":{"version":{"const":9}}}', + ): + with tempfile.TemporaryDirectory() as root: + directory = Path(root) + target = directory / "android-v8.schema.json" + target.write_text("existing") + with patch.object( + m, + "github", + side_effect=response if isinstance(response, Exception) else None, + return_value=response, + ): + with self.assertRaises((RuntimeError, ValueError)): + m.renew("b" * 40, directory) + self.assertEqual("existing", target.read_text()) + self.assertEqual([target], list(directory.iterdir())) From d5e4acd92561bddc74043a3995962d9cb1074c52 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 21:58:50 +0300 Subject: [PATCH 2/4] Retry emulator startup once before the test runner begins --- .github/workflows/android-device.yml | 20 ++++++++++++++++++-- AGENTS.md | 3 ++- docs/en/fastlane.md | 4 +++- docs/ru/fastlane.md | 6 ++++-- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 6edadac..9edcfd3 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -85,8 +85,10 @@ jobs: - name: Build debug and instrumentation APKs run: bundle exec fastlane android device_test_build - name: Run real Android tests + id: device_tests + continue-on-error: true uses: reactivecircus/android-emulator-runner@v2.38.0 - with: + with: &device_test_options api-level: ${{ inputs.api }} target: google_apis arch: x86_64 @@ -95,7 +97,21 @@ jobs: force-avd-creation: false emulator-options: -accel on -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none disable-animations: true - script: bundle exec fastlane android device_tests api:${{ inputs.api }} + script: | + mkdir -p test-results/android-api${{ inputs.api }} + touch test-results/android-api${{ inputs.api }}/runner-started + bundle exec fastlane android device_tests api:${{ inputs.api }} + - name: Retry emulator startup before tests only + id: device_retry + if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' && hashFiles(format('test-results/android-api{0}/runner-started', inputs.api)) == '' }} + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2.38.0 + with: *device_test_options + - name: Require successful device tests + if: ${{ !cancelled() && steps.device_tests.outcome != 'success' && steps.device_retry.outcome != 'success' }} + run: | + echo 'Device tests failed, or emulator startup failed on both attempts.' >&2 + exit 1 - name: Upload Android evidence if: always() id: evidence diff --git a/AGENTS.md b/AGENTS.md index 781d93f..c4ec462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,4 +134,5 @@ branch names, credentials, signing material, or other secrets. continuing on the merged branch. - Keep required CI deterministic. If a check depends on unreliable hosted-runner capabilities, replace it with JVM coverage where practical or move it to purpose-built infrastructure rather - than normalizing repeated reruns. + than normalizing repeated reruns. Emulator startup may retry once before the test runner starts; + never retry runner/test failures or allow two failed startup attempts to pass. diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 94a2dd1..a74c4af 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -251,7 +251,9 @@ Release branches `release/vX.Y.Z` always run all CI suites; skipped jobs cannot CI runs API 26 and API 35 as independent jobs with independent successful-history baselines. Both use Ubuntu 24.04, explicit KVM permissions, mandatory hardware acceleration and clean AVD snapshots keyed by API, emulator/system-image versions and workflow content. No software fallback -or automatic test retry is used. Main pushes and full reruns execute both scenarios. +or automatic test retry is used. Emulator startup gets one retry only if the test runner has +not started; a runner-started marker prevents retrying any test/setup failure inside the runner. +Both startup attempts failing keeps the required check red. Main pushes and full reruns execute both scenarios. Locally, start a **disposable** Google APIs emulator with English system UI and set `ANDROID_SERIAL=emulator-5554` (use its actual serial), then run: diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index f980b89..4c161e3 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -255,8 +255,10 @@ Release-ветки `release/vX.Y.Z` всегда запускают все на CI запускает API 26 и API 35 отдельными заданиями с независимой историей успешных проверок. Оба используют Ubuntu 24.04, явные разрешения KVM, обязательное аппаратное ускорение и чистые снимки AVD. Ключ кеша включает API, версии эмулятора/system image и содержимое workflow. -Программного fallback и автоматических повторов тестов нет. Push в main и полный rerun -выполняют оба сценария. +Программного fallback и автоматических повторов тестов нет. Запуск эмулятора повторяется +один раз, только если тестовый runner ещё не стартовал. Маркер runner-started запрещает +повторять ошибки тестов и подготовки внутри runner. Две ошибки запуска оставляют required check +красным. Push в main и полный rerun выполняют оба сценария. Локально запустите **одноразовый** Google APIs эмулятор с английским системным интерфейсом, задайте `ANDROID_SERIAL=emulator-5554` (фактический serial своего эмулятора) и выполните: From 1c4cdc32d48cb35a98b68fca93b426bd7fdf8cd2 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 22:08:53 +0300 Subject: [PATCH 3/4] Dismiss Gboard overlays before entering the document filename --- .../java/net/megaproxy487/DocumentsDeviceTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt index 80b7d2c..2d87559 100644 --- a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt @@ -65,6 +65,12 @@ class DocumentsDeviceTest : DeviceTestBase() { appNode(By.text(text(R.string.passwords_omitted_message))) systemNode(By.text(text(R.string.export_action))).click() downloads() + // A fresh API 26 Gboard tutorial overlays the filename and hides it from accessibility. + device.waitForIdle() + if (device.hasObject(By.pkg("com.google.android.inputmethod.latin"))) { + device.pressBack() + device.waitForIdle() + } systemNode(By.clazz("android.widget.EditText")).text = filename systemNode(By.res("android:id/button1")).click() appNode(By.text(text(R.string.configuration_exported))) From 22ec1f9a99a8ceff762dbc05904ecc852f74d295 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 22:11:50 +0300 Subject: [PATCH 4/4] Retry transient Android SDK installation failures once --- .github/workflows/android-device.yml | 8 ++++++-- AGENTS.md | 3 ++- docs/en/fastlane.md | 1 + docs/ru/fastlane.md | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 9edcfd3..31a375d 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -48,7 +48,11 @@ jobs: env: DEVICE_API: ${{ inputs.api }} run: | - sdkmanager 'emulator' 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0' 'ndk;29.0.14206865' "system-images;android-$DEVICE_API;google_apis;x86_64" + packages=('emulator' 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0' 'ndk;29.0.14206865' "system-images;android-$DEVICE_API;google_apis;x86_64") + if ! sdkmanager "${packages[@]}"; then + echo 'SDK installation failed; retrying once.' >&2 + sdkmanager "${packages[@]}" + fi echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" "$ANDROID_HOME/emulator/emulator" -accel-check snapshot_hash=$(cat "$ANDROID_HOME/emulator/source.properties" "$ANDROID_HOME/system-images/android-$DEVICE_API/google_apis/x86_64/source.properties" | sha256sum | cut -d ' ' -f 1) @@ -108,7 +112,7 @@ jobs: uses: reactivecircus/android-emulator-runner@v2.38.0 with: *device_test_options - name: Require successful device tests - if: ${{ !cancelled() && steps.device_tests.outcome != 'success' && steps.device_retry.outcome != 'success' }} + if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' && steps.device_retry.outcome != 'success' }} run: | echo 'Device tests failed, or emulator startup failed on both attempts.' >&2 exit 1 diff --git a/AGENTS.md b/AGENTS.md index c4ec462..def5d56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,4 +135,5 @@ branch names, credentials, signing material, or other secrets. - Keep required CI deterministic. If a check depends on unreliable hosted-runner capabilities, replace it with JVM coverage where practical or move it to purpose-built infrastructure rather than normalizing repeated reruns. Emulator startup may retry once before the test runner starts; - never retry runner/test failures or allow two failed startup attempts to pass. + never retry runner/test failures or allow two failed startup attempts to pass. SDK installation + may retry once for transient download failures. diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index a74c4af..8fc9ad8 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -253,6 +253,7 @@ Both use Ubuntu 24.04, explicit KVM permissions, mandatory hardware acceleration snapshots keyed by API, emulator/system-image versions and workflow content. No software fallback or automatic test retry is used. Emulator startup gets one retry only if the test runner has not started; a runner-started marker prevents retrying any test/setup failure inside the runner. +SDK installation also gets one retry for transient download failures. Both startup attempts failing keeps the required check red. Main pushes and full reruns execute both scenarios. Locally, start a **disposable** Google APIs emulator with English system UI and set diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 4c161e3..a963653 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -258,7 +258,7 @@ CI запускает API 26 и API 35 отдельными заданиями Программного fallback и автоматических повторов тестов нет. Запуск эмулятора повторяется один раз, только если тестовый runner ещё не стартовал. Маркер runner-started запрещает повторять ошибки тестов и подготовки внутри runner. Две ошибки запуска оставляют required check -красным. Push в main и полный rerun выполняют оба сценария. +красным. Установка SDK также получает один повтор при временной ошибке загрузки. Push в main и полный rerun выполняют оба сценария. Локально запустите **одноразовый** Google APIs эмулятор с английским системным интерфейсом, задайте `ANDROID_SERIAL=emulator-5554` (фактический serial своего эмулятора) и выполните: