feat(android): Managed configuration (#9227)

Adds managed configuration support for Android in line with other
platforms.

Related: #4505 
Related: #9203 
Related: #9196
This commit is contained in:
Jamil
2025-05-26 16:37:02 -07:00
committed by GitHub
parent d25e378b5e
commit f2d88f49a0
26 changed files with 732 additions and 60 deletions

View File

@@ -106,6 +106,9 @@ proguard/
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# Build files
.kotlin/
# NDK
obj/

View File

@@ -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 {

View File

@@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<queries>
<intent>
@@ -31,6 +32,15 @@
android:theme="@style/AppTheme.Base"
android:usesCleartextTraffic="true">
<receiver
android:name=".core.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity
android:name=".core.presentation.MainActivity"
android:exported="true">

View File

@@ -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)
}
}
}
}
}

View File

@@ -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

View File

@@ -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<String>,
)
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<Config> =
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<Config> =
@@ -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<Unit> =
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<String?> =
flow {
emit(sharedPreferences.getString(ACCOUNT_SLUG_KEY, null))
}.flowOn(coroutineDispatcher)
fun getActorName(): Flow<String?> =
flow {
emit(getActorNameSync())
@@ -149,6 +226,16 @@ internal class Repository
fun getNonceSync(): String? = sharedPreferences.getString(NONCE_KEY, null)
fun saveAccountSlug(value: String): Flow<Unit> =
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"

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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 {}
}
}
}

View File

@@ -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")
},
)
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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<ViewAction>()
val actionLiveData: LiveData<ViewAction> = 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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19.5,12c0,-0.23 -0.01,-0.45 -0.03,-0.68l1.86,-1.41c0.4,-0.3 0.51,-0.86 0.26,-1.3l-1.87,-3.23c-0.25,-0.44 -0.79,-0.62 -1.25,-0.42l-2.15,0.91c-0.37,-0.26 -0.76,-0.49 -1.17,-0.68l-0.29,-2.31C14.8,2.38 14.37,2 13.87,2h-3.73C9.63,2 9.2,2.38 9.14,2.88L8.85,5.19c-0.41,0.19 -0.8,0.42 -1.17,0.68L5.53,4.96c-0.46,-0.2 -1,-0.02 -1.25,0.42L2.41,8.62c-0.25,0.44 -0.14,0.99 0.26,1.3l1.86,1.41C4.51,11.55 4.5,11.77 4.5,12s0.01,0.45 0.03,0.68l-1.86,1.41c-0.4,0.3 -0.51,0.86 -0.26,1.3l1.87,3.23c0.25,0.44 0.79,0.62 1.25,0.42l2.15,-0.91c0.37,0.26 0.76,0.49 1.17,0.68l0.29,2.31C9.2,21.62 9.63,22 10.13,22h3.73c0.5,0 0.93,-0.38 0.99,-0.88l0.29,-2.31c0.41,-0.19 0.8,-0.42 1.17,-0.68l2.15,0.91c0.46,0.2 1,0.02 1.25,-0.42l1.87,-3.23c0.25,-0.44 0.14,-0.99 -0.26,-1.3l-1.86,-1.41C19.49,12.45 19.5,12.23 19.5,12zM12.04,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5s3.5,1.57 3.5,3.5S13.97,15.5 12.04,15.5z"/>
</vector>

View File

@@ -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" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:icon="@drawable/rounded_settings_black_24dp"
android:text="@string/advanced_settings_title" />
<com.google.android.material.tabs.TabItem

View File

