Add Android client support and unify cross-platform builds

This commit is contained in:
2026-03-22 21:25:09 +08:00
parent 6558d1acdb
commit 4210ab7675
44 changed files with 2241 additions and 328 deletions

5
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.gradle
/build
/app/build
/local.properties
/captures

27
android/README.md Normal file
View File

@@ -0,0 +1,27 @@
# GoTunnel Android Host
This directory contains a minimal Android Studio / Gradle project skeleton for the GoTunnel Android host app.
## What is included
- Foreground service shell for keeping the tunnel process alive
- Boot receiver for auto-start on device reboot
- Network recovery helper for reconnect/restart triggers
- Basic configuration screen for server address and token
- Notification channel and ongoing service notification
- A stub bridge layer that can later be replaced with a gomobile/native Go core binding
## Current status
The Go tunnel core is not wired into Android yet. `GoTunnelBridge` returns a stub controller so the app structure can be developed independently from the Go runtime integration.
## Open in Android Studio
Open the `android/` folder as a Gradle project. Android Studio can sync it directly and generate a wrapper if you want to build from the command line later.
## Notes
- The foreground service is marked as `dataSync` and starts in sticky mode.
- Auto-start is controlled by the saved configuration.
- Network restoration currently triggers a restart hook in the stub controller.
- Replace the stub bridge with a native binding when the Go client core is exported for Android.

View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.gotunnel.android"
compileSdk = 34
defaultConfig {
applicationId = "com.gotunnel.android"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("com.google.android.material:material:1.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

2
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Placeholder rules for the Android host shell.
# Add Go bridge / native binding rules here when the core integration is introduced.

View File

@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".GoTunnelApp"
android:allowBackup="true"
android:icon="@drawable/ic_gotunnel_app"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_gotunnel_app"
android:supportsRtl="true"
android:theme="@style/Theme.GoTunnel">
<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=".service.TunnelService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,11 @@
package com.gotunnel.android
import android.app.Application
import com.gotunnel.android.service.NotificationHelper
class GoTunnelApp : Application() {
override fun onCreate() {
super.onCreate()
NotificationHelper.ensureChannel(this)
}
}

View File

@@ -0,0 +1,153 @@
package com.gotunnel.android
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.gotunnel.android.bridge.TunnelStatus
import com.gotunnel.android.config.AppConfig
import com.gotunnel.android.config.ConfigStore
import com.gotunnel.android.config.ServiceStateStore
import com.gotunnel.android.databinding.ActivityMainBinding
import com.gotunnel.android.service.TunnelService
import java.text.DateFormat
import java.util.Date
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var configStore: ConfigStore
private lateinit var stateStore: ServiceStateStore
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
Toast.makeText(this, R.string.notification_permission_denied, Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
configStore = ConfigStore(this)
stateStore = ServiceStateStore(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.startButton.setOnClickListener {
val config = readForm()
configStore.save(config)
renderState()
ContextCompat.startForegroundService(
this,
TunnelService.createStartIntent(this, "manual-start"),
)
Toast.makeText(this, R.string.service_start_requested, Toast.LENGTH_SHORT).show()
}
binding.stopButton.setOnClickListener {
ContextCompat.startForegroundService(
this,
TunnelService.createStopIntent(this, "manual-stop"),
)
Toast.makeText(this, R.string.service_stop_requested, Toast.LENGTH_SHORT).show()
}
binding.batteryButton.setOnClickListener {
openBatteryOptimizationSettings()
}
}
override fun onResume() {
super.onResume()
renderState()
}
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() {
val state = stateStore.load()
val timestamp = if (state.updatedAt > 0L) {
DateFormat.getDateTimeInstance().format(Date(state.updatedAt))
} else {
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)
val hint = when (state.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)
}
private fun requestNotificationPermissionIfNeeded() {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
return
}
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
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)
}
}

View File

@@ -0,0 +1,10 @@
package com.gotunnel.android.bridge
import android.content.Context
object GoTunnelBridge {
fun create(context: Context): TunnelController {
// Stub bridge for the Android shell. Replace with a native Go binding later.
return StubTunnelController(context.applicationContext)
}
}

View File

