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
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t
## Why MegaProxy

- **Private by design.** No account, ads, analytics, tracking identifiers, or background telemetry.
- **Your infrastructure.** Connect to HTTPS, SSH, or SSH-with-jump servers that you configure.
- **Your infrastructure.** Connect to your HTTPS or SSH servers, directly or through a jump server.
- **End-to-end application encryption.** HTTPS proxying uses CONNECT without intercepting or
decrypting application traffic.
- **Flexible routing.** Route the whole device or only selected applications through the VPN.
Expand All @@ -35,7 +35,7 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t
### Connection profiles

- Multiple named, colored, reorderable profiles.
- HTTPS proxies over TLS with Basic authentication.
- HTTPS proxies over TLS with Basic authentication, including two-proxy HTTPS with Jump chains.
- HTTP/2 CONNECT multiplexing when supported by the proxy, with automatic HTTP/1.1 fallback.
- SSH `direct-tcpip` transport and SSH through a jump host.
- SSH password and unencrypted private-key authentication.
Expand Down Expand Up @@ -189,12 +189,33 @@ Server configurations and setup instructions are maintained separately in
### 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
8. 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.

### HTTPS with Jump

Select **HTTPS with Jump** to use two HTTPS CONNECT proxies in sequence:
phone → jump proxy → destination proxy → website. Enter the destination proxy in the main
connection fields and the first hop in **Jump HTTPS proxy**. Both ports default to 443.
The jump proxy must allow CONNECT to the destination proxy hostname and port; it resolves that
hostname. Only the jump proxy is bootstrapped on the phone. Both hops use the selected TLS
fingerprint and support HTTP/1.1 and HTTP/2 CONNECT independently.

Each hop verifies its own TLS certificate and can use separate Basic Auth credentials. The
optional shared-authentication setting reuses the destination username and password. Certificate
verification exceptions apply only to the selected hop. Connection tests and DoH use the chain;
local-network destinations still follow the existing bypass setting. A failed hop never causes
fallback to a direct connection to the destination proxy.

JSON schema version 8 stores this mode as `proxy.type: "HTTPS_JUMP"`, with first-hop settings in
`proxy.jump`: `host`, `port`, `sameAuthentication`, `username`, `password`, and
`allowInvalidProxyCertificate`. Export passwords only when needed. Older application versions
reject version 8 files, preventing a chain from being imported as a single proxy. ProxyList
exports support single HTTPS proxies only and omit chain profiles.

## Current limitations

