From 7d7af70b892c1cd43dd69b69fbde539aec219837 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Thu, 3 Sep 2026 20:43:56 +0300 Subject: [PATCH] Release 0.0.11 with stable config imports --- .fdroid.yml | 28 +- README.md | 9 + app/build.gradle.kts | 4 +- .../net/megaproxy487/ProxySettingsActivity.kt | 404 +++++++++++++++--- .../java/net/megaproxy487/data/ConfigStore.kt | 127 ++++-- .../net/megaproxy487/data/ConfigTransfer.kt | 36 +- .../net/megaproxy487/data/FoxyProxyParser.kt | 2 +- app/src/main/res/values-ru/strings.xml | 51 +++ app/src/main/res/values/strings.xml | 53 ++- .../megaproxy487/data/ConfigTransferTest.kt | 44 +- .../android/en-US/changelogs/11000.txt | 3 + .../android/ru-RU/changelogs/11000.txt | 3 + 12 files changed, 662 insertions(+), 102 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/11000.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/11000.txt diff --git a/.fdroid.yml b/.fdroid.yml index 0d04af6..e6bd0b6 100644 --- a/.fdroid.yml +++ b/.fdroid.yml @@ -12,9 +12,9 @@ RepoType: git Repo: https://github.com/andre487/AndroidMegaProxy.git Builds: - - versionName: 0.0.10 - versionCode: 10001 - commit: v0.0.10 + - versionName: 0.0.11 + versionCode: 11001 + commit: v0.0.11 subdir: app ndk: 29.0.14206865 sudo: @@ -39,9 +39,9 @@ Builds: - export PATH=$GOROOT/bin:$GOPATH/bin:$PATH - MEGAPROXY_ANDROID_ABI=armeabi-v7a ../scripts/build-fdroid-native.sh - - versionName: 0.0.10 - versionCode: 10002 - commit: v0.0.10 + - versionName: 0.0.11 + versionCode: 11002 + commit: v0.0.11 subdir: app ndk: 29.0.14206865 sudo: @@ -66,9 +66,9 @@ Builds: - export PATH=$GOROOT/bin:$GOPATH/bin:$PATH - MEGAPROXY_ANDROID_ABI=arm64-v8a ../scripts/build-fdroid-native.sh - - versionName: 0.0.10 - versionCode: 10003 - commit: v0.0.10 + - versionName: 0.0.11 + versionCode: 11003 + commit: v0.0.11 subdir: app ndk: 29.0.14206865 sudo: @@ -93,9 +93,9 @@ Builds: - export PATH=$GOROOT/bin:$GOPATH/bin:$PATH - MEGAPROXY_ANDROID_ABI=x86 ../scripts/build-fdroid-native.sh - - versionName: 0.0.10 - versionCode: 10004 - commit: v0.0.10 + - versionName: 0.0.11 + versionCode: 11004 + commit: v0.0.11 subdir: app ndk: 29.0.14206865 sudo: @@ -129,5 +129,5 @@ VercodeOperation: AutoUpdateMode: Version UpdateCheckMode: Tags UpdateCheckData: app/build.gradle.kts|versionCodeBase = (\d+)|.|versionName = "([^"]+)" -CurrentVersion: 0.0.10 -CurrentVersionCode: 10004 +CurrentVersion: 0.0.11 +CurrentVersionCode: 11004 diff --git a/README.md b/README.md index 61661c8..8d573f2 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,15 @@ After installation: Server configurations and setup instructions are maintained separately in [MegaProxyServer](https://github.com/andre487/MegaProxyServer). +### Generated configuration imports + +External generators can produce MegaProxy JSON files using schema `net.megaproxy487.config`, version +7. Every profile must have a stable, generator-controlled `id`. Reimporting a file updates profiles +with matching IDs and adds only new IDs; it does not create duplicates. Omitted password and SSH +private-key fields preserve credentials already stored on the device, while explicit empty values +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. + ## Current limitations - Only TCP application traffic is forwarded. General SOCKS5 UDP and QUIC forwarding are not diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13b43cd..aa92f39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,7 @@ val releaseKeystorePath = providers.environmentVariable("MEGAPROXY_KEYSTORE_PATH val releaseKeystorePassword = providers.environmentVariable("MEGAPROXY_KEYSTORE_PASSWORD").orNull val releaseKeyAlias = providers.environmentVariable("MEGAPROXY_KEY_ALIAS").orNull val releaseKeyPassword = providers.environmentVariable("MEGAPROXY_KEY_PASSWORD").orNull -val versionCodeBase = 10 +val versionCodeBase = 11 val versionVariant = providers.gradleProperty("megaproxyVersionVariant") .orElse("universal") .get() @@ -43,7 +43,7 @@ android { // universal code below the ABI variants lets app stores prefer the // smaller compatible APK when both are available. versionCode = versionCodeBase * 1000 + versionVariantCode - versionName = "0.0.10" + versionName = "0.0.11" buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" if (versionVariant != "universal") { diff --git a/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt b/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt index 46da028..0040240 100644 --- a/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt +++ b/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.WindowInsets @@ -103,6 +104,102 @@ class ProfilesActivity : LocalizedActivity() { } } +private data class MissingProfilesReview( + val profiles: List, + val importSummary: String, +) + +private data class ProfileOptionsReview( + val profile: ProxyProfile, + val changedGroups: List, +) + +private data class ImportOptionsReview( + val configuration: PortableConfiguration, + val existingProfiles: Map, + val profiles: List, + val globalChanged: Boolean, + val currentDiagnosticLogLimitMb: Int, +) + +private fun profileOptionGroups(current: ProxyProfile, imported: ProxyProfile): List = buildList { + if (current.config.type != imported.config.type || current.config.port != imported.config.port || + current.config.jumpPort != imported.config.jumpPort || + current.config.sameJumpAuthentication != imported.config.sameJumpAuthentication + ) add(R.string.import_option_connection) + if (current.config.allowInvalidProxyCertificate != imported.config.allowInvalidProxyCertificate || + current.config.profile != imported.config.profile || current.config.customJa3 != imported.config.customJa3 || + current.config.sshProfile != imported.config.sshProfile || + current.config.trustedHostKey != imported.config.trustedHostKey || + current.config.acceptAnyHostKey != imported.config.acceptAnyHostKey || + current.config.jumpTrustedHostKey != imported.config.jumpTrustedHostKey || + current.config.jumpAcceptAnyHostKey != imported.config.jumpAcceptAnyHostKey + ) add(R.string.import_option_security) + if (current.config.dnsProvider != imported.config.dnsProvider || + current.config.customDohUrl != imported.config.customDohUrl + ) add(R.string.import_option_dns) + if (current.config.selectedPackages != imported.config.selectedPackages || + current.config.allowIpv6 != imported.config.allowIpv6 || + current.config.routeAllApps != imported.config.routeAllApps || + current.config.bypassLocalNetworks != imported.config.bypassLocalNetworks + ) add(R.string.import_option_routing) +} + +private fun keepLocalProfileOptions(current: ProxyProfile, imported: ProxyProfile): ProxyProfile = current.copy( + id = imported.id, + name = imported.name, + colorIndex = imported.colorIndex, + countryCode = imported.countryCode, + config = current.config.copy( + host = imported.config.host, + username = imported.config.username, + password = imported.config.password, + privateKey = imported.config.privateKey, + jumpHost = imported.config.jumpHost, + jumpUsername = imported.config.jumpUsername, + jumpPassword = imported.config.jumpPassword, + jumpPrivateKey = imported.config.jumpPrivateKey, + ), +) + +private fun importOptionKey(profileId: String, group: Int) = "$profileId:$group" + +private fun applySelectedProfileOptions( + current: ProxyProfile, + imported: ProxyProfile, + selected: Set, +): ProxyProfile { + var result = keepLocalProfileOptions(current, imported) + fun selected(group: Int) = importOptionKey(imported.id, group) in selected + if (selected(R.string.import_option_connection)) result = result.copy(config = result.config.copy( + type = imported.config.type, + port = imported.config.port, + jumpPort = imported.config.jumpPort, + sameJumpAuthentication = imported.config.sameJumpAuthentication, + )) + if (selected(R.string.import_option_security)) result = result.copy(config = result.config.copy( + allowInvalidProxyCertificate = imported.config.allowInvalidProxyCertificate, + profile = imported.config.profile, + customJa3 = imported.config.customJa3, + sshProfile = imported.config.sshProfile, + trustedHostKey = imported.config.trustedHostKey, + acceptAnyHostKey = imported.config.acceptAnyHostKey, + jumpTrustedHostKey = imported.config.jumpTrustedHostKey, + jumpAcceptAnyHostKey = imported.config.jumpAcceptAnyHostKey, + )) + if (selected(R.string.import_option_dns)) result = result.copy(config = result.config.copy( + dnsProvider = imported.config.dnsProvider, + customDohUrl = imported.config.customDohUrl, + )) + if (selected(R.string.import_option_routing)) result = result.copy(config = result.config.copy( + selectedPackages = imported.config.selectedPackages, + allowIpv6 = imported.config.allowIpv6, + routeAllApps = imported.config.routeAllApps, + bypassLocalNetworks = imported.config.bypassLocalNetworks, + )) + return result +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SettingsScreen(activity: Activity) { @@ -122,6 +219,11 @@ private fun SettingsScreen(activity: Activity) { var pendingExportContent by remember { mutableStateOf("") } var transferMessage by remember { mutableStateOf(null) } var pendingUnsafeImport by remember { mutableStateOf(null) } + var missingProfilesReview by remember { mutableStateOf(null) } + val selectedMissingProfileIds = remember { mutableStateListOf() } + var importOptionsReview by remember { mutableStateOf(null) } + val selectedImportOptionKeys = remember { mutableStateListOf() } + var applyImportedGlobalOptions by remember { mutableStateOf(false) } val lifecycleOwner = LocalLifecycleOwner.current val listState = rememberLazyListState() var draggedProfileId by remember { mutableStateOf(null) } @@ -158,29 +260,78 @@ private fun SettingsScreen(activity: Activity) { ?: error("Could not open the export file") } } - result.onSuccess { transferMessage = "Configuration exported successfully." } - .onFailure { importError = it.message ?: "Could not export the configuration" } + result.onSuccess { transferMessage = activity.getString(R.string.configuration_exported) } + .onFailure { + importError = activity.getString( + R.string.configuration_export_failed, + it.message ?: activity.getString(R.string.unknown_error), + ) + } } } fun applyJsonImport(configuration: PortableConfiguration) { scope.launch { - val added = withContext(ConfigIoDispatcher) { + val result = withContext(ConfigIoDispatcher) { store.importConfiguration(configuration).also { if (ProxyVpnService.isRunning) store.markPendingReconnect() PersistentDiagnosticLog.setLimitMb(store.diagnosticLogLimitMb()) } } refresh() - val missingPasswords = added.count { it.config.password.isEmpty() } - transferMessage = buildString { - append("Imported ${added.size} profile(s) from JSON.") - if (configuration.skippedProfiles > 0) append(" Skipped ${configuration.skippedProfiles} invalid profile(s).") - if (missingPasswords > 0) append(" $missingPasswords profile(s) require a password.") - if (ProxyVpnService.isAlwaysOnMode && ProxyVpnService.isRunning) append(" Reconnect the VPN to apply the imported Always-on profile selection.") + val imported = result.added + result.updated + result.unchanged + val missingPasswords = imported.count { it.config.password.isEmpty() } + val summary = listOfNotNull( + activity.getString(R.string.config_import_summary, result.added.size, result.updated.size, result.unchanged.size), + activity.getString(R.string.config_import_skipped, configuration.skippedProfiles) + .takeIf { configuration.skippedProfiles > 0 }, + activity.getString(R.string.config_import_missing_passwords, missingPasswords) + .takeIf { missingPasswords > 0 }, + activity.getString(R.string.config_import_always_on_reconnect) + .takeIf { ProxyVpnService.isAlwaysOnMode && ProxyVpnService.isRunning }, + ).joinToString(" ") + if (result.missing.isEmpty()) { + transferMessage = summary + } else { + selectedMissingProfileIds.clear() + missingProfilesReview = MissingProfilesReview(result.missing, summary) } importedProfileCount = 0 } } + fun prepareJsonImport(configuration: PortableConfiguration) { + scope.launch { + val review = withContext(ConfigIoDispatcher) { + val existing = store.profiles().associateBy(ProxyProfile::id) + val profileReviews = configuration.profiles.mapNotNull { imported -> + val current = existing[imported.id] ?: return@mapNotNull null + profileOptionGroups(current, imported) + .takeIf(List::isNotEmpty) + ?.let { ProfileOptionsReview(imported, it) } + } + val importedGlobal = configuration.globalConnectionSettings + val globalChanged = importedGlobal != null && ( + importedGlobal != store.globalConnectionSettings() || + configuration.diagnosticLogLimitMb != store.diagnosticLogLimitMb() || + configuration.activeProfileId?.let { it != store.activeProfileId() } == true || + configuration.alwaysOnProfileId?.let { it != store.alwaysOnProfileId() } == true + ) + ImportOptionsReview( + configuration = configuration, + existingProfiles = existing, + profiles = profileReviews, + globalChanged = globalChanged, + currentDiagnosticLogLimitMb = store.diagnosticLogLimitMb(), + ) + } + if (review.profiles.isEmpty() && !review.globalChanged) { + applyJsonImport(configuration) + } else { + selectedImportOptionKeys.clear() + applyImportedGlobalOptions = false + importOptionsReview = review + } + } + } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) refresh() } @@ -212,14 +363,14 @@ private fun SettingsScreen(activity: Activity) { text.trimStart().startsWith('{') if (isJson) { val isMegaProxy = runCatching { - org.json.JSONObject(text).optString("schema") == "dev.megaproxy.config" + ConfigTransfer.isSupportedSchema(org.json.JSONObject(text).optString("schema")) }.getOrDefault(false) if (isMegaProxy) { val configuration = ConfigTransfer.importJson(text) if (configuration.profiles.any { it.config.allowInvalidProxyCertificate || it.config.acceptAnyHostKey || it.config.jumpAcceptAnyHostKey }) { pendingUnsafeImport = configuration } else { - applyJsonImport(configuration) + prepareJsonImport(configuration) } } else { val imported = FoxyProxyParser.parse(text).getOrThrow() @@ -229,7 +380,7 @@ private fun SettingsScreen(activity: Activity) { skippedNonHttps = imported.skippedNonHttps showImportFilterNotice = imported.skippedNonHttps > 0 if (imported.skippedNonHttps == 0) { - transferMessage = "Imported ${added.size} HTTPS proxy profile(s) from FoxyProxy." + transferMessage = activity.getString(R.string.imported_foxyproxy, added.size) } } } else { @@ -246,14 +397,19 @@ private fun SettingsScreen(activity: Activity) { showImportFilterNotice = imported.skippedNonHttps > 0 if (imported.skippedNonHttps == 0) { transferMessage = if (isSuperProxy) { - "Imported ${added.size} HTTPS proxy profile(s) from Super Proxy. Certificate pins were not imported." + activity.getString(R.string.imported_super_proxy, added.size) } else { - "Imported ${added.size} HTTPS proxy profile(s)." + activity.getString(R.string.imported_https_profiles, added.size) } } } importError = null - }.onFailure { importError = it.message ?: "Could not import the configuration" } + }.onFailure { + importError = activity.getString( + R.string.configuration_import_failed, + it.message ?: activity.getString(R.string.unknown_error), + ) + } } } @@ -263,7 +419,7 @@ private fun SettingsScreen(activity: Activity) { title = { Text(stringResource(R.string.profiles)) }, navigationIcon = { IconButton(onClick = { activity.finish() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, actions = { @@ -282,7 +438,7 @@ private fun SettingsScreen(activity: Activity) { edit(profile) } }) { - Icon(Icons.Default.Add, contentDescription = "Add profile") + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_profile)) } }, contentWindowInsets = WindowInsets.safeDrawing, @@ -298,7 +454,7 @@ private fun SettingsScreen(activity: Activity) { ) { item { Text( - "Long press and drag a profile to reorder it.", + stringResource(R.string.profile_reorder_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), @@ -350,10 +506,10 @@ private fun SettingsScreen(activity: Activity) { .semantics { customActions = buildList { if (profiles.firstOrNull()?.id != profile.id) { - add(CustomAccessibilityAction("Move up") { moveProfile(profile.id, -1) }) + add(CustomAccessibilityAction(activity.getString(R.string.move_up)) { moveProfile(profile.id, -1) }) } if (profiles.lastOrNull()?.id != profile.id) { - add(CustomAccessibilityAction("Move down") { moveProfile(profile.id, 1) }) + add(CustomAccessibilityAction(activity.getString(R.string.move_down)) { moveProfile(profile.id, 1) }) } } }, @@ -376,8 +532,8 @@ private fun SettingsScreen(activity: Activity) { deleteProfile?.let { profile -> AlertDialog( onDismissRequest = { deleteProfile = null }, - title = { Text("Delete ${profile.displayName}?") }, - text = { Text("The profile and its encrypted credentials will be removed.") }, + title = { Text(stringResource(R.string.delete_profile_title, profile.displayName)) }, + text = { Text(stringResource(R.string.delete_profile_message)) }, confirmButton = { TextButton(onClick = { deleteProfile = null scope.launch { @@ -389,84 +545,230 @@ private fun SettingsScreen(activity: Activity) { refresh() if (reconnect) ProxyVpnService.reconnect(activity) } - }, enabled = profiles.size > 1) { Text("Delete") } }, - dismissButton = { TextButton(onClick = { deleteProfile = null }) { Text("Cancel") } }, + }, enabled = profiles.size > 1) { Text(stringResource(R.string.delete)) } }, + dismissButton = { TextButton(onClick = { deleteProfile = null }) { Text(stringResource(R.string.cancel)) } }, ) } if (showImportFilterNotice) { AlertDialog( onDismissRequest = { showImportFilterNotice = false }, - title = { Text("Only HTTPS proxies were imported") }, - text = { Text("Imported $importedProfileCount HTTPS proxy profile(s). Skipped $skippedNonHttps non-HTTPS URL(s).") }, - confirmButton = { TextButton(onClick = { showImportFilterNotice = false }) { Text("Continue") } }, + title = { Text(stringResource(R.string.only_https_imported_title)) }, + text = { Text(stringResource(R.string.only_https_imported_message, importedProfileCount, skippedNonHttps)) }, + confirmButton = { TextButton(onClick = { showImportFilterNotice = false }) { Text(stringResource(R.string.continue_action)) } }, ) } if (showExportDialog) { AlertDialog( onDismissRequest = { showExportDialog = false }, - title = { Text("Export configuration") }, + title = { Text(stringResource(R.string.export_configuration_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Format") + Text(stringResource(R.string.format)) Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(exportFormat == ConfigExportFormat.JSON, { exportFormat = ConfigExportFormat.JSON }) - Text("JSON · all MegaProxy settings") + Text(stringResource(R.string.export_json_description)) } Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(exportFormat == ConfigExportFormat.PROXY_LIST, { exportFormat = ConfigExportFormat.PROXY_LIST }) - Text("ProxyList.txt · compatible proxy fields only") + Text(stringResource(R.string.export_proxy_list_description)) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(includePasswords, { includePasswords = it }) - Text("Include passwords") + Text(stringResource(R.string.include_passwords)) } if (!includePasswords) { - Text("Passwords will be left empty and must be entered after import.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.passwords_omitted_message), style = MaterialTheme.typography.bodySmall) } if (exportFormat == ConfigExportFormat.JSON) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(includePrivateKeys, { includePrivateKeys = it }) - Text("Include SSH private keys") + Text(stringResource(R.string.include_private_keys)) } - if (includePrivateKeys) Text("Private keys will be exported in plain text.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + if (includePrivateKeys) Text(stringResource(R.string.private_keys_plaintext_warning), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } }, confirmButton = { TextButton(onClick = { showExportDialog = false if (includePasswords || includePrivateKeys) showPasswordExportWarning = true else launchExport() - }) { Text("Export") } }, - dismissButton = { TextButton(onClick = { showExportDialog = false }) { Text("Cancel") } }, + }) { Text(stringResource(R.string.export_action)) } }, + dismissButton = { TextButton(onClick = { showExportDialog = false }) { Text(stringResource(R.string.cancel)) } }, ) } if (showPasswordExportWarning) { AlertDialog( onDismissRequest = { showPasswordExportWarning = false }, - title = { Text("Export secrets in plain text?") }, - text = { Text("Anyone with access to the exported file will be able to read the selected passwords or SSH private keys. Store and transfer it securely.") }, + title = { Text(stringResource(R.string.export_secrets_title)) }, + text = { Text(stringResource(R.string.export_secrets_message)) }, confirmButton = { TextButton(onClick = { showPasswordExportWarning = false launchExport() - }) { Text("Export anyway") } }, - dismissButton = { TextButton(onClick = { showPasswordExportWarning = false }) { Text("Cancel") } }, + }) { Text(stringResource(R.string.export_anyway)) } }, + dismissButton = { TextButton(onClick = { showPasswordExportWarning = false }) { Text(stringResource(R.string.cancel)) } }, ) } transferMessage?.let { message -> AlertDialog( onDismissRequest = { transferMessage = null }, - title = { Text("Configuration transfer") }, + title = { Text(stringResource(R.string.configuration_transfer)) }, text = { Text(message) }, - confirmButton = { TextButton(onClick = { transferMessage = null }) { Text("OK") } }, + confirmButton = { TextButton(onClick = { transferMessage = null }) { Text(stringResource(R.string.ok)) } }, ) } pendingUnsafeImport?.let { configuration -> AlertDialog( onDismissRequest = { pendingUnsafeImport = null }, - title = { Text("Import insecure verification settings?") }, - text = { Text("One or more profiles disable HTTPS certificate or SSH host-key verification. This permits server impersonation and should only be used for servers you control.") }, + title = { Text(stringResource(R.string.unsafe_import_title)) }, + text = { Text(stringResource(R.string.unsafe_import_message)) }, confirmButton = { TextButton(onClick = { pendingUnsafeImport = null - applyJsonImport(configuration) - }) { Text("Import anyway") } }, - dismissButton = { TextButton(onClick = { pendingUnsafeImport = null }) { Text("Cancel") } }, + prepareJsonImport(configuration) + }) { Text(stringResource(R.string.import_anyway)) } }, + dismissButton = { TextButton(onClick = { pendingUnsafeImport = null }) { Text(stringResource(R.string.cancel)) } }, + ) + } + importOptionsReview?.let { review -> + AlertDialog( + onDismissRequest = { + importOptionsReview = null + selectedImportOptionKeys.clear() + applyImportedGlobalOptions = false + }, + title = { Text(stringResource(R.string.import_options_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.import_options_message)) + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) { + items(review.profiles, key = { it.profile.id }) { item -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.padding(top = 10.dp)) { + Text(item.profile.displayName, style = MaterialTheme.typography.titleSmall) + item.changedGroups.forEach { group -> + val key = importOptionKey(item.profile.id, group) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = key in selectedImportOptionKeys, + onCheckedChange = { checked -> + if (checked) selectedImportOptionKeys.add(key) + else selectedImportOptionKeys.remove(key) + }, + ) + Text(stringResource(group)) + } + } + } + } + } + if (review.globalChanged) item { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = applyImportedGlobalOptions, + onCheckedChange = { applyImportedGlobalOptions = it }, + ) + Text(stringResource(R.string.import_global_options)) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + val selectedOptions = selectedImportOptionKeys.toSet() + val applyGlobals = !review.globalChanged || applyImportedGlobalOptions + val resolved = review.configuration.copy( + profiles = review.configuration.profiles.map { imported -> + val current = review.existingProfiles[imported.id] + if (current != null) applySelectedProfileOptions(current, imported, selectedOptions) + else imported + }, + activeProfileId = review.configuration.activeProfileId.takeIf { applyGlobals }, + alwaysOnProfileId = review.configuration.alwaysOnProfileId.takeIf { applyGlobals }, + diagnosticLogLimitMb = if (applyGlobals) { + review.configuration.diagnosticLogLimitMb + } else review.currentDiagnosticLogLimitMb, + globalConnectionSettings = review.configuration.globalConnectionSettings.takeIf { applyGlobals }, + ) + importOptionsReview = null + selectedImportOptionKeys.clear() + applyImportedGlobalOptions = false + applyJsonImport(resolved) + }) { Text(stringResource(R.string.apply_import)) } + }, + dismissButton = { + TextButton(onClick = { + importOptionsReview = null + selectedImportOptionKeys.clear() + applyImportedGlobalOptions = false + }) { Text(stringResource(R.string.cancel)) } + }, + ) + } + missingProfilesReview?.let { review -> + AlertDialog( + onDismissRequest = { + missingProfilesReview = null + selectedMissingProfileIds.clear() + transferMessage = review.importSummary + }, + title = { Text(stringResource(R.string.remove_missing_profiles_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.remove_missing_profiles_message)) + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 320.dp)) { + items(review.profiles, key = { it.id }) { profile -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = profile.id in selectedMissingProfileIds, + onCheckedChange = { checked -> + if (checked) selectedMissingProfileIds.add(profile.id) + else selectedMissingProfileIds.remove(profile.id) + }, + ) + Column { + Text(profile.displayName) + Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = selectedMissingProfileIds.isNotEmpty(), + onClick = { + val selected = selectedMissingProfileIds.toSet() + missingProfilesReview = null + selectedMissingProfileIds.clear() + scope.launch { + val reconnect = withContext(ConfigIoDispatcher) { + val desired = store.isConnectionDesired() + store.deleteProfiles(selected).also { + if (ProxyVpnService.isRunning) store.markPendingReconnect() + } && desired + } + refresh() + if (reconnect) ProxyVpnService.reconnect(activity) + transferMessage = review.importSummary + " " + + activity.getString(R.string.config_import_removed, selected.size) + } + }, + ) { Text(stringResource(R.string.remove_selected)) } + }, + dismissButton = { + TextButton(onClick = { + missingProfilesReview = null + selectedMissingProfileIds.clear() + transferMessage = review.importSummary + }) { Text(stringResource(R.string.keep_all)) } + }, ) } } @@ -516,9 +818,9 @@ private fun ProfileCard( } } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onConfigure) { Text("Configure", color = foreground) } - TextButton(onClick = onClone) { Text("Clone", color = foreground) } - TextButton(onClick = onDelete) { Text("Delete", color = foreground) } + TextButton(onClick = onConfigure) { Text(stringResource(R.string.configure), color = foreground) } + TextButton(onClick = onClone) { Text(stringResource(R.string.clone), color = foreground) } + TextButton(onClick = onDelete) { Text(stringResource(R.string.delete), color = foreground) } } } } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 59a8105..96dee89 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -24,6 +24,13 @@ import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec +data class ConfigurationImportResult( + val added: List, + val updated: List, + val unchanged: List, + val missing: List, +) + class ConfigStore(context: Context) { private val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE) @@ -209,47 +216,93 @@ class ConfigStore(context: Context) { fun importProfiles(imported: List): List { val existing = profiles() val allocated = existing.toMutableList() - val added = imported.map { source -> - val profile = ProxyProfile( - id = UUID.randomUUID().toString(), - name = source.name, - colorIndex = ProfileColorMatcher.colorIndexForFlag( - source.countryCode, - nextColorIndex(allocated), - ), - countryCode = source.countryCode, - config = source.config, - ) - allocated += profile - profile + val changed = mutableListOf() + imported.forEach { source -> + val matchIndex = allocated.indexOfFirst { it.importIdentity() == source.importIdentity() } + if (matchIndex >= 0) { + val current = allocated[matchIndex] + val updated = current.copy( + name = source.name.ifBlank { current.name }, + countryCode = source.countryCode.ifBlank { current.countryCode }, + config = current.config.copy( + type = source.config.type, + host = source.config.host, + port = source.config.port, + username = source.config.username, + password = source.config.password.ifEmpty { current.config.password }, + ), + ) + allocated[matchIndex] = updated + if (updated != current) changed += updated + } else { + val profile = ProxyProfile( + id = UUID.randomUUID().toString(), + name = source.name, + colorIndex = ProfileColorMatcher.colorIndexForFlag( + source.countryCode, + nextColorIndex(allocated), + ), + countryCode = source.countryCode, + config = source.config, + ) + allocated += profile + changed += profile + } } - writeProfiles(existing + added) - return added + if (allocated != existing) writeProfiles(allocated) + return changed } @Synchronized - fun importConfiguration(configuration: PortableConfiguration): List { + fun importConfiguration(configuration: PortableConfiguration): ConfigurationImportResult { val existing = profiles() - val idMap = mutableMapOf() - val added = configuration.profiles.map { source -> - val newId = UUID.randomUUID().toString() - idMap[source.id] = newId - source.copy(id = newId) + val existingById = existing.associateBy(ProxyProfile::id) + val importedById = configuration.profiles.associateBy(ProxyProfile::id) + val added = mutableListOf() + val updated = mutableListOf() + val unchanged = mutableListOf() + val merged = configuration.profiles.map { source -> + val current = existingById[source.id] + val presence = configuration.secretPresence[source.id] ?: ProfileSecretPresence() + val resolved = if (current == null) source else source.copy(config = source.config.copy( + password = source.config.password.takeIf { presence.password } ?: current.config.password, + privateKey = source.config.privateKey.takeIf { presence.privateKey } ?: current.config.privateKey, + jumpPassword = source.config.jumpPassword.takeIf { presence.jumpPassword } ?: current.config.jumpPassword, + jumpPrivateKey = source.config.jumpPrivateKey.takeIf { presence.jumpPrivateKey } ?: current.config.jumpPrivateKey, + )) + when { + current == null -> added += resolved + current == resolved -> unchanged += resolved + else -> updated += resolved + } + resolved } - writeProfiles(existing + added) + val missing = existing.filter { it.id !in importedById } + writeProfiles(existing.map { importedById[it.id]?.let { mergedProfile -> + merged.first { profile -> profile.id == mergedProfile.id } + } ?: it } + added) val editor = prefs.edit() .putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) - configuration.activeProfileId?.let(idMap::get)?.let { editor.putString(ACTIVE_PROFILE_ID, it) } - configuration.alwaysOnProfileId?.let(idMap::get)?.let { editor.putString(ALWAYS_ON_PROFILE_ID, it) } + configuration.activeProfileId?.takeIf(importedById::containsKey)?.let { editor.putString(ACTIVE_PROFILE_ID, it) } + configuration.alwaysOnProfileId?.takeIf(importedById::containsKey)?.let { editor.putString(ALWAYS_ON_PROFILE_ID, it) } editor.apply() configuration.globalConnectionSettings?.let { importedSettings -> saveGlobalConnectionSettings(importedSettings.copy( - failoverProfileIds = importedSettings.failoverProfileIds.mapNotNull(idMap::get), + failoverProfileIds = importedSettings.failoverProfileIds.filter(importedById::containsKey), )) } - return added + return ConfigurationImportResult(added, updated, unchanged, missing) } + private fun ProxyProfile.importIdentity(): String = config.importIdentity() + + private fun ImportedProxy.importIdentity(): String = config.importIdentity() + + private fun ProxyConfig.importIdentity(): String = listOf( + type.name, host.trim().lowercase(), port.toString(), username, + jumpHost.trim().lowercase(), jumpPort.toString(), jumpUsername, + ).joinToString("\u0000") + @Synchronized fun saveProfile(profile: ProxyProfile) { val current = profiles() @@ -296,6 +349,28 @@ class ConfigStore(context: Context) { return true } + @Synchronized + fun deleteProfiles(ids: Set): Boolean { + if (ids.isEmpty()) return false + val current = profiles() + val remaining = current.filter { it.id !in ids } + if (remaining.isEmpty() || remaining.size == current.size) return false + val removedIds = current.map(ProxyProfile::id).toSet() - remaining.map(ProxyProfile::id).toSet() + val replacement = remaining.first().id + val connectionWasDeleted = connectionProfileId() in removedIds + val editor = prefs.edit().putString(PROFILES, encodeProfiles(remaining)) + if (activeProfileId() in removedIds) editor.putString(ACTIVE_PROFILE_ID, replacement) + if (alwaysOnProfileId() in removedIds) editor.putString(ALWAYS_ON_PROFILE_ID, replacement) + if (connectionWasDeleted) editor.putString(CONNECTION_PROFILE_ID, replacement) + editor.apply() + val settings = globalConnectionSettings() + saveGlobalConnectionSettings(settings.copy( + failoverProfileIds = settings.failoverProfileIds.filterNot(removedIds::contains), + )) + if (connectionWasDeleted && isFailoverActive()) setFailoverState(false, null) + return connectionWasDeleted + } + /** Compatibility accessor for callers that operate on the selected profile. */ fun load(): ProxyConfig = activeProfile().config diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index 8b4540e..c283f27 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -26,10 +26,22 @@ data class PortableConfiguration( val diagnosticLogLimitMb: Int = 3, val globalConnectionSettings: GlobalConnectionSettings? = null, val skippedProfiles: Int = 0, + val secretPresence: Map = emptyMap(), +) + +data class ProfileSecretPresence( + val password: Boolean = false, + val privateKey: Boolean = false, + val jumpPassword: Boolean = false, + val jumpPrivateKey: Boolean = false, ) object ConfigTransfer { - const val SCHEMA_VERSION = 6 + const val SCHEMA_ID = "net.megaproxy487.config" + const val LEGACY_SCHEMA_ID = "dev.megaproxy.config" + const val SCHEMA_VERSION = 7 + + fun isSupportedSchema(value: String): Boolean = value == SCHEMA_ID || value == LEGACY_SCHEMA_ID fun exportProxyList(profiles: List, includePasswords: Boolean): String = profiles.filter { it.config.type == ProxyType.HTTPS }.joinToString("\n", postfix = "\n") { profile -> @@ -48,7 +60,7 @@ object ConfigTransfer { } fun exportJson(store: ConfigStore, includePasswords: Boolean, includePrivateKeys: Boolean = false): String = JSONObject().apply { - put("schema", "dev.megaproxy.config") + put("schema", SCHEMA_ID) put("version", SCHEMA_VERSION) put("passwordsIncluded", includePasswords) put("privateKeysIncluded", includePrivateKeys) @@ -84,16 +96,22 @@ object ConfigTransfer { fun importJson(text: String): PortableConfiguration { val root = JSONObject(text) - require(root.optString("schema") == "dev.megaproxy.config") { "This is not a MegaProxy configuration file" } + require(isSupportedSchema(root.optString("schema"))) { "This is not a MegaProxy configuration file" } val version = root.optInt("version", 0) require(version in 1..SCHEMA_VERSION) { "Unsupported MegaProxy configuration version: $version" } val array = root.optJSONArray("profiles") ?: error("The configuration has no profiles") require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains more than $MAX_IMPORTED_PROFILES profiles" } + if (version >= 7) require((0 until array.length()).all { index -> + array.optJSONObject(index)?.optString("id")?.let { it.isNotBlank() && it.length <= 256 } == true + }) { "Every profile in version 7 or newer must have a stable ID" } val decoded = (0 until array.length()).map { index -> runCatching { decodeProfile(array.getJSONObject(index), index) } } val decodedProfiles = decoded.mapNotNull(Result::getOrNull) require(decodedProfiles.isNotEmpty()) { "The configuration contains no usable profiles" } + require(decodedProfiles.map(ProxyProfile::id).distinct().size == decodedProfiles.size) { + "The configuration contains duplicate profile IDs" + } val legacyIpv6 = root.optJSONObject("routing")?.optBoolean("allowIpv6", false) ?: false val profiles = if (version in 2..5) decodedProfiles.map { it.copy(config = it.config.copy(allowIpv6 = legacyIpv6)) @@ -109,6 +127,18 @@ object ConfigTransfer { } } else null, skippedProfiles = decoded.count(Result::isFailure), + secretPresence = (0 until array.length()).mapNotNull { index -> + val item = array.optJSONObject(index) ?: return@mapNotNull null + val id = item.optString("id").ifBlank { "import-$index" } + val proxy = item.optJSONObject("proxy") ?: JSONObject() + val jump = proxy.optJSONObject("jump") + id to ProfileSecretPresence( + password = proxy.has("password"), + privateKey = proxy.has("privateKey"), + jumpPassword = jump?.has("password") == true, + jumpPrivateKey = jump?.has("privateKey") == true, + ) + }.toMap(), ) } diff --git a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt index c1ef4a4..4ed7a03 100644 --- a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt +++ b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt @@ -6,7 +6,7 @@ import org.json.JSONObject object FoxyProxyParser { fun parse(text: String): Result = runCatching { val root = JSONObject(text) - require(root.optString("schema") != "dev.megaproxy.config") { + require(!ConfigTransfer.isSupportedSchema(root.optString("schema"))) { "This is a MegaProxy configuration, not a FoxyProxy configuration" } val data = root.optJSONArray("data") diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 913f34f..133cf0d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -46,6 +46,57 @@ Копировать Импорт Экспорт + Добавлено: %1$d, обновлено: %2$d, без изменений: %3$d. + Пропущено некорректных профилей: %1$d. + Профилей без пароля: %1$d. + Переподключите VPN, чтобы применить импортированный профиль постоянного VPN. + Удалено профилей: %1$d. + Удалить профили, отсутствующие в конфигурации? + Эти локальные профили отсутствуют в импортированном файле. Изначально ни один профиль не выбран — отметьте только те, которые действительно нужно удалить. + Удалить выбранные + Оставить все + Добавить профиль + Удерживайте и перетаскивайте профили, чтобы изменить их порядок. + Переместить выше + Переместить ниже + Удалить + Клонировать + Удалить профиль «%1$s»? + Профиль и его зашифрованные учётные данные будут удалены. + Импортированы только HTTPS-прокси + Импортировано HTTPS-профилей: %1$d. Пропущено URL других типов: %2$d. + Продолжить + Экспорт конфигурации + Формат + JSON · все настройки MegaProxy + ProxyList.txt · только совместимые поля прокси + Включить пароли + Пароли останутся пустыми — их потребуется ввести после импорта. + Включить закрытые ключи SSH + Закрытые ключи будут экспортированы в открытом виде. + Экспортировать секреты в открытом виде? + Любой, у кого будет доступ к файлу, сможет прочитать выбранные пароли и закрытые ключи SSH. Храните и передавайте файл безопасно. + Всё равно экспортировать + Перенос конфигурации + Импортировать небезопасные настройки проверки? + В одном или нескольких профилях отключена проверка HTTPS-сертификата или ключа SSH-сервера. Это позволяет подменить сервер; используйте такие настройки только для контролируемых вами серверов. + Всё равно импортировать + Конфигурация успешно экспортирована. + Импортировано HTTPS-профилей из FoxyProxy: %1$d. + Импортировано HTTPS-профилей из Super Proxy: %1$d. Привязки сертификатов не импортированы. + Импортировано HTTPS-профилей: %1$d. + ОК + Не удалось экспортировать конфигурацию: %1$s + Не удалось импортировать конфигурацию: %1$s + Неизвестная ошибка + Проверка импортируемых параметров + Название, оформление, хост, логин и переданные учётные данные обновятся автоматически. Выберите дополнительные настройки, которые нужно заменить. + Применить импортированные глобальные настройки + параметры подключения + проверка TLS и SSH + DNS + маршрутизация и IPv6 + Применить импорт Изменения сохраняются автоматически. Отмена Скрыть diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29d2240..9b03c83 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ MegaProxy - Back Settings Connection Profiles @@ -47,6 +46,58 @@ Copy Import Export + Added %1$d, updated %2$d, unchanged %3$d profile(s). + Skipped %1$d invalid profile(s). + %1$d profile(s) require a password. + Reconnect the VPN to apply the imported Always-on profile selection. + 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. + Remove selected + Keep all + Back + Add profile + Long press and drag a profile to reorder it. + Move up + Move down + Delete + Clone + Delete %1$s? + The profile and its encrypted credentials will be removed. + Only HTTPS proxies were imported + Imported %1$d HTTPS proxy profile(s). Skipped %2$d non-HTTPS URL(s). + Continue + Export configuration + Format + JSON · all MegaProxy settings + ProxyList.txt · compatible proxy fields only + Include passwords + Passwords will be left empty and must be entered after import. + Include SSH private keys + Private keys will be exported in plain text. + Export secrets in plain text? + Anyone with access to the exported file will be able to read the selected passwords or SSH private keys. Store and transfer it securely. + Export anyway + Configuration transfer + Import insecure verification settings? + One or more profiles disable HTTPS certificate or SSH host-key verification. This permits server impersonation and should only be used for servers you control. + Import anyway + Configuration exported successfully. + Imported %1$d HTTPS proxy profile(s) from FoxyProxy. + Imported %1$d HTTPS proxy profile(s) from Super Proxy. Certificate pins were not imported. + Imported %1$d HTTPS proxy profile(s). + OK + Could not export the configuration: %1$s + Could not import the configuration: %1$s + Unknown error + Review imported options + Name, appearance, host, username and provided credentials will update automatically. Select the additional settings you want to replace. + Apply imported global settings + connection options + TLS and SSH verification + DNS + routing and IPv6 + Apply import Changes are saved automatically. Cancel Dismiss diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt index f7f1e2b..b641ce4 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt @@ -39,7 +39,7 @@ class ConfigTransferTest { val imported = ConfigTransfer.importJson( """ { - "schema":"dev.megaproxy.config", + "schema":"net.megaproxy487.config", "version":1, "activeProfileId":"source", "alwaysOnProfileId":"source", @@ -71,7 +71,7 @@ class ConfigTransferTest { val imported = ConfigTransfer.importJson( """ { - "schema":"dev.megaproxy.config", + "schema":"net.megaproxy487.config", "version":2, "tls":{"fingerprint":"SAMSUNG_INTERNET","customJa3":"ignored"}, "routing":{"selectedPackages":["com.example.missing"],"routeAllApps":false}, @@ -90,7 +90,7 @@ class ConfigTransferTest { @Test fun `version one keeps per-profile IPv6 setting`() { val imported = ConfigTransfer.importJson( - """{"schema":"dev.megaproxy.config","version":1,"profiles":[{ + """{"schema":"net.megaproxy487.config","version":1,"profiles":[{ "id":"one","proxy":{"host":"proxy.example"},"routing":{"allowIpv6":true} }]}""", ) @@ -101,7 +101,7 @@ class ConfigTransferTest { @Test fun `legacy global IPv6 setting migrates to every profile`() { val imported = ConfigTransfer.importJson( - """{"schema":"dev.megaproxy.config","version":5, + """{"schema":"net.megaproxy487.config","version":5, "routing":{"allowIpv6":true},"profiles":[ {"id":"one","proxy":{"host":"one.example"}}, {"id":"two","proxy":{"host":"two.example"}} @@ -110,4 +110,40 @@ class ConfigTransferTest { assertTrue(imported.profiles.all { it.config.allowIpv6 }) } + + @Test + fun `json profile id is stable and secret presence is recorded`() { + val imported = ConfigTransfer.importJson( + """{"schema":"net.megaproxy487.config","version":7,"profiles":[ + {"id":"generated-server-1","proxy":{"host":"one.example","password":""}}, + {"id":"generated-server-2","proxy":{"host":"two.example","privateKey":"key"}} + ]}""", + ) + + assertEquals(listOf("generated-server-1", "generated-server-2"), imported.profiles.map { it.id }) + assertTrue(imported.secretPresence.getValue("generated-server-1").password) + assertFalse(imported.secretPresence.getValue("generated-server-1").privateKey) + assertTrue(imported.secretPresence.getValue("generated-server-2").privateKey) + } + + @Test(expected = IllegalArgumentException::class) + fun `json import rejects duplicate profile ids`() { + ConfigTransfer.importJson( + """{"schema":"net.megaproxy487.config","version":7,"profiles":[ + {"id":"same","proxy":{"host":"one.example"}}, + {"id":"same","proxy":{"host":"two.example"}} + ]}""", + ) + } + + @Test + fun `legacy schema id remains importable`() { + val imported = ConfigTransfer.importJson( + """{"schema":"dev.megaproxy.config","version":6,"profiles":[ + {"id":"legacy","proxy":{"host":"legacy.example"}} + ]}""", + ) + + assertEquals("legacy", imported.profiles.single().id) + } } diff --git a/fastlane/metadata/android/en-US/changelogs/11000.txt b/fastlane/metadata/android/en-US/changelogs/11000.txt new file mode 100644 index 0000000..331bb14 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11000.txt @@ -0,0 +1,3 @@ +Import generated configurations without duplicating profiles. + +Stable profile IDs now update existing profiles, while a review wizard protects local connection, security, DNS and routing options. Missing profiles can be selected for optional removal. diff --git a/fastlane/metadata/android/ru-RU/changelogs/11000.txt b/fastlane/metadata/android/ru-RU/changelogs/11000.txt new file mode 100644 index 0000000..894c61f --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/11000.txt @@ -0,0 +1,3 @@ +Импорт конфигураций от внешних генераторов без дублирования профилей. + +Стабильные ID обновляют существующие профили, а мастер защищает локальные параметры подключения, безопасности, DNS и маршрутизации. Отсутствующие профили можно выборочно удалить.