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
18 changes: 17 additions & 1 deletion .github/workflows/android-device.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,28 @@ jobs:
avd-name: megaproxy-device
profile: pixel_5
force-avd-creation: false
emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
# Stream kernel failures independently of ADB. Combining this with -logcat
# makes API 35 assign two serial devices to stdio and abort before boot.
emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -show-kernel
disable-animations: true
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: Collect runner diagnostics
if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' }}
env:
DEVICE_API: ${{ inputs.api }}
run: |
mkdir -p "test-results/android-api$DEVICE_API"
{
date -u
"$ANDROID_HOME/platform-tools/adb" devices -l
ps -eo pid,ppid,stat,rss,comm
free -m
df -h
sudo dmesg --ctime | tail -200
} > "test-results/android-api$DEVICE_API/host-diagnostics.txt" 2>&1
- 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)) == '' }}
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ branch names, credentials, signing material, or other secrets.
- Compose interaction tests may run in `app/src/test` using Robolectric with a pinned SDK and
plain test Application. Inject platform operations; do not load Go JNI or real Keystore in
those tests. Gradle resolves pinned API 26/35 Robolectric SDK jars before tests; the test
runtime uses offline resolution. They run through Fastlane Android checks without an emulator.
runtime uses offline resolution. UI tests dispatch configuration work deterministically;
a controlled worker test verifies the background-read boundary. They run through Fastlane
Android checks without an emulator.
- Exercise real screens and ConfigStore with a test-only Keystore provider. Before asserting
service commands or their absence, drain ordered configuration writes and check both completion
and failure state; `pending == 0` alone does not prove success. Recorded service intents do not
Expand Down Expand Up @@ -101,6 +103,9 @@ branch names, credentials, signing material, or other secrets.

## Product and UI conventions

- MegaProxy is selected for split tunneling by default. Existing lists gain it once on upgrade;
subsequent explicit exclusion must survive reloads, unrelated edits and profile changes.

- All user-visible UI text must use Android string resources and be supplied in both the default
English resources and Russian resources. The app language selects UI words; number, date and
other data formatting follows the system locale. Do not localize standardized data-unit symbols.
Expand Down
25 changes: 18 additions & 7 deletions app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ class DnsDeviceTest : DeviceTestBase() {
connect()
dnsQuery(true)
dnsQuery(false)
if (type == ProxyType.HTTPS && mode == "https") {
DatagramSocket().use { first ->
dnsQuery(false, first)
repeat(80) { dnsQuery(false) }
// Reuse an application socket after its idle TUN mapping was evicted.
dnsQuery(false, first)
}
}
click(R.string.disconnect)
stopped()
}
Expand Down Expand Up @@ -80,7 +88,7 @@ class DnsDeviceTest : DeviceTestBase() {
stopped()
}

