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


![image](https://github.com/user-attachments/assets/71354b02-1280-4703-bd54-e1d6d1f3e2e5)
This commit is contained in:
Gabi
2024-08-07 19:47:49 -03:00
committed by GitHub
parent bf7e41d6c9
commit cf87474098
11 changed files with 151 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -136,4 +136,5 @@
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>

View File

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