- Only TCP application traffic is forwarded. General SOCKS5 UDP and QUIC forwarding are not
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack
val runTest = {
val configStore = ConfigStore(activity)
val error = configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config)
.connectionValidationError()
.connectionValidationError()?.let { activity.getString(it) }
if (error != null) {
TestDiagnosticLog.fail(error)
} else {
Expand Down
138 changes: 126 additions & 12 deletions app/src/main/java/net/megaproxy487/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import java.text.DateFormat
import java.util.Date
import androidx.compose.ui.platform.LocalConfiguration
import net.megaproxy487.vpn.ConnectionSession
import net.megaproxy487.vpn.connectionDuration
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.rememberLauncherForActivityResult
Expand Down Expand Up @@ -58,6 +64,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.foundation.clickable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.stringResource
Expand All @@ -70,6 +84,7 @@ import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
Expand Down Expand Up @@ -148,8 +163,9 @@ class MainActivity : LocalizedActivity() {
}

@Composable
private fun ProfileTypeBadge(type: ProxyType, foreground: Color) {
private fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = foreground.copy(alpha = 0.14f),
contentColor = foreground,
shape = RoundedCornerShape(50),
Expand All @@ -158,11 +174,57 @@ private fun ProfileTypeBadge(type: ProxyType, foreground: Color) {
Text(
when (type) {
ProxyType.HTTPS -> "HTTPS"
ProxyType.HTTPS_JUMP -> stringResource(R.string.https_with_jump)
ProxyType.SSH -> "SSH"
ProxyType.SSH_JUMP -> "SSH + Jump"
},
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.sp),
maxLines = 1,
softWrap = false,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}

/** Keep the type readable while a long name fades beneath its trailing badge. */
@Composable
private fun ProfileSelectorLabel(
name: String,
type: ProxyType,
foreground: Color,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodyLarge,
) {
var badgeWidth by remember { mutableStateOf(0) }
Box(modifier, contentAlignment = Alignment.CenterStart) {
Text(
name,
color = foreground,
style = style,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Clip,
modifier = Modifier.fillMaxWidth()
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
// Mask only the name layer: the badge and its border stay crisp.
val end = (size.width - badgeWidth - 6.dp.toPx()).coerceAtLeast(0f)
val start = (end - 24.dp.toPx()).coerceAtLeast(0f)
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, Color.Transparent),
startX = start,
endX = end.coerceAtLeast(start + 1f),
),
blendMode = BlendMode.DstIn,
)
},
)
ProfileTypeBadge(
type,
foreground,
Modifier.align(Alignment.CenterEnd).onSizeChanged { badgeWidth = it.width },
)
}
}
Expand Down Expand Up @@ -297,7 +359,7 @@ internal fun MainScreen(
systemVpnStatus = readAlwaysOnVpnStatus(activity)
error = null
} else {
error = globalSettings.applyTo(store.activeProfile().config).validationError()
error = globalSettings.applyTo(store.activeProfile().config).validationError()?.let { activity.getString(it) }
}
if (error == null && !isAlwaysOnVpnActive(activity)) {
if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(
Expand Down Expand Up @@ -390,7 +452,7 @@ internal fun MainScreen(
val displayedProfileId = if (alwaysOn) runtimeProfileId.ifEmpty { connectionProfileId } else activeProfileId
val activeProfile = profiles.firstOrNull { it.id == displayedProfileId } ?: profiles.first()
val actualProfile = profiles.firstOrNull { it.id == runtimeProfileId }
val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()
val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()?.let { activity.getString(it) }
val profileColor = Color(ProfileColors.argb[Math.floorMod(activeProfile.colorIndex, ProfileColors.argb.size)])
val onProfileColor = if (profileColor.luminance() > 0.45f) Color.Black else Color.White
Box(Modifier.fillMaxWidth()) {
Expand Down Expand Up @@ -420,21 +482,32 @@ internal fun MainScreen(
Text(activeProfile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp))
}
}
Text(activeProfile.displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
ProfileTypeBadge(activeProfile.config.type, onProfileColor)
ProfileSelectorLabel(
activeProfile.displayName,
activeProfile.config.type,
onProfileColor,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
)
Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.select_profile), tint = onProfileColor)
}
if (actualProfile != null && actualProfile.id != activeProfile.id && connection != VpnConnectionState.DISCONNECTED) {
Text(stringResource(R.string.connected_through, actualProfile.displayNameWithFlag), style = MaterialTheme.typography.bodySmall)
}
DropdownMenu(profileMenuExpanded, { profileMenuExpanded = false }) {
DropdownMenu(
profileMenuExpanded,
{ profileMenuExpanded = false },
modifier = Modifier.widthIn(min = 280.dp),
) {
profiles.forEach { profile ->
DropdownMenuItem(
text = {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(profile.displayNameWithFlag, modifier = Modifier.weight(1f))
ProfileTypeBadge(profile.config.type, MaterialTheme.colorScheme.onSurface)
}
ProfileSelectorLabel(
profile.displayNameWithFlag,
profile.config.type,
MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
},
onClick = {
val useAsAlwaysOn = isAlwaysOnVpnActive(activity)
Expand Down Expand Up @@ -714,6 +787,47 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp),
)
val session by VpnRuntimeState.session
session?.let { ConnectionTimingDetails(it) }
}
}

@Composable
private fun ConnectionTimingDetails(session: ConnectionSession) {
val lifecycleOwner = LocalLifecycleOwner.current
var duration by remember(session) {
mutableStateOf(connectionDuration(session.elapsedMillis(SystemClock.elapsedRealtime())))
}
LaunchedEffect(session, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (true) {
duration = connectionDuration(session.elapsedMillis(SystemClock.elapsedRealtime()))
delay(duration.nextUpdateDelayMillis)
}
}
}
val locale = LocalConfiguration.current.locales[0]
val startedAt = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, locale)
.format(Date(session.startedAtMillis))
val seconds = duration.seconds
val elapsed = when {
seconds < 60 -> stringResource(R.string.connection_duration_seconds, seconds)
seconds < 600 -> stringResource(R.string.connection_duration_minutes_seconds, seconds / 60, seconds % 60)
seconds < 3_600 -> stringResource(R.string.connection_duration_minutes, seconds / 60)
seconds < 86_400 -> stringResource(R.string.connection_duration_hours_minutes, seconds / 3_600, seconds / 60 % 60)
else -> stringResource(R.string.connection_duration_days_hours, seconds / 86_400, seconds / 3_600 % 24)
}
Column(Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp)) {
Text(
stringResource(R.string.connection_started_at, startedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.connection_duration, elapsed),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

Expand Down
Loading