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,36 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "org.vpnshare.app"
compileSdk = 36
defaultConfig {
applicationId = "org.vpnshare"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":apps:android:core:domain"))
implementation(project(":apps:android:feature:share"))
implementation(project(":apps:android:service:gateway"))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling)
}

View File

@@ -0,0 +1,38 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.usb.accessory" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<application
android:name=".VpnShareApplication"
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.VpnShare">
<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="org.vpnshare.gateway.VpnShareGatewayService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>

View File

@@ -0,0 +1,31 @@
package org.vpnshare.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import org.vpnshare.feature.share.ShareScreen
import org.vpnshare.gateway.VpnShareGatewayService
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ShareScreen(
onStartShare = {
startForegroundService(
Intent(this, VpnShareGatewayService::class.java)
.setAction(VpnShareGatewayService.ACTION_START)
)
},
onStopShare = {
startService(
Intent(this, VpnShareGatewayService::class.java)
.setAction(VpnShareGatewayService.ACTION_STOP)
)
}
)
}
}
}

View File

@@ -0,0 +1,5 @@
package org.vpnshare.app
import android.app.Application
class VpnShareApplication : Application()

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#0F172A"
android:pathData="M24,4L40,10V22C40,32 33.4,40.7 24,44C14.6,40.7 8,32 8,22V10L24,4Z" />
<path
android:fillColor="#38BDF8"
android:pathData="M16,23C16,18.6 19.6,15 24,15C28.4,15 32,18.6 32,23C32,27.4 28.4,31 24,31C19.6,31 16,27.4 16,23Z" />
<path
android:fillColor="#F8FAFC"
android:pathData="M22,20H26V34H22V20Z" />
</vector>

View File

@@ -0,0 +1,6 @@
<resources>
<string name="app_name">VPN Share</string>
<string name="gateway_channel_name">VPN Share sessions</string>
<string name="gateway_notification_title">VPN Share is active</string>
<string name="gateway_notification_text">Sharing through the phone VPN</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="Theme.VpnShare" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">#F8FAFC</item>
<item name="android:statusBarColor">#F8FAFC</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "org.vpnshare.domain"
compileSdk = 36
defaultConfig {
minSdk = 26
}
}

View File

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

View File

@@ -0,0 +1,83 @@
package org.vpnshare.domain.model
import java.time.Instant
@JvmInline
value class PeerId(val value: String)
enum class ShareTransport {
Usb,
Wifi,
Hotspot
}
enum class ClientPlatform {
Windows,
Linux,
MacOs,
Android,
Ios,
Unknown
}
data class GatewayConfig(
val preferredTransport: ShareTransport = ShareTransport.Usb,
val allowWifiFallback: Boolean = true,
val allowHotspot: Boolean = false,
val maxPeers: Int = 4,
val defaultMtu: Int = 1280
)
data class VpnStatus(
val active: Boolean,
val networkName: String?,
val supportsIpv4: Boolean,
val supportsIpv6: Boolean
)
data class PairingRequest(
val requestId: String,
val displayName: String,
val platform: ClientPlatform,
val transport: ShareTransport,
val createdAt: Instant,
val expiresAt: Instant
) {
fun isExpired(now: Instant): Boolean = !expiresAt.isAfter(now)
}
data class PeerDevice(
val id: PeerId,
val displayName: String,
val platform: ClientPlatform,
val trustedAt: Instant,
val lastSeenAt: Instant?,
val revoked: Boolean = false
)
data class TunnelLease(
val peerId: PeerId,
val ipv4Address: String,
val ipv6Address: String?,
val dnsGateway: String,
val mtu: Int,
val routes: List<String>,
val expiresAt: Instant
)
data class GatewayStats(
val connectedPeers: Int = 0,
val bytesFromClients: Long = 0,
val bytesToClients: Long = 0
)
sealed interface ShareState {
data object Stopped : ShareState
data class Starting(val config: GatewayConfig) : ShareState
data class Running(
val config: GatewayConfig,
val vpnStatus: VpnStatus,
val stats: GatewayStats
) : ShareState
data class Failed(val reason: String) : ShareState
}

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
package org.vpnshare.engine
import org.vpnshare.domain.model.PairingRequest
import org.vpnshare.domain.model.PeerId
import org.vpnshare.domain.model.ShareState
sealed interface GatewayEvent {
data class StateChanged(val state: ShareState) : GatewayEvent
data class PairingRequested(val request: PairingRequest) : GatewayEvent
data class PeerConnected(val peerId: PeerId) : GatewayEvent
data class PeerDisconnected(val peerId: PeerId, val reason: String) : GatewayEvent
data class Warning(val message: String) : GatewayEvent
}

View File

@@ -0,0 +1,67 @@
package org.vpnshare.engine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.vpnshare.domain.model.GatewayConfig
import org.vpnshare.domain.model.GatewayStats
import org.vpnshare.domain.model.PairingRequest
import org.vpnshare.domain.model.PeerId
import org.vpnshare.domain.model.ShareState
import org.vpnshare.domain.model.VpnStatus
class RustVpnShareEngine(
private val versionProvider: EngineVersionProvider = JniEngineVersionProvider()
) : VpnShareEngine {
private val mutableEvents = MutableSharedFlow<GatewayEvent>(extraBufferCapacity = 64)
override val events: Flow<GatewayEvent> = mutableEvents.asSharedFlow()
override suspend fun startGateway(config: GatewayConfig) {
mutableEvents.emit(GatewayEvent.StateChanged(ShareState.Starting(config)))
mutableEvents.emit(
GatewayEvent.StateChanged(
ShareState.Running(
config = config,
vpnStatus = VpnStatus(
active = false,
networkName = null,
supportsIpv4 = true,
supportsIpv6 = false
),
stats = GatewayStats()
)
)
)
mutableEvents.emit(GatewayEvent.Warning("Rust core linked as ${versionProvider.version()}"))
}
override suspend fun stopGateway() {
mutableEvents.emit(GatewayEvent.StateChanged(ShareState.Stopped))
}
override suspend fun approvePairing(request: PairingRequest): PeerId {
val peerId = PeerId("peer-${request.requestId}")
mutableEvents.emit(GatewayEvent.PeerConnected(peerId))
return peerId
}
override suspend fun rejectPairing(request: PairingRequest) {
mutableEvents.emit(GatewayEvent.Warning("Rejected pairing request ${request.requestId}"))
}
}
interface EngineVersionProvider {
fun version(): String
}
class JniEngineVersionProvider : EngineVersionProvider {
override fun version(): String {
return runCatching {
System.loadLibrary("vpnshare_ffi")
nativeVersion()
}.getOrDefault("unlinked")
}
private external fun nativeVersion(): String
}

View File

@@ -0,0 +1,18 @@
package org.vpnshare.engine
import kotlinx.coroutines.flow.Flow
import org.vpnshare.domain.model.GatewayConfig
import org.vpnshare.domain.model.PairingRequest
import org.vpnshare.domain.model.PeerId
interface VpnShareEngine {
val events: Flow<GatewayEvent>
suspend fun startGateway(config: GatewayConfig)
suspend fun stopGateway()
suspend fun approvePairing(request: PairingRequest): PeerId
suspend fun rejectPairing(request: PairingRequest)
}

View File

@@ -0,0 +1,28 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "org.vpnshare.feature.share"
compileSdk = 36
defaultConfig {
minSdk = 26
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(project(":apps:android:core:domain"))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
}

View File

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

View File

@@ -0,0 +1,100 @@
package org.vpnshare.feature.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun ShareScreen(
onStartShare: () -> Unit,
onStopShare: () -> Unit
) {
MaterialTheme {
Surface(
color = Color(0xFFF8FAFC),
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "VPN Share",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF0F172A)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Share this phone's active VPN with a paired device.",
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF475569)
)
}
Surface(
shape = RoundedCornerShape(8.dp),
color = Color.White,
shadowElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "USB first",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = Color(0xFF0F172A)
)
Text(
text = "Connect a computer with USB, approve pairing on this phone, and the client configures routing automatically.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF475569)
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = onStartShare,
modifier = Modifier.weight(1f)
) {
Text("Share")
}
OutlinedButton(
onClick = onStopShare,
modifier = Modifier.weight(1f)
) {
Text("Stop")
}
}
}
}
}
}

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()
}
}
}