From 6a8c34a4dd15c4567b57e0c2393a1cd23a9dd862 Mon Sep 17 00:00:00 2001 From: Jamil Date: Tue, 28 May 2024 10:27:00 -0700 Subject: [PATCH] feat(android): Show resource details when tapping a Resource (#5134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor ResourcesAdapter and SessionActivity to use ListAdapter which is faster/more efficient according to AI - Deserialize remaining properties from onUpdateResources - Show a bottom sheet fragment when Resource is tapped instead of simply copying the address Screenshot 2024-05-26 at 6 39 09 PM Fixes #3514 --- kotlin/android/app/build.gradle.kts | 1 + .../session/ui/ResourceDetailsBottomSheet.kt | 125 ++++++++++++++++++ .../features/session/ui/ResourcesAdapter.kt | 63 ++++----- .../features/session/ui/SessionActivity.kt | 13 +- .../firezone/android/tunnel/model/Resource.kt | 28 +++- .../dev/firezone/android/tunnel/model/Site.kt | 13 ++ .../res/layout/fragment_resource_details.xml | 125 ++++++++++++++++++ 7 files changed, 318 insertions(+), 50 deletions(-) create mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt create mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Site.kt create mode 100644 kotlin/android/app/src/main/res/layout/fragment_resource_details.xml diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index 6db345ff8..bf6f7406a 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -176,6 +176,7 @@ dependencies { // Hilt implementation("com.google.dagger:hilt-android:2.51.1") + implementation("androidx.browser:browser:1.8.0") kapt("androidx.hilt:hilt-compiler:1.2.0") kapt("com.google.dagger:hilt-android-compiler:2.51.1") // Instrumented Tests 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 new file mode 100644 index 000000000..bb8d43e45 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt @@ -0,0 +1,125 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.features.session.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +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() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + return inflater.inflate(R.layout.fragment_resource_details, container, false) + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + val resourceNameTextView: TextView = view.findViewById(R.id.tvResourceName) + val resourceAddressTextView: TextView = view.findViewById(R.id.tvResourceAddress) + val resourceAddressDescriptionTextView: TextView = view.findViewById(R.id.tvResourceAddressDescription) + val siteNameTextView: TextView = view.findViewById(R.id.tvSiteName) + val siteStatusTextView: TextView = view.findViewById(R.id.tvSiteStatus) + val statusIndicatorDot: ImageView = view.findViewById(R.id.statusIndicatorDot) + val labelSite: TextView = view.findViewById(R.id.labelSite) + val siteNameLayout: LinearLayout = view.findViewById(R.id.siteNameLayout) + val siteStatusLayout: LinearLayout = view.findViewById(R.id.siteStatusLayout) + + resourceNameTextView.text = resource.name + val displayAddress = resource.addressDescription ?: resource.address + resourceAddressTextView.text = displayAddress + + if (!resource.addressDescription.isNullOrEmpty()) { + resourceAddressDescriptionTextView.text = resource.addressDescription + resourceAddressDescriptionTextView.visibility = View.VISIBLE + } + + val addressUri = resource.addressDescription?.let { Uri.parse(it) } + if (addressUri != null && addressUri.scheme != null) { + resourceAddressTextView.setTextColor(Color.BLUE) + resourceAddressTextView.setTypeface(null, Typeface.ITALIC) + resourceAddressTextView.setOnClickListener { + openUrl(resource.addressDescription!!) + } + } else { + resourceAddressTextView.setOnClickListener { + copyToClipboard(displayAddress) + Toast.makeText(requireContext(), "Address copied to clipboard", Toast.LENGTH_SHORT).show() + } + } + + resourceNameTextView.setOnClickListener { + copyToClipboard(resource.name) + Toast.makeText(requireContext(), "Name copied to clipboard", Toast.LENGTH_SHORT).show() + } + + if (!resource.sites.isNullOrEmpty()) { + val site = resource.sites.first() + siteNameTextView.text = site.name + siteNameLayout.visibility = View.VISIBLE + + // Setting site status based on resource status + val statusText = + when (resource.status) { + StatusEnum.ONLINE -> "Gateway connected" + StatusEnum.OFFLINE -> "All Gateways offline" + StatusEnum.UNKNOWN -> "No activity" + } + siteStatusTextView.text = statusText + siteStatusLayout.visibility = View.VISIBLE + labelSite.visibility = View.VISIBLE + + // Set status indicator dot color + val dotColor = + when (resource.status) { + StatusEnum.ONLINE -> Color.GREEN + StatusEnum.OFFLINE -> Color.RED + StatusEnum.UNKNOWN -> Color.GRAY + } + val dotDrawable = GradientDrawable() + dotDrawable.shape = GradientDrawable.OVAL + dotDrawable.setColor(dotColor) + statusIndicatorDot.setImageDrawable(dotDrawable) + statusIndicatorDot.visibility = View.VISIBLE + + siteNameTextView.setOnClickListener { + copyToClipboard(site.name) + Toast.makeText(requireContext(), "Site name copied to clipboard", Toast.LENGTH_SHORT).show() + } + } + } + + private fun copyToClipboard(text: String) { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Copied Text", text) + clipboard.setPrimaryClip(clip) + } + + private fun openUrl(url: String) { + val builder = CustomTabsIntent.Builder() + val customTabsIntent = builder.build() + customTabsIntent.launchUrl(requireContext(), Uri.parse(url)) + } +} 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 5d1db2f11..a6906cd9f 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 @@ -3,42 +3,33 @@ package dev.firezone.android.features.session.ui import android.view.LayoutInflater import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity 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( - private var clickListener: ((Resource) -> Unit)? = null, -) : RecyclerView.Adapter() { - private val resources: MutableList = mutableListOf() - - fun updateResources(updatedResources: List) { - val diffCallback = ResourcesCallback(resources, updatedResources) - val diffCourses = DiffUtil.calculateDiff(diffCallback) - resources.clear() - resources.addAll(updatedResources) - diffCourses.dispatchUpdatesTo(this) - } - +internal class ResourcesAdapter : ListAdapter(ResourceDiffCallback()) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): ViewHolder { - return ViewHolder( - ListItemResourceBinding.inflate(LayoutInflater.from(parent.context), parent, false), - ) + val binding = ListItemResourceBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) } - override fun getItemCount(): Int = resources.size - override fun onBindViewHolder( holder: ViewHolder, position: Int, ) { - holder.bind(resources[position]) + val resource = getItem(position) + holder.bind(resource) holder.itemView.setOnClickListener { - clickListener?.invoke(resources[holder.adapterPosition]) + // Show bottom sheet + val fragmentManager = (holder.itemView.context as AppCompatActivity).supportFragmentManager + val bottomSheet = ResourceDetailsBottomSheet(resource) + bottomSheet.show(fragmentManager, "ResourceDetailsBottomSheet") } } @@ -48,26 +39,20 @@ internal class ResourcesAdapter( binding.addressText.text = resource.address } } -} -class ResourcesCallback(private val oldList: List, private val newList: List) : DiffUtil.Callback() { - override fun getOldListSize(): Int = oldList.size + class ResourceDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Resource, + newItem: Resource, + ): Boolean { + return oldItem.id == newItem.id + } - override fun getNewListSize(): Int = newList.size - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int, - ): Boolean { - return oldList[oldItemPosition] === newList[newItemPosition] - } - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int, - ): Boolean { - val (type1, id1, address1, name1) = oldList[oldItemPosition] - val (type2, id2, address2, name2) = newList[newItemPosition] - return type1 == type2 && id1 == id2 && address1 == address2 && name1 == name2 + override fun areContentsTheSame( + oldItem: Resource, + newItem: Resource, + ): 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 53fc79182..86eef3e24 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 @@ -13,11 +13,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint -import dev.firezone.android.core.utils.ClipboardUtils import dev.firezone.android.databinding.ActivitySessionBinding import dev.firezone.android.features.settings.ui.SettingsActivity import dev.firezone.android.tunnel.TunnelService -import dev.firezone.android.tunnel.model.Resource @AndroidEntryPoint internal class SessionActivity : AppCompatActivity() { @@ -45,10 +43,8 @@ internal class SessionActivity : AppCompatActivity() { serviceBound = false } } - private val resourcesAdapter: ResourcesAdapter = - ResourcesAdapter { resource -> - ClipboardUtils.copyToClipboard(this@SessionActivity, resource.name, resource.address) - } + + private val resourcesAdapter = ResourcesAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -97,9 +93,6 @@ internal class SessionActivity : AppCompatActivity() { binding.rvResourcesList.addItemDecoration(dividerItemDecoration) binding.rvResourcesList.adapter = resourcesAdapter binding.rvResourcesList.layoutManager = layoutManager - - // Hack to show a connecting message until the service is bound - resourcesAdapter.updateResources(listOf(Resource("", "", "", "Connecting..."))) } private fun setupObservers() { @@ -111,7 +104,7 @@ internal class SessionActivity : AppCompatActivity() { } viewModel.resourcesLiveData.observe(this) { resources -> - resourcesAdapter.updateResources(resources) + resourcesAdapter.submitList(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 b88105d5c..7139c1a0d 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 @@ -2,14 +2,40 @@ package dev.firezone.android.tunnel.model import android.os.Parcelable +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @JsonClass(generateAdapter = true) @Parcelize data class Resource( - val type: String, + val type: TypeEnum, val id: String, val address: String, + @Json(name = "address_description") val addressDescription: String?, + val sites: List?, val name: String, + val status: StatusEnum, ) : Parcelable + +enum class TypeEnum { + @Json(name = "dns") + DNS, + + @Json(name = "ip") + IP, + + @Json(name = "cidr") + CIDR, +} + +enum class StatusEnum { + @Json(name = "Unknown") + UNKNOWN, + + @Json(name = "Offline") + OFFLINE, + + @Json(name = "Online") + ONLINE, +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Site.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Site.kt new file mode 100644 index 000000000..323108990 --- /dev/null +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/model/Site.kt @@ -0,0 +1,13 @@ +/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */ +package dev.firezone.android.tunnel.model + +import android.os.Parcelable +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class Site( + val id: String, + val name: String, +) : Parcelable 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 new file mode 100644 index 000000000..3dab06ee4 --- /dev/null +++ b/kotlin/android/app/src/main/res/layout/fragment_resource_details.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +