From cf874740989c9ab816ba0d714395fe477d4ab1f6 Mon Sep 17 00:00:00 2001 From: Gabi Date: Wed, 7 Aug 2024 19:47:49 -0300 Subject: [PATCH] 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) --- .../firezone/android/core/data/Repository.kt | 13 +++++++ .../session/ui/ResourceDetailsBottomSheet.kt | 3 +- .../features/session/ui/ResourcesAdapter.kt | 38 +++++++++++++----- .../features/session/ui/SessionActivity.kt | 11 +++++- .../features/session/ui/SessionViewModel.kt | 7 ++-- .../features/session/ui/ViewResource.kt | 30 ++++++++++++++ .../firezone/android/tunnel/ConnlibSession.kt | 6 +++ .../firezone/android/tunnel/TunnelService.kt | 39 ++++++++++++++++--- .../firezone/android/tunnel/model/Resource.kt | 2 + .../res/layout/fragment_resource_details.xml | 1 + .../main/res/layout/list_item_resource.xml | 31 +++++++++++---- 11 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt 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 07e8cf02d..fb8aca0b3 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,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 { + val jsonString = sharedPreferences.getString(DISABLED_RESOURCES_KEY, null) ?: return hashSetOf() + val type = object : TypeToken>() {}.type + return Gson().fromJson(jsonString, type) + } + + fun saveDisabledResourcesSync(value: Set): Unit = + sharedPreferences.edit().putString(DISABLED_RESOURCES_KEY, Gson().toJson(value)) + .apply() + fun saveNonce(value: String): Flow = 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" } } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt index d1620d42a..834979acf 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt @@ -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 diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt index 025b4e23e..7942aa288 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt @@ -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(ResourceDiffCallback()) { +internal class ResourcesAdapter(private val activity: SessionActivity) : ListAdapter( + ResourceDiffCallback(), +) { private var favoriteResources: HashSet = HashSet() override fun onCreateViewHolder( @@ -26,7 +28,7 @@ internal class ResourcesAdapter() : ListAdapter onSwitchToggled(newResource) } holder.itemView.setOnClickListener { // Show bottom sheet val isFavorite = favoriteResources.contains(resource.id) @@ -36,24 +38,42 @@ internal class ResourcesAdapter() : ListAdapter 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() { + class ResourceDiffCallback : DiffUtil.ItemCallback() { 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 } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt index e85df4a4a..e92010b3e 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionActivity.kt @@ -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()) } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt index 85f146f9f..344006e18 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt @@ -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()) private val _serviceStatusLiveData = MutableLiveData() - private val _resourcesLiveData = MutableLiveData>(emptyList()) + private val _resourcesLiveData = MutableLiveData>(emptyList()) private var showOnlyFavorites: Boolean = false val favoriteResourcesLiveData: MutableLiveData> get() = _favoriteResourcesLiveData val serviceStatusLiveData: MutableLiveData get() = _serviceStatusLiveData - val resourcesLiveData: MutableLiveData> + val resourcesLiveData: MutableLiveData> get() = _resourcesLiveData private val favoriteResources: HashSet @@ -57,7 +56,7 @@ internal class SessionViewModel fun clearToken() = repo.clearToken() // The subset of Resources to actually render - fun resourcesList(): List { + fun resourcesList(): List { val resources = resourcesLiveData.value!! return if (favoriteResources.isEmpty()) { resources diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt new file mode 100644 index 000000000..6e0e91534 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ViewResource.kt @@ -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?, + 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, + ) +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt index 7a960f7fc..f0c5fb752 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt @@ -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, 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 8bd55a8ed..5e17f95ab 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 @@ -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 = mutableListOf() private var tunnelRoutes: MutableList = mutableListOf() - private var _tunnelResources: List = emptyList() + private var _tunnelResources: List = emptyList() private var _tunnelState: State = State.DOWN private var networkCallback: NetworkMonitor? = null + private var disabledResources: MutableSet = 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 + var tunnelResources: List 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? = null - private var resourcesLiveData: MutableLiveData>? = null + private var resourcesLiveData: MutableLiveData>? = 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>().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>) { + fun setResourcesLiveData(liveData: MutableLiveData>) { 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) { + private fun updateResourcesLiveData(resources: List) { resourcesLiveData?.postValue(resources) } diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt index 7139c1a0d..67938ae42 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Resource.kt @@ -16,6 +16,8 @@ data class Resource( val sites: List?, val name: String, val status: StatusEnum, + var enabled: Boolean = true, + @Json(name = "can_toggle") val canToggle: Boolean, ) : Parcelable enum class TypeEnum { diff --git a/kotlin/android/app/src/main/res/layout/fragment_resource_details.xml b/kotlin/android/app/src/main/res/layout/fragment_resource_details.xml index 6660b9bb5..23366dd62 100644 --- a/kotlin/android/app/src/main/res/layout/fragment_resource_details.xml +++ b/kotlin/android/app/src/main/res/layout/fragment_resource_details.xml @@ -136,4 +136,5 @@ android:layout_height="wrap_content" android:layout_weight="1" /> + diff --git a/kotlin/android/app/src/main/res/layout/list_item_resource.xml b/kotlin/android/app/src/main/res/layout/list_item_resource.xml index 5bb4630f9..bf7905436 100644 --- a/kotlin/android/app/src/main/res/layout/list_item_resource.xml +++ b/kotlin/android/app/src/main/res/layout/list_item_resource.xml @@ -1,7 +1,8 @@ @@ -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" /> + app:layout_constraintTop_toBottomOf="@id/resourceNameText" + app:layout_constraintVertical_bias="0.0" + tools:text="Resource Address" /> + +