feat(android): scaffold kotlin app with settings screen and foreground service shell

This commit is contained in:
Dark-Avery
2026-03-16 19:45:57 +03:00
parent ec70188385
commit 47e5c6241d
22 changed files with 1110 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_proxy_app"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_proxy_app"
android:supportsRtl="true"
android:theme="@style/Theme.TgWsProxy">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ProxyForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@@ -0,0 +1,132 @@
package org.flowseal.tgwsproxy
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.flowseal.tgwsproxy.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var settingsStore: ProxySettingsStore
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (!granted) {
Toast.makeText(
this,
"Без уведомлений Android может скрыть foreground service.",
Toast.LENGTH_LONG,
).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
settingsStore = ProxySettingsStore(this)
setContentView(binding.root)
binding.startButton.setOnClickListener { onStartClicked() }
binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) }
binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) }
renderConfig(settingsStore.load())
requestNotificationPermissionIfNeeded()
observeServiceState()
}
private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? {
val validation = collectConfigFromForm().validate()
val config = validation.normalized
if (config == null) {
binding.errorText.text = validation.errorMessage
binding.errorText.isVisible = true
return null
}
binding.errorText.isVisible = false
settingsStore.save(config)
if (showMessage) {
Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show()
}
return config
}
private fun onStartClicked() {
onSaveClicked(showMessage = false) ?: return
ProxyForegroundService.start(this)
Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show()
}
private fun renderConfig(config: ProxyConfig) {
binding.hostInput.setText(config.host)
binding.portInput.setText(config.portText)
binding.dcIpInput.setText(config.dcIpText)
binding.verboseSwitch.isChecked = config.verbose
}
private fun collectConfigFromForm(): ProxyConfig {
return ProxyConfig(
host = binding.hostInput.text?.toString().orEmpty(),
portText = binding.portInput.text?.toString().orEmpty(),
dcIpText = binding.dcIpInput.text?.toString().orEmpty(),
verbose = binding.verboseSwitch.isChecked,
)
}
private fun observeServiceState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.isRunning.collect { isRunning ->
binding.statusValue.text = getString(
if (isRunning) R.string.status_running else R.string.status_stopped,
)
binding.startButton.isEnabled = !isRunning
binding.stopButton.isEnabled = isRunning
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.activeConfig.collect { config ->
binding.serviceHint.text = if (config == null) {
getString(R.string.service_hint_idle)
} else {
getString(
R.string.service_hint_running,
config.host,
config.port,
)
}
}
}
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return
}
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
) {
return
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

View File

@@ -0,0 +1,84 @@
package org.flowseal.tgwsproxy
data class ProxyConfig(
val host: String = DEFAULT_HOST,
val portText: String = DEFAULT_PORT.toString(),
val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"),
val verbose: Boolean = false,
) {
fun validate(): ValidationResult {
val hostValue = host.trim()
if (!isIpv4Address(hostValue)) {
return ValidationResult(errorMessage = "IP-адрес прокси указан некорректно.")
}
val portValue = portText.trim().toIntOrNull()
?: return ValidationResult(errorMessage = "Порт должен быть числом.")
if (portValue !in 1..65535) {
return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.")
}
val lines = dcIpText
.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toList()
if (lines.isEmpty()) {
return ValidationResult(errorMessage = "Добавьте хотя бы один DC:IP маппинг.")
}
for (line in lines) {
val parts = line.split(":", limit = 2)
val dcValue = parts.firstOrNull()?.toIntOrNull()
val ipValue = parts.getOrNull(1)?.trim().orEmpty()
if (parts.size != 2 || dcValue == null || !isIpv4Address(ipValue)) {
return ValidationResult(errorMessage = "Строка \"$line\" должна быть в формате DC:IP.")
}
}
return ValidationResult(
normalized = NormalizedProxyConfig(
host = hostValue,
port = portValue,
dcIpList = lines,
verbose = verbose,
)
)
}
companion object {
const val DEFAULT_HOST = "127.0.0.1"
const val DEFAULT_PORT = 1080
val DEFAULT_DC_IP_LINES = listOf(
"2:149.154.167.220",
"4:149.154.167.220",
)
private fun isIpv4Address(value: String): Boolean {
val octets = value.split(".")
if (octets.size != 4) {
return false
}
return octets.all { octet ->
octet.isNotEmpty() &&
octet.length <= 3 &&
octet.all(Char::isDigit) &&
octet.toIntOrNull() in 0..255
}
}
}
}
data class ValidationResult(
val normalized: NormalizedProxyConfig? = null,
val errorMessage: String? = null,
)
data class NormalizedProxyConfig(
val host: String,
val port: Int,
val dcIpList: List<String>,
val verbose: Boolean,
)

View File

@@ -0,0 +1,98 @@
package org.flowseal.tgwsproxy
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class ProxyForegroundService : Service() {
private lateinit var settingsStore: ProxySettingsStore
override fun onCreate() {
super.onCreate()
settingsStore = ProxySettingsStore(this)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return when (intent?.action) {
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
START_NOT_STICKY
}
else -> {
val config = settingsStore.load().validate().normalized
if (config == null) {
stopSelf()
START_NOT_STICKY
} else {
ProxyServiceState.markStarted(config)
startForeground(NOTIFICATION_ID, buildNotification(config))
START_STICKY
}
}
}
}
override fun onDestroy() {
ProxyServiceState.markStopped()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(config: NormalizedProxyConfig): Notification {
val contentText = "SOCKS5 ${config.host}:${config.port} • service shell active"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
companion object {
private const val CHANNEL_ID = "proxy_service"
private const val NOTIFICATION_ID = 1001
private const val ACTION_START = "org.flowseal.tgwsproxy.action.START"
private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP"
fun start(context: Context) {
val intent = Intent(context, ProxyForegroundService::class.java).apply {
action = ACTION_START
}
androidx.core.content.ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent = Intent(context, ProxyForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,22 @@
package org.flowseal.tgwsproxy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object ProxyServiceState {
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning
private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null)
val activeConfig: StateFlow<NormalizedProxyConfig?> = _activeConfig
fun markStarted(config: NormalizedProxyConfig) {
_activeConfig.value = config
_isRunning.value = true
}
fun markStopped() {
_activeConfig.value = null
_isRunning.value = false
}
}

View File

@@ -0,0 +1,36 @@
package org.flowseal.tgwsproxy
import android.content.Context
class ProxySettingsStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(): ProxyConfig {
return ProxyConfig(
host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(),
portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(),
dcIpText = preferences.getString(
KEY_DC_IP_TEXT,
ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"),
).orEmpty(),
verbose = preferences.getBoolean(KEY_VERBOSE, false),
)
}
fun save(config: NormalizedProxyConfig) {
preferences.edit()
.putString(KEY_HOST, config.host)
.putInt(KEY_PORT, config.port)
.putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n"))
.putBoolean(KEY_VERBOSE, config.verbose)
.apply()
}
companion object {
private const val PREFS_NAME = "proxy_settings"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_DC_IP_TEXT = "dc_ip_text"
private const val KEY_VERBOSE = "verbose"
}
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1E88E5"
android:pathData="M54,10A44,44 0 1,1 10,54A44,44 0 0,1 54,10Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M33,34h42v10H59v30H49V44H33z" />
</vector>

View File

@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textStyle="bold" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/statusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/status_stopped"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/serviceHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/service_hint_idle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:hint="@string/host_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/hostInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/port_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/portInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/dc_ip_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/dcIpInput"
android:layout_width="match_parent"
android:layout_height="140dp"
android:gravity="top|start"
android:inputType="textMultiLine"
android:minLines="5" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/verboseSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/verbose_label" />
<TextView
android:id="@+id/errorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/save_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="@string/stop_button" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="proxy_blue">#1E88E5</color>
<color name="proxy_navy">#0B1F33</color>
<color name="proxy_surface">#F4F8FC</color>
<color name="proxy_white">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TG WS Proxy</string>
<string name="subtitle">Android shell for the local Telegram SOCKS5 proxy. The embedded Python runtime will be wired in the next commit.</string>
<string name="status_label">Foreground service</string>
<string name="status_running">Running</string>
<string name="status_stopped">Stopped</string>
<string name="service_hint_idle">The Android service shell is ready. Save settings, then start the service.</string>
<string name="service_hint_running">Foreground service active for %1$s:%2$d. Python proxy bootstrap will be connected next.</string>
<string name="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
<string name="verbose_label">Verbose logging</string>
<string name="save_button">Save Settings</string>
<string name="start_button">Start Service</string>
<string name="stop_button">Stop Service</string>
<string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string>
<string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</string>
<string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</string>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.TgWsProxy" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/proxy_blue</item>
<item name="colorSecondary">@color/proxy_navy</item>
<item name="android:statusBarColor">@color/proxy_navy</item>
<item name="android:navigationBarColor">@color/proxy_white</item>
<item name="colorSurface">@color/proxy_surface</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>