mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(android): Show resource details when tapping a Resource (#5134)
- 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 <img width="672" alt="Screenshot 2024-05-26 at 6 39 09 PM" src="https://github.com/firezone/firezone/assets/167144/eab06f6f-e67b-4127-8d90-b5ab22035506"> Fixes #3514
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<ResourcesAdapter.ViewHolder>() {
|
||||
private val resources: MutableList<Resource> = mutableListOf()
|
||||
|
||||
fun updateResources(updatedResources: List<Resource>) {
|
||||
val diffCallback = ResourcesCallback(resources, updatedResources)
|
||||
val diffCourses = DiffUtil.calculateDiff(diffCallback)
|
||||
resources.clear()
|
||||
resources.addAll(updatedResources)
|
||||
diffCourses.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
internal class ResourcesAdapter : ListAdapter<Resource, ResourcesAdapter.ViewHolder>(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<Resource>, private val newList: List<Resource>) : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
class ResourceDiffCallback : DiffUtil.ItemCallback<Resource>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Site>?,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- Resource Section -->
|
||||
<TextView
|
||||
android:id="@+id/labelResource"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Resource"
|
||||
android:textStyle="bold"
|
||||
android:textSize="18sp"
|
||||
android:paddingBottom="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="8dp">
|
||||
<TextView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Name:"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:id="@+id/tvResourceName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="8dp">
|
||||
<TextView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Address:"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:id="@+id/tvResourceAddress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/resourceDescriptionLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Description:"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:id="@+id/tvResourceAddressDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Site Section -->
|
||||
<TextView
|
||||
android:id="@+id/labelSite"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Site"
|
||||
android:textStyle="bold"
|
||||
android:textSize="18sp"
|
||||
android:paddingBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/siteNameLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="8dp"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Name:"
|
||||
android:textStyle="bold" />
|
||||
<TextView
|
||||
android:id="@+id/tvSiteName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/siteStatusLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Status:"
|
||||
android:textStyle="bold" />
|
||||
<ImageView
|
||||
android:id="@+id/statusIndicatorDot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:visibility="gone" />
|
||||
<TextView
|
||||
android:id="@+id/tvSiteStatus"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
Reference in New Issue
Block a user