diff --git a/kotlin/android/app/build.gradle b/kotlin/android/app/build.gradle index d659b746b..25930963e 100644 --- a/kotlin/android/app/build.gradle +++ b/kotlin/android/app/build.gradle @@ -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" diff --git a/kotlin/android/app/src/main/AndroidManifest.xml b/kotlin/android/app/src/main/AndroidManifest.xml index 9fb314808..deb73a8ec 100644 --- a/kotlin/android/app/src/main/AndroidManifest.xml +++ b/kotlin/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepository.kt index 9068b2f75..21f805dba 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepository.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepository.kt @@ -12,9 +12,5 @@ internal interface PreferenceRepository { fun saveToken(value: String): Flow - fun saveIsConnectedSync(value: Boolean) - - fun saveIsConnected(value: Boolean): Flow - fun validateCsrfToken(value: String): Flow } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepositoryImpl.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepositoryImpl.kt index 3dee491c3..6057d36c4 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepositoryImpl.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepositoryImpl.kt @@ -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 = flow { - emit( - saveIsConnectedSync(value) - ) - }.flowOn(coroutineDispatcher) - override fun validateCsrfToken(value: String): Flow = 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" } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/AppModule.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/AppModule.kt index e7e308425..afaa982a4 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/AppModule.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/AppModule.kt @@ -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) } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveIsConnectedUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveIsConnectedUseCase.kt deleted file mode 100644 index 6f1917b1f..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveIsConnectedUseCase.kt +++ /dev/null @@ -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) - } -} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt index 32621972b..a8d40464b 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt @@ -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 = 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) { - //TODO("Not yet implemented") + override fun onResourcesUpdate(resources: List) { 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 diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelManager.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelManager.kt index c14178841..e95f35574 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelManager.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelManager.kt @@ -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> = 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>().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!") } } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt index ed323f841..3920c0629 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt @@ -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>().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 } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/TunnelListener.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/TunnelListener.kt index 54d3e305d..b7017b669 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/TunnelListener.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/TunnelListener.kt @@ -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) - - fun onDisconnect(error: String?): Boolean + fun onResourcesUpdate(resources: List) fun onError(error: String): Boolean } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepository.kt new file mode 100644 index 000000000..2db2d9761 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepository.kt @@ -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) + + fun getResources(): List + + fun addRoute(route: String) + + fun removeRoute(route: String) + + fun getRoutes(): List + + 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" + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepositoryImpl.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepositoryImpl.kt new file mode 100644 index 000000000..b199d3dde --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepositoryImpl.kt @@ -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().toJson(config) + sharedPreferences.edit().putString(CONFIG_KEY, json).apply() + } + } + + override fun getConfig(): TunnelConfig? = synchronized(lock) { + val json = sharedPreferences.getString(CONFIG_KEY, "{}") ?: "{}" + return moshi.adapter().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) { + synchronized(lock) { + val json = moshi.adapter>().toJson(resources) + sharedPreferences.edit().putString(RESOURCES_KEY, json).apply() + } + } + + override fun getResources(): List { + synchronized(lock) { + val json = sharedPreferences.getString(RESOURCES_KEY, "[]") ?: "[]" + return moshi.adapter>().fromJson(json) ?: emptyList() + } + } + + override fun addRoute(route: String) { + synchronized(lock) { + getRoutes().toMutableList().run { + add(route) + val json = moshi.adapter>().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>().toJson(this) + sharedPreferences.edit().putString(ROUTES_KEY, json).apply() + } + } + } + + override fun getRoutes(): List = synchronized(lock) { + val json = sharedPreferences.getString(ROUTES_KEY, "[]") ?: "[]" + return moshi.adapter>().fromJson(json) ?: emptyList() + } + + override fun clear() { + synchronized(lock) { + sharedPreferences.edit().clear().apply() + } + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/di/TunnelModule.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/di/TunnelModule.kt new file mode 100644 index 000000000..fa8a783e1 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/di/TunnelModule.kt @@ -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) +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt index d6aa2a88f..651368b33 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt @@ -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 diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Tunnel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Tunnel.kt index 1b39f791a..b61bdec90 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Tunnel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Tunnel.kt @@ -1,15 +1,19 @@ package dev.firezone.android.tunnel.model -data class Tunnel( - val config: TunnelConfig, - var state: State = State.Down, - val routes: MutableList = mutableListOf(), - val resources: MutableList = 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 = emptyList(), + val resources: List = emptyList(), +): Parcelable { + + enum class State { + Up, Connecting, Down; } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/TunnelConfig.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/TunnelConfig.kt index 54ed8e1de..d4c852f95 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/TunnelConfig.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/TunnelConfig.kt @@ -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