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
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Expand Down Expand Up @@ -42,6 +43,15 @@
android:exported="false"
android:theme="@android:style/Theme.NoTitleBar" />

<receiver
android:name="com.amurcanov.tgwsproxy.BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>

<service
android:name="com.amurcanov.tgwsproxy.ProxyService"
android:exported="false"
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/amurcanov/tgwsproxy/AutostartPreferences.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.amurcanov.tgwsproxy

import android.content.SharedPreferences

internal class AutostartPreferences(
private val prefs: SharedPreferences,
) {
fun isEnabled(): Boolean = prefs.getBoolean(KEY_AUTOSTART_ON_BOOT, false)

fun setEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_AUTOSTART_ON_BOOT, enabled).apply()
}

companion object {
const val KEY_AUTOSTART_ON_BOOT = "autostart_on_boot"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.amurcanov.tgwsproxy

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp

@Composable
internal fun AutostartSettingsSection(
enabled: Boolean,
onEnabledChange: (Boolean) -> 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,
)
}
}
108 changes: 108 additions & 0 deletions app/src/main/java/com/amurcanov/tgwsproxy/BootCompletedReceiver.kt
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
}
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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),
Expand Down
67 changes: 67 additions & 0 deletions app/src/main/java/com/amurcanov/tgwsproxy/ProxySavedConfigStore.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
55 changes: 20 additions & 35 deletions app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> = _isRunning
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,9 @@
<string name="notification_line_latency">Задержка: %1$s</string>
<string name="notification_line_connections">Соединения: %1$d</string>
<string name="notification_line_last_error">Последняя ошибка: %1$s</string>
<string name="section_service">Служба</string>
<string name="autostart_on_boot_title">Автозапуск при включении устройства</string>
<string name="autostart_on_boot_hint">Прокси будет автоматически запускаться после загрузки Android с последней сохранённой конфигурацией.</string>
<string name="section_notifications">Уведомления</string>
<string name="section_notifications_hint">Android требует видимое уведомление для foreground service. TgWsProxy может сделать его компактным или минимальным, но не может полностью убрать его во время работы прокси.</string>
<string name="notification_display_mode_label">Уведомление сервиса</string>
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,9 @@
<string name="notification_line_latency">Latency: %1$s</string>
<string name="notification_line_connections">Connections: %1$d</string>
<string name="notification_line_last_error">Last error: %1$s</string>
<string name="section_service">Service</string>
<string name="autostart_on_boot_title">Start automatically when device turns on</string>
<string name="autostart_on_boot_hint">The proxy will start automatically after Android finishes booting using the last saved configuration.</string>
<string name="section_notifications">Notifications</string>
<string name="section_notifications_hint">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.</string>
<string name="notification_display_mode_label">Service notification</string>
Expand Down
Loading
Loading