Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/android-device.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -85,8 +89,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
Expand All @@ -95,7 +101,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 == 'failure' && 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
Expand Down
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,4 +134,6 @@ 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. SDK installation
may retry once for transient download failures.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/net/megaproxy487/ProfilesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ data class PortableConfiguration(
val globalConnectionSettings: GlobalConnectionSettings? = null,
val skippedProfiles: Int = 0,
val secretPresence: Map<String, ProfileSecretPresence> = emptyMap(),
val notice: ConfigImportNotice = ConfigImportNotice(),
)

data class ProfileSecretPresence(
Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
<string name="config_import_skipped">Пропущено некорректных профилей: %1$d.</string>
<string name="config_import_missing_passwords">Профилей без пароля: %1$d.</string>
<string name="config_import_always_on_reconnect">Переподключите VPN, чтобы применить импортированный профиль постоянного VPN.</string>
<string name="config_import_browser_fields">Конфигурация содержит настройки для браузеров. На Android они игнорируются.</string>
<string name="config_import_unknown_fields">Конфигурация содержит поля, неизвестные спецификации MegaProxy. Они игнорируются.</string>
<string name="config_import_removed">Удалено профилей: %1$d.</string>
<string name="remove_missing_profiles_title">Удалить профили, отсутствующие в конфигурации?</string>
<string name="remove_missing_profiles_message">Эти локальные профили отсутствуют в импортированном файле. Изначально ни один профиль не выбран — отметьте только те, которые действительно нужно удалить.</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
<string name="config_import_skipped">Skipped %1$d invalid profile(s).</string>
<string name="config_import_missing_passwords">%1$d profile(s) have no password.</string>
<string name="config_import_always_on_reconnect">Reconnect the VPN to apply the imported Always-on profile selection.</string>
<string name="config_import_browser_fields">The configuration contains browser settings. They are ignored on Android.</string>
<string name="config_import_unknown_fields">The configuration contains fields unknown to the MegaProxy specification. They are ignored.</string>
<string name="config_import_removed">Removed %1$d profile(s).</string>
<string name="remove_missing_profiles_title">Remove profiles missing from the configuration?</string>
<string name="remove_missing_profiles_message">These local profiles were not present in the imported file. None are selected by default; select only profiles you really want to remove.</string>
Expand Down
24 changes: 24 additions & 0 deletions app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading