diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 36f7651..5d70117 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,6 +27,10 @@
+
+
if (!granted) {
@@ -33,7 +32,6 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
requestNotificationPermissionIfNeeded()
binding = ActivityMainBinding.inflate(layoutInflater)
@@ -41,21 +39,20 @@ class MainActivity : AppCompatActivity() {
configStore = ConfigStore(this)
stateStore = ServiceStateStore(this)
+ logStore = LogStore(this)
- populateForm(configStore.load())
- renderState()
-
- binding.saveButton.setOnClickListener {
- val config = readForm()
- configStore.save(config)
- renderState()
- Toast.makeText(this, R.string.config_saved, Toast.LENGTH_SHORT).show()
+ binding.topToolbar.setNavigationOnClickListener {
+ startActivity(Intent(this, SettingsActivity::class.java))
}
binding.startButton.setOnClickListener {
- val config = readForm()
- configStore.save(config)
- renderState()
+ val config = configStore.load()
+ if (config.serverAddress.isBlank() || config.token.isBlank()) {
+ Toast.makeText(this, R.string.config_missing, Toast.LENGTH_SHORT).show()
+ startActivity(Intent(this, SettingsActivity::class.java))
+ return@setOnClickListener
+ }
+
ContextCompat.startForegroundService(
this,
TunnelService.createStartIntent(this, "manual-start"),
@@ -70,36 +67,15 @@ class MainActivity : AppCompatActivity() {
)
Toast.makeText(this, R.string.service_stop_requested, Toast.LENGTH_SHORT).show()
}
-
- binding.batteryButton.setOnClickListener {
- openBatteryOptimizationSettings()
- }
}
override fun onResume() {
super.onResume()
- renderState()
+ renderScreen()
}
- private fun populateForm(config: AppConfig) {
- binding.serverAddressInput.setText(config.serverAddress)
- binding.tokenInput.setText(config.token)
- binding.autoStartSwitch.isChecked = config.autoStart
- binding.autoReconnectSwitch.isChecked = config.autoReconnect
- binding.useTlsSwitch.isChecked = config.useTls
- }
-
- private fun readForm(): AppConfig {
- return AppConfig(
- serverAddress = binding.serverAddressInput.text?.toString().orEmpty().trim(),
- token = binding.tokenInput.text?.toString().orEmpty().trim(),
- autoStart = binding.autoStartSwitch.isChecked,
- autoReconnect = binding.autoReconnectSwitch.isChecked,
- useTls = binding.useTlsSwitch.isChecked,
- )
- }
-
- private fun renderState() {
+ private fun renderScreen() {
+ val config = configStore.load()
val state = stateStore.load()
val timestamp = if (state.updatedAt > 0L) {
DateFormat.getDateTimeInstance().format(Date(state.updatedAt))
@@ -107,21 +83,37 @@ class MainActivity : AppCompatActivity() {
getString(R.string.state_never_updated)
}
- binding.stateValue.text = getString(
- R.string.state_format,
- state.status.name,
- state.detail.ifBlank { getString(R.string.state_no_detail) },
- )
- binding.stateMeta.text = getString(R.string.state_meta_format, timestamp)
+ binding.statusValue.text = getStatusLabel(state.status)
+ binding.statusDetail.text = state.detail.ifBlank { getString(R.string.state_no_detail) }
+ binding.statusMeta.text = getString(R.string.state_meta_format, timestamp)
+ binding.stateHint.text = getStateHint(state.status)
+ binding.serverSummary.text = if (config.serverAddress.isBlank()) {
+ getString(R.string.status_server_unconfigured)
+ } else {
+ getString(R.string.status_server_configured, config.serverAddress)
+ }
+ binding.logValue.text = logStore.render()
+ }
- val hint = when (state.status) {
+ private fun getStatusLabel(status: TunnelStatus): String {
+ return when (status) {
+ TunnelStatus.RUNNING -> getString(R.string.status_running)
+ TunnelStatus.STARTING -> getString(R.string.status_starting)
+ TunnelStatus.RECONNECTING -> getString(R.string.status_reconnecting)
+ TunnelStatus.ERROR -> getString(R.string.status_error)
+ TunnelStatus.STOPPED -> getString(R.string.status_stopped)
+ }
+ }
+
+ private fun getStateHint(status: TunnelStatus): String {
+ val messageId = when (status) {
TunnelStatus.RUNNING -> R.string.state_hint_running
TunnelStatus.STARTING -> R.string.state_hint_starting
TunnelStatus.RECONNECTING -> R.string.state_hint_reconnecting
TunnelStatus.ERROR -> R.string.state_hint_error
TunnelStatus.STOPPED -> R.string.state_hint_stopped
}
- binding.stateHint.text = getString(hint)
+ return getString(messageId)
}
private fun requestNotificationPermissionIfNeeded() {
@@ -137,17 +129,4 @@ class MainActivity : AppCompatActivity() {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
-
- private fun openBatteryOptimizationSettings() {
- val powerManager = getSystemService(PowerManager::class.java)
- if (powerManager != null && powerManager.isIgnoringBatteryOptimizations(packageName)) {
- Toast.makeText(this, R.string.battery_optimization_already_disabled, Toast.LENGTH_SHORT).show()
- return
- }
-
- val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
- data = Uri.parse("package:$packageName")
- }
- startActivity(intent)
- }
}
diff --git a/android/app/src/main/java/com/gotunnel/android/SettingsActivity.kt b/android/app/src/main/java/com/gotunnel/android/SettingsActivity.kt
new file mode 100644
index 0000000..6529664
--- /dev/null
+++ b/android/app/src/main/java/com/gotunnel/android/SettingsActivity.kt
@@ -0,0 +1,70 @@
+package com.gotunnel.android
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.os.PowerManager
+import android.provider.Settings
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import com.gotunnel.android.config.AppConfig
+import com.gotunnel.android.config.ConfigStore
+import com.gotunnel.android.databinding.ActivitySettingsBinding
+
+class SettingsActivity : AppCompatActivity() {
+ private lateinit var binding: ActivitySettingsBinding
+ private lateinit var configStore: ConfigStore
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = ActivitySettingsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ configStore = ConfigStore(this)
+ populateForm(configStore.load())
+
+ binding.topToolbar.setNavigationOnClickListener {
+ finish()
+ }
+
+ binding.saveButton.setOnClickListener {
+ configStore.save(readForm())
+ Toast.makeText(this, R.string.config_saved, Toast.LENGTH_SHORT).show()
+ finish()
+ }
+
+ binding.batteryButton.setOnClickListener {
+ openBatteryOptimizationSettings()
+ }
+ }
+
+ private fun populateForm(config: AppConfig) {
+ binding.serverAddressInput.setText(config.serverAddress)
+ binding.tokenInput.setText(config.token)
+ binding.autoStartSwitch.isChecked = config.autoStart
+ binding.autoReconnectSwitch.isChecked = config.autoReconnect
+ }
+
+ private fun readForm(): AppConfig {
+ return AppConfig(
+ serverAddress = binding.serverAddressInput.text?.toString().orEmpty().trim(),
+ token = binding.tokenInput.text?.toString().orEmpty().trim(),
+ autoStart = binding.autoStartSwitch.isChecked,
+ autoReconnect = binding.autoReconnectSwitch.isChecked,
+ )
+ }
+
+ private fun openBatteryOptimizationSettings() {
+ val powerManager = getSystemService(PowerManager::class.java)
+ if (powerManager != null && powerManager.isIgnoringBatteryOptimizations(packageName)) {
+ Toast.makeText(this, R.string.battery_optimization_already_disabled, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
+ data = Uri.parse("package:$packageName")
+ }
+ startActivity(intent)
+ }
+}
diff --git a/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt b/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt
index 8c1a613..9ef7d2b 100644
--- a/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt
+++ b/android/app/src/main/java/com/gotunnel/android/config/AppConfig.kt
@@ -5,5 +5,4 @@ data class AppConfig(
val token: String = "",
val autoStart: Boolean = true,
val autoReconnect: Boolean = true,
- val useTls: Boolean = true,
)
diff --git a/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt b/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt
index 7a5d65d..dff9866 100644
--- a/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt
+++ b/android/app/src/main/java/com/gotunnel/android/config/ConfigStore.kt
@@ -11,7 +11,6 @@ class ConfigStore(context: Context) {
token = prefs.getString(KEY_TOKEN, "") ?: "",
autoStart = prefs.getBoolean(KEY_AUTO_START, true),
autoReconnect = prefs.getBoolean(KEY_AUTO_RECONNECT, true),
- useTls = prefs.getBoolean(KEY_USE_TLS, true),
)
}
@@ -21,7 +20,7 @@ class ConfigStore(context: Context) {
.putString(KEY_TOKEN, config.token)
.putBoolean(KEY_AUTO_START, config.autoStart)
.putBoolean(KEY_AUTO_RECONNECT, config.autoReconnect)
- .putBoolean(KEY_USE_TLS, config.useTls)
+ .remove(KEY_USE_TLS)
.apply()
}
diff --git a/android/app/src/main/java/com/gotunnel/android/config/LogStore.kt b/android/app/src/main/java/com/gotunnel/android/config/LogStore.kt
new file mode 100644
index 0000000..0e259dd
--- /dev/null
+++ b/android/app/src/main/java/com/gotunnel/android/config/LogStore.kt
@@ -0,0 +1,52 @@
+package com.gotunnel.android.config
+
+import android.content.Context
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class LogStore(context: Context) {
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ fun append(message: String) {
+ if (message.isBlank()) {
+ return
+ }
+
+ val current = load().toMutableList()
+ current += "${timestamp()} $message"
+ while (current.size > MAX_LINES) {
+ current.removeAt(0)
+ }
+
+ prefs.edit().putString(KEY_LOGS, current.joinToString(SEPARATOR)).apply()
+ }
+
+ fun load(): List {
+ val raw = prefs.getString(KEY_LOGS, "") ?: ""
+ if (raw.isBlank()) {
+ return emptyList()
+ }
+ return raw.split(SEPARATOR).filter { it.isNotBlank() }
+ }
+
+ fun render(): String {
+ val lines = load()
+ return if (lines.isEmpty()) {
+ "No logs yet."
+ } else {
+ lines.joinToString("\n")
+ }
+ }
+
+ private fun timestamp(): String {
+ return SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
+ }
+
+ companion object {
+ private const val PREFS_NAME = "gotunnel_logs"
+ private const val KEY_LOGS = "logs"
+ private const val MAX_LINES = 80
+ private const val SEPARATOR = "\u0001"
+ }
+}
diff --git a/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt b/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt
index ec10576..51f37d8 100644
--- a/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt
+++ b/android/app/src/main/java/com/gotunnel/android/service/NotificationHelper.kt
@@ -75,7 +75,7 @@ object NotificationHelper {
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_gotunnel_notification)
- .setContentTitle(context.getString(R.string.notification_title, status.name))
+ .setContentTitle(context.getString(R.string.notification_title, statusLabel(context, status)))
.setContentText(baseText)
.setStyle(NotificationCompat.BigTextStyle().bigText(baseText))
.setOngoing(status != TunnelStatus.STOPPED)
@@ -86,6 +86,16 @@ object NotificationHelper {
.build()
}
+ private fun statusLabel(context: Context, status: TunnelStatus): String {
+ return when (status) {
+ TunnelStatus.RUNNING -> context.getString(R.string.status_running)
+ TunnelStatus.STARTING -> context.getString(R.string.status_starting)
+ TunnelStatus.RECONNECTING -> context.getString(R.string.status_reconnecting)
+ TunnelStatus.ERROR -> context.getString(R.string.status_error)
+ TunnelStatus.STOPPED -> context.getString(R.string.status_stopped)
+ }
+ }
+
private fun pendingIntentFlags(): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
diff --git a/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt b/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt
index a54a80a..cdb66c2 100644
--- a/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt
+++ b/android/app/src/main/java/com/gotunnel/android/service/TunnelService.kt
@@ -10,6 +10,7 @@ import com.gotunnel.android.bridge.TunnelController
import com.gotunnel.android.bridge.TunnelStatus
import com.gotunnel.android.config.AppConfig
import com.gotunnel.android.config.ConfigStore
+import com.gotunnel.android.config.LogStore
import com.gotunnel.android.config.ServiceStateStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -20,6 +21,7 @@ class TunnelService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var configStore: ConfigStore
private lateinit var stateStore: ServiceStateStore
+ private lateinit var logStore: LogStore
private lateinit var controller: TunnelController
private lateinit var networkMonitor: NetworkMonitor
private var currentConfig: AppConfig = AppConfig()
@@ -29,16 +31,18 @@ class TunnelService : Service() {
super.onCreate()
configStore = ConfigStore(this)
stateStore = ServiceStateStore(this)
+ logStore = LogStore(this)
controller = GoTunnelBridge.create(applicationContext)
controller.setListener(object : TunnelController.Listener {
override fun onStatusChanged(status: TunnelStatus, detail: String) {
stateStore.save(status, detail)
+ logStore.append("status: ${status.name} ${detail.ifBlank { "" }}".trim())
updateNotification(status, detail)
}
override fun onLog(message: String) {
val current = stateStore.load()
- stateStore.save(current.status, message)
+ logStore.append(message)
updateNotification(current.status, message)
}
})
@@ -57,6 +61,7 @@ class TunnelService : Service() {
onLost = {
val detail = getString(com.gotunnel.android.R.string.network_lost)
stateStore.save(TunnelStatus.RECONNECTING, detail)
+ logStore.append(detail)
updateNotification(TunnelStatus.RECONNECTING, detail)
},
)
@@ -98,12 +103,7 @@ class TunnelService : Service() {
NotificationHelper.ensureChannel(this)
startForeground(
NotificationHelper.NOTIFICATION_ID,
- NotificationHelper.build(
- this,
- state.status,
- state.detail,
- config,
- ),
+ NotificationHelper.build(this, state.status, state.detail, config),
)
}
@@ -111,11 +111,13 @@ class TunnelService : Service() {
currentConfig = configStore.load()
controller.updateConfig(currentConfig)
stateStore.save(TunnelStatus.STARTING, reason)
+ logStore.append("start requested: $reason")
updateNotification(TunnelStatus.STARTING, reason)
if (!isConfigReady(currentConfig)) {
val detail = getString(com.gotunnel.android.R.string.config_missing)
stateStore.save(TunnelStatus.STOPPED, detail)
+ logStore.append(detail)
updateNotification(TunnelStatus.STOPPED, detail)
return
}
@@ -130,6 +132,7 @@ class TunnelService : Service() {
networkMonitorPrimed = false
controller.stop(reason)
stateStore.save(TunnelStatus.STOPPED, reason)
+ logStore.append("stop requested: $reason")
updateNotification(TunnelStatus.STOPPED, reason)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 3f9e1b3..28d226a 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -1,125 +1,223 @@
-
+ android:padding="20dp">
-
+ android:background="@android:color/transparent"
+ app:navigationIcon="@android:drawable/ic_menu_manage"
+ app:navigationIconTint="@color/gotunnel_text"
+ app:subtitle="@string/main_subtitle"
+ app:subtitleTextColor="@color/gotunnel_text_muted"
+ app:title="@string/home_title"
+ app:titleTextColor="@color/gotunnel_text" />
-
-
-
-
-
+ app:cardBackgroundColor="@color/gotunnel_surface"
+ app:cardCornerRadius="24dp"
+ app:strokeColor="@color/gotunnel_border"
+ app:strokeWidth="1dp">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:orientation="vertical"
+ android:padding="20dp">
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ android:orientation="vertical"
+ android:padding="20dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..4c80981
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index d2a477d..5be0488 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,7 +1,9 @@
- #0F172A
- #111827
- #0EA5A8
- #38BDF8
- #E5E7EB
+ #F3F7FB
+ #FFFFFF
+ #0F766E
+ #0891B2
+ #0F172A
+ #475569
+ #D9E2EC
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6651507..794e402 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,36 +1,54 @@
GoTunnel
- Android host shell for the GoTunnel client core.
- Server address, for example 10.0.2.2:7000
- Authentication token
+ GoTunnel Client
+ Status, recent logs, and supported proxy types
+ Client Settings
+ Connection settings and startup behavior
+ Basic Connection
+ Startup and Recovery
+ Server Address
+ Example: 1.2.3.4:7000. This is the GoTunnel server endpoint.
+ Access Token
+ The token issued by the server to identify this client.
Start on boot
- Restart on network restore
- Use TLS
- Save
- Start
- Stop
- Battery optimization
- Configuration saved
- Service start requested
- Service stop requested
+ Restore the foreground service after reboot or app update.
+ Reconnect when network returns
+ Restart the client automatically after connectivity is restored.
+ Save Settings
+ Start Client
+ Stop Client
+ Battery Optimization
+ Software Status
+ Recent Logs
+ Supported Proxies
+ The current Android shell can present these proxy types. Live proxy rules can be shown after the native Go core is connected.
+ Client settings saved.
+ Client start requested.
+ Client stop requested.
Battery optimization is already disabled for GoTunnel.
Notification permission was denied. Foreground service notifications may be limited.
- Never updated
- No detail
- State: %1$s\nDetail: %2$s
- Updated: %1$s
- The foreground service is idle until a configuration is saved and started.
- The host shell is preparing a tunnel session.
- The host shell is ready. The native Go tunnel core can be attached here later.
- The host shell is waiting for connectivity to return.
- The last session reported an error. Check the configuration and service notification.
- GoTunnel service
- Keeps the Android host shell running in the foreground
+ Not updated yet
+ No detail available
+ Last update: %1$s
+ The client is idle. Save settings first, then start it from the home screen.
+ The client is preparing a tunnel connection.
+ The client is running and ready for the native Go core to handle real proxy traffic.
+ The client is waiting for the network and will reconnect automatically.
+ The last run failed. Check the settings and recent logs.
+ Running
+ Starting
+ Reconnecting
+ Error
+ Stopped
+ No server is configured yet. Open Settings to finish the basic client setup.
+ Current server: %1$s
+ GoTunnel foreground service
+ Keeps the GoTunnel Android client running in the foreground
GoTunnel - %1$s
- Configured for %1$s
+ Current target: %1$s
No server configured yet
Restart
Stop
- Server address and token are required
- Network lost
+ Please open Settings and fill in the server address and access token first.
+ Network connection lost
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
index 6f49424..716eab8 100644
--- a/android/app/src/main/res/values/themes.xml
+++ b/android/app/src/main/res/values/themes.xml
@@ -1,13 +1,17 @@
-