refactor(android): Simplify tunnel implementation and fix tunnel lifecycle (#3583)

Fixes #3578 
Fixes #3551 

The issue turned out to be a bunk Repository. Upon unraveling that ball
of yarn, I decided to clean up the Tunnel implementation altogether. It
uses the existing tunnel in-memory store for pushing updates to a
connected SessionActivity.

This PR includes many bug fixes as well.
This commit is contained in:
Jamil
2024-02-13 13:10:51 -08:00
committed by GitHub
parent eafa890519
commit cde1c50f95
41 changed files with 555 additions and 1117 deletions

View File

@@ -68,6 +68,12 @@ jobs:
# TODO: See https://github.com/firezone/firezone/issues/2311
# TODO: See https://github.com/firezone/firezone/issues/2309
./gradlew testReleaseUnitTest
- name: Upload app bundle
uses: actions/upload-artifact@v4
with:
name: Android app bundle
path: |
./kotlin/android/app/build/outputs/bundle/*
- name: Upload release
if: ${{ github.event_name == 'workflow_dispatch' || (github.ref == 'refs/heads/main' && contains(github.event.head_commit.modified, 'elixir/VERSION')) }}
env:
@@ -76,4 +82,4 @@ jobs:
FIREBASE_APP_TESTERS: ${{ vars.FIREBASE_APP_TESTERS }}
run: |
echo -n "$FIREBASE_APP_DISTRIBUTION_CREDENTIALS" > $FIREBASE_CREDENTIALS_PATH
./gradlew --info appDistributionUploadRelease
./gradlew --info appDistributionUploadRelease uploadCrashlyticsSymbolFileRelease

View File

@@ -1,3 +1,5 @@
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
plugins {
id("com.android.application")
id("org.mozilla.rust-android-gradle.rust-android")
@@ -42,13 +44,13 @@ android {
namespace = "dev.firezone.android"
compileSdk = 34
ndkVersion = "25.2.9519653"
ndkVersion = "26.1.10909125"
defaultConfig {
applicationId = "dev.firezone.android"
// Android 8
minSdk = 26
targetSdk = 33
targetSdk = 34
versionCode = (System.currentTimeMillis() / 1000 / 10).toInt()
// mark:automatic-version
versionName = "1.0.0"
@@ -105,6 +107,15 @@ android {
)
isDebuggable = false
configure<CrashlyticsExtension> {
// Enable processing and uploading of native symbols to Firebase servers.
// By default, this is disabled to improve build speeds.
// This flag must be enabled to see properly-symbolicated native
// stack traces in the Crashlytics dashboard.
nativeSymbolUploadEnabled = true
unstrippedNativeLibsDir = layout.buildDirectory.dir("rustJniLibs")
}
resValue("string", "app_name", "\"Firezone\"")
buildConfigField("String", "AUTH_BASE_URL", "\"https://app.firezone.dev\"")
@@ -196,11 +207,12 @@ dependencies {
implementation("androidx.browser:browser:1.7.0")
// Import the BoM for the Firebase platform
implementation(platform("com.google.firebase:firebase-bom:32.3.1"))
implementation(platform("com.google.firebase:firebase-bom:32.7.1"))
// Add the dependencies for the Crashlytics and Analytics libraries
// When using the BoM, you don't specify versions in Firebase library dependencies
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-crashlytics-ndk")
implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-installations-ktx")
}
@@ -217,7 +229,13 @@ cargo {
verbose = true
module = "../../../rust/connlib/clients/android"
libname = "connlib"
targets = listOf("arm", "arm64", "x86_64", "x86")
targets =
listOf(
"arm64",
"x86_64",
"x86",
"arm",
)
targetDirectory = "../../../rust/target"
}

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -8,6 +7,8 @@
<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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<queries>
<intent>
@@ -16,6 +17,7 @@
</queries>
<application
android:gwpAsanMode="always"
android:name=".core.FirezoneApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -71,6 +73,7 @@
android:exported="false" />
<service
android:foregroundServiceType="systemExempted"
android:name="dev.firezone.android.tunnel.TunnelService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE">

View File

@@ -1,8 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.data
import kotlinx.coroutines.flow.Flow
internal interface AuthRepository {
fun generateNonce(key: String): Flow<String>
}

View File

@@ -1,36 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.data
import android.content.SharedPreferences
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.security.SecureRandom
import javax.inject.Inject
internal class AuthRepositoryImpl
@Inject
constructor(
private val coroutineDispatcher: CoroutineDispatcher,
private val sharedPreferences: SharedPreferences,
) : AuthRepository {
override fun generateNonce(key: String): Flow<String> =
flow {
val random = SecureRandom.getInstanceStrong()
val bytes = ByteArray(NONCE_LENGTH)
random.nextBytes(bytes)
val encodedStr: String = bytes.joinToString("") { "%02x".format(it) }
sharedPreferences
.edit()
.putString(key, encodedStr)
.apply()
emit(encodedStr)
}.flowOn(coroutineDispatcher)
companion object {
private const val NONCE_LENGTH = 32
}
}

View File

@@ -4,7 +4,7 @@ package dev.firezone.android.core.data
import dev.firezone.android.core.data.model.Config
import kotlinx.coroutines.flow.Flow
internal interface PreferenceRepository {
interface Repository {
fun getConfigSync(): Config
fun getConfig(): Flow<Config>
@@ -17,11 +17,39 @@ internal interface PreferenceRepository {
fun saveDeviceIdSync(value: String): Unit
fun getToken(): Flow<String?>
fun getTokenSync(): String?
fun getStateSync(): String?
fun getNonceSync(): String?
fun getDeviceIdSync(): String?
fun getActorName(): Flow<String?>
fun getActorNameSync(): String?
fun saveNonce(value: String): Flow<Unit>
fun saveState(value: String): Flow<Unit>
fun saveStateSync(value: String): Unit
fun saveNonceSync(value: String): Unit
fun saveToken(value: String): Flow<Unit>
fun saveActorName(value: String): Flow<Unit>
fun validateState(value: String): Flow<Boolean>
fun clearToken()
fun clearNonce()
fun clearState()
fun clearActorName()
}

View File

@@ -3,6 +3,7 @@ package dev.firezone.android.core.data
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import dev.firezone.android.BuildConfig
import dev.firezone.android.core.data.model.Config
import kotlinx.coroutines.CoroutineDispatcher
@@ -12,29 +13,22 @@ import kotlinx.coroutines.flow.flowOn
import java.security.MessageDigest
import javax.inject.Inject
internal class PreferenceRepositoryImpl
internal class RepositoryImpl
@Inject
constructor(
private val context: Context,
private val coroutineDispatcher: CoroutineDispatcher,
private val sharedPreferences: SharedPreferences,
) : PreferenceRepository {
private val appRestrictions: Bundle,
) : Repository {
override fun getConfigSync(): Config {
val restrictionsManager =
context.getSystemService(Context.RESTRICTIONS_SERVICE)
as android.content.RestrictionsManager
val appRestrictions = restrictionsManager.applicationRestrictions
return Config(
authBaseUrl =
sharedPreferences.getString(AUTH_BASE_URL_KEY, null)
?: BuildConfig.AUTH_BASE_URL,
apiUrl = sharedPreferences.getString(API_URL_KEY, null) ?: BuildConfig.API_URL,
logFilter =
sharedPreferences.getString(LOG_FILTER_KEY, null)
?: BuildConfig.LOG_FILTER,
token =
appRestrictions.getString(TOKEN_KEY, null)
?: sharedPreferences.getString(TOKEN_KEY, null),
sharedPreferences.getString(AUTH_BASE_URL_KEY, null)
?: BuildConfig.AUTH_BASE_URL,
sharedPreferences.getString(API_URL_KEY, null)
?: BuildConfig.API_URL,
sharedPreferences.getString(LOG_FILTER_KEY, null)
?: BuildConfig.LOG_FILTER,
)
}
@@ -61,12 +55,52 @@ internal class PreferenceRepositoryImpl
override fun getDeviceIdSync(): String? = sharedPreferences.getString(DEVICE_ID_KEY, null)
override fun getToken(): Flow<String?> =
flow {
emit(
appRestrictions.getString(TOKEN_KEY, null)
?: sharedPreferences.getString(TOKEN_KEY, null),
)
}.flowOn(coroutineDispatcher)
override fun getTokenSync(): String? =
appRestrictions.getString(TOKEN_KEY, null)
?: sharedPreferences.getString(TOKEN_KEY, null)
override fun getStateSync(): String? = sharedPreferences.getString(STATE_KEY, null)
override fun getActorName(): Flow<String?> =
flow {
emit(getActorNameSync())
}.flowOn(coroutineDispatcher)
override fun getActorNameSync(): String? =
sharedPreferences.getString(ACTOR_NAME_KEY, null)?.let {
if (it.isNotEmpty()) "Signed in as $it" else "Signed in"
}
override fun getNonceSync(): String? = sharedPreferences.getString(NONCE_KEY, null)
override fun saveDeviceIdSync(value: String): Unit =
sharedPreferences
.edit()
.putString(DEVICE_ID_KEY, value)
.apply()
override fun saveNonce(value: String): Flow<Unit> =
flow {
emit(saveNonceSync(value))
}.flowOn(coroutineDispatcher)
override fun saveNonceSync(value: String) = sharedPreferences.edit().putString(NONCE_KEY, value).apply()
override fun saveState(value: String): Flow<Unit> =
flow {
emit(saveStateSync(value))
}.flowOn(coroutineDispatcher)
override fun saveStateSync(value: String) = sharedPreferences.edit().putString(STATE_KEY, value).apply()
override fun saveToken(value: String): Flow<Unit> =
flow {
val nonce = sharedPreferences.getString(NONCE_KEY, "").orEmpty()
@@ -78,6 +112,16 @@ internal class PreferenceRepositoryImpl
)
}.flowOn(coroutineDispatcher)
override fun saveActorName(value: String): Flow<Unit> =
flow {
emit(
sharedPreferences
.edit()
.putString(ACTOR_NAME_KEY, value)
.apply(),
)
}.flowOn(coroutineDispatcher)
override fun validateState(value: String): Flow<Boolean> =
flow {
val state = sharedPreferences.getString(STATE_KEY, "").orEmpty()
@@ -86,15 +130,35 @@ internal class PreferenceRepositoryImpl
override fun clearToken() {
sharedPreferences.edit().apply {
remove(NONCE_KEY)
remove(TOKEN_KEY)
apply()
}
}
override fun clearNonce() {
sharedPreferences.edit().apply {
remove(NONCE_KEY)
apply()
}
}
override fun clearState() {
sharedPreferences.edit().apply {
remove(STATE_KEY)
apply()
}
}
override fun clearActorName() {
sharedPreferences.edit().apply {
remove(ACTOR_NAME_KEY)
apply()
}
}
companion object {
private const val AUTH_BASE_URL_KEY = "authBaseUrl"
private const val ACTOR_NAME_KEY = "actorName"
private const val API_URL_KEY = "apiUrl"
private const val LOG_FILTER_KEY = "logFilter"
private const val TOKEN_KEY = "token"

View File

@@ -1,9 +1,8 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.data.model
internal data class Config(
data class Config(
val authBaseUrl: String,
val apiUrl: String,
val logFilter: String,
val token: String?,
)

View File

@@ -3,30 +3,32 @@ package dev.firezone.android.core.di
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.getSystemService
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.data.AuthRepository
import dev.firezone.android.core.data.AuthRepositoryImpl
import dev.firezone.android.core.data.PreferenceRepository
import dev.firezone.android.core.data.PreferenceRepositoryImpl
import dev.firezone.android.core.data.Repository
import dev.firezone.android.core.data.RepositoryImpl
import kotlinx.coroutines.CoroutineDispatcher
@Module
@InstallIn(SingletonComponent::class)
class DataModule {
@Provides
internal fun provideAuthRepository(
@IoDispatcher coroutineDispatcher: CoroutineDispatcher,
sharedPreferences: SharedPreferences,
): AuthRepository = AuthRepositoryImpl(coroutineDispatcher, sharedPreferences)
@Provides
internal fun providePreferenceRepository(
internal fun provideRepository(
@ApplicationContext context: Context,
@IoDispatcher coroutineDispatcher: CoroutineDispatcher,
sharedPreferences: SharedPreferences,
): PreferenceRepository = PreferenceRepositoryImpl(context, coroutineDispatcher, sharedPreferences)
): Repository =
RepositoryImpl(
context,
coroutineDispatcher,
sharedPreferences,
(
context.getSystemService(Context.RESTRICTIONS_SERVICE)
as android.content.RestrictionsManager
).applicationRestrictions,
)
}

View File

@@ -1,14 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.auth
import dev.firezone.android.core.data.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class GetNonceUseCase
@Inject
constructor(
private val repository: AuthRepository,
) {
operator fun invoke(): Flow<String> = repository.generateNonce("nonce")
}

View File

@@ -1,14 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.auth
import dev.firezone.android.core.data.AuthRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class GetStateUseCase
@Inject
constructor(
private val repository: AuthRepository,
) {
operator fun invoke(): Flow<String> = repository.generateNonce("state")
}

View File

@@ -1,17 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.preference
import dev.firezone.android.core.data.PreferenceRepository
import dev.firezone.android.core.data.model.Config
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class GetConfigUseCase
@Inject
constructor(
private val repository: PreferenceRepository,
) {
operator fun invoke(): Flow<Config> = repository.getConfig()
fun sync(): Config = repository.getConfigSync()
}

View File

@@ -1,18 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.preference
import dev.firezone.android.core.data.PreferenceRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class SaveSettingsUseCase
@Inject
constructor(
private val repository: PreferenceRepository,
) {
operator fun invoke(
authBaseUrl: String,
apiUrl: String,
logFilter: String,
): Flow<Unit> = repository.saveSettings(authBaseUrl, apiUrl, logFilter)
}

View File

@@ -1,14 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.preference
import dev.firezone.android.core.data.PreferenceRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class SaveTokenUseCase
@Inject
constructor(
private val repository: PreferenceRepository,
) {
operator fun invoke(token: String): Flow<Unit> = repository.saveToken(token)
}

View File

@@ -1,14 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.core.domain.preference
import dev.firezone.android.core.data.PreferenceRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
internal class ValidateStateUseCase
@Inject
constructor(
private val repository: PreferenceRepository,
) {
operator fun invoke(value: String): Flow<Boolean> = repository.validateState(value)
}

View File

@@ -43,6 +43,9 @@ class AuthActivity : AppCompatActivity(R.layout.activity_auth) {
}
}
// We can't close this webview because it's opened with ACTION_VIEW.
// If we want more control over it we need to embed our own WebView which
// has its own set of tradeoffs.
private fun setupWebView(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)

View File

@@ -6,21 +6,16 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.auth.GetNonceUseCase
import dev.firezone.android.core.domain.auth.GetStateUseCase
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import kotlinx.coroutines.flow.firstOrNull
import dev.firezone.android.core.data.Repository
import kotlinx.coroutines.launch
import java.lang.Exception
import java.security.SecureRandom
import javax.inject.Inject
@HiltViewModel
internal class AuthViewModel
@Inject
constructor(
private val getConfigUseCase: GetConfigUseCase,
private val getStateUseCase: GetStateUseCase,
private val getNonceUseCase: GetNonceUseCase,
private val repo: Repository,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
@@ -28,37 +23,31 @@ internal class AuthViewModel
private var authFlowLaunched: Boolean = false
fun onActivityResume() =
try {
viewModelScope.launch {
val config =
getConfigUseCase()
.firstOrNull() ?: throw Exception("config cannot be null")
viewModelScope.launch {
val state = generateRandomString(NONCE_LENGTH)
val nonce = generateRandomString(NONCE_LENGTH)
repo.saveNonceSync(nonce)
repo.saveStateSync(state)
val config = repo.getConfigSync()!!
val token = repo.getTokenSync()
val state =
getStateUseCase()
.firstOrNull() ?: throw Exception("state cannot be null")
val nonce =
getNonceUseCase()
.firstOrNull() ?: throw Exception("nonce cannot be null")
actionMutableLiveData.postValue(
if (authFlowLaunched || config.token != null) {
ViewAction.NavigateToSignIn
} else {
authFlowLaunched = true
ViewAction.LaunchAuthFlow(
url =
"${config.authBaseUrl}" +
"?state=$state&nonce=$nonce&as=client",
)
},
)
}
} catch (e: Exception) {
actionMutableLiveData.postValue(ViewAction.ShowError)
actionMutableLiveData.postValue(
if (authFlowLaunched || token != null) {
ViewAction.NavigateToSignIn
} else {
authFlowLaunched = true
ViewAction.LaunchAuthFlow("${config.authBaseUrl}?state=$state&nonce=$nonce&as=client")
},
)
}
private fun generateRandomString(length: Int): String {
val random = SecureRandom.getInstanceStrong()
val bytes = ByteArray(length)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
internal sealed class ViewAction {
data class LaunchAuthFlow(val url: String) : ViewAction()
@@ -66,4 +55,8 @@ internal class AuthViewModel
object ShowError : ViewAction()
}
internal companion object {
private const val NONCE_LENGTH = 32
}
}

View File

@@ -4,12 +4,12 @@ package dev.firezone.android.features.customuri.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.core.presentation.MainActivity
import dev.firezone.android.databinding.ActivityCustomUriHandlerBinding
import dev.firezone.android.tunnel.TunnelService
@AndroidEntryPoint
class CustomUriHandlerActivity : AppCompatActivity(R.layout.activity_custom_uri_handler) {
@@ -28,28 +28,17 @@ class CustomUriHandlerActivity : AppCompatActivity(R.layout.activity_custom_uri_
viewModel.actionLiveData.observe(this) { action ->
when (action) {
CustomUriViewModel.ViewAction.AuthFlowComplete -> {
TunnelService.start(this@CustomUriHandlerActivity)
startActivity(
Intent(this@CustomUriHandlerActivity, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
Intent(this, MainActivity::class.java),
)
finish()
}
CustomUriViewModel.ViewAction.ShowError -> showError()
else -> {
throw IllegalStateException("Unknown action: $action")
}
}
finish()
}
}
private fun showError() {
AlertDialog.Builder(this)
.setTitle(R.string.error_dialog_title)
.setMessage(R.string.error_dialog_message)
.setPositiveButton(
R.string.error_dialog_button_text,
) { _, _ ->
this@CustomUriHandlerActivity.finish()
}
.setIcon(R.drawable.ic_firezone_logo)
.show()
}
}

View File

@@ -7,53 +7,59 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.preference.SaveTokenUseCase
import dev.firezone.android.core.domain.preference.ValidateStateUseCase
import dev.firezone.android.core.data.Repository
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import java.lang.IllegalStateException
import javax.inject.Inject
@HiltViewModel
internal class CustomUriViewModel
@Inject
constructor(
private val validateStateUseCase: ValidateStateUseCase,
private val saveTokenUseCase: SaveTokenUseCase,
private val repo: Repository,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
fun parseCustomUri(intent: Intent) {
Log.d("CustomUriViewModel", "Parsing callback...")
viewModelScope.launch {
Log.d("CustomUriViewModel", "viewmodelScope.launch")
when (intent.data?.host) {
PATH_CALLBACK -> {
Log.d("CustomUriViewModel", "PATH_CALLBACK")
intent.data?.getQueryParameter(QUERY_ACTOR_NAME)?.let { actorName ->
Log.d("CustomUriViewModel", "Found actor name: $actorName")
repo.saveActorName(actorName).collect()
}
intent.data?.getQueryParameter(QUERY_CLIENT_STATE)?.let { state ->
Log.d("CustomUriViewModel", "state: $state")
if (validateStateUseCase(state).firstOrNull() == true) {
if (repo.validateState(state).firstOrNull() == true) {
Log.d("CustomUriViewModel", "Valid state parameter. Continuing to save state...")
} else {
Log.d("CustomUriViewModel", "Invalid state parameter! Ignoring...")
actionMutableLiveData.postValue(ViewAction.ShowError)
throw IllegalStateException("Invalid state parameter $state! Authentication will not succeed...")
}
intent.data?.getQueryParameter(QUERY_CLIENT_AUTH_FRAGMENT)?.let { fragment ->
if (fragment.isNotBlank()) {
Log.d("CustomUriViewModel", "Found valid auth fragment in response")
saveTokenUseCase(fragment).collect()
// Save token, then clear nonce and state since we don't
// need to keep them around anymore
repo.saveToken(fragment).collect()
repo.clearNonce()
repo.clearState()
actionMutableLiveData.postValue(ViewAction.AuthFlowComplete)
} else {
Log.d("CustomUriViewModel", "Didn't find auth fragment in response!")
throw IllegalStateException("Invalid auth fragment $fragment! Authentication will not succeed...")
}
}
actionMutableLiveData.postValue(ViewAction.AuthFlowComplete)
}
}
else -> {
Log.d("CustomUriViewModel", "Unknown path segment: ${intent.data?.lastPathSegment}")
Firebase.crashlytics.log("Unknown path segment: ${intent.data?.lastPathSegment}")
Log.e("CustomUriViewModel", "Unknown path segment: ${intent.data?.lastPathSegment}")
}
}
}
@@ -64,12 +70,9 @@ internal class CustomUriViewModel
private const val QUERY_CLIENT_STATE = "state"
private const val QUERY_CLIENT_AUTH_FRAGMENT = "fragment"
private const val QUERY_ACTOR_NAME = "actor_name"
private const val QUERY_IDENTITY_PROVIDER_IDENTIFIER = "identity_provider_identifier"
}
internal sealed class ViewAction {
object AuthFlowComplete : ViewAction()
object ShowError : ViewAction()
}
}

View File

@@ -1,29 +1,49 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.session.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.core.presentation.MainActivity
import dev.firezone.android.core.utils.ClipboardUtils
import dev.firezone.android.databinding.ActivitySessionBinding
import dev.firezone.android.features.settings.ui.SettingsActivity
import kotlinx.coroutines.launch
import dev.firezone.android.tunnel.TunnelService
import dev.firezone.android.tunnel.model.Resource
@AndroidEntryPoint
internal class SessionActivity : AppCompatActivity() {
private lateinit var binding: ActivitySessionBinding
private var tunnelService: TunnelService? = null
private var serviceBound = false
private val viewModel: SessionViewModel by viewModels()
private val serviceConnection =
object : ServiceConnection {
override fun onServiceConnected(
name: ComponentName?,
service: IBinder?,
) {
Log.d(TAG, "onServiceConnected")
val binder = service as TunnelService.LocalBinder
tunnelService = binder.getService()
serviceBound = true
tunnelService?.setServiceStateLiveData(viewModel.serviceStatusLiveData)
tunnelService?.setResourcesLiveData(viewModel.resourcesLiveData)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d(TAG, "onServiceDisconnected")
serviceBound = false
}
}
private val resourcesAdapter: ResourcesAdapter =
ResourcesAdapter { resource ->
ClipboardUtils.copyToClipboard(this@SessionActivity, resource.name, resource.address)
@@ -34,19 +54,31 @@ internal class SessionActivity : AppCompatActivity() {
binding = ActivitySessionBinding.inflate(layoutInflater)
setContentView(binding.root)
// Bind to existing TunnelService
val intent = Intent(this, TunnelService::class.java)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
setupViews()
setupObservers()
viewModel.connect(this@SessionActivity)
}
override fun onDestroy() {
super.onDestroy()
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
}
private fun setupViews() {
binding.btSignOut.setOnClickListener {
viewModel.disconnect()
Log.d(TAG, "Sign out button clicked")
viewModel.clearToken()
viewModel.clearActorName()
tunnelService?.disconnect()
}
binding.btSettings.setOnClickListener {
startActivity(Intent(this@SessionActivity, SettingsActivity::class.java))
}
binding.tvActorName.text = viewModel.getActorName()
val layoutManager = LinearLayoutManager(this@SessionActivity)
val dividerItemDecoration =
@@ -57,42 +89,25 @@ internal class SessionActivity : AppCompatActivity() {
binding.rvResourcesList.addItemDecoration(dividerItemDecoration)
binding.rvResourcesList.adapter = resourcesAdapter
binding.rvResourcesList.layoutManager = layoutManager
// Hack to show a connecting message until the service is bound
resourcesAdapter.updateResources(listOf(Resource("", "", "", "Connecting...")))
}
private fun setupObservers() {
viewModel.actionLiveData.observe(this@SessionActivity) { action ->
when (action) {
SessionViewModel.ViewAction.NavigateToSignIn -> {
startActivity(
Intent(this, MainActivity::class.java),
)
finish()
}
SessionViewModel.ViewAction.ShowError -> showError()
// Go back to MainActivity if the service dies
viewModel.serviceStatusLiveData.observe(this) { tunnelState ->
if (tunnelState == TunnelService.Companion.State.DOWN) {
finish()
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.resources?.let {
resourcesAdapter.updateResources(it)
}
}
}
viewModel.resourcesLiveData.observe(this) { resources ->
resourcesAdapter.updateResources(resources)
}
}
private fun showError() {
AlertDialog.Builder(this@SessionActivity)
.setTitle(R.string.error_dialog_title)
.setMessage(R.string.error_dialog_message)
.setPositiveButton(
R.string.error_dialog_button_text,
) { dialog, _ ->
dialog.dismiss()
}
.setIcon(R.drawable.ic_firezone_logo)
.show()
companion object {
private const val TAG = "SessionActivity"
}
}

View File

@@ -1,112 +1,31 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.session.ui
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.TunnelService
import dev.firezone.android.tunnel.callback.TunnelListener
import dev.firezone.android.tunnel.data.TunnelRepository
import dev.firezone.android.core.data.Repository
import dev.firezone.android.tunnel.TunnelService.Companion.State
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 kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class SessionViewModel
@Inject
constructor(
private val tunnelManager: TunnelManager,
private val tunnelRepository: TunnelRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState
constructor() : ViewModel() {
@Inject
internal lateinit var repo: Repository
private val _serviceStatusLiveData = MutableLiveData<State>()
private val _resourcesLiveData = MutableLiveData<List<Resource>>(emptyList())
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
val serviceStatusLiveData: MutableLiveData<State>
get() = _serviceStatusLiveData
val resourcesLiveData: MutableLiveData<List<Resource>>
get() = _resourcesLiveData
private val tunnelListener =
object : TunnelListener {
override fun onTunnelStateUpdate(state: Tunnel.State) {
when (state) {
Tunnel.State.Down -> {
onDisconnect()
}
Tunnel.State.Closed -> {
onClosed()
}
else -> {
_uiState.value =
_uiState.value.copy(
state = state,
)
}
}
}
fun clearToken() = repo.clearToken()
override fun onResourcesUpdate(resources: List<Resource>) {
Log.d("TunnelManager", "onUpdateResources: $resources")
_uiState.value =
_uiState.value.copy(
resources = resources,
)
}
}
fun clearActorName() = repo.clearActorName()
fun connect(context: Context) {
viewModelScope.launch {
tunnelManager.addListener(tunnelListener)
val isServiceRunning = TunnelService.isRunning(context)
if (!isServiceRunning ||
tunnelRepository.getState() == Tunnel.State.Down ||
tunnelRepository.getState() == Tunnel.State.Closed
) {
tunnelManager.connect()
} else {
_uiState.value =
_uiState.value.copy(
state = tunnelRepository.getState(),
resources = tunnelRepository.getResources(),
)
}
}
}
override fun onCleared() {
super.onCleared()
tunnelManager.removeListener(tunnelListener)
}
fun disconnect() {
tunnelManager.disconnect()
}
private fun onDisconnect() {
// no-op
}
private fun onClosed() {
tunnelManager.removeListener(tunnelListener)
actionMutableLiveData.postValue(ViewAction.NavigateToSignIn)
}
internal data class UiState(
val state: Tunnel.State = Tunnel.State.Down,
val resources: List<Resource>? = null,
)
internal sealed class ViewAction {
object NavigateToSignIn : ViewAction()
object ShowError : ViewAction()
}
fun getActorName() = repo.getActorNameSync()
}

View File

@@ -10,8 +10,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.domain.preference.SaveSettingsUseCase
import dev.firezone.android.core.data.Repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -32,8 +31,7 @@ import javax.inject.Inject
internal class SettingsViewModel
@Inject
constructor(
private val getConfigUseCase: GetConfigUseCase,
private val saveSettingsUseCase: SaveSettingsUseCase,
private val repo: Repository,
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState
@@ -47,12 +45,12 @@ internal class SettingsViewModel
fun populateFieldsFromConfig() {
viewModelScope.launch {
getConfigUseCase().collect {
repo.getConfig().collect {
actionMutableLiveData.postValue(
ViewAction.FillSettings(
it.authBaseUrl.orEmpty(),
it.apiUrl.orEmpty(),
it.logFilter.orEmpty(),
it.authBaseUrl,
it.apiUrl,
it.logFilter,
),
)
}
@@ -73,7 +71,7 @@ internal class SettingsViewModel
fun onSaveSettingsCompleted() {
viewModelScope.launch {
saveSettingsUseCase(authBaseUrl, apiUrl, logFilter).collect {
repo.saveSettings(authBaseUrl, apiUrl, logFilter).collect {
actionMutableLiveData.postValue(ViewAction.NavigateBack)
}
}
@@ -209,7 +207,7 @@ internal class SettingsViewModel
)
internal sealed class ViewAction {
object NavigateBack : ViewAction()
data object NavigateBack : ViewAction()
data class FillSettings(
val authBaseUrl: String,

View File

@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
@@ -15,7 +14,6 @@ import dev.firezone.android.features.auth.ui.AuthActivity
@AndroidEntryPoint
internal class SignInFragment : Fragment(R.layout.fragment_sign_in) {
private lateinit var binding: FragmentSignInBinding
private val viewModel: SignInViewModel by viewModels()
override fun onViewCreated(
view: View,

View File

@@ -1,23 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.signin.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import javax.inject.Inject
@HiltViewModel
internal class SignInViewModel
@Inject
constructor(
private val useCase: GetConfigUseCase,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<SignInViewAction>()
val actionLiveData: LiveData<SignInViewAction> = actionMutableLiveData
internal sealed class SignInViewAction {
object NavigateToAuthActivity : SignInViewAction()
}
}

View File

@@ -29,7 +29,7 @@ internal class SplashFragment : Fragment(R.layout.fragment_splash) {
override fun onResume() {
super.onResume()
viewModel.checkUserState(requireContext())
viewModel.checkTunnelState(requireContext())
}
private fun setupActionObservers() {

View File

@@ -2,15 +2,15 @@
package dev.firezone.android.features.splash.ui
import android.content.Context
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.data.Repository
import dev.firezone.android.tunnel.TunnelService
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -20,28 +20,28 @@ private const val REQUEST_DELAY = 1000L
internal class SplashViewModel
@Inject
constructor(
private val useCase: GetConfigUseCase,
private val repo: Repository,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
internal fun checkUserState(context: Context) {
internal fun checkTunnelState(context: Context) {
viewModelScope.launch {
// Stay a while and enjoy the logo
delay(REQUEST_DELAY)
if (!hasVpnPermissions(context)) {
actionMutableLiveData.postValue(ViewAction.NavigateToVpnPermission)
} else {
useCase.invoke()
.catch {
Log.e("Error", it.message.toString())
}
.collect { user ->
if (user.token.isNullOrBlank()) {
actionMutableLiveData.postValue(ViewAction.NavigateToSignIn)
} else {
actionMutableLiveData.postValue(ViewAction.NavigateToSession)
}
repo.getToken().collect {
if (it.isNullOrBlank()) {
actionMutableLiveData.postValue(ViewAction.NavigateToSignIn)
} else {
// token will be re-read by the TunnelService
if (!TunnelService.isRunning(context)) TunnelService.start(context)
actionMutableLiveData.postValue(ViewAction.NavigateToSession)
}
}
}
}
}

View File

@@ -1,117 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.webview.ui
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Bundle
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.databinding.ActivityWebViewBinding
@AndroidEntryPoint
internal class WebViewActivity : AppCompatActivity(R.layout.activity_web_view) {
private lateinit var binding: ActivityWebViewBinding
private val viewModel: WebViewViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityWebViewBinding.inflate(layoutInflater)
setupActionObservers()
}
private fun setupActionObservers() {
viewModel.actionLiveData.observe(this) { action ->
when (action) {
is WebViewViewModel.ViewAction.ShowError -> showError()
}
}
}
private fun setupWebView(url: String) {
binding.webview.apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
setSupportMultipleWindows(true)
allowFileAccess = true
allowContentAccess = true
javaScriptCanOpenWindowsAutomatically = true
cacheMode = WebSettings.LOAD_NO_CACHE
useWideViewPort = true
}
loadUrl(url)
webViewClient = WebViewBrowser()
}
}
private inner class WebViewBrowser : WebViewClient() {
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?,
) {
super.onPageStarted(view, url, favicon)
}
override fun onPageFinished(
view: WebView?,
url: String?,
) {
super.onPageFinished(view, url)
}
override fun onPageCommitVisible(
view: WebView?,
url: String?,
) {
super.onPageCommitVisible(view, url)
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
super.onReceivedSslError(view, handler, error)
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
showError()
super.onReceivedError(view, request, error)
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
return super.shouldOverrideUrlLoading(view, request)
}
}
private fun showError() {
AlertDialog.Builder(this)
.setTitle(R.string.error_dialog_title)
.setMessage(R.string.error_dialog_message)
.setPositiveButton(
R.string.error_dialog_button_text,
) { _, _ ->
this@WebViewActivity.finish()
}
.setIcon(R.drawable.ic_firezone_logo)
.show()
}
}

View File

@@ -1,23 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.features.webview.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import javax.inject.Inject
@HiltViewModel
internal class WebViewViewModel
@Inject
constructor(
private val getConfigUseCase: GetConfigUseCase,
) : ViewModel() {
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
internal sealed class ViewAction {
object ShowError : ViewAction()
}
}

View File

@@ -1,7 +1,7 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.tunnel
object TunnelSession {
object ConnlibSession {
external fun connect(
apiUrl: String,
token: String,
@@ -13,5 +13,5 @@ object TunnelSession {
callback: Any,
): Long
external fun disconnect(session: Long): Boolean
external fun disconnect(connlibSession: Long): Boolean
}

View File

@@ -1,95 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.tunnel
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import dev.firezone.android.core.data.PreferenceRepository
import dev.firezone.android.tunnel.callback.TunnelListener
import dev.firezone.android.tunnel.data.TunnelRepository
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TunnelManager
@Inject
constructor(
private val appContext: Context,
private val tunnelRepository: TunnelRepository,
private val preferenceRepository: PreferenceRepository,
) {
private val listeners: MutableSet<WeakReference<TunnelListener>> = mutableSetOf()
private val tunnelRepositoryListener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
TunnelRepository.STATE_KEY -> {
listeners.forEach {
it.get()?.onTunnelStateUpdate(tunnelRepository.getState())
}
}
TunnelRepository.RESOURCES_KEY -> {
listeners.forEach {
it.get()?.onResourcesUpdate(tunnelRepository.getResources())
}
}
}
}
fun addListener(listener: TunnelListener) {
val contains =
listeners.any {
it.get() == listener
}
if (!contains) {
listeners.add(WeakReference(listener))
}
tunnelRepository.addListener(tunnelRepositoryListener)
}
fun removeListener(listener: TunnelListener) {
listeners.firstOrNull {
it.get() == listener
}?.let {
it.clear()
listeners.remove(it)
}
if (listeners.isEmpty()) {
tunnelRepository.removeListener(tunnelRepositoryListener)
}
}
fun connect() {
startVPNService()
}
fun disconnect() {
stopVPNService()
clearSessionData()
}
private fun startVPNService() {
val intent = Intent(appContext, TunnelService::class.java)
intent.action = TunnelService.ACTION_CONNECT
appContext.startService(intent)
}
private fun stopVPNService() {
val intent = Intent(appContext, TunnelService::class.java)
intent.action = TunnelService.ACTION_DISCONNECT
appContext.startService(intent)
}
private fun clearSessionData() {
preferenceRepository.clearToken()
tunnelRepository.clearAll()
}
internal companion object {
private const val TAG: String = "TunnelManager"
}
}

View File

@@ -9,23 +9,23 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.os.Binder
import android.os.Build
import android.system.OsConstants
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.MutableLiveData
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.core.data.PreferenceRepository
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.data.Repository
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.Cidr
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
import dev.firezone.android.tunnel.model.TunnelConfig
import dev.firezone.android.tunnel.util.DnsServersDetector
import java.nio.file.Files
import java.nio.file.Paths
@@ -36,59 +36,73 @@ import javax.inject.Inject
@OptIn(ExperimentalStdlibApi::class)
class TunnelService : VpnService() {
@Inject
internal lateinit var getConfigUseCase: GetConfigUseCase
@Inject
internal lateinit var tunnelRepository: TunnelRepository
@Inject
internal lateinit var preferenceRepository: PreferenceRepository
internal lateinit var repo: Repository
@Inject
internal lateinit var moshi: Moshi
private var sessionPtr: Long? = null
private var tunnelIpv4Address: String? = null
private var tunnelIpv6Address: String? = null
private var tunnelDnsAddresses: MutableList<String> = mutableListOf()
private var tunnelRoutes: MutableList<Cidr> = mutableListOf()
private var connlibSessionPtr: Long? = null
private var _tunnelResources: List<Resource> = emptyList()
private var _tunnelState: State = State.DOWN
private var shouldReconnect: Boolean = false
var startedByUser: Boolean = false
private val activeTunnel: Tunnel?
get() = tunnelRepository.get()
var tunnelResources: List<Resource>
get() = _tunnelResources
set(value) {
_tunnelResources = value
updateResourcesLiveData(value)
}
var tunnelState: State
get() = _tunnelState
set(value) {
_tunnelState = value
updateServiceStateLiveData(value)
}
// Used to update the UI when the SessionActivity is bound to this service
private var serviceStateLiveData: MutableLiveData<State>? = null
private var resourcesLiveData: MutableLiveData<List<Resource>>? = null
// For binding the SessionActivity view to this service
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): TunnelService = this@TunnelService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
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)
Firebase.crashlytics.log("onUpdateResources: $resourceListJSON")
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let {
tunnelResources = it
}
}
override fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
addressIPv4: String,
addressIPv6: String,
dnsAddresses: String,
): Int {
Log.d(
TAG,
"""
onSetInterfaceConfig:
[IPv4:$tunnelAddressIPv4]
[IPv6:$tunnelAddressIPv6]
[dns:$dnsAddresses]
""".trimIndent(),
)
Log.d(TAG, "onSetInterfaceConfig: $addressIPv4, $addressIPv6, $dnsAddresses")
Firebase.crashlytics.log("onSetInterfaceConfig: $addressIPv4, $addressIPv6, $dnsAddresses")
moshi.adapter<List<String>>().fromJson(dnsAddresses)?.let { dns ->
tunnelRepository.setConfig(
TunnelConfig(
tunnelAddressIPv4,
tunnelAddressIPv6,
dns,
),
)
}
// init tunnel config
tunnelDnsAddresses = moshi.adapter<MutableList<String>>().fromJson(dnsAddresses)!!
tunnelIpv4Address = addressIPv4
tunnelIpv6Address = addressIPv6
// TODO: throw error if failed to establish VpnService
// start VPN
val fd = buildVpnService().establish()?.detachFd() ?: -1
protect(fd)
return fd
@@ -96,9 +110,11 @@ class TunnelService : VpnService() {
override fun onTunnelReady(): Boolean {
Log.d(TAG, "onTunnelReady")
Firebase.crashlytics.log("onTunnelReady")
tunnelRepository.setState(Tunnel.State.Up)
tunnelState = State.UP
updateStatusNotification("Status: Connected")
return true
}
@@ -107,8 +123,10 @@ class TunnelService : VpnService() {
prefix: Int,
): Int {
Log.d(TAG, "onAddRoute: $addr/$prefix")
Firebase.crashlytics.log("onAddRoute: $addr/$prefix")
val route = Cidr(addr, prefix)
tunnelRepository.addRoute(route)
tunnelRoutes.add(route)
val fd = buildVpnService().establish()?.detachFd() ?: -1
protect(fd)
return fd
@@ -119,130 +137,153 @@ class TunnelService : VpnService() {
prefix: Int,
): Int {
Log.d(TAG, "onRemoveRoute: $addr/$prefix")
Firebase.crashlytics.log("onRemoveRoute: $addr/$prefix")
val route = Cidr(addr, prefix)
tunnelRepository.removeRoute(route)
tunnelRoutes.remove(route)
val fd = buildVpnService().establish()?.detachFd() ?: -1
protect(fd)
return fd
}
override fun getSystemDefaultResolvers(): Array<ByteArray> {
return DnsServersDetector(this@TunnelService).servers.map { it.address }
.toTypedArray()
val found = DnsServersDetector(this@TunnelService).servers
Log.d(TAG, "getSystemDefaultResolvers: $found")
Firebase.crashlytics.log("getSystemDefaultResolvers: $found")
return found.map {
it.address
}.toTypedArray()
}
override fun onDisconnect(error: String?): Boolean {
onSessionDisconnected(
error = error?.takeUnless { it == "null" },
)
// Something called disconnect() already, so assume it was user or system initiated.
override fun onDisconnect(): Boolean {
Log.d(TAG, "onDisconnect")
Firebase.crashlytics.log("onDisconnect")
shutdown()
return true
}
// Unexpected disconnect, most likely a 401. Clear the token and initiate a stop of the
// service.
override fun onDisconnect(error: String): Boolean {
Log.d(TAG, "onDisconnect: $error")
Firebase.crashlytics.log("onDisconnect: $error")
// This is a no-op if the token is being read from MDM
repo.clearToken()
repo.clearActorName()
shutdown()
return true
}
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "onCreate")
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy")
}
// Primary callback used to start and stop the VPN service
// This can be called either from the UI or from the system
// via AlwaysOnVpn.
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
if (intent?.getBooleanExtra("startedByUser", false) == true) {
startedByUser = true
}
connect()
return START_STICKY
}
private fun onTunnelStateUpdate(state: Tunnel.State) {
tunnelRepository.setState(state)
// Happens when a user removes the VPN configuration in the System settings
override fun onRevoke() {
Log.d(TAG, "onRevoke")
connlibSessionPtr?.let {
ConnlibSession.disconnect(it)
}
}
// Call this to stop the tunnel and shutdown the service, leaving the token intact.
fun disconnect() {
Log.d(TAG, "disconnect")
// Connlib should call onDisconnect() when it's done, with no error.
connlibSessionPtr!!.let {
ConnlibSession.disconnect(it)
}
}
private fun shutdown() {
Log.d(TAG, "shutdown")
connlibSessionPtr = null
stopSelf()
tunnelState = State.DOWN
}
private fun connect() {
try {
val config = getConfigUseCase.sync()
val token = repo.getTokenSync()
val config = repo.getConfigSync()
if (tunnelRepository.getState() == Tunnel.State.Up) {
shouldReconnect = true
disconnect()
} else if (config.token != null) {
onTunnelStateUpdate(Tunnel.State.Connecting)
updateStatusNotification("Status: Connecting...")
System.loadLibrary("connlib")
if (!token.isNullOrBlank()) {
tunnelState = State.CONNECTING
updateStatusNotification("Status: Connecting...")
System.loadLibrary("connlib")
sessionPtr =
TunnelSession.connect(
apiUrl = config.apiUrl,
token = config.token,
deviceId = deviceId(),
deviceName = Build.MODEL,
osVersion = Build.VERSION.RELEASE,
logDir = getLogDir(),
logFilter = config.logFilter,
callback = callback,
)
}
} catch (exception: Exception) {
Log.e(TAG, "connect(): " + exception.message.toString())
connlibSessionPtr =
ConnlibSession.connect(
apiUrl = config.apiUrl,
token = token,
deviceId = deviceId(),
deviceName = Build.MODEL,
osVersion = Build.VERSION.RELEASE,
logDir = getLogDir(),
logFilter = config.logFilter,
callback = callback,
)
}
}
private fun disconnect() {
Log.d(TAG, "disconnect(): Attempting to disconnect session")
try {
sessionPtr?.let {
TunnelSession.disconnect(it)
} ?: onSessionDisconnected(null)
} catch (exception: Exception) {
Log.e(TAG, exception.message.toString())
}
fun setServiceStateLiveData(liveData: MutableLiveData<State>) {
serviceStateLiveData = liveData
// Update the newly bound SessionActivity with our current state
serviceStateLiveData?.postValue(tunnelState)
}
private fun onSessionDisconnected(error: String?) {
sessionPtr = null
onTunnelStateUpdate(Tunnel.State.Down)
fun setResourcesLiveData(liveData: MutableLiveData<List<Resource>>) {
resourcesLiveData = liveData
if (shouldReconnect && error == null) {
shouldReconnect = false
connect()
} else {
tunnelRepository.clearAll()
preferenceRepository.clearToken()
onTunnelStateUpdate(Tunnel.State.Closed)
stopForeground(STOP_FOREGROUND_REMOVE)
}
// Update the newly bound SessionActivity with our current resources
resourcesLiveData?.postValue(tunnelResources)
}
private fun updateServiceStateLiveData(state: State) {
serviceStateLiveData?.postValue(state)
}
private fun updateResourcesLiveData(resources: List<Resource>) {
resourcesLiveData?.postValue(resources)
}
private fun deviceId(): String {
// Get the deviceId from the preferenceRepository, or save a new UUIDv4 and return that if it doesn't exist
val deviceId =
preferenceRepository.getDeviceIdSync() ?: run {
repo.getDeviceIdSync() ?: run {
val newDeviceId = UUID.randomUUID().toString()
preferenceRepository.saveDeviceIdSync(newDeviceId)
repo.saveDeviceIdSync(newDeviceId)
newDeviceId
} ?: throw IllegalStateException("Device ID is null")
}
Log.d(TAG, "Device ID: $deviceId")
return deviceId
}
private fun getLogDir(): String {
// Create log directory if it doesn't exist
val logDir = cacheDir.absolutePath + "/logs"
Files.createDirectories(Paths.get(logDir))
return logDir
}
private fun configIntent(): PendingIntent? {
return PendingIntent.getActivity(
this,
@@ -252,37 +293,51 @@ class TunnelService : VpnService() {
)
}
private fun buildVpnService(): VpnService.Builder =
TunnelService().Builder().apply {
activeTunnel?.let { tunnel ->
allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6)
// Allow traffic to bypass the VPN interface when Always-on VPN is enabled.
allowBypass()
private fun getLogDir(): String {
// Create log directory if it doesn't exist
val logDir = cacheDir.absolutePath + "/logs"
Files.createDirectories(Paths.get(logDir))
return logDir
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(false) // Inherit the metered status from the underlying networks.
}
private fun buildVpnService(): VpnService.Builder {
return Builder().apply {
Firebase.crashlytics.log("Building VPN service")
// Allow traffic to bypass the VPN interface when Always-on VPN is enabled.
allowBypass()
setUnderlyingNetworks(null) // Use all available networks.
addAddress(tunnel.config.tunnelAddressIPv4, 32)
addAddress(tunnel.config.tunnelAddressIPv6, 128)
tunnel.config.dnsAddresses.forEach { dns ->
addDnsServer(dns)
}
tunnel.routes.forEach {
addRoute(it.address, it.prefix)
}
setSession(SESSION_NAME)
// TODO: Can we do better?
setMtu(DEFAULT_MTU)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Firebase.crashlytics.log("Setting transport info")
setMetered(false) // Inherit the metered status from the underlying networks.
}
Firebase.crashlytics.log("Setting underlying networks")
setUnderlyingNetworks(null) // Use all available networks.
Log.d(TAG, "Routes: $tunnelRoutes")
Firebase.crashlytics.log("Routes: $tunnelRoutes")
tunnelRoutes.forEach {
addRoute(it.address, it.prefix)
}
Log.d(TAG, "DNS Servers: $tunnelDnsAddresses")
Firebase.crashlytics.log("DNS Servers: $tunnelDnsAddresses")
tunnelDnsAddresses.forEach { dns ->
addDnsServer(dns)
}
Log.d(TAG, "IPv4 Address: $tunnelIpv4Address")
Firebase.crashlytics.log("IPv4 Address: $tunnelIpv4Address")
addAddress(tunnelIpv4Address!!, 32)
Log.d(TAG, "IPv6 Address: $tunnelIpv6Address")
Firebase.crashlytics.log("IPv6 Address: $tunnelIpv6Address")
addAddress(tunnelIpv6Address!!, 128)
setSession(SESSION_NAME)
setMtu(MTU)
}
}
private fun updateStatusNotification(message: String?) {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
@@ -293,7 +348,7 @@ class TunnelService : VpnService() {
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT,
)
chan.description = "firezone connection status"
chan.description = "Firezone connection status"
manager.createNotificationChannel(chan)
@@ -312,8 +367,11 @@ class TunnelService : VpnService() {
}
companion object {
const val ACTION_CONNECT = "dev.firezone.android.tunnel.CONNECT"
const val ACTION_DISCONNECT = "dev.firezone.android.tunnel.DISCONNECT"
enum class State {
CONNECTING,
UP,
DOWN,
}
private const val NOTIFICATION_CHANNEL_ID = "firezone-connection-status"
private const val NOTIFICATION_CHANNEL_NAME = "firezone-connection-status"
@@ -322,8 +380,9 @@ class TunnelService : VpnService() {
private const val TAG: String = "TunnelService"
private const val SESSION_NAME: String = "Firezone Connection"
private const val DEFAULT_MTU: Int = 1280
private const val MTU: Int = 1280
// FIXME: Find another way to check if we're running
@SuppressWarnings("deprecation")
fun isRunning(context: Context): Boolean {
val manager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
@@ -332,7 +391,15 @@ class TunnelService : VpnService() {
return true
}
}
return false
}
fun start(context: Context) {
Log.d(TAG, "Starting TunnelService")
val intent = Intent(context, TunnelService::class.java)
intent.putExtra("startedByUser", true)
context.startService(intent)
}
}
}

View File

@@ -3,8 +3,8 @@ package dev.firezone.android.tunnel.callback
interface ConnlibCallback {
fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
addressIPv4: String,
addressIPv6: String,
dnsAddresses: String,
): Int
@@ -22,7 +22,10 @@ interface ConnlibCallback {
fun onUpdateResources(resourceListJSON: String)
fun onDisconnect(error: String?): Boolean
// The JNI doesn't support nullable types, so we need two method signatures
fun onDisconnect(error: String): Boolean
fun onDisconnect(): Boolean
fun getSystemDefaultResolvers(): Array<ByteArray>
}

View File

@@ -1,11 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.tunnel.callback
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.Tunnel
interface TunnelListener {
fun onTunnelStateUpdate(state: Tunnel.State)
fun onResourcesUpdate(resources: List<Resource>)
}

View File

@@ -1,44 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
package dev.firezone.android.tunnel.data
import android.content.SharedPreferences
import dev.firezone.android.tunnel.model.Cidr
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: Cidr)
fun removeRoute(route: Cidr)
fun getRoutes(): List<Cidr>
fun clearAll()
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

@@ -1,118 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
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.Cidr
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.Closed
}
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: Cidr) {
synchronized(lock) {
getRoutes().toMutableList().run {
add(route)
val json = moshi.adapter<List<Cidr>>().toJson(this)
sharedPreferences.edit().putString(ROUTES_KEY, json).apply()
}
}
}
override fun removeRoute(route: Cidr) {
synchronized(lock) {
getRoutes().toMutableList().run {
remove(route)
val json = moshi.adapter<List<Cidr>>().toJson(this)
sharedPreferences.edit().putString(ROUTES_KEY, json).apply()
}
}
}
override fun getRoutes(): List<Cidr> =
synchronized(lock) {
val json = sharedPreferences.getString(ROUTES_KEY, "[]") ?: "[]"
return moshi.adapter<List<Cidr>>().fromJson(json) ?: emptyList()
}
override fun clearAll() {
synchronized(lock) {
sharedPreferences.edit().clear().apply()
}
}
}

View File

@@ -1,53 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
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.core.data.PreferenceRepository
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,
preferenceRepository: PreferenceRepository,
): TunnelManager = TunnelManager(appContext, tunnelRepository, preferenceRepository)
}

View File

@@ -1,22 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
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 Tunnel(
val config: TunnelConfig = TunnelConfig(),
var state: State = State.Down,
val routes: List<Cidr> = emptyList(),
val resources: List<Resource> = emptyList(),
) : Parcelable {
enum class State {
Connecting,
Up,
Down,
Closed,
}
}

View File

@@ -1,13 +0,0 @@
/* Licensed under Apache 2.0 (C) 2023 Firezone, Inc. */
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 dnsAddresses: List<String> = emptyList(),
val dnsFallbackStrategy: String = "",
) : Parcelable

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
@@ -56,27 +57,27 @@
android:layout_height="0dp"
android:layout_marginTop="@dimen/spacing_2x"
android:layout_marginBottom="@dimen/spacing_2x"
app:layout_constraintBottom_toTopOf="@id/btSignOut"
app:layout_constraintTop_toBottomOf="@id/tvResourcesList"
app:layout_constraintBottom_toTopOf="@id/btSignOut" />
tools:layout_editor_absoluteX="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btSignOut"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/sign_out"
android:layout_marginBottom="@dimen/spacing_2x"
app:layout_constraintBottom_toTopOf="@id/btSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btSettings"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/settings"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/tvActorName"
android:layout_width="200dp"
android:layout_height="20dp"
android:layout_marginTop="26dp"
android:textAlignment="textEnd"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/llContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -416,7 +416,7 @@ fn connect(
/// fd must be a valid file descriptor
#[allow(non_snake_case)]
#[no_mangle]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_TunnelSession_connect(
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_connect(
mut env: JNIEnv,
_class: JClass,
api_url: JString,
@@ -462,7 +462,7 @@ pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_TunnelSession_con
/// Pointers must be valid
#[allow(non_snake_case)]
#[no_mangle]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_TunnelSession_disconnect(
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_disconnect(
mut env: JNIEnv,
_: JClass,
session: *mut Session<CallbackHandler>,