init
Some checks failed
CI / Rust (push) Successful in 20s
CI / Android (push) Failing after 8m35s

This commit is contained in:
2026-05-31 15:36:07 +03:30
commit 4ffbc3bffe
61 changed files with 2760 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "org.vpnshare.gateway"
compileSdk = 36
defaultConfig {
minSdk = 26
}
}
dependencies {
implementation(project(":apps:android:core:domain"))
implementation(project(":apps:android:core:engine"))
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
}

View File

@@ -0,0 +1 @@
<manifest />

View File

@@ -0,0 +1,38 @@
package org.vpnshare.gateway
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.NetworkCapabilities
import org.vpnshare.domain.model.VpnStatus
class VpnDetector(context: Context) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
fun snapshot(): VpnStatus {
val activeNetwork = connectivityManager.activeNetwork
val activeCapabilities = activeNetwork?.let(connectivityManager::getNetworkCapabilities)
val vpnNetwork = connectivityManager.allNetworks.firstOrNull { network ->
connectivityManager.getNetworkCapabilities(network)
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
val linkProperties: LinkProperties? = (vpnNetwork ?: activeNetwork)
?.let(connectivityManager::getLinkProperties)
return VpnStatus(
active = activeCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true ||
vpnNetwork != null,
networkName = linkProperties?.interfaceName,
supportsIpv4 = linkProperties?.linkAddresses
?.any { it.address.address.size == IPV4_BYTES } ?: false,
supportsIpv6 = linkProperties?.linkAddresses
?.any { it.address.address.size == IPV6_BYTES } ?: false
)
}
private companion object {
const val IPV4_BYTES = 4
const val IPV6_BYTES = 16
}
}

View File

@@ -0,0 +1,109 @@
package org.vpnshare.gateway
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.vpnshare.domain.model.GatewayConfig
import org.vpnshare.engine.RustVpnShareEngine
class VpnShareGatewayService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val engine = RustVpnShareEngine()
private lateinit var vpnDetector: VpnDetector
override fun onCreate() {
super.onCreate()
vpnDetector = VpnDetector(this)
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
scope.launch { engine.stopGateway() }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
else -> startGateway()
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startGateway() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
scope.launch {
val vpn = vpnDetector.snapshot()
engine.startGateway(GatewayConfig())
if (!vpn.active) {
// The UI layer will render this event once event collection is wired.
// The gateway still accepts local pairing so users can start their VPN.
}
}
}
private fun buildNotification(): Notification {
val stopIntent = Intent(this, VpnShareGatewayService::class.java).setAction(ACTION_STOP)
val stopPendingIntent = PendingIntent.getService(
this,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setContentTitle("VPN Share is active")
.setContentText("Sharing through the phone VPN")
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"VPN Share sessions",
NotificationManager.IMPORTANCE_LOW
)
)
}
companion object {
const val ACTION_START = "org.vpnshare.action.START"
const val ACTION_STOP = "org.vpnshare.action.STOP"
private const val CHANNEL_ID = "vpnshare.gateway"
private const val NOTIFICATION_ID = 1001
}
}

View File

@@ -0,0 +1,40 @@
package org.vpnshare.gateway.discovery
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
class NsdDiscoveryPublisher(context: Context) {
private val nsdManager = context.getSystemService(NsdManager::class.java)
private var listener: NsdManager.RegistrationListener? = null
fun publish(instanceName: String, port: Int) {
stop()
val serviceInfo = NsdServiceInfo().apply {
serviceName = instanceName
serviceType = SERVICE_TYPE
setPort(port)
setAttribute("v", "1")
setAttribute("caps", "vshp,qr,resume")
}
val registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) = Unit
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
}
listener = registrationListener
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
fun stop() {
listener?.let {
runCatching { nsdManager.unregisterService(it) }
}
listener = null
}
companion object {
const val SERVICE_TYPE = "_vpnshare._udp."
}
}

View File

@@ -0,0 +1,54 @@
package org.vpnshare.gateway.transport
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.os.Looper
class LocalOnlyHotspotController(
private val wifiManager: WifiManager
) {
private var reservation: WifiManager.LocalOnlyHotspotReservation? = null
fun start(callback: Callback) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
callback.onFailed("Local-only hotspot requires Android 8.0 or newer")
return
}
wifiManager.startLocalOnlyHotspot(
object : WifiManager.LocalOnlyHotspotCallback() {
override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation) {
this@LocalOnlyHotspotController.reservation = reservation
val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
reservation.softApConfiguration?.ssid.orEmpty()
} else {
@Suppress("DEPRECATION")
reservation.wifiConfiguration?.SSID.orEmpty()
}
callback.onStarted(config)
}
override fun onStopped() {
this@LocalOnlyHotspotController.reservation = null
callback.onStopped()
}
override fun onFailed(reason: Int) {
callback.onFailed("Local-only hotspot failed: $reason")
}
},
Handler(Looper.getMainLooper())
)
}
fun stop() {
reservation?.close()
reservation = null
}
interface Callback {
fun onStarted(ssid: String)
fun onStopped()
fun onFailed(reason: String)
}
}

View File

@@ -0,0 +1,30 @@
package org.vpnshare.gateway.transport
import android.hardware.usb.UsbAccessory
import android.hardware.usb.UsbManager
import android.os.ParcelFileDescriptor
import java.io.Closeable
import java.io.FileInputStream
import java.io.FileOutputStream
class UsbAccessoryTransport(
private val usbManager: UsbManager
) {
fun open(accessory: UsbAccessory): Session? {
val descriptor = usbManager.openAccessory(accessory) ?: return null
return Session(descriptor)
}
class Session(
private val descriptor: ParcelFileDescriptor
) : Closeable {
val input = FileInputStream(descriptor.fileDescriptor)
val output = FileOutputStream(descriptor.fileDescriptor)
override fun close() {
runCatching { input.close() }
runCatching { output.close() }
descriptor.close()
}
}
}