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 @@ -