diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0b4cfea72..10eff0663 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -42,6 +43,15 @@ android:exported="false" android:theme="@android:style/Theme.NoTitleBar" /> + + + + + + Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + ) { + Text( + text = stringResource(R.string.autostart_on_boot_title), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.autostart_on_boot_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + Switch( + checked = enabled, + onCheckedChange = onEnabledChange, + ) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/BootCompletedReceiver.kt b/app/src/main/java/com/amurcanov/tgwsproxy/BootCompletedReceiver.kt new file mode 100644 index 000000000..89b5f9281 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/BootCompletedReceiver.kt @@ -0,0 +1,108 @@ +package com.amurcanov.tgwsproxy + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +internal enum class BootAutostartDecision { + DISABLED, + ALREADY_RUNNING, + INVALID_SAVED_CONFIG, + START, +} + +internal object BootAutostartPolicy { + fun evaluate( + enabled: Boolean, + savedConfig: ProxySavedConfig, + alreadyRunning: Boolean, + ): BootAutostartDecision { + if (!enabled) { + return BootAutostartDecision.DISABLED + } + if (alreadyRunning) { + return BootAutostartDecision.ALREADY_RUNNING + } + if (!savedConfig.isSufficientForStart()) { + return BootAutostartDecision.INVALID_SAVED_CONFIG + } + return BootAutostartDecision.START + } +} + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) { + return + } + + val appContext = context.applicationContext + AppLogger.i( + context = appContext, + category = AppLogCategory.APP, + message = "BOOT_COMPLETED_RECEIVED", + ) + + val prefs = appContext.getSharedPreferences(PROXY_PREFS_NAME, Context.MODE_PRIVATE) + val autostartEnabled = AutostartPreferences(prefs).isEnabled() + if (!autostartEnabled) { + AppLogger.i( + context = appContext, + category = AppLogCategory.APP, + message = "BOOT_AUTOSTART_DISABLED", + ) + return + } + + val savedConfig = ProxySavedConfigStore(prefs).load() + when ( + BootAutostartPolicy.evaluate( + enabled = true, + savedConfig = savedConfig, + alreadyRunning = ProxyService.isRunning.value, + ) + ) { + BootAutostartDecision.DISABLED -> Unit + + BootAutostartDecision.ALREADY_RUNNING -> { + AppLogger.i( + context = appContext, + category = AppLogCategory.APP, + message = "BOOT_AUTOSTART_ALREADY_RUNNING", + ) + } + + BootAutostartDecision.INVALID_SAVED_CONFIG -> { + AppLogger.w( + context = appContext, + category = AppLogCategory.APP, + message = "BOOT_AUTOSTART_SKIPPED_INVALID_STATE", + details = mapOf("frontend" to savedConfig.frontendType.name), + ) + } + + BootAutostartDecision.START -> { + val serviceIntent = Intent(appContext, ProxyService::class.java).apply { + action = ProxyService.ACTION_START + } + try { + ContextCompat.startForegroundService(appContext, serviceIntent) + AppLogger.i( + context = appContext, + category = AppLogCategory.APP, + message = "BOOT_AUTOSTART_START_REQUESTED", + details = mapOf("frontend" to savedConfig.frontendType.name), + ) + } catch (error: RuntimeException) { + AppLogger.e( + context = appContext, + category = AppLogCategory.APP, + message = "BOOT_AUTOSTART_START_FAILED", + details = mapOf("error" to error.javaClass.simpleName), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt index 9dea59854..c39584347 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt @@ -335,6 +335,10 @@ private fun ProxyScreen( var notificationPrefs by remember { mutableStateOf(NotificationPreferences.load(context)) } + val autostartPreferences = remember { AutostartPreferences(prefs) } + var autostartOnBoot by remember { + mutableStateOf(autostartPreferences.isEnabled()) + } var showOnboarding by remember { mutableStateOf(!prefs.getBoolean("onboarding_completed", false)) } @@ -2876,6 +2880,16 @@ private fun ProxyScreen( titleRes = R.string.settings_section_app_title, subtitleRes = R.string.settings_section_app_subtitle, ) + SettingsSectionCard(titleRes = R.string.section_service) { + AutostartSettingsSection( + enabled = autostartOnBoot, + onEnabledChange = { enabled -> + autostartOnBoot = enabled + autostartPreferences.setEnabled(enabled) + }, + ) + } + SettingsSectionCard( titleRes = R.string.section_notifications, subtitle = stringResource(R.string.section_notifications_hint), diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxySavedConfigStore.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxySavedConfigStore.kt new file mode 100644 index 000000000..1cf9b3ca0 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxySavedConfigStore.kt @@ -0,0 +1,67 @@ +package com.amurcanov.tgwsproxy + +import android.content.SharedPreferences + +internal const val PROXY_PREFS_NAME = "ProxyPrefs" + +internal data class ProxySavedConfig( + val port: Int = DEFAULT_LOCAL_PROXY_PORT, + val runtimeConfig: String = "", + val poolSize: Int = 4, + val frontendType: LocalProxyFrontendType = LocalProxyFrontendType.DEFAULT, +) { + fun isSufficientForStart(): Boolean { + if (port !in 1..65535 || poolSize <= 0) { + return false + } + return when (frontendType) { + LocalProxyFrontendType.SOCKS5 -> runtimeConfig.isNotBlank() + LocalProxyFrontendType.MTPROTO_EXPERIMENTAL -> true + } + } +} + +internal class ProxySavedConfigStore( + private val prefs: SharedPreferences, +) { + fun load(): ProxySavedConfig { + val savedPort = prefs.getInt(KEY_LAST_PORT, DEFAULT_LOCAL_PROXY_PORT) + val migrationDone = prefs.getBoolean(KEY_LAST_PORT_DEFAULT_MIGRATED, false) + val port = if (!migrationDone && savedPort == LEGACY_DEFAULT_LOCAL_PROXY_PORT) { + DEFAULT_LOCAL_PROXY_PORT + } else { + savedPort + } + + if (savedPort != port || !migrationDone) { + prefs.edit() + .putInt(KEY_LAST_PORT, port) + .putBoolean(KEY_LAST_PORT_DEFAULT_MIGRATED, true) + .apply() + } + + return ProxySavedConfig( + port = port, + runtimeConfig = prefs.getString(KEY_LAST_IPS, "").orEmpty(), + poolSize = prefs.getInt(KEY_LAST_POOL, 4), + frontendType = LocalProxyFrontendRepository(prefs).load(), + ) + } + + fun save(config: ProxySavedConfig) { + prefs.edit() + .putInt(KEY_LAST_PORT, config.port) + .putBoolean(KEY_LAST_PORT_DEFAULT_MIGRATED, true) + .putString(KEY_LAST_IPS, config.runtimeConfig) + .putInt(KEY_LAST_POOL, config.poolSize) + .apply() + LocalProxyFrontendRepository(prefs).save(config.frontendType) + } + + companion object { + private const val KEY_LAST_PORT = "last_proxy_port" + private const val KEY_LAST_PORT_DEFAULT_MIGRATED = "last_proxy_port_default_1443_migrated" + private const val KEY_LAST_IPS = "last_runtime_ips" + private const val KEY_LAST_POOL = "last_proxy_pool" + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt index aab439113..dff2165ff 100644 --- a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt @@ -51,12 +51,8 @@ class ProxyService : Service() { ) private val LEGACY_NOTIFICATION_IDS = listOf(1, 2) - private const val PREFS = "ProxyPrefs" + private const val PREFS = PROXY_PREFS_NAME private const val KEY_NOTIFICATION_CHANNELS_MIGRATED = "notification_channels_v3_migrated" - private const val KEY_LAST_PORT = "last_proxy_port" - private const val KEY_LAST_PORT_DEFAULT_MIGRATED = "last_proxy_port_default_1443_migrated" - private const val KEY_LAST_IPS = "last_runtime_ips" - private const val KEY_LAST_POOL = "last_proxy_pool" private val _isRunning = MutableStateFlow(false) val isRunning: StateFlow = _isRunning @@ -118,39 +114,28 @@ class ProxyService : Service() { poolSize: Int, frontendType: LocalProxyFrontendType, ) { - lastFrontendType = frontendType - val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val editor = prefs.edit() - lastPort = port - lastIps = ips - lastPoolSize = poolSize - editor - .putInt(KEY_LAST_PORT, port) - .putBoolean(KEY_LAST_PORT_DEFAULT_MIGRATED, true) - .putString(KEY_LAST_IPS, ips) - .putInt(KEY_LAST_POOL, poolSize) - editor.apply() - LocalProxyFrontendRepository(prefs).save(frontendType) + val config = ProxySavedConfig( + port = port, + runtimeConfig = ips, + poolSize = poolSize, + frontendType = frontendType, + ) + ProxySavedConfigStore(getSharedPreferences(PREFS, Context.MODE_PRIVATE)).save(config) + applyLastConfig(config) } private fun loadLastConfig() { - val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val savedPort = prefs.getInt(KEY_LAST_PORT, DEFAULT_LOCAL_PROXY_PORT) - val portMigrationDone = prefs.getBoolean(KEY_LAST_PORT_DEFAULT_MIGRATED, false) - lastPort = if (!portMigrationDone && savedPort == LEGACY_DEFAULT_LOCAL_PROXY_PORT) { - DEFAULT_LOCAL_PROXY_PORT - } else { - savedPort - } - lastIps = prefs.getString(KEY_LAST_IPS, "").orEmpty() - lastPoolSize = prefs.getInt(KEY_LAST_POOL, 4) - lastFrontendType = LocalProxyFrontendRepository(prefs).load() - if (savedPort != lastPort || !portMigrationDone) { - prefs.edit() - .putInt(KEY_LAST_PORT, lastPort) - .putBoolean(KEY_LAST_PORT_DEFAULT_MIGRATED, true) - .apply() - } + val config = ProxySavedConfigStore( + getSharedPreferences(PREFS, Context.MODE_PRIVATE), + ).load() + applyLastConfig(config) + } + + private fun applyLastConfig(config: ProxySavedConfig) { + lastPort = config.port + lastIps = config.runtimeConfig + lastPoolSize = config.poolSize + lastFrontendType = config.frontendType } private fun startProxy( diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c94f8aec1..347d3027b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -361,6 +361,9 @@ Задержка: %1$s Соединения: %1$d Последняя ошибка: %1$s + Служба + Автозапуск при включении устройства + Прокси будет автоматически запускаться после загрузки Android с последней сохранённой конфигурацией. Уведомления Android требует видимое уведомление для foreground service. TgWsProxy может сделать его компактным или минимальным, но не может полностью убрать его во время работы прокси. Уведомление сервиса diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2368b5bc6..d1ff2a1f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -361,6 +361,9 @@ Latency: %1$s Connections: %1$d Last error: %1$s + Service + Start automatically when device turns on + The proxy will start automatically after Android finishes booting using the last saved configuration. Notifications Android requires a visible notification for foreground services. TgWsProxy can make it compact or minimal, but cannot fully remove it while the proxy is running. Service notification diff --git a/app/src/test/java/com/amurcanov/tgwsproxy/AutostartPreferencesTest.kt b/app/src/test/java/com/amurcanov/tgwsproxy/AutostartPreferencesTest.kt new file mode 100644 index 000000000..d22681645 --- /dev/null +++ b/app/src/test/java/com/amurcanov/tgwsproxy/AutostartPreferencesTest.kt @@ -0,0 +1,26 @@ +package com.amurcanov.tgwsproxy + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutostartPreferencesTest { + @Test + fun default_isDisabled() { + val store = AutostartPreferences(InMemorySharedPreferences()) + + assertFalse(store.isEnabled()) + } + + @Test + fun setEnabled_persistsValue() { + val prefs = InMemorySharedPreferences() + val store = AutostartPreferences(prefs) + + store.setEnabled(true) + assertTrue(store.isEnabled()) + + store.setEnabled(false) + assertFalse(store.isEnabled()) + } +} diff --git a/app/src/test/java/com/amurcanov/tgwsproxy/BootAutostartPolicyTest.kt b/app/src/test/java/com/amurcanov/tgwsproxy/BootAutostartPolicyTest.kt new file mode 100644 index 000000000..98d9fb803 --- /dev/null +++ b/app/src/test/java/com/amurcanov/tgwsproxy/BootAutostartPolicyTest.kt @@ -0,0 +1,80 @@ +package com.amurcanov.tgwsproxy + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BootAutostartPolicyTest { + @Test + fun disabled_skipsStart() { + assertEquals( + BootAutostartDecision.DISABLED, + BootAutostartPolicy.evaluate( + enabled = false, + savedConfig = validSocksConfig(), + alreadyRunning = false, + ), + ) + } + + @Test + fun enabledAndValid_starts() { + assertEquals( + BootAutostartDecision.START, + BootAutostartPolicy.evaluate( + enabled = true, + savedConfig = validSocksConfig(), + alreadyRunning = false, + ), + ) + } + + @Test + fun enabledAndInvalidSocksConfig_skipsStart() { + assertEquals( + BootAutostartDecision.INVALID_SAVED_CONFIG, + BootAutostartPolicy.evaluate( + enabled = true, + savedConfig = validSocksConfig().copy(runtimeConfig = ""), + alreadyRunning = false, + ), + ) + } + + @Test + fun mtProto_doesNotRequireRuntimeIps() { + assertEquals( + BootAutostartDecision.START, + BootAutostartPolicy.evaluate( + enabled = true, + savedConfig = ProxySavedConfig( + port = DEFAULT_LOCAL_PROXY_PORT, + runtimeConfig = "", + poolSize = 4, + frontendType = LocalProxyFrontendType.MTPROTO_EXPERIMENTAL, + ), + alreadyRunning = false, + ), + ) + } + + @Test + fun repeatedDelivery_whenServiceAlreadyRunning_skipsSecondStart() { + assertEquals( + BootAutostartDecision.ALREADY_RUNNING, + BootAutostartPolicy.evaluate( + enabled = true, + savedConfig = validSocksConfig(), + alreadyRunning = true, + ), + ) + } + + private fun validSocksConfig(): ProxySavedConfig { + return ProxySavedConfig( + port = DEFAULT_LOCAL_PROXY_PORT, + runtimeConfig = "runtime-config", + poolSize = 4, + frontendType = LocalProxyFrontendType.SOCKS5, + ) + } +} diff --git a/app/src/test/java/com/amurcanov/tgwsproxy/ProxySavedConfigStoreTest.kt b/app/src/test/java/com/amurcanov/tgwsproxy/ProxySavedConfigStoreTest.kt new file mode 100644 index 000000000..91ddf597e --- /dev/null +++ b/app/src/test/java/com/amurcanov/tgwsproxy/ProxySavedConfigStoreTest.kt @@ -0,0 +1,38 @@ +package com.amurcanov.tgwsproxy + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProxySavedConfigStoreTest { + @Test + fun saveAndLoad_roundTripsServiceConfig() { + val prefs = InMemorySharedPreferences() + val store = ProxySavedConfigStore(prefs) + val expected = ProxySavedConfig( + port = 2443, + runtimeConfig = "saved-runtime-config", + poolSize = 6, + frontendType = LocalProxyFrontendType.SOCKS5, + ) + + store.save(expected) + + assertEquals(expected, store.load()) + } + + @Test + fun load_migratesLegacyDefaultPort() { + val prefs = InMemorySharedPreferences() + prefs.edit() + .putInt("last_proxy_port", LEGACY_DEFAULT_LOCAL_PROXY_PORT) + .putString( + LocalProxyFrontendRepository.KEY_FRONTEND_TYPE, + LocalProxyFrontendType.MTPROTO_EXPERIMENTAL.prefValue, + ) + .apply() + + val loaded = ProxySavedConfigStore(prefs).load() + + assertEquals(DEFAULT_LOCAL_PROXY_PORT, loaded.port) + } +} diff --git a/docs/testing/README.md b/docs/testing/README.md index de1cdc0c5..f2c039153 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -101,6 +101,18 @@ Automation contract: [../development/github-automation.md](../development/github - [ ] Stop/start the proxy after a failed route attempt - [ ] Verify the local listener is usable after reconnect/restart +## Boot autostart + +- [ ] Leave **Autostart on boot** disabled, reboot the device, and verify that the proxy service does not start. +- [ ] Start the proxy once with a valid configuration, enable **Autostart on boot**, then reboot without manually opening the app. +- [ ] After `BOOT_COMPLETED`, verify that the existing foreground notification appears and the saved local proxy port is reachable. +- [ ] Open the app and verify that the UI reports the already-running `ProxyService`. +- [ ] Stop the proxy manually, verify that the autostart setting remains enabled, reboot again, and verify that the proxy starts. +- [ ] For SOCKS5, remove/clear the saved runtime configuration in a test setup and verify that boot start is skipped without a crash or restart loop. +- [ ] Verify that repeated boot delivery does not create a second proxy runtime. +- [ ] Disable autostart, reboot again, and verify that no proxy service is started. +- [ ] Review `TgWsProxy` logcat for privacy-safe `BOOT_*` events only; no domains, keys, tokens, or private runtime configuration should be logged. + ## Release smoke (before tagging) Release process: [../releases/release.md](../releases/release.md).