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
28 changes: 14 additions & 14 deletions .fdroid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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") {
Expand Down
404 changes: 353 additions & 51 deletions app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt

Large diffs are not rendered by default.

127 changes: 101 additions & 26 deletions app/src/main/java/net/megaproxy487/data/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

data class ConfigurationImportResult(
val added: List<ProxyProfile>,
val updated: List<ProxyProfile>,
val unchanged: List<ProxyProfile>,
val missing: List<ProxyProfile>,
)

class ConfigStore(context: Context) {
private val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE)

Expand Down Expand Up @@ -209,47 +216,93 @@ class ConfigStore(context: Context) {
fun importProfiles(imported: List<ImportedProxy>): List<ProxyProfile> {
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<ProxyProfile>()
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<ProxyProfile> {
fun importConfiguration(configuration: PortableConfiguration): ConfigurationImportResult {
val existing = profiles()
val idMap = mutableMapOf<String, String>()
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<ProxyProfile>()
val updated = mutableListOf<ProxyProfile>()
val unchanged = mutableListOf<ProxyProfile>()
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()
Expand Down Expand Up @@ -296,6 +349,28 @@ class ConfigStore(context: Context) {
return true
}

@Synchronized
fun deleteProfiles(ids: Set<String>): 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

Expand Down
36 changes: 33 additions & 3 deletions app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,22 @@ data class PortableConfiguration(
val diagnosticLogLimitMb: Int = 3,
val globalConnectionSettings: GlobalConnectionSettings? = null,
val skippedProfiles: Int = 0,
val secretPresence: Map<String, ProfileSecretPresence> = 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<ProxyProfile>, includePasswords: Boolean): String =
profiles.filter { it.config.type == ProxyType.HTTPS }.joinToString("\n", postfix = "\n") { profile ->
Expand All @@ -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)
Expand Down Expand Up @@ -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<ProxyProfile>::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))
Expand All @@ -109,6 +127,18 @@ object ConfigTransfer {
}
} else null,
skippedProfiles = decoded.count(Result<ProxyProfile>::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(),
)
}

Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import org.json.JSONObject
object FoxyProxyParser {
fun parse(text: String): Result<ProxyListImportResult> = 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")
Expand Down
Loading