@@ -0,0 +1,64 @@
package com.gotunnel.android.bridge
import android.content.Context
import com.gotunnel.android.config.AppConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class StubTunnelController(
@Suppress("unused") private val context: Context,
) : TunnelController {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var listener: TunnelController.Listener? = null
private var config: AppConfig = AppConfig()
private var job: Job? = null
override val isRunning: Boolean
get() = job?.isActive == true
override fun setListener(listener: TunnelController.Listener?) {
this.listener = listener
}
override fun updateConfig(config: AppConfig) {
this.config = config
}
override fun start(config: AppConfig) {
updateConfig(config)
if (isRunning) {
listener?.onLog("Stub tunnel already running")
return
}
job = scope.launch {
listener?.onStatusChanged(TunnelStatus.STARTING, "Preparing tunnel session")
delay(400)
listener?.onLog("Stub tunnel prepared for ${config.serverAddress}")
listener?.onStatusChanged(TunnelStatus.RUNNING, "Waiting for native Go core")
while (isActive) {
delay(30_000)
listener?.onLog("Stub keepalive tick for ${this@StubTunnelController.config.serverAddress}")
}
}
}
override fun stop(reason: String) {
listener?.onLog("Stub tunnel stop requested: $reason")
job?.cancel()
job = null
listener?.onStatusChanged(TunnelStatus.STOPPED, reason)
}
override fun restart(reason: String) {
listener?.onStatusChanged(TunnelStatus.RECONNECTING, reason)
stop(reason)
start(config)
}
}

View File

@@ -0,0 +1,26 @@
package com.gotunnel.android.bridge
import com.gotunnel.android.config.AppConfig
enum class TunnelStatus {
STOPPED,
STARTING,
RUNNING,
RECONNECTING,
ERROR,
}
interface TunnelController {
interface Listener {
fun onStatusChanged(status: TunnelStatus, detail: String = "")
fun onLog(message: String)
}
val isRunning: Boolean
fun setListener(listener: Listener?)
fun updateConfig(config: AppConfig)
fun start(config: AppConfig)
fun stop(reason: String = "manual")
fun restart(reason: String = "manual")
}

View File

@@ -0,0 +1,9 @@
package com.gotunnel.android.config
data class AppConfig(
val serverAddress: String = "",
val token: String = "",
val autoStart: Boolean = true,
val autoReconnect: Boolean = true,
val useTls: Boolean = true,
)

View File

@@ -0,0 +1,36 @@
package com.gotunnel.android.config
import android.content.Context
class ConfigStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(): AppConfig {
return AppConfig(
serverAddress = prefs.getString(KEY_SERVER_ADDRESS, "") ?: "",
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),
)
}
fun save(config: AppConfig) {
prefs.edit()
.putString(KEY_SERVER_ADDRESS, config.serverAddress)
.putString(KEY_TOKEN, config.token)
.putBoolean(KEY_AUTO_START, config.autoStart)
.putBoolean(KEY_AUTO_RECONNECT, config.autoReconnect)
.putBoolean(KEY_USE_TLS, config.useTls)
.apply()
}
companion object {
private const val PREFS_NAME = "gotunnel_config"
private const val KEY_SERVER_ADDRESS = "server_address"
private const val KEY_TOKEN = "token"
private const val KEY_AUTO_START = "auto_start"
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
private const val KEY_USE_TLS = "use_tls"
}
}

View File

@@ -0,0 +1,40 @@
package com.gotunnel.android.config
import android.content.Context
import com.gotunnel.android.bridge.TunnelStatus
data class ServiceState(
val status: TunnelStatus = TunnelStatus.STOPPED,
val detail: String = "",
val updatedAt: Long = 0L,
)
class ServiceStateStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(): ServiceState {
val statusName = prefs.getString(KEY_STATUS, TunnelStatus.STOPPED.name) ?: TunnelStatus.STOPPED.name
val status = runCatching { TunnelStatus.valueOf(statusName) }.getOrDefault(TunnelStatus.STOPPED)
return ServiceState(
status = status,
detail = prefs.getString(KEY_DETAIL, "") ?: "",
updatedAt = prefs.getLong(KEY_UPDATED_AT, 0L),
)
}
fun save(status: TunnelStatus, detail: String) {
prefs.edit()
.putString(KEY_STATUS, status.name)
.putString(KEY_DETAIL, detail)
.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
.apply()
}
companion object {
private const val PREFS_NAME = "gotunnel_state"
private const val KEY_STATUS = "status"
private const val KEY_DETAIL = "detail"
private const val KEY_UPDATED_AT = "updated_at"
}
}

