Add Android client support and unify cross-platform builds
This commit is contained in:
48
android/app/build.gradle.kts
Normal file
48
android/app/build.gradle.kts
Normal 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
2
android/app/proguard-rules.pro
vendored
Normal 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.
|
||||
47
android/app/src/main/AndroidManifest.xml
Normal file
47
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
153
android/app/src/main/java/com/gotunnel/android/MainActivity.kt
Normal file
153
android/app/src/main/java/com/gotunnel/android/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
android/app/src/main/res/drawable/ic_gotunnel_app.xml
Normal file
19
android/app/src/main/res/drawable/ic_gotunnel_app.xml
Normal 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>
|
||||
@@ -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>
|
||||
125
android/app/src/main/res/layout/activity_main.xml
Normal file
125
android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
7
android/app/src/main/res/values/colors.xml
Normal file
7
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
36
android/app/src/main/res/values/strings.xml
Normal file
36
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
13
android/app/src/main/res/values/themes.xml
Normal file
13
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
Reference in New Issue
Block a user