diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 4aaef96..5b43535 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -305,7 +305,7 @@ class ConfigStore(context: Context) { val missing = existing.filter { it.id !in importedById } requireUi(merged.isNotEmpty()) { UiException(R.string.error_config_no_usable) } val editor = prefs.edit().putString(PROFILES, encodeProfiles(mergeResolvedProfiles(existing, merged, added))) - .putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) + if (configuration.diagnosticLogLimitPresent) editor.putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) if (existing.isEmpty()) { val first = merged.first().id editor.putString(ACTIVE_PROFILE_ID, first) @@ -316,10 +316,11 @@ class ConfigStore(context: Context) { } 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) } - configuration.globalConnectionSettings?.let { importedSettings -> + configuration.globalConnectionSettings?.let { + val importedSettings = configuration.mergeGlobalSettings(globalConnectionSettings()) editor.putBoolean(IPV6_PROFILE_MIGRATED, true) .putString(GLOBAL_CONNECTION_SETTINGS, encodeGlobalConnectionSettings(importedSettings.copy( - failoverProfileIds = importedSettings.failoverProfileIds.filter(importedById::containsKey), + failoverProfileIds = importedSettings.failoverProfileIds.filter { it in importedById || it in existingById }, ))) } if (configuration.subscriptionPresent) { @@ -385,7 +386,7 @@ class ConfigStore(context: Context) { requireUi(ids.size == merged.size) { UiException(R.string.error_config_duplicate_ids) } val oldGlobal = globalConnectionSettings() val oldConnection = oldGlobal.applyTo(connectionProfile().config) - val nextGlobal = (configuration.globalConnectionSettings ?: oldGlobal).let { + val nextGlobal = configuration.mergeGlobalSettings(oldGlobal).let { it.copy(failoverProfileIds = it.failoverProfileIds.filter(ids::contains)) } val preferred = configuration.activeProfileId?.takeIf { id -> incoming.any { it.id == id } } ?: incoming.first().id @@ -399,7 +400,7 @@ class ConfigStore(context: Context) { .putString(CONNECTION_PROFILE_ID, nextConnection) .putString(GLOBAL_CONNECTION_SETTINGS, encodeGlobalConnectionSettings(nextGlobal)) .putBoolean(IPV6_PROFILE_MIGRATED, true) - if (configuration.globalConnectionSettings != null) editor.putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) + if (configuration.diagnosticLogLimitPresent) editor.putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) if ((connectionProfileId() !in ids || alwaysOnProfileId() !in ids) && isFailoverActive()) { editor.putBoolean(FAILOVER_ACTIVE, false).remove(FAILOVER_NOTICE) } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index 55e5c76..6d705d3 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -34,7 +34,29 @@ data class PortableConfiguration( val notice: ConfigImportNotice = ConfigImportNotice(), val subscriptionPresent: Boolean = false, val subscription: ConfigSubscription? = null, -) + val globalSettingFields: Set? = null, + val diagnosticLogLimitPresent: Boolean = true, +) { + internal fun mergeGlobalSettings(current: GlobalConnectionSettings): GlobalConnectionSettings { + val incoming = globalConnectionSettings ?: return current + val fields = globalSettingFields ?: return incoming + return current.copy( + tlsProfile = if ("tls.fingerprint" in fields) incoming.tlsProfile else current.tlsProfile, + customJa3 = if ("tls.customJa3" in fields) incoming.customJa3 else current.customJa3, + sshProfile = if ("ssh.fingerprint" in fields) incoming.sshProfile else current.sshProfile, + sshAuthMode = if ("ssh.authMode" in fields) incoming.sshAuthMode else current.sshAuthMode, + sshKeepaliveSeconds = if ("ssh.keepaliveSeconds" in fields) incoming.sshKeepaliveSeconds else current.sshKeepaliveSeconds, + sshMaxChannels = if ("ssh.maxChannels" in fields) incoming.sshMaxChannels else current.sshMaxChannels, + sshRotationMinutes = if ("ssh.rotationMinutes" in fields) incoming.sshRotationMinutes else current.sshRotationMinutes, + sshRotationMb = if ("ssh.rotationMb" in fields) incoming.sshRotationMb else current.sshRotationMb, + failoverMode = if ("failover.mode" in fields) incoming.failoverMode else current.failoverMode, + failoverProfileIds = if ("failover.profileIds" in fields) incoming.failoverProfileIds else current.failoverProfileIds, + selectedPackages = if ("routing.selectedPackages" in fields) incoming.selectedPackages else current.selectedPackages, + routeAllApps = if ("routing.routeAllApps" in fields) incoming.routeAllApps else current.routeAllApps, + bypassLocalNetworks = if ("routing.bypassLocalNetworks" in fields) incoming.bypassLocalNetworks else current.bypassLocalNetworks, + ) + } +} data class ProfileSecretPresence( val password: Boolean = false, @@ -137,7 +159,12 @@ object ConfigTransfer { val profiles = if (version in 2..5) decodedProfiles.map { it.copy(config = it.config.copy(allowIpv6 = legacyIpv6)) } else decodedProfiles + val globalFields = listOf("tls", "ssh", "failover", "routing").flatMap { group -> + root.optJSONObject(group)?.keys()?.asSequence()?.map { "$group.$it" }?.toList().orEmpty() + }.toSet() return PortableConfiguration( + globalSettingFields = globalFields + if (version in 2..3) setOf("ssh.fingerprint") else emptySet(), + diagnosticLogLimitPresent = root.has("diagnosticLogLimitMb"), notice = ConfigImportNotices.inspect(root), subscriptionPresent = acceptSubscription && root.has("subscription"), subscription = if (!acceptSubscription || !root.has("subscription") || root.isNull("subscription")) null @@ -147,7 +174,7 @@ object ConfigTransfer { activeProfileId = root.optString("activeProfileId").ifBlank { null }, alwaysOnProfileId = root.optString("alwaysOnProfileId").ifBlank { null }, diagnosticLogLimitMb = root.optInt("diagnosticLogLimitMb", 3).coerceIn(1, 100), - globalConnectionSettings = if (version >= 2) { + globalConnectionSettings = if (version >= 2 && (globalFields.isNotEmpty() || version < 4)) { decodeGlobalSettings(root).let { global -> if (version < 4) global.copy(sshProfile = profiles.first().config.sshProfile) else global } diff --git a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt index 32d933c..080ac7f 100644 --- a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt @@ -51,6 +51,52 @@ class ConfigSubscriptionIntegrationTest { private fun apply(value: PortableConfiguration, running: Boolean = false) = store.applySubscriptionSnapshot( store.subscriptionState()!!.generation, value, 0, 1000, running) + @Test fun omittedAndPartialGlobalsPreserveLocalSettingsOnImportAndRefresh() { + bootstrap(profile("one")) + store.setConnectionProfile("one") + val local = GlobalConnectionSettings( + tlsProfile = TlsProfile.FIREFOX_ANDROID, customJa3 = "local-ja3", + sshProfile = SshProfile.OPENSSH_TERMUX, sshAuthMode = SshAuthMode.KEY_ONLY, + sshKeepaliveSeconds = 90, sshMaxChannels = 16, sshRotationMinutes = 10, sshRotationMb = 128, + failoverMode = FailoverMode.SELECTED, failoverProfileIds = listOf("one"), + routeAllApps = false, selectedPackages = setOf("org.example.app"), bypassLocalNetworks = false, + ) + store.saveGlobalConnectionSettings(local) + store.setDiagnosticLogLimitMb(20) + val json = document(profile("one")) + val minimal = parseSubscriptionSnapshot(json.toString(), store.profiles()) + assertNull(minimal.globalConnectionSettings) + assertTrue(apply(minimal, running = true)) + assertEquals(local, ConfigStore(context).globalConnectionSettings()) + assertEquals(20, store.diagnosticLogLimitMb()) + assertFalse(store.hasPendingReconnect()) + store.importConfiguration(ConfigTransfer.importJson(json.toString())) + assertEquals(local, store.globalConnectionSettings()) + assertEquals(20, store.diagnosticLogLimitMb()) + + json.put("tls", JSONObject().put("fingerprint", "CHROME_ANDROID")) + json.put("routing", JSONObject().put("bypassLocalNetworks", true)) + val partial = parseSubscriptionSnapshot(json.toString(), store.profiles()) + // Resolve against storage at commit time, including edits made after parsing. + val newer = local.copy(selectedPackages = setOf("org.example.new")) + store.saveGlobalConnectionSettings(newer) + assertTrue(apply(partial, running = true)) + val expected = newer.copy(tlsProfile = TlsProfile.CHROME_ANDROID, bypassLocalNetworks = true) + assertEquals(expected, ConfigStore(context).globalConnectionSettings()) + assertEquals(20, store.diagnosticLogLimitMb()) + assertTrue(store.hasPendingReconnect()) + store.saveGlobalConnectionSettings(newer) + store.importConfiguration(ConfigTransfer.importJson(json.toString())) + assertEquals(expected, store.globalConnectionSettings()) + + json.put("routing", JSONObject().put("routeAllApps", true).put("selectedPackages", JSONArray())) + json.put("diagnosticLogLimitMb", 8) + assertTrue(apply(parseSubscriptionSnapshot(json.toString(), store.profiles()))) + assertTrue(store.globalConnectionSettings().routeAllApps) + assertTrue(store.globalConnectionSettings().selectedPackages.isEmpty()) + assertEquals(8, store.diagnosticLogLimitMb()) + } + @Test fun updateDuringStartupRequiresReconnectAndNotificationAfterCommit() { bootstrap(profile("one")) store.setConnectionProfile("one")