feat(android): Tunnel lifecycle implementation (#2061)

Fixes #1970

- Implemented TunnelService and added dummy routes.
- Updated service lifecycle and implemented foreground notification.
- Updated TunnelManager to observe events and propagate them to its
listeners.
This commit is contained in:
Pratik Velani
2023-09-19 03:52:48 +05:30
committed by GitHub
parent 9281b7fede
commit be168f4e5b
16 changed files with 512 additions and 276 deletions

View File

@@ -114,7 +114,7 @@ dependencies {
// Moshi
implementation "com.squareup.moshi:moshi-kotlin:1.12.0"
implementation 'com.squareup.moshi:moshi:1.12.0'
implementation "com.squareup.moshi:moshi:1.12.0"
// Gson
implementation "com.google.code.gson:gson:2.9.0"

View File

@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- XXX: Set usesCleartextTraffic to false for added security when APIMock is removed or served over https -->

View File

@@ -12,9 +12,5 @@ internal interface PreferenceRepository {
fun saveToken(value: String): Flow<Unit>
fun saveIsConnectedSync(value: Boolean)
fun saveIsConnected(value: Boolean): Flow<Unit>
fun validateCsrfToken(value: String): Flow<Boolean>
}

View File

@@ -15,7 +15,6 @@ internal class PreferenceRepositoryImpl @Inject constructor(
override fun getConfigSync(): Config = Config(
accountId = sharedPreferences.getString(ACCOUNT_ID_KEY, null),
isConnected = sharedPreferences.getBoolean(IS_CONNECTED_KEY, false),
token = sharedPreferences.getString(TOKEN_KEY, null),
)
@@ -41,19 +40,6 @@ internal class PreferenceRepositoryImpl @Inject constructor(
)
}.flowOn(coroutineDispatcher)
override fun saveIsConnectedSync(value: Boolean) {
sharedPreferences
.edit()
.putBoolean(IS_CONNECTED_KEY, value)
.apply()
}
override fun saveIsConnected(value: Boolean): Flow<Unit> = flow {
emit(
saveIsConnectedSync(value)
)
}.flowOn(coroutineDispatcher)
override fun validateCsrfToken(value: String): Flow<Boolean> = flow {
val token = sharedPreferences.getString(CSRF_KEY, "")
emit(token == value)
@@ -61,7 +47,6 @@ internal class PreferenceRepositoryImpl @Inject constructor(
companion object {
private const val ACCOUNT_ID_KEY = "accountId"
private const val IS_CONNECTED_KEY = "isConnected"
private const val TOKEN_KEY = "token"
private const val CSRF_KEY = "csrf"
}

View File

@@ -6,15 +6,10 @@ import android.content.SharedPreferences
import android.content.res.Resources
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
import dev.firezone.android.tunnel.TunnelManager
internal const val ENCRYPTED_SHARED_PREFERENCES = "encryptedSharedPreferences"
@@ -39,12 +34,4 @@ object AppModule {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
@Provides
internal fun provideTunnelManager(
@ApplicationContext appContext: Context,
getConfigUseCase: GetConfigUseCase,
saveIsConnectedUseCase: SaveIsConnectedUseCase,
moshi: Moshi,
): TunnelManager = TunnelManager(appContext, getConfigUseCase, saveIsConnectedUseCase, moshi)
}

View File

@@ -1,16 +0,0 @@
package dev.firezone.android.core.domain.preference
import dev.firezone.android.core.data.PreferenceRepository
import javax.inject.Inject
internal class SaveIsConnectedUseCase @Inject constructor(
private val repository: PreferenceRepository
) {
operator fun invoke(isConnected: Boolean) {
repository.saveIsConnectedSync(isConnected)
}
fun sync(isConnected: Boolean) {
repository.saveIsConnectedSync(isConnected)
}
}

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.tunnel.callback.TunnelListener
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@@ -25,41 +26,17 @@ internal class SessionViewModel @Inject constructor(
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
private val tunnelListener = object: TunnelListener {
override fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
dnsAddress: String,
dnsFallbackStrategy: String
) {
//TODO("Not yet implemented")
override fun onTunnelStateUpdate(state: Tunnel.State) {
TODO("Not yet implemented")
}
override fun onTunnelReady(): Boolean {
//TODO("Not yet implemented")
return true
}
override fun onAddRoute(cidrAddress: String) {
//TODO("Not yet implemented")
}
override fun onRemoveRoute(cidrAddress: String) {
//TODO("Not yet implemented")
}
override fun onUpdateResources(resources: List<Resource>) {
//TODO("Not yet implemented")
override fun onResourcesUpdate(resources: List<Resource>) {
Log.d("TunnelManager", "onUpdateResources: $resources")
_uiState.value = _uiState.value.copy (
resources = resources
)
}
override fun onDisconnect(error: String?): Boolean {
//TODO("Not yet implemented")
return true
}
override fun onError(error: String): Boolean {
//TODO("Not yet implemented")
return true

View File

@@ -2,113 +2,29 @@ package dev.firezone.android.tunnel
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.provider.Settings
import android.content.SharedPreferences
import android.util.Log
import com.google.firebase.installations.FirebaseInstallations
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dev.firezone.android.BuildConfig
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
import dev.firezone.android.tunnel.callback.ConnlibCallback
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.callback.TunnelListener
import dev.firezone.android.tunnel.data.TunnelRepository
import dev.firezone.android.tunnel.model.Tunnel
import dev.firezone.android.tunnel.model.TunnelConfig
import java.lang.ref.WeakReference
import java.nio.file.Files
import java.nio.file.Paths
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@OptIn(ExperimentalStdlibApi::class)
internal class TunnelManager @Inject constructor(
private val appContext: Context,
private val getConfigUseCase: GetConfigUseCase,
private val saveIsConnectedUseCase: SaveIsConnectedUseCase,
private val moshi: Moshi,
private val tunnelRepository: TunnelRepository,
) {
private var activeTunnel: Tunnel? = null
private val listeners: MutableSet<WeakReference<TunnelListener>> = mutableSetOf()
private val callback: ConnlibCallback = object: ConnlibCallback {
override fun onUpdateResources(resourceListJSON: String) {
// TODO: Call into client app to update resources list and routing table
Log.d(TAG, "onUpdateResources: $resourceListJSON")
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let { resources ->
listeners.onEach {
it.get()?.onUpdateResources(resources)
}
private val tunnelRepositoryListener = SharedPreferences.OnSharedPreferenceChangeListener { _, s ->
if (s == TunnelRepository.RESOURCES_KEY) {
listeners.forEach {
it.get()?.onResourcesUpdate(tunnelRepository.getResources())
}
}
override fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
dnsAddress: String,
dnsFallbackStrategy: String
): Int {
Log.d(TAG, "onSetInterfaceConfig: [IPv4:$tunnelAddressIPv4] [IPv6:$tunnelAddressIPv6] [dns:$dnsAddress] [dnsFallbackStrategy:$dnsFallbackStrategy]")
val tunnel = Tunnel(
config = TunnelConfig(
tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy
)
)
listeners.onEach {
it.get()?.onSetInterfaceConfig(tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy)
}
return buildVpnService(tunnelAddressIPv4, tunnelAddressIPv6).establish()?.detachFd() ?: -1
}
override fun onTunnelReady(): Boolean {
Log.d(TAG, "onTunnelReady")
listeners.onEach {
it.get()?.onTunnelReady()
}
return true
}
override fun onError(error: String): Boolean {
Log.d(TAG, "onError: $error")
listeners.onEach {
it.get()?.onError(error)
}
return true
}
override fun onAddRoute(cidrAddress: String) {
Log.d(TAG, "onAddRoute: $cidrAddress")
listeners.onEach {
it.get()?.onAddRoute(cidrAddress)
}
}
override fun onRemoveRoute(cidrAddress: String) {
Log.d(TAG, "onRemoveRoute: $cidrAddress")
listeners.onEach {
it.get()?.onRemoveRoute(cidrAddress)
}
}
override fun onDisconnect(error: String?): Boolean {
Log.d(TAG, "onDisconnect $error")
listeners.onEach {
it.get()?.onDisconnect(error)
}
return true
}
}
fun addListener(listener: TunnelListener) {
@@ -119,6 +35,9 @@ internal class TunnelManager @Inject constructor(
if (!contains) {
listeners.add(WeakReference(listener))
}
tunnelRepository.addListener(tunnelRepositoryListener)
tunnelRepository.setState(Tunnel.State.Connecting)
}
fun removeListener(listener: TunnelListener) {
@@ -128,99 +47,39 @@ internal class TunnelManager @Inject constructor(
it.clear()
listeners.remove(it)
}
if (listeners.isEmpty()) {
tunnelRepository.removeListener(tunnelRepositoryListener)
}
}
fun startVPN() {
fun connect() {
startVPNService()
}
fun disconnect() {
stopVPNService()
}
private fun startVPNService() {
val intent = Intent(appContext, TunnelService::class.java)
intent.action = TunnelService.ACTION_CONNECT
appContext.startService(intent)
}
fun stopVPN() {
private fun stopVPNService() {
val intent = Intent(appContext, TunnelService::class.java)
intent.action = TunnelService.ACTION_DISCONNECT
appContext.startService(intent)
}
fun connect() {
try {
val config = getConfigUseCase.sync()
Log.d("Connlib", "accountId: ${config.accountId}")
Log.d("Connlib", "token: ${config.token}")
if (config.accountId != null && config.token != null) {
Log.d("Connlib", "Attempting to establish TunnelSession...")
sessionPtr = TunnelSession.connect(
controlPlaneUrl = BuildConfig.CONTROL_PLANE_URL,
token = config.token,
deviceId = deviceId(),
logDir = appContext.filesDir.absolutePath,
callback = callback
)
Log.d("Connlib", "connlib session started! sessionPtr: ${sessionPtr}")
setConnectionStatus(true)
}
} catch (exception: Exception) {
Log.e("Connection error:", exception.message.toString())
}
}
fun disconnect() {
try {
TunnelSession.disconnect(sessionPtr!!)
setConnectionStatus(false)
} catch (exception: Exception) {
Log.e("Disconnection error:", exception.message.toString())
}
}
private fun deviceId(): String {
val deviceId = FirebaseInstallations
.getInstance()
.getId()
Log.d("Connlib", "Device ID: ${deviceId}")
return deviceId.toString()
}
private fun getLogDir(): String {
// Create log directory if it doesn't exist
val logDir = appContext.cacheDir.absolutePath + "/log"
Files.createDirectories(Paths.get(logDir))
return logDir
}
private fun setConnectionStatus(value: Boolean) {
saveIsConnectedUseCase.sync(value)
}
private fun buildVpnService(ipv4Address: String, ipv6Address: String): VpnService.Builder =
TunnelService().Builder().apply {
addAddress(ipv4Address, 32)
addAddress(ipv6Address, 128)
// TODO: These are the staging Resources. Remove these in favor of the onUpdateResources callback.
addRoute("172.31.93.123", 32)
addRoute("172.31.83.10", 32)
addRoute("172.31.82.179", 32)
setSession("Firezone VPN")
// TODO: Can we do better?
setMtu(1280)
}
internal companion object {
var sessionPtr: Long? = null
private const val TAG: String = "TunnelManager"
init {
Log.d("Connlib","Attempting to load library from main app...")
Log.d(TAG,"Attempting to load library from main app...")
System.loadLibrary("connlib")
Log.d("Connlib","Library loaded from main app!")
Log.d(TAG,"Library loaded from main app!")
}
}
}

View File

@@ -1,27 +1,264 @@
package dev.firezone.android.tunnel
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.system.OsConstants
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.installations.FirebaseInstallations
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.BuildConfig
import dev.firezone.android.R
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.presentation.MainActivity
import dev.firezone.android.tunnel.callback.ConnlibCallback
import dev.firezone.android.tunnel.data.TunnelRepository
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
import dev.firezone.android.tunnel.model.TunnelConfig
import java.nio.file.Files
import java.nio.file.Paths
import javax.inject.Inject
@AndroidEntryPoint
@OptIn(ExperimentalStdlibApi::class)
class TunnelService: VpnService() {
@Inject
internal lateinit var getConfigUseCase: GetConfigUseCase
@Inject
internal lateinit var tunnelRepository: TunnelRepository
@Inject
internal lateinit var moshi: Moshi
private var sessionPtr: Long? = null
private val activeTunnel: Tunnel?
get() = tunnelRepository.get()
private val callback: ConnlibCallback = object: ConnlibCallback {
override fun onUpdateResources(resourceListJSON: String) {
Log.d(TAG, "onUpdateResources: $resourceListJSON")
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let { resources ->
tunnelRepository.setResources(resources)
}
}
override fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
dnsAddress: String,
dnsFallbackStrategy: String
): Int {
Log.d(TAG, "onSetInterfaceConfig: [IPv4:$tunnelAddressIPv4] [IPv6:$tunnelAddressIPv6] [dns:$dnsAddress] [dnsFallbackStrategy:$dnsFallbackStrategy]")
tunnelRepository.setConfig(
TunnelConfig(
tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy
)
)
// TODO: throw error if failed to establish VpnService
val fd = buildVpnService().establish()?.detachFd() ?: -1
protect(fd)
return fd
}
override fun onTunnelReady(): Boolean {
Log.d(TAG, "onTunnelReady")
tunnelRepository.setState(Tunnel.State.Up)
updateStatusNotification("Status: Connected")
return true
}
override fun onError(error: String): Boolean {
Log.d(TAG, "onError: $error")
return true
}
override fun onAddRoute(cidrAddress: String) {
Log.d(TAG, "onAddRoute: $cidrAddress")
tunnelRepository.addRoute(cidrAddress)
}
override fun onRemoveRoute(cidrAddress: String) {
Log.d(TAG, "onRemoveRoute: $cidrAddress")
tunnelRepository.removeRoute(cidrAddress)
}
override fun onDisconnect(error: String?): Boolean {
Log.d(TAG, "onDisconnect $error")
onTunnelStateUpdate(Tunnel.State.Down)
return true
}
}
override fun onCreate() {
super.onCreate()
Log.d("FirezoneVpnService", "onCreate")
Log.d(TAG, "onCreate")
}
override fun onDestroy() {
super.onDestroy()
Log.d("FirezoneVpnService", "onDestroy")
Log.d(TAG, "onDestroy")
}
override fun onStartCommand(intent: android.content.Intent?, flags: Int, startId: Int): Int {
Log.d("FirezoneVpnService", "onStartCommand")
return super.onStartCommand(intent, flags, startId)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand")
if (intent != null && ACTION_DISCONNECT == intent.action) {
disconnect()
return START_NOT_STICKY
}
connect()
return START_STICKY
}
private fun onTunnelStateUpdate(state: Tunnel.State) {
tunnelRepository.setState(state)
}
private fun connect() {
try {
val config = getConfigUseCase.sync()
Log.d("Connlib", "accountId: ${config.accountId}")
Log.d("Connlib", "token: ${config.token}")
if (config.accountId != null && config.token != null) {
Log.d("Connlib", "Attempting to establish TunnelSession...")
sessionPtr = TunnelSession.connect(
controlPlaneUrl = BuildConfig.CONTROL_PLANE_URL,
token = config.token,
deviceId = deviceId(),
logDir = getLogDir(),
callback = callback
)
Log.d(TAG, "connlib session started! sessionPtr: $sessionPtr")
onTunnelStateUpdate(Tunnel.State.Connecting)
updateStatusNotification("Status: Connecting...")
}
} catch (exception: Exception) {
Log.e(TAG, exception.message.toString())
}
}
private fun disconnect() {
try {
sessionPtr?.let {
TunnelSession.disconnect(it)
}
tunnelRepository.setState(Tunnel.State.Down)
} catch (exception: Exception) {
Log.e(TAG, exception.message.toString())
}
stopForeground(STOP_FOREGROUND_REMOVE)
}
private fun deviceId(): String {
val deviceId = FirebaseInstallations.getInstance().id
Log.d(TAG, "Device ID: $deviceId")
return deviceId.toString()
}
private fun getLogDir(): String {
// Create log directory if it doesn't exist
val logDir = cacheDir.absolutePath + "/log"
Files.createDirectories(Paths.get(logDir))
return logDir
}
private fun configIntent(): PendingIntent? {
return PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun buildVpnService(): VpnService.Builder =
TunnelService().Builder().apply {
activeTunnel?.let { tunnel ->
allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6);
setMetered(false); // Inherit the metered status from the underlying networks.
setUnderlyingNetworks(null); // Use all available networks.
addAddress(tunnel.config.tunnelAddressIPv4, 32)
addAddress(tunnel.config.tunnelAddressIPv6, 128)
addDnsServer(tunnel.config.dnsAddress)
/*tunnel.routes.forEach {
addRoute(it, 32)
}*/
// TODO: These are the staging Resources. Remove these in favor of the onUpdateResources callback.
addRoute("100.100.111.1", 32)
addRoute("172.31.82.179", 32)
addRoute("172.31.83.10", 32)
addRoute("172.31.92.238", 32)
addRoute("172.31.93.123", 32)
setSession(SESSION_NAME)
// TODO: Can we do better?
setMtu(DEFAULT_MTU)
}
}
private fun updateStatusNotification(message: String?) {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val chan = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
)
chan.description = "firezone vpn status"
manager.createNotificationChannel(chan)
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
val notification = notificationBuilder.setOngoing(true)
.setSmallIcon(R.drawable.ic_firezone_logo)
.setContentTitle(NOTIFICATION_TITLE)
.setContentText(message)
.setPriority(NotificationManager.IMPORTANCE_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.setContentIntent(configIntent())
.build()
startForeground(STATUS_NOTIFICATION_ID, notification)
}
companion object {
val ACTION_CONNECT = "dev.firezone.android.tunnel.CONNECT"
val ACTION_DISCONNECT = "dev.firezone.android.tunnel.DISCONNECT"
const val ACTION_CONNECT = "dev.firezone.android.tunnel.CONNECT"
const val ACTION_DISCONNECT = "dev.firezone.android.tunnel.DISCONNECT"
private const val NOTIFICATION_CHANNEL_ID = "firezone-vpn-status"
private const val NOTIFICATION_CHANNEL_NAME = "firezone-vpn-status"
private const val STATUS_NOTIFICATION_ID = 1337
private const val NOTIFICATION_TITLE = "Firezone Vpn Status"
private const val TAG: String = "TunnelService"
private const val SESSION_NAME: String = "Firezone VPN"
private const val DEFAULT_MTU: Int = 1280
}
}

View File

@@ -1,20 +1,13 @@
package dev.firezone.android.tunnel.callback
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
interface TunnelListener {
fun onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String)
fun onTunnelStateUpdate(state: Tunnel.State)
fun onTunnelReady(): Boolean
fun onAddRoute(cidrAddress: String)
fun onRemoveRoute(cidrAddress: String)
fun onUpdateResources(resources: List<Resource>)
fun onDisconnect(error: String?): Boolean
fun onResourcesUpdate(resources: List<Resource>)
fun onError(error: String): Boolean
}

View File

@@ -0,0 +1,43 @@
package dev.firezone.android.tunnel.data
import android.content.SharedPreferences
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
import dev.firezone.android.tunnel.model.TunnelConfig
interface TunnelRepository {
fun get(): Tunnel?
fun setConfig(config: TunnelConfig)
fun getConfig(): TunnelConfig?
fun setState(state: Tunnel.State)
fun getState(): Tunnel.State
fun setResources(resources: List<Resource>)
fun getResources(): List<Resource>
fun addRoute(route: String)
fun removeRoute(route: String)
fun getRoutes(): List<String>
fun clear()
fun addListener(callback: SharedPreferences.OnSharedPreferenceChangeListener)
fun removeListener(callback: SharedPreferences.OnSharedPreferenceChangeListener)
companion object {
const val TAG = "TunnelRepository"
const val CONFIG_KEY = "tunnelConfigKey"
const val STATE_KEY = "tunnelStateKey"
const val RESOURCES_KEY = "tunnelResourcesKey"
const val ROUTES_KEY = "tunnelRoutesKey"
}
}

View File

@@ -0,0 +1,112 @@
package dev.firezone.android.tunnel.data
import android.content.SharedPreferences
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.CONFIG_KEY
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.RESOURCES_KEY
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.ROUTES_KEY
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.STATE_KEY
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
import dev.firezone.android.tunnel.model.TunnelConfig
import java.lang.Exception
import javax.inject.Inject
@OptIn(ExperimentalStdlibApi::class)
class TunnelRepositoryImpl @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val moshi: Moshi,
) : TunnelRepository {
private val lock = Any()
override fun addListener(callback: SharedPreferences.OnSharedPreferenceChangeListener) {
sharedPreferences.registerOnSharedPreferenceChangeListener(callback)
}
override fun removeListener(callback: SharedPreferences.OnSharedPreferenceChangeListener) {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(callback)
}
override fun get(): Tunnel? = synchronized(lock) {
return try {
Tunnel(
config = requireNotNull(getConfig()),
state = getState(),
routes = getRoutes(),
resources = getResources(),
)
} catch (e: Exception) {
null
}
}
override fun setConfig(config: TunnelConfig) {
synchronized(lock) {
val json = moshi.adapter<TunnelConfig>().toJson(config)
sharedPreferences.edit().putString(CONFIG_KEY, json).apply()
}
}
override fun getConfig(): TunnelConfig? = synchronized(lock) {
val json = sharedPreferences.getString(CONFIG_KEY, "{}") ?: "{}"
return moshi.adapter<TunnelConfig>().fromJson(json)
}
override fun setState(state: Tunnel.State) {
synchronized(lock) {
sharedPreferences.edit().putString(STATE_KEY, state.name).apply()
}
}
override fun getState(): Tunnel.State {
val json = sharedPreferences.getString(STATE_KEY, null)
return json?.let { Tunnel.State.valueOf(it) } ?: Tunnel.State.Down
}
override fun setResources(resources: List<Resource>) {
synchronized(lock) {
val json = moshi.adapter<List<Resource>>().toJson(resources)
sharedPreferences.edit().putString(RESOURCES_KEY, json).apply()
}
}
override fun getResources(): List<Resource> {
synchronized(lock) {
val json = sharedPreferences.getString(RESOURCES_KEY, "[]") ?: "[]"
return moshi.adapter<List<Resource>>().fromJson(json) ?: emptyList()
}
}
override fun addRoute(route: String) {
synchronized(lock) {
getRoutes().toMutableList().run {
add(route)
val json = moshi.adapter<List<String>>().toJson(this)
sharedPreferences.edit().putString(ROUTES_KEY, json).apply()
}
}
}
override fun removeRoute(route: String) {
synchronized(lock) {
getRoutes().toMutableList().run {
remove(route)
val json = moshi.adapter<List<String>>().toJson(this)
sharedPreferences.edit().putString(ROUTES_KEY, json).apply()
}
}
}
override fun getRoutes(): List<String> = synchronized(lock) {
val json = sharedPreferences.getString(ROUTES_KEY, "[]") ?: "[]"
return moshi.adapter<List<String>>().fromJson(json) ?: emptyList()
}
override fun clear() {
synchronized(lock) {
sharedPreferences.edit().clear().apply()
}
}
}

View File

@@ -0,0 +1,51 @@
package dev.firezone.android.tunnel.di
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.data.TunnelRepository
import dev.firezone.android.tunnel.data.TunnelRepositoryImpl
import javax.inject.Named
import javax.inject.Singleton
internal const val TUNNEL_ENCRYPTED_SHARED_PREFERENCES = "tunnelEncryptedSharedPreferences"
@Module
@InstallIn(SingletonComponent::class)
object TunnelModule {
@Singleton
@Provides
internal fun provideTunnelRepository(
@Named(TunnelRepository.TAG) sharedPreferences: SharedPreferences,
moshi: Moshi,
): TunnelRepository = TunnelRepositoryImpl(sharedPreferences, moshi)
@Provides
@Named(TunnelRepository.TAG)
internal fun provideTunnelEncryptedSharedPreferences(app: Application): SharedPreferences =
EncryptedSharedPreferences.create(
app.applicationContext,
TUNNEL_ENCRYPTED_SHARED_PREFERENCES,
MasterKey.Builder(app.applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
@Provides
internal fun provideTunnelManager(
@ApplicationContext appContext: Context,
tunnelRepository: TunnelRepository,
): TunnelManager = TunnelManager(appContext, tunnelRepository)
}

View File

@@ -1,11 +1,14 @@
package dev.firezone.android.tunnel.model
import android.os.Parcelable
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
@JsonClass(generateAdapter = true)
@Parcelize
data class Resource(
val type: String,
val id: String,
val address: String,
val name: String,
)
): Parcelable

View File

@@ -1,15 +1,19 @@
package dev.firezone.android.tunnel.model
data class Tunnel(
val config: TunnelConfig,
var state: State = State.Down,
val routes: MutableList<String> = mutableListOf(),
val resources: MutableList<String> = mutableListOf(),
) {
import android.os.Parcelable
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
sealed interface State {
object Up: State
object CONNECTING: State
object Down: State
@JsonClass(generateAdapter = true)
@Parcelize
data class Tunnel(
val config: TunnelConfig = TunnelConfig(),
var state: State = State.Down,
val routes: List<String> = emptyList(),
val resources: List<Resource> = emptyList(),
): Parcelable {
enum class State {
Up, Connecting, Down;
}
}

View File

@@ -1,8 +1,12 @@
package dev.firezone.android.tunnel.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class TunnelConfig (
val tunnelAddressIPv4: String,
val tunnelAddressIPv6: String,
val dnsAddress: String,
val dnsFallbackStrategy: String,
)
val tunnelAddressIPv4: String = "",
val tunnelAddressIPv6: String = "",
val dnsAddress: String = "",
val dnsFallbackStrategy: String = "",
): Parcelable