private fun dnsQuery(tcp: Boolean) {
private fun dnsQuery(tcp: Boolean, udpSocket: DatagramSocket? = null) {
val query = byteArrayOf(0x48, 0x73, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 7) +
"private".toByteArray(Charsets.US_ASCII) + byteArrayOf(4) +
"test".toByteArray(Charsets.US_ASCII) + byteArrayOf(0, 0, 1, 0, 1)
Expand All @@ -89,12 +97,15 @@ class DnsDeviceTest : DeviceTestBase() {
socket.connect(InetSocketAddress("10.77.0.2", 53), 10_000)
DataOutputStream(socket.getOutputStream()).apply { writeShort(query.size); write(query); flush() }
DataInputStream(socket.getInputStream()).let { input -> ByteArray(input.readUnsignedShort()).also { input.readFully(it) } }
} else DatagramSocket().use { socket ->
socket.soTimeout = 5_000
socket.send(DatagramPacket(query, query.size, InetAddress.getByName("10.77.0.2"), 53))
val packet = DatagramPacket(ByteArray(512), 512)
socket.receive(packet)
packet.data.copyOf(packet.length)
} else {
fun exchange(socket: DatagramSocket): ByteArray {
socket.soTimeout = 5_000
socket.send(DatagramPacket(query, query.size, InetAddress.getByName("10.77.0.2"), 53))
val packet = DatagramPacket(ByteArray(512), 512)
socket.receive(packet)
return packet.data.copyOf(packet.length)
}
if (udpSocket != null) exchange(udpSocket) else DatagramSocket().use(::exchange)
}
assertEquals(0x48, response[0].toInt())
assertEquals(0x73, response[1].toInt())
Expand Down
16 changes: 10 additions & 6 deletions app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,17 @@ class VpnDeviceTest : DeviceTestBase() {
@Test fun notificationActionStopsRealService() {
connect()
roundTrip()
device.openNotification()
val action = device.wait(androidx.test.uiautomator.Until.findObject(
androidx.test.uiautomator.By.desc(text(R.string.disconnect))), 10_000)
assertNotNull("VPN notification must expose Disconnect", action)
action.click()
val manager = context.getSystemService(android.app.NotificationManager::class.java)
var action: android.app.Notification.Action? = null
await("VPN notification must expose Disconnect") {
action = manager.activeNotifications.asSequence().flatMap {
it.notification.actions.orEmpty().asSequence()
}.firstOrNull { it.title.toString() == text(R.string.disconnect) }
action != null
}
// Send the published PendingIntent through Android; shade expansion varies by API/device.
requireNotNull(action).actionIntent.send()
stopped()
device.pressBack()
directOriginUnavailable()
}

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/net/megaproxy487/SpeedTestScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
Expand Down Expand Up @@ -65,6 +66,7 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) {
var megabytes by remember { mutableStateOf(SPEED_TEST_MEGABYTES.first()) }
var log by remember { mutableStateOf<List<String>>(emptyList()) }
val scope = rememberCoroutineScope()
val store = remember(activity) { net.megaproxy487.data.ConfigStore(activity) }
Scaffold(
topBar = {
TopAppBar(
Expand All @@ -86,6 +88,15 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) {
stringResource(R.string.cloudflare_speed_explanation, megabytes, megabytes * 4),
style = MaterialTheme.typography.bodyMedium,
)
val settings = readConfiguration(store) { store.globalConnectionSettings() }
if (settings != null && !settings.routeAllApps && activity.packageName !in settings.selectedPackages) {
Card(colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)) {
Text(stringResource(R.string.speed_split_tunnel_warning), Modifier.padding(12.dp))
}
}
Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.SpaceEvenly) {
SPEED_TEST_MEGABYTES.forEach { size ->
Row(
Expand Down
13 changes: 10 additions & 3 deletions app/src/main/java/net/megaproxy487/data/ConfigIo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@ package net.megaproxy487.data
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import java.util.concurrent.Executors
import kotlin.coroutines.CoroutineContext

/** Ordered background dispatcher for configuration encryption, JSON and persistence. */
val ConfigIoDispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { task ->
Thread(task, "megaproxy-config-io").apply { isDaemon = true }
}.asCoroutineDispatcher()
object ConfigIoDispatcher : CoroutineDispatcher() {
private val worker = Executors.newSingleThreadExecutor { task ->
Thread(task, "megaproxy-config-io").apply { isDaemon = true }
}.asCoroutineDispatcher()
@Volatile internal var delegate: CoroutineDispatcher = worker
internal fun reset() { delegate = worker }
override fun isDispatchNeeded(context: CoroutineContext) = delegate.isDispatchNeeded(context)
override fun dispatch(context: CoroutineContext, block: Runnable) = delegate.dispatch(context, block)
}
16 changes: 11 additions & 5 deletions app/src/main/java/net/megaproxy487/data/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,10 @@ class ConfigStore(context: Context) {
if (stored != null) {
val decoded = decodeGlobalConnectionSettings(stored)
migrateGlobalIpv6ToProfiles(stored)
if (!operationResult { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) {
val upgraded = decoded.copy(sshProfile = activeProfile().config.sshProfile)
val fields = JSONObject(stored)
if (!fields.has("sshProfile") || !fields.optBoolean("megaProxyRoutingDefaultApplied", false)) {
val upgraded = if (fields.has("sshProfile")) decoded
else decoded.copy(sshProfile = activeProfile().config.sshProfile)
saveGlobalConnectionSettings(upgraded)
return@synchronized upgraded
}
Expand All @@ -190,7 +192,7 @@ class ConfigStore(context: Context) {
tlsProfile = source.profile.takeIf { it.available } ?: TlsProfile.DEFAULT,
sshProfile = source.sshProfile,
customJa3 = source.customJa3,
selectedPackages = source.selectedPackages,
selectedPackages = source.selectedPackages + context.packageName,
routeAllApps = source.routeAllApps,
bypassLocalNetworks = source.bypassLocalNetworks,
)
Expand Down Expand Up @@ -760,13 +762,14 @@ class ConfigStore(context: Context) {
put("failoverProfileIds", JSONArray(settings.failoverProfileIds))
put("customJa3", settings.customJa3.trim())
put("packages", JSONArray(settings.selectedPackages.sorted()))
put("megaProxyRoutingDefaultApplied", true)
put("routeAllApps", settings.routeAllApps)
put("bypassLocalNetworks", settings.bypassLocalNetworks)
}.toString()

private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult {
val item = JSONObject(value)
for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6")) {
for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied")) {
require(!item.has(key) || item.get(key) is Boolean)
}
for (key in listOf("packages", "failoverProfileIds")) {
Expand All @@ -791,7 +794,10 @@ class ConfigStore(context: Context) {
customJa3 = item.optString("customJa3"),
selectedPackages = item.optJSONArray("packages")?.let { array ->
(0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) }.toSet()
}.orEmpty(),
}.orEmpty().let { packages ->
// Old lists did not record whether excluding MegaProxy was deliberate.
if (item.optBoolean("megaProxyRoutingDefaultApplied", false)) packages else packages + context.packageName
},
routeAllApps = item.optBoolean("routeAllApps", true),
bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true),
)
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,12 @@ internal fun retainProfileSecrets(
presence: ProfileSecretPresence,
): net.megaproxy487.model.ProxyProfile {
if (current == null) return source
val sameDns = source.config.dnsProvider == current.config.dnsProvider &&
(source.config.dnsProvider != net.megaproxy487.model.DnsProvider.CUSTOM ||
source.config.customDohUrl.trim() == current.config.customDohUrl.trim())
return source.copy(config = source.config.copy(
allowInsecureDns = sameDns && current.config.allowInsecureDns,
allowInvalidDohCertificate = sameDns && current.config.allowInvalidDohCertificate,
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,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -576,4 +576,5 @@
<string name="allow_invalid_doh_certificate_warning">Допускает самоподписанные, просроченные сертификаты и несовпадение имени только для DoH. DNS-трафик может быть перехвачен. Проверка сертификата прокси не меняется.</string>
<string name="battery_settings_unavailable">Настройки батареи недоступны на этом устройстве.</string>
<string name="error_config_capacity">Достигнут лимит хранилища конфигурации. Удалите профили или уменьшите их содержимое.</string>
<string name="speed_split_tunnel_warning">MegaProxy не выбран для раздельного туннелирования. Для измерения скорости VPN-туннеля включите «Направлять MegaProxy через VPN» в настройках раздельного туннелирования и переподключитесь. Основную сеть можно измерить.</string>
</resources>
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -577,4 +577,5 @@
<string name="allow_invalid_doh_certificate_warning">Allows self-signed, expired and mismatched certificates for DoH only. DNS traffic may be intercepted. Proxy certificate verification is unchanged.</string>
<string name="battery_settings_unavailable">Battery settings are unavailable on this device.</string>
<string name="error_config_capacity">Configuration storage limit reached. Remove profiles or reduce their contents.</string>
<string name="speed_split_tunnel_warning">MegaProxy is not selected for split tunneling. To measure VPN tunnel speed, enable “Route MegaProxy through VPN” in split tunneling settings and reconnect. The underlying network can still be measured.</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ class ConfigSubscriptionIntegrationTest {
private fun apply(value: PortableConfiguration, running: Boolean = false) = store.applySubscriptionSnapshot(
store.subscriptionState()!!.generation, value, 0, 1000, running)

@Test fun jsonRefreshPreservesLocalDnsConsentOnlyForUnchangedResolver() {
val original = profile("one").let { it.copy(config = it.config.copy(
dnsProvider = DnsProvider.CUSTOM, customDohUrl = "https://dns.example/dns-query")) }
bootstrap(original)
store.saveProfile(original.copy(config = original.config.copy(
allowInsecureDns = true, allowInvalidDohCertificate = true)))
val rotated = original.copy(config = original.config.copy(password = "rotated"))
assertTrue(apply(parseSubscriptionSnapshot(document(rotated).toString(), store.profiles())))
val retained = ConfigStore(context).profile("one")!!.config
assertTrue(retained.allowInsecureDns)
assertTrue(retained.allowInvalidDohCertificate)
assertEquals("rotated", retained.password)
val changed = rotated.copy(config = rotated.config.copy(customDohUrl = "https://other.example/dns-query"))
assertTrue(apply(parseSubscriptionSnapshot(document(changed).toString(), store.profiles())))
val reset = ConfigStore(context).profile("one")!!.config
assertFalse(reset.allowInsecureDns)
assertFalse(reset.allowInvalidDohCertificate)
}

@Test fun plainDnsConsentSurvivesJsonImportForUnchangedResolver() {
val original = profile("one").let { it.copy(config = it.config.copy(
dnsProvider = DnsProvider.CUSTOM, customDohUrl = "tcp://1.1.1.1", allowInsecureDns = true)) }
store.saveProfile(original, createIfMissing = true)
store.importConfiguration(ConfigTransfer.importJson(document(original).toString()))
assertTrue(ConfigStore(context).profile("one")!!.config.allowInsecureDns)
}

@Test fun omittedAndPartialGlobalsPreserveLocalSettingsOnImportAndRefresh() {
bootstrap(profile("one"))
store.setConnectionProfile("one")
Expand Down
27 changes: 17 additions & 10 deletions app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,40 @@ package net.megaproxy487
import android.os.Looper
import androidx.compose.material3.Text
import androidx.compose.ui.test.onNodeWithText
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import net.megaproxy487.data.ConfigIoDispatcher
import org.junit.Assert.assertFalse
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.CoroutineContext

class ConfigurationReadUiTest : MainUiTestBase() {
@Test fun slowConfigurationReadLeavesUiResponsiveAndDoesNotExposeDefaults() {
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val tasks = LinkedBlockingQueue<Runnable>()
ConfigIoDispatcher.delegate = object : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) { tasks.add(block) }
}
val worker = Executors.newSingleThreadExecutor()
val onMain = AtomicBoolean()
try {
compose.setContent {
val configuration = readConfiguration("slow") {
onMain.set(Looper.myLooper() == Looper.getMainLooper())
entered.countDown()
release.await() // The test owns completion; finally always releases the worker.
"Loaded configuration"
}
Text(configuration ?: "Loading configuration")
}
compose.waitForIdle()
compose.waitUntil(5_000) { entered.count == 0L }
compose.onNodeWithText("Loaded configuration").assertDoesNotExist()
worker.submit(checkNotNull(tasks.poll(5, TimeUnit.SECONDS))).get(5, TimeUnit.SECONDS)
assertFalse(onMain.get())
release.countDown()
drainConfigIo()
waitForText("Loaded configuration")
} finally { release.countDown() }
} finally {
ConfigIoDispatcher.delegate = Dispatchers.Unconfined
worker.shutdownNow()
}
}
}
Loading
Loading