feat(android): add VPN quick settings tile (#2511)

add an Android Quick Settings tile for starting and stopping EasyTier VPN networks
persist tile actions until the Tauri frontend is ready, so cold-start clicks are not lost
add the standard Quick Settings preferences activity alias so long-pressing the tile opens EasyTier
keep network and VPN lifecycle ownership in the existing frontend reconciliation flow
This commit is contained in:
Zhengqi Zhang
2026-09-05 18:08:20 +08:00
committed by GitHub
parent 164e2db6ae
commit f19bcfb400
24 changed files with 626 additions and 7 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test:mobile-vpn": "vitest run src/composables/mobile_vpn.test.ts",
"test:mobile-vpn": "vitest run src/composables/mobile_vpn*.test.ts",
"tauri": "tauri",
"lint": "eslint . --ignore-pattern src-tauri",
"lint:fix": "eslint . --ignore-pattern src-tauri --fix"
@@ -40,6 +40,7 @@
"vpnservice:allow-prepare-vpn",
"vpnservice:allow-start-vpn",
"vpnservice:allow-stop-vpn",
"vpnservice:allow-consume-vpn-tile-action",
"vpnservice:allow-registerListener",
"os:default",
"os:allow-os-type",
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:icon="@mipmap/ic_launcher"
@@ -22,6 +23,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity-alias
android:name=".VpnTilePreferencesActivity"
android:targetActivity=".MainActivity"
android:exported="true"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>
<service
android:name=".MainForegroundService"
android:foregroundServiceType="dataSync"
@@ -46,6 +57,9 @@
android:label="@string/main_activity_title"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the user-initiated EasyTier VPN connection." />
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
+6
View File
@@ -12,6 +12,7 @@ declare global {
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const collectNetworkInfo: typeof import('./composables/backend')['collectNetworkInfo']
const computed: typeof import('vue')['computed']
const consumePendingMobileVpnTileAction: typeof import('./composables/mobile_vpn')['consumePendingMobileVpnTileAction']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
@@ -20,6 +21,7 @@ declare global {
const defineStore: typeof import('pinia')['defineStore']
const deleteNetworkInstance: typeof import('./composables/backend')['deleteNetworkInstance']
const effectScope: typeof import('vue')['effectScope']
const executeVpnTileAction: typeof import('./composables/mobile_vpn_tile')['executeVpnTileAction']
const generateMenuItem: typeof import('./composables/tray')['generateMenuItem']
const generateNetworkConfig: typeof import('./composables/backend')['generateNetworkConfig']
const getActivePinia: typeof import('pinia')['getActivePinia']
@@ -87,6 +89,7 @@ declare global {
const setActivePinia: typeof import('pinia')['setActivePinia']
const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setMobileVpnTileActionHandler: typeof import('./composables/mobile_vpn')['setMobileVpnTileActionHandler']
const setServiceStatus: typeof import('./composables/backend')['setServiceStatus']
const setTrayMenu: typeof import('./composables/tray')['setTrayMenu']
const setTrayRunState: typeof import('./composables/tray')['setTrayRunState']
@@ -140,6 +143,7 @@ declare module 'vue' {
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly collectNetworkInfo: UnwrapRef<typeof import('./composables/backend')['collectNetworkInfo']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly consumePendingMobileVpnTileAction: UnwrapRef<typeof import('./composables/mobile_vpn')['consumePendingMobileVpnTileAction']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
@@ -148,6 +152,7 @@ declare module 'vue' {
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly deleteNetworkInstance: UnwrapRef<typeof import('./composables/backend')['deleteNetworkInstance']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly executeVpnTileAction: UnwrapRef<typeof import('./composables/mobile_vpn_tile')['executeVpnTileAction']>
readonly generateMenuItem: UnwrapRef<typeof import('./composables/tray')['generateMenuItem']>
readonly generateNetworkConfig: UnwrapRef<typeof import('./composables/backend')['generateNetworkConfig']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
@@ -215,6 +220,7 @@ declare module 'vue' {
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/backend')['setLoggingLevel']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly setMobileVpnTileActionHandler: UnwrapRef<typeof import('./composables/mobile_vpn')['setMobileVpnTileActionHandler']>
readonly setServiceStatus: UnwrapRef<typeof import('./composables/backend')['setServiceStatus']>
readonly setTrayMenu: UnwrapRef<typeof import('./composables/tray')['setTrayMenu']>
readonly setTrayRunState: UnwrapRef<typeof import('./composables/tray')['setTrayRunState']>
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => {
collectNetworkInfo: vi.fn(async (instanceId: string) => ({
info: { map: { [instanceId]: networkInfo.get(instanceId) } },
})),
consumeVpnTileAction: vi.fn(async () => ({})),
getConfig: vi.fn(async (instanceId: string) => configs.get(instanceId)),
getVpnStatus: vi.fn<() => Promise<Record<string, unknown>>>(async () => ({ running: false })),
listNetworkInstanceIds: vi.fn<() => Promise<{ running_inst_ids: unknown[] }>>(async () => ({ running_inst_ids: [] })),
@@ -43,6 +44,7 @@ vi.mock('easytier-frontend-lib', () => ({
}))
vi.mock('tauri-plugin-vpnservice-api', () => ({
consume_vpn_tile_action: mocks.consumeVpnTileAction,
get_vpn_status: mocks.getVpnStatus,
prepare_vpn: mocks.prepareVpn,
start_vpn: mocks.startVpn,
@@ -91,6 +93,8 @@ beforeEach(() => {
mocks.networkInfo.clear()
mocks.addPluginListener.mockClear()
mocks.collectNetworkInfo.mockClear()
mocks.consumeVpnTileAction.mockReset()
mocks.consumeVpnTileAction.mockResolvedValue({})
mocks.getConfig.mockClear()
mocks.getVpnStatus.mockReset()
mocks.getVpnStatus.mockResolvedValue({ running: false })
@@ -230,3 +234,22 @@ describe('mobile VPN reconciliation ownership', () => {
expect(mocks.startVpn).not.toHaveBeenCalled()
})
})
describe('mobile VPN tile action delivery', () => {
it('does not consume a pending action before a handler is ready', async () => {
const vpn = await loadVpnModule()
expect(await vpn.consumePendingMobileVpnTileAction()).toBe(false)
expect(mocks.consumeVpnTileAction).not.toHaveBeenCalled()
})
it('consumes and dispatches a pending action once a handler is registered', async () => {
const vpn = await loadVpnModule()
const handler = vi.fn(async () => undefined)
mocks.consumeVpnTileAction.mockResolvedValue({ action: 'start' })
vpn.setMobileVpnTileActionHandler(handler)
expect(await vpn.consumePendingMobileVpnTileAction()).toBe(true)
expect(handler).toHaveBeenCalledWith('start')
})
})
+45 -1
View File
@@ -1,7 +1,14 @@
import type { NetworkTypes } from 'easytier-frontend-lib'
import { addPluginListener } from '@tauri-apps/api/core'
import { Utils } from 'easytier-frontend-lib'
import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
import {
consume_vpn_tile_action,
get_vpn_status,
prepare_vpn,
start_vpn,
stop_vpn,
type VpnTileAction,
} from 'tauri-plugin-vpnservice-api'
import { collectNetworkInfo, getConfig, listNetworkInstanceIds, setTunFd } from './backend'
type Route = NetworkTypes.Route
@@ -24,6 +31,8 @@ let vpnReconcileGeneration = 0
let vpnReconcileAttempts = 0
let vpnReconcileQueue: Promise<void> = Promise.resolve()
let vpnPermissionRequest: Promise<boolean> | null = null
let vpnTileActionHandler: ((action: VpnTileAction) => Promise<void>) | undefined
let vpnTileActionQueue: Promise<void> = Promise.resolve()
const curVpnStatus: vpnStatus = {
running: false,
@@ -33,6 +42,31 @@ const curVpnStatus: vpnStatus = {
dns: undefined,
}
export function setMobileVpnTileActionHandler(
handler?: (action: VpnTileAction) => Promise<void>,
) {
vpnTileActionHandler = handler
}
export async function consumePendingMobileVpnTileAction() {
const handler = vpnTileActionHandler
if (!handler) {
return false
}
const action = (await consume_vpn_tile_action())?.action
if (action !== 'start' && action !== 'stop') {
return false
}
const run = vpnTileActionQueue
.catch(error => console.error('previous VPN tile action failed', error))
.then(() => handler(action))
vpnTileActionQueue = run.catch(error => console.error('VPN tile action failed', error))
await run
return true
}
async function requestVpnPermissionOnce() {
console.log('prepare vpn')
const prepare_ret = await prepare_vpn()
@@ -244,6 +278,16 @@ async function registerVpnServiceListener() {
'vpn_service_stop',
onVpnServiceStop,
)
await addPluginListener(
'vpnservice',
'vpn_tile_action',
() => {
void consumePendingMobileVpnTileAction().catch((error) => {
console.error('consume VPN tile action failed', error)
})
},
)
}
function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.NetworkConfig): string[] {
@@ -0,0 +1,128 @@
import type { Api, NetworkTypes } from 'easytier-frontend-lib'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { executeVpnTileAction } from './mobile_vpn_tile'
vi.mock('easytier-frontend-lib', () => ({
Utils: {
UuidToStr: (value: unknown) => String(value),
},
}))
function createApi(runningIds: string[], disabledIds: string[], noTunIds: string[] = []) {
const configs = new Map(
[...new Set([...runningIds, ...disabledIds])].map(instanceId => [
instanceId,
{ instance_id: instanceId, no_tun: noTunIds.includes(instanceId) } as NetworkTypes.NetworkConfig,
]),
)
return {
api: {
list_network_instance_ids: vi.fn(async () => ({
running_inst_ids: runningIds,
disabled_inst_ids: disabledIds,
})),
get_network_config: vi.fn(async (instanceId: string) => configs.get(instanceId)!),
run_network: vi.fn(async () => undefined),
update_network_instance_state: vi.fn(async () => undefined),
} as unknown as Api.RemoteClient,
configs,
}
}
describe('vpn quick settings tile actions', () => {
const syncVpnService = vi.fn(async () => undefined)
beforeEach(() => {
syncVpnService.mockClear()
})
it('starts the last configured network and reconciles the Android VPN', async () => {
const { api, configs } = createApi([], ['first', 'last'])
const result = await executeVpnTileAction('start', api, {
lastInstanceId: 'last',
syncVpnService,
})
expect(api.run_network).toHaveBeenCalledWith(configs.get('last'), true)
expect(syncVpnService).toHaveBeenCalledOnce()
expect(result).toEqual({ action: 'start', instanceId: 'last', changed: true })
})
it('skips a no_tun last network and starts the first TUN-capable network', async () => {
const { api, configs } = createApi([], ['first', 'last'], ['last'])
const result = await executeVpnTileAction('start', api, {
lastInstanceId: 'last',
syncVpnService,
})
expect(api.run_network).toHaveBeenCalledWith(configs.get('first'), true)
expect(syncVpnService).toHaveBeenCalledOnce()
expect(result).toEqual({ action: 'start', instanceId: 'first', changed: true })
})
it('does nothing when only no_tun networks are configured', async () => {
const { api } = createApi([], ['headless'], ['headless'])
const result = await executeVpnTileAction('start', api, {
lastInstanceId: 'headless',
syncVpnService,
})
expect(api.run_network).not.toHaveBeenCalled()
expect(syncVpnService).not.toHaveBeenCalled()
expect(result).toEqual({ action: 'start', changed: false })
})
it('does not restart an already running network', async () => {
const { api } = createApi(['running'], [])
const result = await executeVpnTileAction('start', api, {
lastInstanceId: 'running',
syncVpnService,
})
expect(api.run_network).not.toHaveBeenCalled()
expect(syncVpnService).toHaveBeenCalledOnce()
expect(result.changed).toBe(false)
})
it('stops the last running network, falling back to the first running network', async () => {
const { api } = createApi(['first', 'second'], ['disabled'])
const result = await executeVpnTileAction('stop', api, {
lastInstanceId: 'disabled',
syncVpnService,
})
expect(api.update_network_instance_state).toHaveBeenCalledWith('first', true)
expect(syncVpnService).toHaveBeenCalledOnce()
expect(result).toEqual({ action: 'stop', instanceId: 'first', changed: true })
})
it('does not select a running no_tun network for stop', async () => {
const { api } = createApi(['headless', 'vpn'], [], ['headless'])
const result = await executeVpnTileAction('stop', api, {
lastInstanceId: 'headless',
syncVpnService,
})
expect(api.update_network_instance_state).toHaveBeenCalledWith('vpn', true)
expect(syncVpnService).toHaveBeenCalledOnce()
expect(result).toEqual({ action: 'stop', instanceId: 'vpn', changed: true })
})
it('reports that no action is possible when no matching network exists', async () => {
const { api } = createApi([], ['configured'])
const result = await executeVpnTileAction('stop', api, {
syncVpnService,
})
expect(api.update_network_instance_state).not.toHaveBeenCalled()
expect(syncVpnService).not.toHaveBeenCalled()
expect(result).toEqual({ action: 'stop', changed: false })
})
})
@@ -0,0 +1,55 @@
import type { Api } from 'easytier-frontend-lib'
import type { VpnTileAction } from 'tauri-plugin-vpnservice-api'
import { Utils } from 'easytier-frontend-lib'
export interface VpnTileActionResult {
action: VpnTileAction
instanceId?: string
changed: boolean
}
export interface VpnTileActionOptions {
lastInstanceId?: string | null
syncVpnService: () => Promise<void>
}
export async function executeVpnTileAction(
action: VpnTileAction,
api: Api.RemoteClient,
options: VpnTileActionOptions,
): Promise<VpnTileActionResult> {
const response = await api.list_network_instance_ids()
const runningIds = (response.running_inst_ids ?? []).map(Utils.UuidToStr)
const disabledIds = (response.disabled_inst_ids ?? []).map(Utils.UuidToStr)
const configuredIds = [...new Set([...runningIds, ...disabledIds])]
const candidateIds = action === 'stop' ? runningIds : configuredIds
const candidateConfigs = new Map(
await Promise.all(candidateIds.map(async (instanceId) => {
const config = await api.get_network_config(instanceId)
return [instanceId, config] as const
})),
)
const candidates = candidateIds.filter(instanceId => !candidateConfigs.get(instanceId)?.no_tun)
const instanceId = options.lastInstanceId && candidates.includes(options.lastInstanceId)
? options.lastInstanceId
: candidates[0]
if (!instanceId) {
return { action, changed: false }
}
let changed = false
if (action === 'start' && !runningIds.includes(instanceId)) {
const config = candidateConfigs.get(instanceId)!
await api.run_network(config, true)
changed = true
}
else if (action === 'stop') {
await api.update_network_instance_state(instanceId, true)
changed = true
}
await options.syncVpnService()
return { action, instanceId, changed }
}
+46 -1
View File
@@ -9,7 +9,13 @@ import { exit } from '@tauri-apps/plugin-process'
import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib"
import type { MenuItem } from 'primevue/menuitem'
import { useTray } from '~/composables/tray'
import { initMobileVpnService, syncMobileVpnService } from '~/composables/mobile_vpn'
import {
consumePendingMobileVpnTileAction,
initMobileVpnService,
setMobileVpnTileActionHandler,
syncMobileVpnService,
} from '~/composables/mobile_vpn'
import { executeVpnTileAction } from '~/composables/mobile_vpn_tile'
import { GUIRemoteClient } from '~/modules/api'
import { useToast, useConfirm } from 'primevue'
@@ -223,7 +229,10 @@ onMounted(async () => {
await initWithMode(currentMode.value);
if (type() === 'android') {
setMobileVpnTileActionHandler(handleMobileVpnTileAction)
cleanupFns.push(() => setMobileVpnTileActionHandler())
try {
await consumePendingMobileVpnTileAction()
await syncMobileVpnService()
} catch (e: any) {
console.error("easytier sync vpn service failed", e)
@@ -242,6 +251,42 @@ const remoteClient = computed(() => new GUIRemoteClient());
const instanceId = ref<string | undefined>(undefined);
const clientRunning = ref(false);
async function handleMobileVpnTileAction(action: 'start' | 'stop') {
try {
const result = await executeVpnTileAction(action, remoteClient.value, {
lastInstanceId: loadLastNetworkInstanceId(),
syncVpnService: syncMobileVpnService,
})
if (!result.instanceId) {
toast.add({
severity: 'warn',
summary: t('vpn_tile_no_network'),
detail: t('vpn_tile_no_network_description'),
life: 5000,
})
return
}
instanceId.value = result.instanceId
saveLastNetworkInstanceId(result.instanceId)
toast.add({
severity: action === 'start' ? 'success' : 'secondary',
summary: t(action === 'start' ? 'vpn_tile_started' : 'vpn_tile_stopped'),
life: 3000,
})
}
catch (error) {
console.error('VPN tile action failed', action, error)
toast.add({
severity: 'error',
summary: t('error'),
detail: t('vpn_tile_action_failed', { error: String(error) }),
life: 8000,
})
}
}
watch(instanceId, (newVal) => {
if (newVal) {
saveLastNetworkInstanceId(newVal);
@@ -72,6 +72,11 @@ logging_level_trace: 跟踪
logging_level_off: 关闭
logging_open_dir: 打开日志目录
logging_copy_dir: 复制日志路径
vpn_tile_no_network: 没有已配置的网络
vpn_tile_no_network_description: 请先在 EasyTier 中配置网络,再使用 VPN 快捷磁贴。
vpn_tile_started: VPN 网络已启动
vpn_tile_stopped: VPN 网络已停止
vpn_tile_action_failed: 'VPN 磁贴操作失败:{error}'
disable_auto_launch: 关闭开机自启
enable_auto_launch: 开启开机自启
hide_dock_icon: 隐藏 Dock 图标
@@ -72,6 +72,11 @@ logging_level_trace: Trace
logging_level_off: Off
logging_open_dir: Open Log Directory
logging_copy_dir: Copy Log Path
vpn_tile_no_network: No configured network
vpn_tile_no_network_description: Configure a network in EasyTier before using the VPN tile.
vpn_tile_started: VPN network started
vpn_tile_stopped: VPN network stopped
vpn_tile_action_failed: 'VPN tile action failed: {error}'
disable_auto_launch: Disable Launch on Reboot
enable_auto_launch: Enable Launch on Reboot
hide_dock_icon: Hide Dock Icon
@@ -7,5 +7,16 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application>
<service
android:name=".EasyTierVpnTileService"
android:exported="true"
android:icon="@drawable/vpn_tile_icon"
android:label="@string/vpn_tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,100 @@
package com.plugin.vpnservice
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
class EasyTierVpnTileService : TileService() {
companion object {
private const val PREFS_NAME = "easytier_vpn_tile"
private const val PENDING_ACTION_KEY = "pending_action"
const val ACTION_START = "start"
const val ACTION_STOP = "stop"
@Synchronized
fun consumePendingAction(context: Context): String? {
val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val action = preferences.getString(PENDING_ACTION_KEY, null)
if (action != null) {
preferences.edit().remove(PENDING_ACTION_KEY).commit()
}
return action
}
fun requestStateUpdate(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
requestListeningState(context, ComponentName(context, EasyTierVpnTileService::class.java))
}
}
private fun pendingAction(context: Context): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PENDING_ACTION_KEY, null)
private fun savePendingAction(context: Context, action: String) {
// TileService may be reclaimed as soon as onClick returns, so persist synchronously.
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(PENDING_ACTION_KEY, action)
.commit()
}
}
override fun onStartListening() {
super.onStartListening()
updateTileState()
}
override fun onClick() {
super.onClick()
if (isLocked) {
unlockAndRun(::handleClick)
} else {
handleClick()
}
}
private fun handleClick() {
val action = pendingAction(this) ?: if (TauriVpnService.self == null) ACTION_START else ACTION_STOP
savePendingAction(this, action)
updateTileState()
val delivered = VpnServicePlugin.dispatchTileAction(action)
val permissionRequired = action == ACTION_START && VpnService.prepare(this) != null
if (!delivered || permissionRequired) {
openApp()
}
}
private fun updateTileState() {
qsTile?.apply {
state = if (TauriVpnService.self == null) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE
updateTile()
}
}
private fun openApp() {
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
} ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
}
@@ -1,10 +1,15 @@
package com.plugin.vpnservice
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.Bundle
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import java.net.InetAddress
import java.util.Arrays
@@ -23,12 +28,16 @@ class TauriVpnService : VpnService() {
const val DNS = "DNS"
const val DISALLOWED_APPLICATIONS = "DISALLOWED_APPLICATIONS"
const val MTU = "MTU"
private const val NOTIFICATION_CHANNEL_ID = "easytier_vpn_channel"
private const val NOTIFICATION_ID = 1356
}
private lateinit var vpnInterface: ParcelFileDescriptor
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
println("vpn on start command ${intent?.getExtras()} $intent")
startVpnForegroundService()
var args = intent?.getExtras()
ipv4Addr = args?.getString(IPV4_ADDR)
routes = args?.getStringArray(ROUTES) ?: emptyArray()
@@ -40,6 +49,7 @@ class TauriVpnService : VpnService() {
var event_data = JSObject()
event_data.put("fd", vpnInterface.fd)
triggerCallback("vpn_service_start", event_data)
EasyTierVpnTileService.requestStateUpdate(this)
return START_STICKY
}
@@ -52,16 +62,20 @@ class TauriVpnService : VpnService() {
override fun onDestroy() {
println("vpn on destroy")
super.onDestroy()
disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
self = null
EasyTierVpnTileService.requestStateUpdate(this)
super.onDestroy()
}
override fun onRevoke() {
println("vpn on revoke")
super.onRevoke()
disconnect()
stopForeground(STOP_FOREGROUND_REMOVE)
self = null
EasyTierVpnTileService.requestStateUpdate(this)
super.onRevoke()
}
private fun disconnect() {
@@ -78,6 +92,53 @@ class TauriVpnService : VpnService() {
dns = null
}
private fun startVpnForegroundService() {
createNotificationChannel()
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val contentIntent = launchIntent?.let {
PendingIntent.getActivity(
this,
0,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_manage)
.setContentTitle("EasyTier VPN is running")
.setContentText("VPN connection is active")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.apply { contentIntent?.let(::setContentIntent) }
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"EasyTier VPN",
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor {
var builder = Builder()
.setSession("TauriVpnService")
@@ -29,7 +29,20 @@ class StartVpnArgs {
@TauriPlugin
class VpnServicePlugin(private val activity: Activity) : Plugin(activity) {
companion object {
@Volatile
private var tileActionCallback: (String) -> Boolean = { false }
fun dispatchTileAction(action: String): Boolean = tileActionCallback(action)
}
private val implementation = Example()
private val tileActionHandler: (String) -> Boolean = { action ->
val data = JSObject()
data.put("action", action)
trigger("vpn_tile_action", data)
true
}
override fun load(webView: WebView) {
println("load vpn service plugin")
@@ -37,6 +50,14 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) {
println("vpn: triggerCallback $event $data")
trigger(event, data)
}
tileActionCallback = tileActionHandler
}
override fun onDestroy() {
if (tileActionCallback === tileActionHandler) {
tileActionCallback = { false }
}
super.onDestroy()
}
@Command
@@ -90,7 +111,11 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) {
intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications)
intent.putExtra(TauriVpnService.MTU, args.mtu)
activity.startService(intent)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
}
invoke.resolve(ret)
}
@@ -116,4 +141,11 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) {
ret.put("dns", TauriVpnService.dns)
invoke.resolve(ret)
}
@Command
fun consumeVpnTileAction(invoke: Invoke) {
val ret = JSObject()
ret.put("action", EasyTierVpnTileService.consumePendingAction(activity))
invoke.resolve(ret)
}
}
@@ -0,0 +1,9 @@
<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,2L4,5.5V11C4,16.05 7.41,20.74 12,22C16.59,20.74 20,16.05 20,11V5.5L12,2ZM12,4.18L18,6.8V11C18,14.89 15.48,18.67 12,19.93C8.52,18.67 6,14.89 6,11V6.8L12,4.18ZM11,7V11H8L12,16V12H15L11,7Z" />
</vector>
@@ -0,0 +1,3 @@
<resources>
<string name="vpn_tile_label">EasyTier VPN</string>
</resources>
+1
View File
@@ -4,6 +4,7 @@ const COMMANDS: &[&str] = &[
"start_vpn",
"stop_vpn",
"get_vpn_status",
"consume_vpn_tile_action",
"registerListener",
];
+10
View File
@@ -28,6 +28,12 @@ export interface VpnStatusResponse {
dns?: string;
}
export type VpnTileAction = 'start' | 'stop';
export interface VpnTileActionResponse {
action?: VpnTileAction;
}
export async function prepare_vpn(): Promise<InvokeResponse | null> {
return await invoke<InvokeResponse>('plugin:vpnservice|prepare_vpn', {})
}
@@ -45,3 +51,7 @@ export async function stop_vpn(): Promise<InvokeResponse | null> {
export async function get_vpn_status(): Promise<VpnStatusResponse | null> {
return await invoke<VpnStatusResponse>('plugin:vpnservice|get_vpn_status', {})
}
export async function consume_vpn_tile_action(): Promise<VpnTileActionResponse> {
return await invoke<VpnTileActionResponse>('plugin:vpnservice|consume_vpn_tile_action', {})
}
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-consume-vpn-tile-action"
description = "Enables the consume_vpn_tile_action command without any pre-configured scope."
commands.allow = ["consume_vpn_tile_action"]
[[permission]]
identifier = "deny-consume-vpn-tile-action"
description = "Denies the consume_vpn_tile_action command without any pre-configured scope."
commands.deny = ["consume_vpn_tile_action"]
@@ -16,6 +16,32 @@ Default permissions for the plugin
</tr>
<tr>
<td>
`vpnservice:allow-consume-vpn-tile-action`
</td>
<td>
Enables the consume_vpn_tile_action command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vpnservice:deny-consume-vpn-tile-action`
</td>
<td>
Denies the consume_vpn_tile_action command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
@@ -294,6 +294,18 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the consume_vpn_tile_action command without any pre-configured scope.",
"type": "string",
"const": "allow-consume-vpn-tile-action",
"markdownDescription": "Enables the consume_vpn_tile_action command without any pre-configured scope."
},
{
"description": "Denies the consume_vpn_tile_action command without any pre-configured scope.",
"type": "string",
"const": "deny-consume-vpn-tile-action",
"markdownDescription": "Denies the consume_vpn_tile_action command without any pre-configured scope."
},
{
"description": "Enables the get_vpn_status command without any pre-configured scope.",
"type": "string",
+9
View File
@@ -57,4 +57,13 @@ impl<R: Runtime> Vpnservice<R> {
.run_mobile_plugin("get_vpn_status", payload)
.map_err(Into::into)
}
pub fn consume_vpn_tile_action(
&self,
payload: VoidRequest,
) -> crate::Result<VpnTileActionResponse> {
self.0
.run_mobile_plugin("consume_vpn_tile_action", payload)
.map_err(Into::into)
}
}
+6
View File
@@ -42,3 +42,9 @@ pub struct VpnStatus {
pub routes: Option<Vec<String>>,
pub dns: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VpnTileActionResponse {
pub action: Option<String>,
}