View File

@@ -0,0 +1,26 @@
package com.gotunnel.android.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.gotunnel.android.config.ConfigStore
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) {
return
}
val config = ConfigStore(context).load()
if (!config.autoStart) {
return
}
ContextCompat.startForegroundService(
context,
TunnelService.createStartIntent(context, action.lowercase()),
)
}
}

View File

@@ -0,0 +1,52 @@
package com.gotunnel.android.service
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
class NetworkMonitor(
context: Context,
private val onAvailable: () -> Unit,
private val onLost: () -> Unit = {},
) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private var registered = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
onAvailable()
}
override fun onLost(network: Network) {
onLost()
}
}
fun start() {
if (registered) {
return
}
connectivityManager.registerDefaultNetworkCallback(callback)
registered = true
}
fun isConnected(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun stop() {
if (!registered) {
return
}
runCatching {
connectivityManager.unregisterNetworkCallback(callback)
}
registered = false
}
}

View File

@@ -0,0 +1,96 @@
package com.gotunnel.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.gotunnel.android.MainActivity
import com.gotunnel.android.R
import com.gotunnel.android.bridge.TunnelStatus
import com.gotunnel.android.config.AppConfig
object NotificationHelper {
const val CHANNEL_ID = "gotunnel_tunnel"
const val NOTIFICATION_ID = 2001
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
fun build(
context: Context,
status: TunnelStatus,
detail: String,
config: AppConfig,
): Notification {
val baseText = when {
detail.isNotBlank() -> detail
config.serverAddress.isNotBlank() -> context.getString(
R.string.notification_text_configured,
config.serverAddress,
)
else -> context.getString(R.string.notification_text_unconfigured)
}
val contentIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
pendingIntentFlags(),
)
val stopIntent = PendingIntent.getService(
context,
1,
TunnelService.createStopIntent(context, "notification-stop"),
pendingIntentFlags(),
)
val restartIntent = PendingIntent.getService(
context,
2,
TunnelService.createRestartIntent(context, "notification-restart"),
pendingIntentFlags(),
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_gotunnel_notification)
.setContentTitle(context.getString(R.string.notification_title, status.name))
.setContentText(baseText)
.setStyle(NotificationCompat.BigTextStyle().bigText(baseText))
.setOngoing(status != TunnelStatus.STOPPED)
.setOnlyAlertOnce(true)
.setContentIntent(contentIntent)
.addAction(android.R.drawable.ic_popup_sync, context.getString(R.string.notification_action_restart), restartIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, context.getString(R.string.notification_action_stop), stopIntent)
.build()
}
private fun pendingIntentFlags(): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
}
}

View File

