mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(android): add UI for enable and disable (#6168)
The UI-side of #6166 This is how it looks if we enable disalable for CIDR resources It still needs some UI tuning probably but we could merge this as is since no client will see it 
This commit is contained in:
@@ -3,6 +3,8 @@ package dev.firezone.android.core.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import dev.firezone.android.BuildConfig
|
||||
import dev.firezone.android.core.data.model.Config
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -92,6 +94,16 @@ internal class Repository
|
||||
.putString(DEVICE_ID_KEY, value)
|
||||
.apply()
|
||||
|
||||
fun getDisabledResourcesSync(): Set<String> {
|
||||
val jsonString = sharedPreferences.getString(DISABLED_RESOURCES_KEY, null) ?: return hashSetOf()
|
||||
val type = object : TypeToken<HashSet<String>>() {}.type
|
||||
return Gson().fromJson(jsonString, type)
|
||||
}
|
||||
|
||||
fun saveDisabledResourcesSync(value: Set<String>): Unit =
|
||||
sharedPreferences.edit().putString(DISABLED_RESOURCES_KEY, Gson().toJson(value))
|
||||
.apply()
|
||||
|
||||
fun saveNonce(value: String): Flow<Unit> =
|
||||
flow {
|
||||
emit(saveNonceSync(value))
|
||||
@@ -171,5 +183,6 @@ internal class Repository
|
||||
private const val NONCE_KEY = "nonce"
|
||||
private const val STATE_KEY = "state"
|
||||
private const val DEVICE_ID_KEY = "deviceId"
|
||||
private const val DISABLED_RESOURCES_KEY = "disabledResources"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,9 @@ import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import dev.firezone.android.R
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.StatusEnum
|
||||
|
||||
class ResourceDetailsBottomSheet(private val resource: Resource) : BottomSheetDialogFragment() {
|
||||
class ResourceDetailsBottomSheet(private val resource: ViewResource) : BottomSheetDialogFragment() {
|
||||
private lateinit var view: View
|
||||
private val viewModel: SessionViewModel by activityViewModels()
|
||||
private var isFavorite: Boolean = false
|
||||
|
||||
@@ -4,13 +4,15 @@ package dev.firezone.android.features.session.ui
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dev.firezone.android.databinding.ListItemResourceBinding
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
|
||||
internal class ResourcesAdapter() : ListAdapter<Resource, ResourcesAdapter.ViewHolder>(ResourceDiffCallback()) {
|
||||
internal class ResourcesAdapter(private val activity: SessionActivity) : ListAdapter<ViewResource, ResourcesAdapter.ViewHolder>(
|
||||
ResourceDiffCallback(),
|
||||
) {
|
||||
private var favoriteResources: HashSet<String> = HashSet()
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
@@ -26,7 +28,7 @@ internal class ResourcesAdapter() : ListAdapter<Resource, ResourcesAdapter.ViewH
|
||||
position: Int,
|
||||
) {
|
||||
val resource = getItem(position)
|
||||
holder.bind(resource)
|
||||
holder.bind(resource) { newResource -> onSwitchToggled(newResource) }
|
||||
holder.itemView.setOnClickListener {
|
||||
// Show bottom sheet
|
||||
val isFavorite = favoriteResources.contains(resource.id)
|
||||
@@ -36,24 +38,42 @@ internal class ResourcesAdapter() : ListAdapter<Resource, ResourcesAdapter.ViewH
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSwitchToggled(resource: ViewResource) {
|
||||
activity.onViewResourceToggled(resource)
|
||||
}
|
||||
|
||||
class ViewHolder(private val binding: ListItemResourceBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(resource: Resource) {
|
||||
fun bind(
|
||||
resource: ViewResource,
|
||||
onSwitchToggled: (ViewResource) -> Unit,
|
||||
) {
|
||||
binding.resourceNameText.text = resource.name
|
||||
binding.addressText.text = resource.address
|
||||
// Without this the item gets reset when out of view, isn't android wonderful?
|
||||
binding.enableSwitch.setOnCheckedChangeListener(null)
|
||||
binding.enableSwitch.isChecked = resource.enabled
|
||||
binding.enableSwitch.isVisible = resource.canToggle
|
||||
|
||||
binding.enableSwitch.setOnCheckedChangeListener {
|
||||
_, isChecked ->
|
||||
resource.enabled = isChecked
|
||||
|
||||
onSwitchToggled(resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceDiffCallback : DiffUtil.ItemCallback<Resource>() {
|
||||
class ResourceDiffCallback : DiffUtil.ItemCallback<ViewResource>() {
|
||||
override fun areItemsTheSame(
|
||||
oldItem: Resource,
|
||||
newItem: Resource,
|
||||
oldItem: ViewResource,
|
||||
newItem: ViewResource,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: Resource,
|
||||
newItem: Resource,
|
||||
oldItem: ViewResource,
|
||||
newItem: ViewResource,
|
||||
): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -43,7 +44,7 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private val resourcesAdapter = ResourcesAdapter()
|
||||
private val resourcesAdapter = ResourcesAdapter(this)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -66,6 +67,11 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onViewResourceToggled(resourceToggled: ViewResource) {
|
||||
Log.d(TAG, "Resource toggled $resourceToggled")
|
||||
tunnelService?.resourceToggled(resourceToggled)
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
binding.btSignOut.setOnClickListener {
|
||||
viewModel.clearToken()
|
||||
@@ -113,7 +119,7 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.resourcesLiveData.observe(this) { value ->
|
||||
viewModel.resourcesLiveData.observe(this) {
|
||||
refreshList()
|
||||
}
|
||||
|
||||
@@ -134,6 +140,7 @@ internal class SessionActivity : AppCompatActivity() {
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
|
||||
resourcesAdapter.submitList(viewModel.resourcesList())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.firezone.android.core.data.Repository
|
||||
import dev.firezone.android.tunnel.TunnelService.Companion.State
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -17,14 +16,14 @@ internal class SessionViewModel
|
||||
internal lateinit var repo: Repository
|
||||
private val _favoriteResourcesLiveData = MutableLiveData<HashSet<String>>(HashSet())
|
||||
private val _serviceStatusLiveData = MutableLiveData<State>()
|
||||
private val _resourcesLiveData = MutableLiveData<List<Resource>>(emptyList())
|
||||
private val _resourcesLiveData = MutableLiveData<List<ViewResource>>(emptyList())
|
||||
private var showOnlyFavorites: Boolean = false
|
||||
|
||||
val favoriteResourcesLiveData: MutableLiveData<HashSet<String>>
|
||||
get() = _favoriteResourcesLiveData
|
||||
val serviceStatusLiveData: MutableLiveData<State>
|
||||
get() = _serviceStatusLiveData
|
||||
val resourcesLiveData: MutableLiveData<List<Resource>>
|
||||
val resourcesLiveData: MutableLiveData<List<ViewResource>>
|
||||
get() = _resourcesLiveData
|
||||
|
||||
private val favoriteResources: HashSet<String>
|
||||
@@ -57,7 +56,7 @@ internal class SessionViewModel
|
||||
fun clearToken() = repo.clearToken()
|
||||
|
||||
// The subset of Resources to actually render
|
||||
fun resourcesList(): List<Resource> {
|
||||
fun resourcesList(): List<ViewResource> {
|
||||
val resources = resourcesLiveData.value!!
|
||||
return if (favoriteResources.isEmpty()) {
|
||||
resources
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
|
||||
package dev.firezone.android.features.session.ui
|
||||
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.Site
|
||||
import dev.firezone.android.tunnel.model.StatusEnum
|
||||
|
||||
data class ViewResource(
|
||||
val id: String,
|
||||
val address: String,
|
||||
val addressDescription: String?,
|
||||
val sites: List<Site>?,
|
||||
val name: String,
|
||||
val status: StatusEnum,
|
||||
var enabled: Boolean = true,
|
||||
var canToggle: Boolean = true,
|
||||
)
|
||||
|
||||
fun Resource.toViewResource(enabled: Boolean): ViewResource {
|
||||
return ViewResource(
|
||||
id = this.id,
|
||||
address = this.address,
|
||||
addressDescription = this.addressDescription,
|
||||
sites = this.sites,
|
||||
name = this.name,
|
||||
status = this.status,
|
||||
enabled = enabled,
|
||||
canToggle = this.canToggle,
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,12 @@ object ConnlibSession {
|
||||
|
||||
external fun disconnect(connlibSession: Long): Boolean
|
||||
|
||||
// `disabledResourceList` is a JSON array of Resource ID strings.
|
||||
external fun setDisabledResources(
|
||||
connlibSession: Long,
|
||||
disabledResourceList: String,
|
||||
): Boolean
|
||||
|
||||
external fun setDns(
|
||||
connlibSession: Long,
|
||||
dnsList: String,
|
||||
|
||||
@@ -18,10 +18,13 @@ import android.os.IBinder
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import com.google.gson.Gson
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.firezone.android.core.data.Repository
|
||||
import dev.firezone.android.features.session.ui.ViewResource
|
||||
import dev.firezone.android.features.session.ui.toViewResource
|
||||
import dev.firezone.android.tunnel.callback.ConnlibCallback
|
||||
import dev.firezone.android.tunnel.model.Cidr
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
@@ -47,9 +50,10 @@ class TunnelService : VpnService() {
|
||||
private var tunnelIpv6Address: String? = null
|
||||
private var tunnelDnsAddresses: MutableList<String> = mutableListOf()
|
||||
private var tunnelRoutes: MutableList<Cidr> = mutableListOf()
|
||||
private var _tunnelResources: List<Resource> = emptyList()
|
||||
private var _tunnelResources: List<ViewResource> = emptyList()
|
||||
private var _tunnelState: State = State.DOWN
|
||||
private var networkCallback: NetworkMonitor? = null
|
||||
private var disabledResources: MutableSet<String> = mutableSetOf()
|
||||
|
||||
// General purpose mutex lock for preventing network monitoring from calling connlib
|
||||
// during shutdown.
|
||||
@@ -58,7 +62,7 @@ class TunnelService : VpnService() {
|
||||
var startedByUser: Boolean = false
|
||||
var connlibSessionPtr: Long? = null
|
||||
|
||||
var tunnelResources: List<Resource>
|
||||
var tunnelResources: List<ViewResource>
|
||||
get() = _tunnelResources
|
||||
set(value) {
|
||||
_tunnelResources = value
|
||||
@@ -73,7 +77,7 @@ class TunnelService : VpnService() {
|
||||
|
||||
// Used to update the UI when the SessionActivity is bound to this service
|
||||
private var serviceStateLiveData: MutableLiveData<State>? = null
|
||||
private var resourcesLiveData: MutableLiveData<List<Resource>>? = null
|
||||
private var resourcesLiveData: MutableLiveData<List<ViewResource>>? = null
|
||||
|
||||
// For binding the SessionActivity view to this service
|
||||
private val binder = LocalBinder()
|
||||
@@ -90,7 +94,8 @@ class TunnelService : VpnService() {
|
||||
object : ConnlibCallback {
|
||||
override fun onUpdateResources(resourceListJSON: String) {
|
||||
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let {
|
||||
tunnelResources = it
|
||||
tunnelResources = it.map { resource -> resource.toViewResource(!disabledResources.contains(resource.id)) }
|
||||
resourcesUpdated()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +209,27 @@ class TunnelService : VpnService() {
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
// UI updates for resources
|
||||
fun resourcesUpdated() {
|
||||
val newResources = tunnelResources.associateBy { it.id }
|
||||
val currentlyDisabled = disabledResources.filter { newResources[it]?.canToggle ?: false }
|
||||
|
||||
connlibSessionPtr?.let {
|
||||
ConnlibSession.setDisabledResources(it, Gson().toJson(currentlyDisabled))
|
||||
}
|
||||
}
|
||||
|
||||
fun resourceToggled(resource: ViewResource) {
|
||||
if (!resource.enabled) {
|
||||
disabledResources.add(resource.id)
|
||||
} else {
|
||||
disabledResources.remove(resource.id)
|
||||
}
|
||||
|
||||
repo.saveDisabledResourcesSync(disabledResources)
|
||||
resourcesUpdated()
|
||||
}
|
||||
|
||||
// Call this to stop the tunnel and shutdown the service, leaving the token intact.
|
||||
fun disconnect() {
|
||||
// Acquire mutex lock
|
||||
@@ -230,6 +256,7 @@ class TunnelService : VpnService() {
|
||||
private fun connect() {
|
||||
val token = appRestrictions.getString("token") ?: repo.getTokenSync()
|
||||
val config = repo.getConfigSync()
|
||||
disabledResources = repo.getDisabledResourcesSync().toMutableSet()
|
||||
|
||||
if (!token.isNullOrBlank()) {
|
||||
tunnelState = State.CONNECTING
|
||||
@@ -279,7 +306,7 @@ class TunnelService : VpnService() {
|
||||
serviceStateLiveData?.postValue(tunnelState)
|
||||
}
|
||||
|
||||
fun setResourcesLiveData(liveData: MutableLiveData<List<Resource>>) {
|
||||
fun setResourcesLiveData(liveData: MutableLiveData<List<ViewResource>>) {
|
||||
resourcesLiveData = liveData
|
||||
|
||||
// Update the newly bound SessionActivity with our current resources
|
||||
@@ -290,7 +317,7 @@ class TunnelService : VpnService() {
|
||||
serviceStateLiveData?.postValue(state)
|
||||
}
|
||||
|
||||
private fun updateResourcesLiveData(resources: List<Resource>) {
|
||||
private fun updateResourcesLiveData(resources: List<ViewResource>) {
|
||||
resourcesLiveData?.postValue(resources)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ data class Resource(
|
||||
val sites: List<Site>?,
|
||||
val name: String,
|
||||
val status: StatusEnum,
|
||||
var enabled: Boolean = true,
|
||||
@Json(name = "can_toggle") val canToggle: Boolean,
|
||||
) : Parcelable
|
||||
|
||||
enum class TypeEnum {
|
||||
|
||||
@@ -136,4 +136,5 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="@dimen/spacing_small">
|
||||
@@ -11,21 +12,37 @@
|
||||
style="@style/AppTheme.Base.Body1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Resource Name"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/enable_switch"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Resource Name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/addressText"
|
||||
style="@style/AppTheme.Base.Caption"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Resource Address"
|
||||
android:textColor="@color/neutral_600"
|
||||
app:layout_constraintTop_toBottomOf="@id/resourceNameText"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/enable_switch"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
app:layout_constraintTop_toBottomOf="@id/resourceNameText"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:text="Resource Address" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/enable_switch"
|
||||
android:layout_width="101dp"
|
||||
android:layout_height="39dp"
|
||||
android:checked="true"
|
||||
android:minHeight="48dp"
|
||||
android:scaleX="1"
|
||||
android:scaleY="1"
|
||||
android:text="Enabled"
|
||||
android:textAlignment="viewStart"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
Reference in New Issue
Block a user