diff --git a/.github/workflows/_kotlin.yml b/.github/workflows/_kotlin.yml index ab5f6873d..cd13c3477 100644 --- a/.github/workflows/_kotlin.yml +++ b/.github/workflows/_kotlin.yml @@ -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 diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index bd13cdfe0..c27a5fef4 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -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 { + // 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" } diff --git a/kotlin/android/app/src/main/AndroidManifest.xml b/kotlin/android/app/src/main/AndroidManifest.xml index c31811897..20d32126b 100644 --- a/kotlin/android/app/src/main/AndroidManifest.xml +++ b/kotlin/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -8,6 +7,8 @@ + + @@ -16,6 +17,7 @@ diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/AuthRepository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/AuthRepository.kt deleted file mode 100644 index 899013d75..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/AuthRepository.kt +++ /dev/null @@ -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 -} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/AuthRepositoryImpl.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/AuthRepositoryImpl.kt deleted file mode 100644 index a581e9210..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/AuthRepositoryImpl.kt +++ /dev/null @@ -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 = - 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 - } - } 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/Repository.kt similarity index 52% rename from kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepository.kt rename to kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt index b1b855b74..5858e389b 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/Repository.kt @@ -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 @@ -17,11 +17,39 @@ internal interface PreferenceRepository { fun saveDeviceIdSync(value: String): Unit + fun getToken(): Flow + + fun getTokenSync(): String? + + fun getStateSync(): String? + + fun getNonceSync(): String? + fun getDeviceIdSync(): String? + fun getActorName(): Flow + + fun getActorNameSync(): String? + + fun saveNonce(value: String): Flow + + fun saveState(value: String): Flow + + fun saveStateSync(value: String): Unit + + fun saveNonceSync(value: String): Unit + fun saveToken(value: String): Flow + fun saveActorName(value: String): Flow + fun validateState(value: String): Flow fun clearToken() + + fun clearNonce() + + fun clearState() + + fun clearActorName() } 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/RepositoryImpl.kt similarity index 54% rename from kotlin/android/app/src/main/java/dev/firezone/android/core/data/PreferenceRepositoryImpl.kt rename to kotlin/android/app/src/main/java/dev/firezone/android/core/data/RepositoryImpl.kt index 80cdb3ac6..841ccd3c9 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/RepositoryImpl.kt @@ -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 = + 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 = + 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 = + flow { + emit(saveNonceSync(value)) + }.flowOn(coroutineDispatcher) + + override fun saveNonceSync(value: String) = sharedPreferences.edit().putString(NONCE_KEY, value).apply() + + override fun saveState(value: String): Flow = + flow { + emit(saveStateSync(value)) + }.flowOn(coroutineDispatcher) + + override fun saveStateSync(value: String) = sharedPreferences.edit().putString(STATE_KEY, value).apply() + override fun saveToken(value: String): Flow = flow { val nonce = sharedPreferences.getString(NONCE_KEY, "").orEmpty() @@ -78,6 +112,16 @@ internal class PreferenceRepositoryImpl ) }.flowOn(coroutineDispatcher) + override fun saveActorName(value: String): Flow = + flow { + emit( + sharedPreferences + .edit() + .putString(ACTOR_NAME_KEY, value) + .apply(), + ) + }.flowOn(coroutineDispatcher) + override fun validateState(value: String): Flow = 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" diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/Config.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/Config.kt index 294f4c1a1..80a505d3a 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/Config.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/Config.kt @@ -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?, ) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt index d0c91e69e..e9b5eccbc 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/DataModule.kt @@ -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, + ) } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/auth/GetNonceUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/auth/GetNonceUseCase.kt deleted file mode 100644 index 5173ad057..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/auth/GetNonceUseCase.kt +++ /dev/null @@ -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 = repository.generateNonce("nonce") - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/auth/GetStateUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/auth/GetStateUseCase.kt deleted file mode 100644 index 3a99811be..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/auth/GetStateUseCase.kt +++ /dev/null @@ -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 = repository.generateNonce("state") - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/GetConfigUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/GetConfigUseCase.kt deleted file mode 100644 index b77f40184..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/GetConfigUseCase.kt +++ /dev/null @@ -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 = repository.getConfig() - - fun sync(): Config = repository.getConfigSync() - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveSettingsUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveSettingsUseCase.kt deleted file mode 100644 index 77f418907..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveSettingsUseCase.kt +++ /dev/null @@ -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 = repository.saveSettings(authBaseUrl, apiUrl, logFilter) - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveTokenUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveTokenUseCase.kt deleted file mode 100644 index 456aad461..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/SaveTokenUseCase.kt +++ /dev/null @@ -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 = repository.saveToken(token) - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/ValidateStateUseCase.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/ValidateStateUseCase.kt deleted file mode 100644 index 477e8294b..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/domain/preference/ValidateStateUseCase.kt +++ /dev/null @@ -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 = repository.validateState(value) - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthActivity.kt index f4c50b09a..4b5bd7dcb 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthActivity.kt @@ -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) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt index abff60179..75c98999d 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/auth/ui/AuthViewModel.kt @@ -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() val actionLiveData: LiveData = 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 + } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt index f62436700..f1912ef82 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriHandlerActivity.kt @@ -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() - } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt index 3647af4f2..665322a26 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/customuri/ui/CustomUriViewModel.kt @@ -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() val actionLiveData: LiveData = 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() } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt index 877ad5ddf..833c74fae 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt @@ -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" } } 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 7e6c3f82d..615ddbc3b 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 @@ -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 + constructor() : ViewModel() { + @Inject + internal lateinit var repo: Repository + private val _serviceStatusLiveData = MutableLiveData() + private val _resourcesLiveData = MutableLiveData>(emptyList()) - private val actionMutableLiveData = MutableLiveData() - val actionLiveData: LiveData = actionMutableLiveData + val serviceStatusLiveData: MutableLiveData + get() = _serviceStatusLiveData + val resourcesLiveData: MutableLiveData> + 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) { - 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? = null, - ) - - internal sealed class ViewAction { - object NavigateToSignIn : ViewAction() - - object ShowError : ViewAction() - } + fun getActorName() = repo.getActorNameSync() } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt index 662062eb8..4980e3545 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsViewModel.kt @@ -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 @@ -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, diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInFragment.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInFragment.kt index ccabbec73..4b6364555 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInFragment.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInFragment.kt @@ -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, diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInViewModel.kt deleted file mode 100644 index 324dee424..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/signin/ui/SignInViewModel.kt +++ /dev/null @@ -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() - val actionLiveData: LiveData = actionMutableLiveData - - internal sealed class SignInViewAction { - object NavigateToAuthActivity : SignInViewAction() - } - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt index 64794e4c4..9208bf8a0 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashFragment.kt @@ -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() { diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt index 7a6582650..cbaab093f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/splash/ui/SplashViewModel.kt @@ -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() val actionLiveData: LiveData = 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) } + } } } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/webview/ui/WebViewActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/webview/ui/WebViewActivity.kt deleted file mode 100644 index 23abc0301..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/webview/ui/WebViewActivity.kt +++ /dev/null @@ -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() - } -} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/webview/ui/WebViewViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/webview/ui/WebViewViewModel.kt deleted file mode 100644 index a760207e9..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/webview/ui/WebViewViewModel.kt +++ /dev/null @@ -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() - val actionLiveData: LiveData = actionMutableLiveData - - internal sealed class ViewAction { - object ShowError : ViewAction() - } - } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelSession.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt similarity index 80% rename from kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelSession.kt rename to kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt index 695ee9493..2158f4c2a 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelSession.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt @@ -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 } 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 deleted file mode 100644 index ee07beddb..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelManager.kt +++ /dev/null @@ -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> = 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" - } - } 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 a5b984211..ebc9963e5 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 @@ -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 = mutableListOf() + private var tunnelRoutes: MutableList = mutableListOf() + private var connlibSessionPtr: Long? = null + private var _tunnelResources: List = 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 + 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? = null + private var resourcesLiveData: MutableLiveData>? = 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>().fromJson(resourceListJSON)?.let { resources -> - tunnelRepository.setResources(resources) + Firebase.crashlytics.log("onUpdateResources: $resourceListJSON") + moshi.adapter>().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>().fromJson(dnsAddresses)?.let { dns -> - tunnelRepository.setConfig( - TunnelConfig( - tunnelAddressIPv4, - tunnelAddressIPv6, - dns, - ), - ) - } + // init tunnel config + tunnelDnsAddresses = moshi.adapter>().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 { - 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) { + 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>) { + 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) { + 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) + } } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt index c1e4098ea..08fbb564e 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt @@ -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 } 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 deleted file mode 100644 index c56923f7e..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/TunnelListener.kt +++ /dev/null @@ -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) -} 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 deleted file mode 100644 index 6a315cc8d..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepository.kt +++ /dev/null @@ -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) - - fun getResources(): List - - fun addRoute(route: Cidr) - - fun removeRoute(route: Cidr) - - fun getRoutes(): List - - 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" - } -} 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 deleted file mode 100644 index ef2540e34..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/data/TunnelRepositoryImpl.kt +++ /dev/null @@ -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().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.Closed - } - - 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: Cidr) { - synchronized(lock) { - getRoutes().toMutableList().run { - add(route) - val json = moshi.adapter>().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>().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 clearAll() { - 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 deleted file mode 100644 index 4149d7747..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/di/TunnelModule.kt +++ /dev/null @@ -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) -} 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 deleted file mode 100644 index a87272805..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Tunnel.kt +++ /dev/null @@ -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 = emptyList(), - val resources: List = emptyList(), -) : Parcelable { - enum class State { - Connecting, - Up, - Down, - Closed, - } -} 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 deleted file mode 100644 index 87d79a4bc..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/TunnelConfig.kt +++ /dev/null @@ -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 = emptyList(), - val dnsFallbackStrategy: String = "", -) : Parcelable diff --git a/kotlin/android/app/src/main/res/layout/activity_session.xml b/kotlin/android/app/src/main/res/layout/activity_session.xml index 35c9fe2d0..bd03513e5 100644 --- a/kotlin/android/app/src/main/res/layout/activity_session.xml +++ b/kotlin/android/app/src/main/res/layout/activity_session.xml @@ -1,6 +1,7 @@ + tools:layout_editor_absoluteX="16dp" /> - - + + diff --git a/kotlin/android/app/src/main/res/layout/activity_web_view.xml b/kotlin/android/app/src/main/res/layout/activity_web_view.xml deleted file mode 100644 index c2490d58f..000000000 --- a/kotlin/android/app/src/main/res/layout/activity_web_view.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/rust/connlib/clients/android/src/lib.rs b/rust/connlib/clients/android/src/lib.rs index d7f83bde1..b593c32bd 100644 --- a/rust/connlib/clients/android/src/lib.rs +++ b/rust/connlib/clients/android/src/lib.rs @@ -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,