@@ -0,0 +1,177 @@
package com.gotunnel.android.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationManagerCompat
import com.gotunnel.android.bridge.GoTunnelBridge
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.ServiceStateStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
class TunnelService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private lateinit var configStore: ConfigStore
private lateinit var stateStore: ServiceStateStore
private lateinit var controller: TunnelController
private lateinit var networkMonitor: NetworkMonitor
private var currentConfig: AppConfig = AppConfig()
private var networkMonitorPrimed = false
override fun onCreate() {
super.onCreate()
configStore = ConfigStore(this)
stateStore = ServiceStateStore(this)
controller = GoTunnelBridge.create(applicationContext)
controller.setListener(object : TunnelController.Listener {
override fun onStatusChanged(status: TunnelStatus, detail: String) {
stateStore.save(status, detail)
updateNotification(status, detail)
}
override fun onLog(message: String) {
val current = stateStore.load()
stateStore.save(current.status, message)
updateNotification(current.status, message)
}
})
networkMonitor = NetworkMonitor(
this,
onAvailable = {
if (networkMonitorPrimed) {
networkMonitorPrimed = false
} else {
val config = configStore.load()
if (config.autoReconnect && controller.isRunning) {
controller.restart("network-restored")
}
}
},
onLost = {
val detail = getString(com.gotunnel.android.R.string.network_lost)
stateStore.save(TunnelStatus.RECONNECTING, detail)
updateNotification(TunnelStatus.RECONNECTING, detail)
},
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
ensureForeground()
when (intent?.action) {
ACTION_STOP -> {
stopServiceInternal(intent.getStringExtra(EXTRA_REASON) ?: "stop")
return START_NOT_STICKY
}
ACTION_RESTART -> {
controller.restart(intent.getStringExtra(EXTRA_REASON) ?: "restart")
}
else -> {
startOrRefreshTunnel(intent?.getStringExtra(EXTRA_REASON) ?: "start")
}
}
return START_STICKY
}
override fun onDestroy() {
runCatching { networkMonitor.stop() }
runCatching { controller.stop("service-destroyed") }
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun ensureForeground() {
val state = stateStore.load()
val config = configStore.load()
NotificationHelper.ensureChannel(this)
startForeground(
NotificationHelper.NOTIFICATION_ID,
NotificationHelper.build(
this,
state.status,
state.detail,
config,
),
)
}
private fun startOrRefreshTunnel(reason: String) {
currentConfig = configStore.load()
controller.updateConfig(currentConfig)
stateStore.save(TunnelStatus.STARTING, reason)
updateNotification(TunnelStatus.STARTING, reason)
if (!isConfigReady(currentConfig)) {
val detail = getString(com.gotunnel.android.R.string.config_missing)
stateStore.save(TunnelStatus.STOPPED, detail)
updateNotification(TunnelStatus.STOPPED, detail)
return
}
networkMonitorPrimed = networkMonitor.isConnected()
controller.start(currentConfig)
runCatching { networkMonitor.start() }
}
private fun stopServiceInternal(reason: String) {
runCatching { networkMonitor.stop() }
networkMonitorPrimed = false
controller.stop(reason)
stateStore.save(TunnelStatus.STOPPED, reason)
updateNotification(TunnelStatus.STOPPED, reason)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun updateNotification(status: TunnelStatus, detail: String) {
val config = currentConfig.takeIf { it.serverAddress.isNotBlank() } ?: configStore.load()
NotificationManagerCompat.from(this).notify(
NotificationHelper.NOTIFICATION_ID,
NotificationHelper.build(this, status, detail, config),
)
}
private fun isConfigReady(config: AppConfig): Boolean {
return config.serverAddress.isNotBlank() && config.token.isNotBlank()
}
companion object {
const val ACTION_START = "com.gotunnel.android.service.action.START"
const val ACTION_STOP = "com.gotunnel.android.service.action.STOP"
const val ACTION_RESTART = "com.gotunnel.android.service.action.RESTART"
const val EXTRA_REASON = "extra_reason"
fun createStartIntent(context: Context, reason: String): Intent {
return Intent(context, TunnelService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_REASON, reason)
}
}
fun createStopIntent(context: Context, reason: String): Intent {
return Intent(context, TunnelService::class.java).apply {
action = ACTION_STOP
putExtra(EXTRA_REASON, reason)
}
}
fun createRestartIntent(context: Context, reason: String): Intent {
return Intent(context, TunnelService::class.java).apply {
action = ACTION_RESTART
putExtra(EXTRA_REASON, reason)
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?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="#0EA5A8"
android:pathData="M54,10a44,44 0 1,0 0,88a44,44 0 1,0 0,-88z" />
<path
android:fillColor="#0F172A"
android:pathData="M39,35h30c4.4,0 8,3.6 8,8v22c0,4.4 -3.6,8 -8,8H39c-4.4,0 -8,-3.6 -8,-8V43c0,-4.4 3.6,-8 8,-8z" />
<path
android:fillColor="#E5E7EB"
android:pathData="M44,43h20c2.2,0 4,1.8 4,4v14c0,2.2 -1.8,4 -4,4H44c-2.2,0 -4,-1.8 -4,-4V47c0,-2.2 1.8,-4 4,-4z" />
<path
android:fillColor="#38BDF8"
android:pathData="M52,49l10,6l-10,6z" />
</vector>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20z" />
<path
android:fillColor="#0EA5A8"
android:pathData="M8,8h8c1.1,0 2,0.9 2,2v4c0,1.1 -0.9,2 -2,2H8c-1.1,0 -2,-0.9 -2,-2v-4c0,-1.1 0.9,-2 2,-2z" />
<path
android:fillColor="#0F172A"
android:pathData="M10,10l4,2l-4,2z" />
</vector>

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
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="24dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="?attr/textAppearanceHeadlineMedium" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/main_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<EditText
android:id="@+id/serverAddressInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:hint="@string/server_address_hint"
android:inputType="textUri"
android:padding="12dp" />
<EditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/token_hint"
android:inputType="textPassword"
android:padding="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoStartSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/auto_start_label" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/autoReconnectSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/auto_reconnect_label" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/useTlsSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/use_tls_label" />
<TextView
android:id="@+id/stateHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/state_hint_stopped"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<TextView
android:id="@+id/stateValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<TextView
android:id="@+id/stateMeta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodySmall" />
<com.google.android.material.button.MaterialButton
android:id="@+id/batteryButton"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/battery_button" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/save_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:text="@string/stop_button" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,7 @@
<resources>
<color name="gotunnel_background">#0F172A</color>
<color name="gotunnel_surface">#111827</color>
<color name="gotunnel_primary">#0EA5A8</color>
<color name="gotunnel_secondary">#38BDF8</color>
<color name="gotunnel_text">#E5E7EB</color>
</resources>

View File

@@ -0,0 +1,36 @@
<resources>
<string name="app_name">GoTunnel</string>
<string name="main_subtitle">Android host shell for the GoTunnel client core.</string>
<string name="server_address_hint">Server address, for example 10.0.2.2:7000</string>
<string name="token_hint">Authentication token</string>
<string name="auto_start_label">Start on boot</string>
<string name="auto_reconnect_label">Restart on network restore</string>
<string name="use_tls_label">Use TLS</string>
<string name="save_button">Save</string>
<string name="start_button">Start</string>
<string name="stop_button">Stop</string>
<string name="battery_button">Battery optimization</string>
<string name="config_saved">Configuration saved</string>
<string name="service_start_requested">Service start requested</string>
<string name="service_stop_requested">Service stop requested</string>
<string name="battery_optimization_already_disabled">Battery optimization is already disabled for GoTunnel.</string>
<string name="notification_permission_denied">Notification permission was denied. Foreground service notifications may be limited.</string>
<string name="state_never_updated">Never updated</string>
<string name="state_no_detail">No detail</string>
<string name="state_format">State: %1$s\nDetail: %2$s</string>
<string name="state_meta_format">Updated: %1$s</string>
<string name="state_hint_stopped">The foreground service is idle until a configuration is saved and started.</string>
<string name="state_hint_starting">The host shell is preparing a tunnel session.</string>
<string name="state_hint_running">The host shell is ready. The native Go tunnel core can be attached here later.</string>
<string name="state_hint_reconnecting">The host shell is waiting for connectivity to return.</string>
<string name="state_hint_error">The last session reported an error. Check the configuration and service notification.</string>
<string name="notification_channel_name">GoTunnel service</string>
<string name="notification_channel_description">Keeps the Android host shell running in the foreground</string>
<string name="notification_title">GoTunnel - %1$s</string>
<string name="notification_text_configured">Configured for %1$s</string>
<string name="notification_text_unconfigured">No server configured yet</string>
<string name="notification_action_restart">Restart</string>
<string name="notification_action_stop">Stop</string>
<string name="config_missing">Server address and token are required</string>
<string name="network_lost">Network lost</string>
</resources>

View File

@@ -0,0 +1,13 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.GoTunnel" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/gotunnel_primary</item>
<item name="colorSecondary">@color/gotunnel_secondary</item>
<item name="colorBackground">@color/gotunnel_background</item>
<item name="colorSurface">@color/gotunnel_surface</item>
<item name="android:textColorPrimary">@color/gotunnel_text</item>
<item name="android:textColorSecondary">@color/gotunnel_text</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/gotunnel_background</item>
<item name="android:navigationBarColor" tools:targetApi="l">@color/gotunnel_background</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "GoTunnelAndroid"
include(":app")