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