@@ -13,31 +13,45 @@
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/ilAuthBaseUrlInput"
android:layout_width="match_parent"
android:id="@+id/ilAuthUrlInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:errorEnabled="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAuthBaseUrlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/auth_base_url"
android:importantForAutofill="no"
android:inputType="text" />
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/ivAuthUrlInfo"> <com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAuthUrlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/auth_url"
android:importantForAutofill="no"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/ivAuthUrlInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:padding="@dimen/spacing_extra_small"
android:src="@drawable/info_24px"
android:contentDescription="@string/managed_setting_info_description"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/ilAuthUrlInput"
app:layout_constraintBottom_toBottomOf="@+id/ilAuthUrlInput" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/ilApiUrlInput"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_1x"
app:errorEnabled="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ilAuthBaseUrlInput">
app:layout_constraintTop_toBottomOf="@id/ilAuthUrlInput"
app:layout_constraintEnd_toStartOf="@id/ivApiUrlInfo">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etApiUrlInput"
@@ -49,14 +63,29 @@
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/ivApiUrlInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:padding="@dimen/spacing_extra_small"
android:src="@drawable/info_24px"
android:contentDescription="@string/managed_setting_info_description"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/ilApiUrlInput"
app:layout_constraintBottom_toBottomOf="@+id/ilApiUrlInput" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/ilLogFilterInput"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_1x"
app:errorEnabled="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ilApiUrlInput">
app:layout_constraintTop_toBottomOf="@id/ilApiUrlInput"
app:layout_constraintEnd_toStartOf="@id/ivLogFilterInfo">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etLogFilterInput"
@@ -68,6 +97,19 @@
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/ivLogFilterInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:padding="@dimen/spacing_extra_small"
android:src="@drawable/info_24px"
android:contentDescription="@string/managed_setting_info_description"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/ilLogFilterInput"
app:layout_constraintBottom_toBottomOf="@+id/ilLogFilterInput" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btResetDefaults"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:padding="@dimen/spacing_medium"
android:fitsSystemWindows="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/ilAccountSlugInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:errorEnabled="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/ivAccountSlugInfo"> <com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAccountSlugInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/account_slug"
android:importantForAutofill="no"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/ivAccountSlugInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:padding="@dimen/spacing_extra_small"
android:src="@drawable/info_24px" android:contentDescription="@string/managed_setting_info_description"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/ilAccountSlugInput"
app:layout_constraintBottom_toBottomOf="@+id/ilAccountSlugInput" />
<FrameLayout
android:id="@+id/flStartOnLoginWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_1x"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ilAccountSlugInput"> <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchStartOnLogin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/start_on_login" />
</FrameLayout>
<FrameLayout
android:id="@+id/flConnectOnStartWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_1x"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/flStartOnLoginWrapper">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchConnectOnStart"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/connect_on_start" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -1,4 +1,5 @@
<resources>
<dimen name="spacing_extra_small">4dp</dimen>
<dimen name="spacing_small">8dp</dimen>
<dimen name="spacing_medium">16dp</dimen>

View File

@@ -8,14 +8,19 @@
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="save">Save</string>
<!-- General Settings -->
<string name="general_settings_title">General</string>
<string name="account_slug">Account Slug</string>
<string name="start_on_login">Start on Boot</string>
<string name="connect_on_start">Connect on Start</string>
<!-- Advanced Settings -->
<string name="advanced_settings_title">Advanced</string>
<string name="auth_base_url">Auth Base URL</string>
<string name="auth_url">Auth URL</string>
<string name="api_url">API URL</string>
<string name="log_filter">Log Filter</string>
<string name="button_reset_to_defaults">Reset to Defaults</string>
<!-- Diagnostic Logs Settings -->
<string name="log_settings_title">Diagnostic Logs</string>
<string name="log_settings_title">Logs</string>
<string name="share_diagnostic_logs">Export Logs</string>
<string name="log_directory_size">Log directory size: %1$s</string>
<string name="clear_log_directory">Clear Log Directory</string>
@@ -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.
</string>
<string name="config_auth_url_title">Auth URL</string>
<string name="config_auth_url_description">
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.
</string>
<string name="config_api_url_title">API URL</string>
<string name="config_api_url_description">
The WebSocket URL used to connect to the Firezone control plane. If unset, the default of
wss://api.firezone.dev will be used.
</string>
<string name="config_log_filter_title">Log Filter</string>
<string name="config_log_filter_description">
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.
</string>
<string name="config_account_slug_title">Account Slug</string>
<string name="config_account_slug_description">
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.
</string>
<string name="config_start_on_login_title">Start on Boot</string>
<string name="config_start_on_login_description">
Whether Firezone should automatically try to connect when the device boots up.
</string>
<string name="config_connect_on_start_title">Connect on Start</string>
<string name="config_connect_on_start_description">
Whether Firezone should automatically try to connect when the app is launched.
</string>
<string name="managed_setting_info_description">
This setting is being managed by your organization.
</string>
</resources>

View File

@@ -24,4 +24,40 @@
android:key="deviceName"
android:restrictionType="string"
android:title="@string/config_device_name_title" />
<restriction
android:description="@string/config_auth_url_description"
android:key="authUrl"
android:restrictionType="string"
android:title="@string/config_auth_url_title" />
<restriction
android:description="@string/config_api_url_description"
android:key="apiUrl"
android:restrictionType="string"
android:title="@string/config_api_url_title" />
<restriction
android:description="@string/config_log_filter_description"
android:key="logFilter"
android:restrictionType="string"
android:title="@string/config_log_filter_title" />
<restriction
android:description="@string/config_account_slug_description"
android:key="accountSlug"
android:restrictionType="string"
android:title="@string/config_account_slug_title" />
<restriction
android:description="@string/config_start_on_login_description"
android:key="startOnLogin"
android:restrictionType="bool"
android:title="@string/config_start_on_login_title" />
<restriction
android:description="@string/config_connect_on_start_description"
android:key="connectOnStart"
android:restrictionType="bool"
android:title="@string/config_connect_on_start_title" />
</restrictions>