From 41426067287ddd969b548ead11faa8d5ebc8f091 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 6 Feb 2026 01:53:31 +0500 Subject: [PATCH] rearrange Ui, prep for ssl support, removed iptables and resorting to use syscalls to allow ports bwlo 1024 --- .../java/sh/sar/textpipe/root/RootManager.kt | 48 ++++ .../sar/textpipe/service/TextpipeService.kt | 68 ++++-- .../java/sh/sar/textpipe/ui/MainScreen.kt | 208 ++++++++++++++++-- .../java/sh/sar/textpipe/ui/MainViewModel.kt | 87 ++++++-- settings.gradle.kts | 1 + 5 files changed, 345 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/sh/sar/textpipe/root/RootManager.kt b/app/src/main/java/sh/sar/textpipe/root/RootManager.kt index 880db9c..886a2b8 100644 --- a/app/src/main/java/sh/sar/textpipe/root/RootManager.kt +++ b/app/src/main/java/sh/sar/textpipe/root/RootManager.kt @@ -98,4 +98,52 @@ object RootManager { } } ?: Pair(false, "Timeout") } + + /** + * Allow binding to privileged ports by lowering the unprivileged port start. + * This uses sysctl to set net.ipv4.ip_unprivileged_port_start to the specified port. + */ + suspend fun allowPrivilegedPort(port: Int): Boolean = withContext(Dispatchers.IO) { + withTimeoutOrNull(5000L) { + try { + val command = "sysctl -w net.ipv4.ip_unprivileged_port_start=$port" + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val completed = process.waitFor(4, TimeUnit.SECONDS) + if (completed) { + val success = process.exitValue() == 0 + if (success) { + android.util.Log.i("RootManager", "Allowed binding to ports >= $port") + } + success + } else { + process.destroyForcibly() + false + } + } catch (e: Exception) { + android.util.Log.e("RootManager", "Failed to allow privileged port", e) + false + } + } ?: false + } + + /** + * Reset the unprivileged port start back to default (1024). + */ + suspend fun resetPrivilegedPorts(): Boolean = withContext(Dispatchers.IO) { + withTimeoutOrNull(5000L) { + try { + val command = "sysctl -w net.ipv4.ip_unprivileged_port_start=1024" + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val completed = process.waitFor(4, TimeUnit.SECONDS) + if (completed) { + process.exitValue() == 0 + } else { + process.destroyForcibly() + false + } + } catch (e: Exception) { + false + } + } ?: false + } } diff --git a/app/src/main/java/sh/sar/textpipe/service/TextpipeService.kt b/app/src/main/java/sh/sar/textpipe/service/TextpipeService.kt index 62cf4c5..b23f31b 100644 --- a/app/src/main/java/sh/sar/textpipe/service/TextpipeService.kt +++ b/app/src/main/java/sh/sar/textpipe/service/TextpipeService.kt @@ -37,7 +37,9 @@ class TextpipeService : Service() { private const val NOTIFICATION_ID = 1 private const val PREFS_NAME = "textpipe_settings" private const val KEY_PORT = "server_port" - private const val KEY_REDIRECT_PORT = "redirect_port" + private const val KEY_SSL_ENABLED = "ssl_enabled" + private const val KEY_SSL_CERT_PATH = "ssl_cert_path" + private const val KEY_SSL_KEY_PATH = "ssl_key_path" private const val DEFAULT_PORT = 8080 private const val ACTION_STOP = "sh.sar.textpipe.STOP" @@ -70,14 +72,34 @@ class TextpipeService : Service() { prefs.edit().putInt(KEY_PORT, port).apply() } - fun getRedirectPort(context: Context): Int { + fun isSslEnabled(context: Context): Boolean { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getInt(KEY_REDIRECT_PORT, 0) + return prefs.getBoolean(KEY_SSL_ENABLED, false) } - fun setRedirectPort(context: Context, port: Int) { + fun setSslEnabled(context: Context, enabled: Boolean) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putInt(KEY_REDIRECT_PORT, port).apply() + prefs.edit().putBoolean(KEY_SSL_ENABLED, enabled).apply() + } + + fun getSslCertPath(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_SSL_CERT_PATH, "") ?: "" + } + + fun setSslCertPath(context: Context, path: String) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_SSL_CERT_PATH, path).apply() + } + + fun getSslKeyPath(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_SSL_KEY_PATH, "") ?: "" + } + + fun setSslKeyPath(context: Context, path: String) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_SSL_KEY_PATH, path).apply() } } @@ -86,8 +108,7 @@ class TextpipeService : Service() { private var textpipeServer: TextpipeServer? = null private lateinit var smsRepository: SmsRepository private lateinit var smsSender: SmsSender - - private var redirectPort: Int = 0 + private var usedPrivilegedPort = false override fun onCreate() { super.onCreate() @@ -125,14 +146,18 @@ class TextpipeService : Service() { _serverPort.value = port serviceScope.launch(Dispatchers.IO) { - // Setup port redirect if configured - redirectPort = getRedirectPort(this@TextpipeService) - if (redirectPort > 0 && redirectPort != port) { - val success = RootManager.setupPortRedirect(redirectPort, port) - if (!success) { - Log.w(TAG, "Failed to setup port redirect from $redirectPort to $port") - setRedirectPort(this@TextpipeService, 0) - redirectPort = 0 + // If port < 1024, we need root to allow binding + if (port < 1024) { + Log.i(TAG, "Port $port requires root access") + val allowed = RootManager.allowPrivilegedPort(port) + if (allowed) { + usedPrivilegedPort = true + Log.i(TAG, "Privileged port access granted for port $port") + } else { + updateNotification("Error: Root required for port $port") + showToast("Root access required for port $port") + Log.e(TAG, "Failed to get root access for port $port") + return@launch } } @@ -140,9 +165,8 @@ class TextpipeService : Service() { when (val result = textpipeServer?.start(port)) { is ServerStartResult.Success -> { _isRunning.value = true - val displayPort = if (redirectPort > 0) redirectPort else port - updateNotification("Running on port $displayPort") - showToast("Server started on port $displayPort") + updateNotification("Running on port $port") + showToast("Server started on port $port") Log.i(TAG, "Server started successfully on port $port") } is ServerStartResult.Error -> { @@ -164,12 +188,12 @@ class TextpipeService : Service() { override fun onDestroy() { Log.i(TAG, "Service onDestroy") - // Clear port redirect asynchronously (fire and forget to avoid ANR) - if (redirectPort > 0) { - val port = textpipeServer?.getPort() ?: 8080 + // Reset privileged port setting if we used it + if (usedPrivilegedPort) { GlobalScope.launch(Dispatchers.IO) { - RootManager.clearPortRedirect(redirectPort, port) + RootManager.resetPrivilegedPorts() } + usedPrivilegedPort = false } textpipeServer?.stop() diff --git a/app/src/main/java/sh/sar/textpipe/ui/MainScreen.kt b/app/src/main/java/sh/sar/textpipe/ui/MainScreen.kt index 1141054..0a6f51e 100644 --- a/app/src/main/java/sh/sar/textpipe/ui/MainScreen.kt +++ b/app/src/main/java/sh/sar/textpipe/ui/MainScreen.kt @@ -1,7 +1,10 @@ package sh.sar.textpipe.ui import android.app.Activity +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -47,6 +50,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import sh.sar.textpipe.sim.SimSlotInfo @@ -57,9 +61,11 @@ fun MainScreen(viewModel: MainViewModel) { val simSlots by viewModel.simSlots.collectAsState() val isRootAvailable by viewModel.isRootAvailable.collectAsState() val portInput by viewModel.portInput.collectAsState() - val redirectPortInput by viewModel.redirectPortInput.collectAsState() val autoStartEnabled by viewModel.autoStartEnabled.collectAsState() val isBatteryOptimized by viewModel.isBatteryOptimized.collectAsState() + val sslEnabled by viewModel.sslEnabled.collectAsState() + val sslCertPath by viewModel.sslCertPath.collectAsState() + val sslKeyPath by viewModel.sslKeyPath.collectAsState() val context = LocalContext.current val activity = context as? Activity @@ -116,9 +122,9 @@ fun MainScreen(viewModel: MainViewModel) { } } - // Settings + // Server Configuration Text( - text = "Settings", + text = "Server Configuration", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -126,14 +132,31 @@ fun MainScreen(viewModel: MainViewModel) { // Port Configuration Card PortConfigCard( portInput = portInput, - redirectPortInput = redirectPortInput, isRootAvailable = isRootAvailable, isRunning = isRunning, onPortChange = { viewModel.setPort(it) }, - onRedirectPortChange = { viewModel.setRedirectPort(it) }, onApply = { viewModel.applyPortSettings() } ) + // SSL Configuration Card + SslConfigCard( + sslEnabled = sslEnabled, + certPath = sslCertPath, + keyPath = sslKeyPath, + isRunning = isRunning, + onSslEnabledChange = { viewModel.setSslEnabled(it) }, + onCertPathChange = { viewModel.setSslCertPath(it) }, + onKeyPathChange = { viewModel.setSslKeyPath(it) }, + onApply = { viewModel.applySslSettings() } + ) + + // Settings + Text( + text = "Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + // Other Settings Card SettingsCard( autoStartEnabled = autoStartEnabled, @@ -144,6 +167,15 @@ fun MainScreen(viewModel: MainViewModel) { }, onRefreshBatteryStatus = { viewModel.refreshBatteryOptimizationStatus() } ) + + // About + Text( + text = "About", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + AboutCard() } } } @@ -212,13 +244,14 @@ private fun ServerStatusCard( @Composable private fun PortConfigCard( portInput: String, - redirectPortInput: String, isRootAvailable: Boolean, isRunning: Boolean, onPortChange: (String) -> Unit, - onRedirectPortChange: (String) -> Unit, onApply: () -> Unit ) { + val port = portInput.toIntOrNull() ?: 0 + val needsRoot = port in 1..1023 + Card( modifier = Modifier.fillMaxWidth() ) { @@ -226,7 +259,7 @@ private fun PortConfigCard( modifier = Modifier.padding(16.dp) ) { Text( - text = "Port Configuration", + text = "Port", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -237,28 +270,25 @@ private fun PortConfigCard( value = portInput, onValueChange = { onPortChange(it.filter { c -> c.isDigit() }) }, label = { Text("Server Port") }, + placeholder = { Text("e.g., 80, 443, 8080") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), singleLine = true ) - if (isRootAvailable) { - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - value = redirectPortInput, - onValueChange = { onRedirectPortChange(it.filter { c -> c.isDigit() }) }, - label = { Text("Redirect Port (requires root)") }, - placeholder = { Text("e.g., 80 or 443") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - + if (needsRoot) { Text( - text = "Ports below 1024 (like 80, 443) require root. Uses iptables to redirect traffic to server port.", + text = if (isRootAvailable) { + "Port $port requires root access (available)" + } else { + "Port $port requires root access (not available)" + }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (isRootAvailable) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, modifier = Modifier.padding(top = 4.dp) ) } @@ -462,3 +492,135 @@ private fun SettingsCard( } } } + +@Composable +private fun SslConfigCard( + sslEnabled: Boolean, + certPath: String, + keyPath: String, + isRunning: Boolean, + onSslEnabledChange: (Boolean) -> Unit, + onCertPathChange: (String) -> Unit, + onKeyPathChange: (String) -> Unit, + onApply: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "SSL / HTTPS", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Enable SSL", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "Coming soon", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = sslEnabled, + onCheckedChange = onSslEnabledChange, + enabled = false + ) + } + + if (sslEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = certPath, + onValueChange = onCertPathChange, + label = { Text("Certificate Path") }, + placeholder = { Text("/sdcard/cert.pem") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = keyPath, + onValueChange = onKeyPathChange, + label = { Text("Private Key Path") }, + placeholder = { Text("/sdcard/key.pem") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Text( + text = "Provide paths to PEM-encoded certificate and private key files", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = onApply, + modifier = Modifier.fillMaxWidth(), + enabled = certPath.isNotBlank() && keyPath.isNotBlank() + ) { + Text(if (isRunning) "Apply & Restart" else "Save") + } + } + } + } +} + +@Composable +private fun AboutCard() { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Textpipe", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "SMS API Gateway for Android. Send and receive SMS messages via REST API with dual SIM support.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "git.shihaam.dev/shihaam/textpipe", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + modifier = Modifier.clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://git.shihaam.dev/shihaam/textpipe")) + context.startActivity(intent) + } + ) + } + } +} diff --git a/app/src/main/java/sh/sar/textpipe/ui/MainViewModel.kt b/app/src/main/java/sh/sar/textpipe/ui/MainViewModel.kt index 4be11af..a220c19 100644 --- a/app/src/main/java/sh/sar/textpipe/ui/MainViewModel.kt +++ b/app/src/main/java/sh/sar/textpipe/ui/MainViewModel.kt @@ -5,11 +5,15 @@ import android.app.Application import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.provider.Settings +import android.widget.Toast import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,26 +40,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _portInput = MutableStateFlow(TextpipeService.getConfiguredPort(application).toString()) val portInput: StateFlow = _portInput.asStateFlow() - private val _redirectPortInput = MutableStateFlow("") - val redirectPortInput: StateFlow = _redirectPortInput.asStateFlow() - private val _autoStartEnabled = MutableStateFlow(BootReceiver.isAutoStartEnabled(application)) val autoStartEnabled: StateFlow = _autoStartEnabled.asStateFlow() private val _isBatteryOptimized = MutableStateFlow(true) val isBatteryOptimized: StateFlow = _isBatteryOptimized.asStateFlow() + private val _sslEnabled = MutableStateFlow(TextpipeService.isSslEnabled(application)) + val sslEnabled: StateFlow = _sslEnabled.asStateFlow() + + private val _sslCertPath = MutableStateFlow(TextpipeService.getSslCertPath(application)) + val sslCertPath: StateFlow = _sslCertPath.asStateFlow() + + private val _sslKeyPath = MutableStateFlow(TextpipeService.getSslKeyPath(application)) + val sslKeyPath: StateFlow = _sslKeyPath.asStateFlow() + init { viewModelScope.launch { refreshSimInfo() } checkRootAvailability() checkBatteryOptimization() - - val redirectPort = TextpipeService.getRedirectPort(application) - if (redirectPort > 0) { - _redirectPortInput.value = redirectPort.toString() - } } fun refreshSimInfo() { @@ -82,39 +87,47 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _portInput.value = port } - fun setRedirectPort(port: String) { - _redirectPortInput.value = port - } - fun applyPortSettings() { val port = _portInput.value.toIntOrNull() ?: return if (port !in 1..65535) return TextpipeService.setConfiguredPort(getApplication(), port) - val redirectPort = _redirectPortInput.value.toIntOrNull() ?: 0 - if (redirectPort in 1..65535 && redirectPort != port) { - TextpipeService.setRedirectPort(getApplication(), redirectPort) - } else { - TextpipeService.setRedirectPort(getApplication(), 0) - } - - // Restart service if running + // Restart service if running, otherwise just save if (isServerRunning.value) { stopServer() - startServer() + // Small delay to let service stop before restarting + viewModelScope.launch { + delay(500) + TextpipeService.start(getApplication()) + showToast("Settings applied, server restarting...") + } + } else { + showToast("Settings saved") } } fun startServer() { - applyPortSettings() + savePortSettings() TextpipeService.start(getApplication()) } + private fun savePortSettings() { + val port = _portInput.value.toIntOrNull() ?: return + if (port !in 1..65535) return + TextpipeService.setConfiguredPort(getApplication(), port) + } + fun stopServer() { TextpipeService.stop(getApplication()) } + private fun showToast(message: String) { + Handler(Looper.getMainLooper()).post { + Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show() + } + } + fun toggleServer() { if (isServerRunning.value) { stopServer() @@ -147,5 +160,35 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun regenerateApiKey(slotIndex: Int) { app.simManager.regenerateApiKey(slotIndex) refreshSimInfo() + showToast("API key regenerated for SIM ${slotIndex + 1}") + } + + fun setSslEnabled(enabled: Boolean) { + _sslEnabled.value = enabled + TextpipeService.setSslEnabled(getApplication(), enabled) + } + + fun setSslCertPath(path: String) { + _sslCertPath.value = path + TextpipeService.setSslCertPath(getApplication(), path) + } + + fun setSslKeyPath(path: String) { + _sslKeyPath.value = path + TextpipeService.setSslKeyPath(getApplication(), path) + } + + fun applySslSettings() { + // Restart service if running to apply SSL changes + if (isServerRunning.value) { + stopServer() + viewModelScope.launch { + delay(500) + TextpipeService.start(getApplication()) + showToast("SSL settings applied, server restarting...") + } + } else { + showToast("SSL settings saved") + } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index e7a5176..e27c8f6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } }