init
This commit is contained in:
36
apps/android/app/build.gradle.kts
Normal file
36
apps/android/app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
38
apps/android/app/src/main/AndroidManifest.xml
Normal file
38
apps/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.vpnshare.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class VpnShareApplication : Application()
|
||||
15
apps/android/app/src/main/res/drawable/ic_launcher.xml
Normal file
15
apps/android/app/src/main/res/drawable/ic_launcher.xml
Normal 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>
|
||||
6
apps/android/app/src/main/res/values/strings.xml
Normal file
6
apps/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
7
apps/android/app/src/main/res/values/styles.xml
Normal file
7
apps/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
13
apps/android/core/domain/build.gradle.kts
Normal file
13
apps/android/core/domain/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
1
apps/android/core/domain/src/main/AndroidManifest.xml
Normal file
1
apps/android/core/domain/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -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
|
||||
}
|
||||
19
apps/android/core/engine/build.gradle.kts
Normal file
19
apps/android/core/engine/build.gradle.kts
Normal 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)
|
||||
}
|
||||
1
apps/android/core/engine/src/main/AndroidManifest.xml
Normal file
1
apps/android/core/engine/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
28
apps/android/feature/share/build.gradle.kts
Normal file
28
apps/android/feature/share/build.gradle.kts
Normal 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)
|
||||
}
|
||||
1
apps/android/feature/share/src/main/AndroidManifest.xml
Normal file
1
apps/android/feature/share/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/android/service/gateway/build.gradle.kts
Normal file
20
apps/android/service/gateway/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user