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:
Jamil
2024-05-28 10:27:00 -07:00
committed by GitHub
parent e2f1617558
commit 6a8c34a4dd
7 changed files with 318 additions and 50 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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