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" />
+
+
+
+
+
+
+
+
+
+
+
+