diff --git a/kotlin/android/.gitignore b/kotlin/android/.gitignore index 7bf9592e4..71e53c20a 100644 --- a/kotlin/android/.gitignore +++ b/kotlin/android/.gitignore @@ -106,6 +106,9 @@ proguard/ # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild +# Build files +.kotlin/ + # NDK obj/ diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index e061b210e..b9aaff971 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -77,7 +77,7 @@ android { isDebuggable = true resValue("string", "app_name", "\"Firezone (Dev)\"") - buildConfigField("String", "AUTH_BASE_URL", "\"https://app.firez.one\"") + buildConfigField("String", "AUTH_URL", "\"https://app.firez.one\"") buildConfigField("String", "API_URL", "\"wss://api.firez.one\"") buildConfigField( "String", @@ -120,7 +120,7 @@ android { resValue("string", "app_name", "\"Firezone\"") - buildConfigField("String", "AUTH_BASE_URL", "\"https://app.firezone.dev\"") + buildConfigField("String", "AUTH_URL", "\"https://app.firezone.dev\"") buildConfigField("String", "API_URL", "\"wss://api.firezone.dev\"") buildConfigField("String", "LOG_FILTER", "\"info\"") firebaseAppDistribution { diff --git a/kotlin/android/app/src/main/AndroidManifest.xml b/kotlin/android/app/src/main/AndroidManifest.xml index d1b1415db..95557eb7d 100644 --- a/kotlin/android/app/src/main/AndroidManifest.xml +++ b/kotlin/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + @@ -31,6 +32,15 @@ android:theme="@style/AppTheme.Base" android:usesCleartextTraffic="true"> + + + + + + diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/BootReceiver.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/BootReceiver.kt new file mode 100644 index 000000000..e603ca0c8 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/BootReceiver.kt @@ -0,0 +1,39 @@ +/* Licensed under Apache 2.0 (C) 2025 Firezone, Inc. */ +package dev.firezone.android.core + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import dev.firezone.android.core.data.Repository +import dev.firezone.android.core.di.ApplicationScope +import dev.firezone.android.tunnel.TunnelService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + @Inject + lateinit var repo: Repository + + @Inject + @ApplicationScope + lateinit var applicationScope: CoroutineScope + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + applicationScope.launch(Dispatchers.IO) { + val userConfig = repo.getConfigSync() + if (userConfig.startOnLogin) { + val serviceIntent = Intent(context, TunnelService::class.java) + context.startService(serviceIntent) + } + } + } + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/BaseUrlInterceptor.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/UrlInterceptor.kt similarity index 86% rename from kotlin/android/app/src/main/java/dev/firezone/android/core/BaseUrlInterceptor.kt rename to kotlin/android/app/src/main/java/dev/firezone/android/core/UrlInterceptor.kt index 016935765..0c2b8c83d 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/BaseUrlInterceptor.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/UrlInterceptor.kt @@ -7,12 +7,12 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.Response -internal class BaseUrlInterceptor( +internal class UrlInterceptor( private val sharedPreferences: SharedPreferences, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - val newUrl = BuildConfig.AUTH_BASE_URL.toHttpUrlOrNull() + val newUrl = BuildConfig.AUTH_URL.toHttpUrlOrNull() val newRequest = originalRequest diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt index 8266faacc..f26af6cee 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt @@ -3,11 +3,13 @@ package dev.firezone.android.core.data import android.content.Context import android.content.SharedPreferences +import android.os.Bundle import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import dev.firezone.android.BuildConfig import dev.firezone.android.core.data.model.Config +import dev.firezone.android.core.data.model.ManagedConfigStatus import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -53,7 +55,7 @@ class Favorites( val inner: HashSet, ) -internal class Repository +class Repository @Inject constructor( private val context: Context, @@ -68,14 +70,50 @@ internal class Repository val favorites = _favorites.asStateFlow() fun getConfigSync(): Config = - Config( - 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, - ) + { + val authURL = + sharedPreferences.getString(MANAGED_AUTH_URL_KEY, null) + ?: sharedPreferences.getString(AUTH_URL_KEY, null) + ?: BuildConfig.AUTH_URL + + val apiURL = + sharedPreferences.getString(MANAGED_API_URL_KEY, null) + ?: sharedPreferences.getString(API_URL_KEY, null) + ?: BuildConfig.API_URL + + val logFilter = + sharedPreferences.getString(MANAGED_LOG_FILTER_KEY, null) + ?: sharedPreferences.getString(LOG_FILTER_KEY, null) + ?: BuildConfig.LOG_FILTER + + val accountSlug = + sharedPreferences.getString(MANAGED_ACCOUNT_SLUG_KEY, null) + ?: sharedPreferences.getString(ACCOUNT_SLUG_KEY, null) + ?: "" + + val startOnLogin = + if (sharedPreferences.contains(MANAGED_START_ON_LOGIN_KEY)) { + sharedPreferences.getBoolean(MANAGED_START_ON_LOGIN_KEY, false) + } else { + sharedPreferences.getBoolean(START_ON_LOGIN_KEY, false) + } + + val connectOnStart = + if (sharedPreferences.contains(MANAGED_CONNECT_ON_START_KEY)) { + sharedPreferences.getBoolean(MANAGED_CONNECT_ON_START_KEY, false) + } else { + sharedPreferences.getBoolean(CONNECT_ON_START_KEY, false) + } + + Config( + authUrl = authURL, + apiUrl = apiURL, + logFilter = logFilter, + accountSlug = accountSlug, + startOnLogin = startOnLogin, + connectOnStart = connectOnStart, + ) + }() fun getConfig(): Flow = flow { @@ -84,9 +122,12 @@ internal class Repository fun getDefaultConfigSync(): Config = Config( - BuildConfig.AUTH_BASE_URL, + BuildConfig.AUTH_URL, BuildConfig.API_URL, BuildConfig.LOG_FILTER, + accountSlug = "", + startOnLogin = false, + connectOnStart = false, ) fun getDefaultConfig(): Flow = @@ -99,13 +140,44 @@ internal class Repository emit( sharedPreferences .edit() - .putString(AUTH_BASE_URL_KEY, value.authBaseUrl) + .putString(AUTH_URL_KEY, value.authUrl) .putString(API_URL_KEY, value.apiUrl) .putString(LOG_FILTER_KEY, value.logFilter) + .putString(ACCOUNT_SLUG_KEY, value.accountSlug) + .putBoolean(START_ON_LOGIN_KEY, value.startOnLogin) + .putBoolean(CONNECT_ON_START_KEY, value.connectOnStart) .apply(), ) }.flowOn(coroutineDispatcher) + // TODO: Consider adding support for the legacy managed configuration keys like token, + // allowedApplications, etc from pilot customer. + fun saveManagedConfiguration(bundle: Bundle): Flow = + flow { + val editor = sharedPreferences.edit() + + if (bundle.containsKey(AUTH_URL_KEY)) { + editor.putString(MANAGED_AUTH_URL_KEY, bundle.getString(AUTH_URL_KEY)) + } + if (bundle.containsKey(API_URL_KEY)) { + editor.putString(MANAGED_API_URL_KEY, bundle.getString(API_URL_KEY)) + } + if (bundle.containsKey(LOG_FILTER_KEY)) { + editor.putString(MANAGED_LOG_FILTER_KEY, bundle.getString(LOG_FILTER_KEY)) + } + if (bundle.containsKey(ACCOUNT_SLUG_KEY)) { + editor.putString(MANAGED_ACCOUNT_SLUG_KEY, bundle.getString(ACCOUNT_SLUG_KEY)) + } + if (bundle.containsKey(START_ON_LOGIN_KEY)) { + editor.putBoolean(MANAGED_START_ON_LOGIN_KEY, bundle.getBoolean(START_ON_LOGIN_KEY, false)) + } + if (bundle.containsKey(CONNECT_ON_START_KEY)) { + editor.putBoolean(MANAGED_CONNECT_ON_START_KEY, bundle.getBoolean(CONNECT_ON_START_KEY, false)) + } + + emit(editor.apply()) + }.flowOn(coroutineDispatcher) + fun getDeviceIdSync(): String? = sharedPreferences.getString(DEVICE_ID_KEY, null) private fun saveFavoritesSync() { @@ -137,6 +209,11 @@ internal class Repository fun getStateSync(): String? = sharedPreferences.getString(STATE_KEY, null) + fun getAccountSlug(): Flow = + flow { + emit(sharedPreferences.getString(ACCOUNT_SLUG_KEY, null)) + }.flowOn(coroutineDispatcher) + fun getActorName(): Flow = flow { emit(getActorNameSync()) @@ -149,6 +226,16 @@ internal class Repository fun getNonceSync(): String? = sharedPreferences.getString(NONCE_KEY, null) + fun saveAccountSlug(value: String): Flow = + flow { + emit( + sharedPreferences + .edit() + .putString(ACCOUNT_SLUG_KEY, value) + .apply(), + ) + }.flowOn(coroutineDispatcher) + fun saveDeviceIdSync(value: String): Unit = sharedPreferences .edit() @@ -236,12 +323,43 @@ internal class Repository } } + fun getManagedStatus(): ManagedConfigStatus = + ManagedConfigStatus( + isAuthUrlManaged = isAuthUrlManaged(), + isApiUrlManaged = isApiUrlManaged(), + isLogFilterManaged = isLogFilterManaged(), + isAccountSlugManaged = isAccountSlugManaged(), + isStartOnLoginManaged = isStartOnLoginManaged(), + isConnectOnStartManaged = isConnectOnStartManaged(), + ) + + fun isAuthUrlManaged(): Boolean = sharedPreferences.contains(MANAGED_AUTH_URL_KEY) + + fun isApiUrlManaged(): Boolean = sharedPreferences.contains(MANAGED_API_URL_KEY) + + fun isLogFilterManaged(): Boolean = sharedPreferences.contains(MANAGED_LOG_FILTER_KEY) + + fun isAccountSlugManaged(): Boolean = sharedPreferences.contains(MANAGED_ACCOUNT_SLUG_KEY) + + fun isStartOnLoginManaged(): Boolean = sharedPreferences.contains(MANAGED_START_ON_LOGIN_KEY) + + fun isConnectOnStartManaged(): Boolean = sharedPreferences.contains(MANAGED_CONNECT_ON_START_KEY) + companion object { - private const val AUTH_BASE_URL_KEY = "authBaseUrl" + private const val AUTH_URL_KEY = "authUrl" private const val ACTOR_NAME_KEY = "actorName" private const val API_URL_KEY = "apiUrl" private const val FAVORITE_RESOURCES_KEY = "favoriteResources" private const val LOG_FILTER_KEY = "logFilter" + private const val ACCOUNT_SLUG_KEY = "accountSlug" + private const val START_ON_LOGIN_KEY = "startOnLogin" + private const val CONNECT_ON_START_KEY = "connectOnStart" + private const val MANAGED_AUTH_URL_KEY = "managedAuthUrl" + private const val MANAGED_API_URL_KEY = "managedApiUrl" + private const val MANAGED_LOG_FILTER_KEY = "managedLogFilter" + private const val MANAGED_ACCOUNT_SLUG_KEY = "managedAccountSlug" + private const val MANAGED_START_ON_LOGIN_KEY = "managedStartOnLogin" + private const val MANAGED_CONNECT_ON_START_KEY = "managedConnectOnStart" private const val TOKEN_KEY = "token" private const val NONCE_KEY = "nonce" private const val STATE_KEY = "state" 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 4781e1f63..530a0b29b 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 @@ -2,7 +2,10 @@ package dev.firezone.android.core.data.model data class Config( - var authBaseUrl: String, + var authUrl: String, var apiUrl: String, var logFilter: String, + var accountSlug: String, + var startOnLogin: Boolean, + var connectOnStart: Boolean, ) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/ManagedConfigStatus.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/ManagedConfigStatus.kt new file mode 100644 index 000000000..b4aaafd08 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/model/ManagedConfigStatus.kt @@ -0,0 +1,11 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.core.data.model + +data class ManagedConfigStatus( + val isAuthUrlManaged: Boolean, + val isApiUrlManaged: Boolean, + val isLogFilterManaged: Boolean, + val isAccountSlugManaged: Boolean, + val isStartOnLoginManaged: Boolean, + val isConnectOnStartManaged: Boolean, +) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/NetworkModule.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/NetworkModule.kt index a2d5d1faf..e28398fc7 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/di/NetworkModule.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/di/NetworkModule.kt @@ -8,7 +8,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dev.firezone.android.core.BaseUrlInterceptor +import dev.firezone.android.core.UrlInterceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -23,7 +23,7 @@ private const val NETWORK_TIMEOUT = 30L internal object NetworkModule { @Provides @Singleton - fun provideBaseUrlInterceptor(sharedPreferences: SharedPreferences): BaseUrlInterceptor = BaseUrlInterceptor(sharedPreferences) + fun provideBaseUrlInterceptor(sharedPreferences: SharedPreferences): UrlInterceptor = UrlInterceptor(sharedPreferences) @Singleton @Provides @@ -35,7 +35,7 @@ internal object NetworkModule { @Singleton @Provides fun provideOkHttpClient( - baseUrlInterceptor: BaseUrlInterceptor, + urlInterceptor: UrlInterceptor, loggingInterceptor: HttpLoggingInterceptor, ) = OkHttpClient .Builder() @@ -44,7 +44,7 @@ internal object NetworkModule { .readTimeout(NETWORK_TIMEOUT, TimeUnit.SECONDS) .writeTimeout(NETWORK_TIMEOUT, TimeUnit.SECONDS) .connectTimeout(NETWORK_TIMEOUT, TimeUnit.SECONDS) - .addInterceptor(baseUrlInterceptor) + .addInterceptor(urlInterceptor) .addInterceptor(loggingInterceptor) .build() diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/presentation/MainActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/presentation/MainActivity.kt index 00cb6c661..876f1e463 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/presentation/MainActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/presentation/MainActivity.kt @@ -1,9 +1,35 @@ /* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ package dev.firezone.android.core.presentation +import android.content.Context +import android.content.RestrictionsManager +import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope // For launching coroutines import dagger.hilt.android.AndroidEntryPoint import dev.firezone.android.R +import dev.firezone.android.core.data.Repository +import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint -internal class MainActivity : AppCompatActivity(R.layout.activity_main) +internal class MainActivity : AppCompatActivity(R.layout.activity_main) { + @Inject + lateinit var repository: Repository + + override fun onResume() { + super.onResume() + + // Apply managed configurations when the app resumes since it's not guaranteed + // the TunnelService is running when the app starts or is backgrounded. + applyManagedConfigurations() + } + + private fun applyManagedConfigurations() { + val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + val appRestrictions: Bundle = restrictionsManager.applicationRestrictions + lifecycleScope.launch { + repository.saveManagedConfiguration(appRestrictions).collect {} + } + } +} 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 a7dbb9174..d7c0a8ca6 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 @@ -36,7 +36,7 @@ internal class AuthViewModel ViewAction.NavigateToSignIn } else { authFlowLaunched = true - ViewAction.LaunchAuthFlow("${config.authBaseUrl}?state=$state&nonce=$nonce&as=client") + ViewAction.LaunchAuthFlow("${config.authUrl}/${config.accountSlug}?state=$state&nonce=$nonce&as=client") }, ) } 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 ab992c9c5..3148a2b04 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 @@ -36,6 +36,9 @@ internal class CustomUriViewModel when (intent.data?.host) { PATH_CALLBACK -> { + intent.data?.getQueryParameter(QUERY_ACCOUNT_SLUG)?.let { accountSlug -> + repo.saveAccountSlug(accountSlug).collect() + } intent.data?.getQueryParameter(QUERY_ACTOR_NAME)?.let { actorName -> repo.saveActorName(actorName).collect() } @@ -68,6 +71,7 @@ internal class CustomUriViewModel companion object { private const val PATH_CALLBACK = "handle_client_sign_in_callback" + private const val QUERY_ACCOUNT_SLUG = "account_slug" private const val QUERY_CLIENT_STATE = "state" private const val QUERY_CLIENT_AUTH_FRAGMENT = "fragment" private const val QUERY_ACTOR_NAME = "actor_name" diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/AdvancedSettingsFragment.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/AdvancedSettingsFragment.kt index eff0631a7..3e6ab19df 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/AdvancedSettingsFragment.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/AdvancedSettingsFragment.kt @@ -4,10 +4,13 @@ package dev.firezone.android.features.settings.ui import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.appcompat.widget.TooltipCompat import androidx.core.widget.doOnTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import dev.firezone.android.R +import dev.firezone.android.core.data.model.ManagedConfigStatus import dev.firezone.android.databinding.FragmentSettingsAdvancedBinding class AdvancedSettingsFragment : Fragment(R.layout.fragment_settings_advanced) { @@ -30,11 +33,11 @@ class AdvancedSettingsFragment : Fragment(R.layout.fragment_settings_advanced) { private fun setupViews() { binding.apply { - etAuthBaseUrlInput.apply { + etAuthUrlInput.apply { imeOptions = EditorInfo.IME_ACTION_DONE setOnClickListener { isCursorVisible = true } doOnTextChanged { text, _, _, _ -> - viewModel.onValidateAuthBaseUrl(text.toString()) + viewModel.onValidateAuthUrl(text.toString()) } } @@ -67,20 +70,74 @@ class AdvancedSettingsFragment : Fragment(R.layout.fragment_settings_advanced) { requireActivity().finish() is SettingsViewModel.ViewAction.FillSettings -> { - binding.etAuthBaseUrlInput.apply { - setText(action.config.authBaseUrl) + binding.etAuthUrlInput.apply { + setText(action.config.authUrl) } + binding.etApiUrlInput.apply { setText(action.config.apiUrl) } + binding.etLogFilterInput.apply { setText(action.config.logFilter) } + + applyManagedStatus(action.managedStatus) } } } } + private fun applyManagedStatus(status: ManagedConfigStatus) { + binding.apply { + val tooltipMessage = getString(R.string.managed_setting_info_description) + + etAuthUrlInput.isEnabled = !status.isAuthUrlManaged + etAuthUrlInput.isFocusable = !status.isAuthUrlManaged + etAuthUrlInput.isClickable = !status.isAuthUrlManaged + ilAuthUrlInput.isEnabled = !status.isAuthUrlManaged + ilAuthUrlInput.isFocusable = !status.isAuthUrlManaged + ilAuthUrlInput.isClickable = !status.isAuthUrlManaged + setupInfoIcon(ivAuthUrlInfo, status.isAuthUrlManaged, tooltipMessage) + + etApiUrlInput.isEnabled = !status.isApiUrlManaged + etApiUrlInput.isFocusable = !status.isApiUrlManaged + etApiUrlInput.isClickable = !status.isApiUrlManaged + ilApiUrlInput.isEnabled = !status.isApiUrlManaged + ilApiUrlInput.isFocusable = !status.isApiUrlManaged + ilApiUrlInput.isClickable = !status.isApiUrlManaged + setupInfoIcon(ivApiUrlInfo, status.isApiUrlManaged, tooltipMessage) + + etLogFilterInput.isEnabled = !status.isLogFilterManaged + etLogFilterInput.isFocusable = !status.isLogFilterManaged + etLogFilterInput.isClickable = !status.isLogFilterManaged + ilLogFilterInput.isEnabled = !status.isLogFilterManaged + ilLogFilterInput.isFocusable = !status.isLogFilterManaged + ilLogFilterInput.isClickable = !status.isLogFilterManaged + setupInfoIcon(ivLogFilterInfo, status.isLogFilterManaged, tooltipMessage) + } + } + + private fun setupInfoIcon( + infoIconView: View, + isManaged: Boolean, + tooltipMessage: String, + ) { + if (isManaged) { + infoIconView.visibility = View.VISIBLE + TooltipCompat.setTooltipText(infoIconView, tooltipMessage) + + infoIconView.setOnClickListener { v -> + Toast.makeText(v.context, tooltipMessage, Toast.LENGTH_SHORT).show() + } + } else { + infoIconView.visibility = View.GONE + TooltipCompat.setTooltipText(infoIconView, null) + infoIconView.setOnClickListener(null) + infoIconView.setOnLongClickListener(null) + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/GeneralSettingsFragment.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/GeneralSettingsFragment.kt new file mode 100644 index 000000000..c47b1c980 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/GeneralSettingsFragment.kt @@ -0,0 +1,150 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.features.settings.ui + +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.appcompat.widget.TooltipCompat +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import dev.firezone.android.R +import dev.firezone.android.core.data.model.ManagedConfigStatus +import dev.firezone.android.databinding.FragmentSettingsGeneralBinding + +class GeneralSettingsFragment : Fragment(R.layout.fragment_settings_general) { + private var _binding: FragmentSettingsGeneralBinding? = null + + val binding get() = _binding!! + + private val viewModel: SettingsViewModel by activityViewModels() + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentSettingsGeneralBinding.bind(view) + + setupViews() + setupActionObservers() + } + + private fun setupViews() { + binding.apply { + etAccountSlugInput.apply { + imeOptions = EditorInfo.IME_ACTION_DONE + setOnClickListener { isCursorVisible = true } + doOnTextChanged { text, _, _, _ -> + viewModel.onValidateAccountSlug(text.toString()) + } + } + switchStartOnLogin.apply { + setOnCheckedChangeListener { _, isChecked -> + viewModel.onStartOnLoginChanged(isChecked) + } + } + + switchConnectOnStart.apply { + setOnCheckedChangeListener { _, isChecked -> + viewModel.onConnectOnStartChanged(isChecked) + } + } + } + } + + private fun setupActionObservers() { + viewModel.actionLiveData.observe(viewLifecycleOwner) { action -> + when (action) { + is SettingsViewModel.ViewAction.NavigateBack -> + requireActivity().finish() + + is SettingsViewModel.ViewAction.FillSettings -> { + binding.etAccountSlugInput.apply { + setText(action.config.accountSlug) + } + + binding.switchStartOnLogin.apply { + isChecked = action.config.startOnLogin + } + + binding.switchConnectOnStart.apply { + isChecked = action.config.connectOnStart + } + + applyManagedStatus(action.managedStatus) + } + } + } + } + + private fun applyManagedStatus(status: ManagedConfigStatus) { + binding.apply { + val tooltipMessage = getString(R.string.managed_setting_info_description) + + etAccountSlugInput.isEnabled = !status.isAccountSlugManaged + etAccountSlugInput.isFocusable = !status.isAccountSlugManaged + etAccountSlugInput.isClickable = !status.isAccountSlugManaged + ilAccountSlugInput.isEnabled = !status.isAccountSlugManaged + setupInfoIcon(ivAccountSlugInfo, status.isAccountSlugManaged, tooltipMessage) + + switchStartOnLogin.isEnabled = !status.isStartOnLoginManaged + switchStartOnLogin.isFocusable = !status.isStartOnLoginManaged + switchStartOnLogin.isClickable = !status.isStartOnLoginManaged + setupTooltipForWrapper(flStartOnLoginWrapper, status.isStartOnLoginManaged, tooltipMessage) + + switchConnectOnStart.isEnabled = !status.isConnectOnStartManaged + switchConnectOnStart.isFocusable = !status.isConnectOnStartManaged + switchConnectOnStart.isClickable = !status.isConnectOnStartManaged + setupTooltipForWrapper(flConnectOnStartWrapper, status.isConnectOnStartManaged, tooltipMessage) + } + } + + private fun setupTooltipForWrapper( + view: View, + isManaged: Boolean, + tooltipMessage: String, + ) { + if (isManaged) { + view.isClickable = true + view.isFocusable = true + TooltipCompat.setTooltipText(view, tooltipMessage) + + view.setOnClickListener { v -> + Toast.makeText(v.context, tooltipMessage, Toast.LENGTH_SHORT).show() + } + } else { + view.isClickable = false + view.isFocusable = false + TooltipCompat.setTooltipText(view, null) + view.setOnLongClickListener(null) + view.setOnClickListener(null) + } + } + + private fun setupInfoIcon( + infoIconView: View, + isManaged: Boolean, + tooltipMessage: String, + ) { + if (isManaged) { + infoIconView.visibility = View.VISIBLE + TooltipCompat.setTooltipText(infoIconView, tooltipMessage) + + infoIconView.setOnClickListener { v -> + Toast.makeText(v.context, tooltipMessage, Toast.LENGTH_SHORT).show() + } + } else { + infoIconView.visibility = View.GONE + TooltipCompat.setTooltipText(infoIconView, null) + infoIconView.setOnClickListener(null) + infoIconView.setOnLongClickListener(null) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsActivity.kt index dc502f1e1..70fad35f9 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/settings/ui/SettingsActivity.kt @@ -44,10 +44,15 @@ internal class SettingsActivity : AppCompatActivity() { when (position) { 0 -> { tab.setIcon(R.drawable.rounded_discover_tune_black_24dp) - tab.setText("Advanced") + tab.setText("General") } 1 -> { + tab.setIcon(R.drawable.rounded_settings_black_24dp) + tab.setText("Advanced") + } + + 2 -> { tab.setIcon(R.drawable.rounded_description_black_24dp) tab.setText("Logs") } @@ -93,7 +98,7 @@ internal class SettingsActivity : AppCompatActivity() { private fun showSaveWarningDialog() { AlertDialog.Builder(this).apply { setTitle("Warning") - setMessage("Changed settings will not be applied until you sign out and sign back in.") + setMessage("Some changed settings will not be applied until you sign out and sign back in.") setPositiveButton("Okay") { _, _ -> viewModel.onSaveSettingsCompleted() } @@ -111,12 +116,13 @@ internal class SettingsActivity : AppCompatActivity() { private inner class SettingsPagerAdapter( activity: FragmentActivity, ) : FragmentStateAdapter(activity) { - override fun getItemCount(): Int = 2 // Two tabs + override fun getItemCount(): Int = 3 // Three tabs override fun createFragment(position: Int): Fragment = when (position) { - 0 -> AdvancedSettingsFragment() - 1 -> LogSettingsFragment() + 0 -> GeneralSettingsFragment() + 1 -> AdvancedSettingsFragment() + 2 -> LogSettingsFragment() else -> throw IllegalArgumentException("Invalid tab position: $position") } } 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 a02e598e6..6f59bc51a 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 @@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.firezone.android.core.data.Repository import dev.firezone.android.core.data.model.Config +import dev.firezone.android.core.data.model.ManagedConfigStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -40,14 +41,25 @@ internal class SettingsViewModel private val actionMutableLiveData = MutableLiveData() val actionLiveData: LiveData = actionMutableLiveData - private var config = Config(authBaseUrl = "", apiUrl = "", logFilter = "") + private var config = + Config( + authUrl = "", + apiUrl = "", + logFilter = "", + accountSlug = "", + startOnLogin = false, + connectOnStart = false, + ) fun populateFieldsFromConfig() { viewModelScope.launch { repo.getConfig().collect { + config = it + onFieldUpdated() actionMutableLiveData.postValue( ViewAction.FillSettings( it, + managedStatus = repo.getManagedStatus(), ), ) } @@ -81,8 +93,8 @@ internal class SettingsViewModel actionMutableLiveData.postValue(ViewAction.NavigateBack) } - fun onValidateAuthBaseUrl(authBaseUrl: String) { - this.config.authBaseUrl = authBaseUrl + fun onValidateAuthUrl(authUrl: String) { + this.config.authUrl = authUrl onFieldUpdated() } @@ -96,6 +108,21 @@ internal class SettingsViewModel onFieldUpdated() } + fun onValidateAccountSlug(accountSlug: String) { + this.config.accountSlug = accountSlug + onFieldUpdated() + } + + fun onStartOnLoginChanged(isChecked: Boolean) { + this.config.startOnLogin = isChecked + onFieldUpdated() + } + + fun onConnectOnStartChanged(isChecked: Boolean) { + this.config.connectOnStart = isChecked + onFieldUpdated() + } + fun deleteLogDirectory(context: Context) { viewModelScope.launch { val logDir = context.cacheDir.absolutePath + "/logs" @@ -152,7 +179,10 @@ internal class SettingsViewModel repo.resetFavorites() onFieldUpdated() actionMutableLiveData.postValue( - ViewAction.FillSettings(config), + ViewAction.FillSettings( + config = config, + managedStatus = repo.getManagedStatus(), + ), ) } @@ -196,12 +226,10 @@ internal class SettingsViewModel ) } - private fun areFieldsValid(): Boolean { - // This comes from the backend account slug validator at elixir/apps/domain/lib/domain/accounts/account/changeset.ex - return URLUtil.isValidUrl(config.authBaseUrl) && + private fun areFieldsValid(): Boolean = + URLUtil.isValidUrl(config.authUrl) && isUriValid(config.apiUrl) && config.logFilter.isNotBlank() - } private fun isUriValid(uri: String): Boolean = try { @@ -221,6 +249,7 @@ internal class SettingsViewModel data class FillSettings( val config: Config, + val managedStatus: ManagedConfigStatus, ) : ViewAction() } } 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 8b797cf79..2ce9a255c 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 @@ -36,13 +36,15 @@ internal class SplashViewModel actionMutableLiveData.postValue(ViewAction.NavigateToVpnPermission) } else { val token = applicationRestrictions.getString("token") ?: repo.getTokenSync() - if (token.isNullOrBlank()) { - actionMutableLiveData.postValue(ViewAction.NavigateToSignIn) - } else { + val connectOnStart = repo.getConfigSync().connectOnStart + + if (!token.isNullOrBlank() && connectOnStart) { // token will be re-read by the TunnelService if (!TunnelService.isRunning(context)) TunnelService.start(context) actionMutableLiveData.postValue(ViewAction.NavigateToSession) + } else { + actionMutableLiveData.postValue(ViewAction.NavigateToSignIn) } } } 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 62b61293c..b4a67bf83 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 @@ -33,6 +33,8 @@ import dev.firezone.android.tunnel.callback.ConnlibCallback import dev.firezone.android.tunnel.model.Cidr import dev.firezone.android.tunnel.model.Resource import dev.firezone.android.tunnel.model.isInternetResource +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import java.nio.file.Files import java.nio.file.Paths import java.util.concurrent.Executors @@ -236,6 +238,7 @@ class TunnelService : VpnService() { // Only change VPN if appRestrictions have changed val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager val newAppRestrictions = restrictionsManager.applicationRestrictions + GlobalScope.launch { repo.saveManagedConfiguration(newAppRestrictions).collect {} } val changed = MANAGED_CONFIGURATIONS.any { newAppRestrictions.getString(it) != appRestrictions.getString(it) } if (!changed) { return diff --git a/kotlin/android/app/src/main/res/drawable/info_24px.xml b/kotlin/android/app/src/main/res/drawable/info_24px.xml new file mode 100644 index 000000000..3186ebff4 --- /dev/null +++ b/kotlin/android/app/src/main/res/drawable/info_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/kotlin/android/app/src/main/res/drawable/rounded_settings_black_24dp.xml b/kotlin/android/app/src/main/res/drawable/rounded_settings_black_24dp.xml new file mode 100644 index 000000000..ba676c817 --- /dev/null +++ b/kotlin/android/app/src/main/res/drawable/rounded_settings_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/kotlin/android/app/src/main/res/layout/activity_settings.xml b/kotlin/android/app/src/main/res/layout/activity_settings.xml index 4ec49a71f..1c0f16954 100644 --- a/kotlin/android/app/src/main/res/layout/activity_settings.xml +++ b/kotlin/android/app/src/main/res/layout/activity_settings.xml @@ -66,6 +66,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:icon="@drawable/rounded_discover_tune_black_24dp" + android:text="@string/general_settings_title" /> + + - - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toStartOf="@id/ivAuthUrlInfo"> + + + + app:layout_constraintTop_toBottomOf="@id/ilAuthUrlInput" + app:layout_constraintEnd_toStartOf="@id/ivApiUrlInfo"> + + + + app:layout_constraintTop_toBottomOf="@id/ilApiUrlInput" + app:layout_constraintEnd_toStartOf="@id/ivLogFilterInfo"> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kotlin/android/app/src/main/res/values/dimens.xml b/kotlin/android/app/src/main/res/values/dimens.xml index fb74f22af..bd95477ac 100644 --- a/kotlin/android/app/src/main/res/values/dimens.xml +++ b/kotlin/android/app/src/main/res/values/dimens.xml @@ -1,4 +1,5 @@ + 4dp 8dp 16dp diff --git a/kotlin/android/app/src/main/res/values/strings.xml b/kotlin/android/app/src/main/res/values/strings.xml index 95cd77227..253788090 100644 --- a/kotlin/android/app/src/main/res/values/strings.xml +++ b/kotlin/android/app/src/main/res/values/strings.xml @@ -8,14 +8,19 @@ Settings Save + + General + Account Slug + Start on Boot + Connect on Start Advanced - Auth Base URL + Auth URL API URL Log Filter Reset to Defaults - Diagnostic Logs + Logs Export Logs Log directory size: %1$s Clear Log Directory @@ -71,4 +76,37 @@ The name of the device. This is used to identify the device in the admin portal. If unset, device\'s model name will be used. + Auth URL + + The base URL used for authentication. If unset, the default of https://app.firezone.dev will + be used. The account slug will be appended to this URL. + + API URL + + The WebSocket URL used to connect to the Firezone control plane. If unset, the default of + wss://api.firezone.dev will be used. + + Log Filter + + A RUST_LOG-formatted filter string used to control the verbosity of the connectivity library + logging. If unset, the default of "info" will be used. + + Account Slug + + The account ID or slug of the Firezone account to authenticate to. If unset, no account slug + will be used, and the user will need to enter it manually on the first sign in attempt. Upon + subsequent sign ins, the account slug will be automatically populated. + + Start on Boot + + Whether Firezone should automatically try to connect when the device boots up. + + Connect on Start + + Whether Firezone should automatically try to connect when the app is launched. + + + + This setting is being managed by your organization. + diff --git a/kotlin/android/app/src/main/res/xml/app_restrictions.xml b/kotlin/android/app/src/main/res/xml/app_restrictions.xml index 90296d198..82d36dc83 100644 --- a/kotlin/android/app/src/main/res/xml/app_restrictions.xml +++ b/kotlin/android/app/src/main/res/xml/app_restrictions.xml @@ -24,4 +24,40 @@ android:key="deviceName" android:restrictionType="string" android:title="@string/config_device_name_title" /> + + + + + + + + + + + +