feat(android): Resources Table UI view (#1954)

* Implements UI for resources received on `onUpdateResources` callback.
* Subsequent call to `onUpdateResources` calculates diff and updates the
updates rows only.

---------

Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
This commit is contained in:
Pratik Velani
2023-08-28 17:57:18 +05:30
committed by GitHub
parent 004f36c6c8
commit f4ff3eb571
22 changed files with 392 additions and 197 deletions

View File

@@ -1,3 +1,3 @@
[codespell]
skip = ./website/.next,./website/pnpm-lock.yaml,./rust/target,Cargo.lock,./website/docs/reference/api/*.mdx,./erl_crash.dump,./apps/*/erl_crash.dump,./cover,./vendor,*.json,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./_build
ignore-words-list = crate,keypair,keypairs,iif,statics,wee,anull,commitish,inout
ignore-words-list = optin,crate,keypair,keypairs,iif,statics,wee,anull,commitish,inout

View File

@@ -114,14 +114,11 @@ dependencies {
// Moshi
implementation "com.squareup.moshi:moshi-kotlin:1.12.0"
implementation 'com.squareup.moshi:moshi:1.12.0'
// Gson
implementation "com.google.code.gson:gson:2.9.0"
// Deep Link
implementation "com.airbnb:deeplinkdispatch:6.1.0"
kapt "com.airbnb:deeplinkdispatch-processor:6.1.0"
// Security
implementation "androidx.security:security-crypto:1.1.0-alpha05"

View File

@@ -6,13 +6,15 @@ import android.content.SharedPreferences
import android.content.res.Resources
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dev.firezone.android.features.session.backend.SessionManager
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
import dev.firezone.android.tunnel.TunnelManager
internal const val ENCRYPTED_SHARED_PREFERENCES = "encryptedSharedPreferences"
@@ -39,8 +41,10 @@ object AppModule {
)
@Provides
internal fun provideSessionManager(
internal fun provideTunnelManager(
@ApplicationContext appContext: Context,
getConfigUseCase: GetConfigUseCase,
saveIsConnectedUseCase: SaveIsConnectedUseCase,
): SessionManager = SessionManager(getConfigUseCase, saveIsConnectedUseCase)
moshi: Moshi,
): TunnelManager = TunnelManager(appContext, getConfigUseCase, saveIsConnectedUseCase, moshi)
}

View File

@@ -7,15 +7,9 @@ import android.util.Log
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.BuildConfig
import dev.firezone.android.R
import dev.firezone.android.core.presentation.MainActivity
import dev.firezone.android.databinding.ActivityAppLinkHandlerBinding
import dev.firezone.android.features.session.backend.SessionManager
import dev.firezone.android.features.splash.ui.SplashFragmentDirections
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.TunnelSession
import javax.inject.Inject
@AndroidEntryPoint
class AppLinkHandlerActivity : AppCompatActivity(R.layout.activity_app_link_handler) {

View File

@@ -7,13 +7,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.BuildConfig
import dev.firezone.android.core.domain.preference.SaveTokenUseCase
import dev.firezone.android.core.domain.preference.ValidateCsrfTokenUseCase
import dev.firezone.android.features.session.backend.SessionManager
import dev.firezone.android.tunnel.TunnelLogger
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.TunnelSession
import kotlinx.coroutines.flow.collect
import javax.inject.Inject
import kotlinx.coroutines.flow.firstOrNull
@@ -24,7 +19,6 @@ internal class AppLinkViewModel @Inject constructor(
private val validateCsrfTokenUseCase: ValidateCsrfTokenUseCase,
private val saveTokenUseCase: SaveTokenUseCase,
) : ViewModel() {
private val callback: TunnelManager = TunnelManager()
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData

View File

@@ -1,70 +0,0 @@
package dev.firezone.android.features.session.backend
import android.net.VpnService
import android.util.Log
import android.provider.Settings
import dev.firezone.android.BuildConfig
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
import dev.firezone.android.tunnel.TunnelCallbacks
import dev.firezone.android.tunnel.TunnelLogger
import dev.firezone.android.tunnel.TunnelSession
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.TunnelService
import javax.inject.Inject
internal class SessionManager @Inject constructor(
private val getConfigUseCase: GetConfigUseCase,
private val saveIsConnectedUseCase: SaveIsConnectedUseCase,
) {
private val callback: TunnelManager = TunnelManager()
fun connect() {
try {
val config = getConfigUseCase.sync()
Log.d("Connlib", "accountId: ${config.accountId}")
Log.d("Connlib", "token: ${config.token}")
if (config.accountId != null && config.token != null) {
Log.d("Connlib", "Attempting to establish TunnelSession...")
sessionPtr = TunnelSession.connect(
BuildConfig.CONTROL_PLANE_URL,
config.token,
Settings.Secure.ANDROID_ID,
TunnelCallbacks()
)
Log.d("Connlib", "connlib session started! sessionPtr: $sessionPtr")
setConnectionStatus(true)
}
} catch (exception: Exception) {
Log.e("Connection error:", exception.message.toString())
}
}
fun disconnect() {
try {
TunnelSession.disconnect(sessionPtr!!)
setConnectionStatus(false)
} catch (exception: Exception) {
Log.e("Disconnection error:", exception.message.toString())
}
}
private fun setConnectionStatus(value: Boolean) {
saveIsConnectedUseCase.sync(value)
}
internal companion object {
var sessionPtr: Long? = null
init {
Log.d("Connlib","Attempting to load library from main app...")
System.loadLibrary("connlib")
Log.d("Connlib","Library loaded from main app!")
TunnelLogger.init()
Log.d("Connlib","Connlib Logger initialized!")
}
}
}

View File

@@ -0,0 +1,58 @@
package dev.firezone.android.features.session.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import dev.firezone.android.databinding.ListItemResourceBinding
import dev.firezone.android.tunnel.model.Resource
import javax.annotation.Nullable
internal class ResourcesAdapter: 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)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ListItemResourceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun getItemCount(): Int = resources.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(resources[position])
}
class ViewHolder(private val binding: ListItemResourceBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(resource: Resource) {
binding.resourceNameText.text = resource.name
binding.typeChip.text = resource.type
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
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
}
}

View File

@@ -1,32 +1,55 @@
package dev.firezone.android.features.session.ui
import android.os.Bundle
import android.view.View
import android.util.Log
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.R
import dev.firezone.android.databinding.FragmentSessionBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@AndroidEntryPoint
internal class SessionFragment : Fragment(R.layout.fragment_session) {
private lateinit var binding: FragmentSessionBinding
private val viewModel: SessionViewModel by viewModels()
private val resourcesAdapter: ResourcesAdapter = ResourcesAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentSessionBinding.bind(view)
setupButtonListeners()
setupActionObservers()
setupViews()
setupObservers()
Log.d("SessionFragment", "Starting session...")
viewModel.startSession()
}
private fun setupActionObservers() {
private fun setupViews() {
binding.btSignOut.setOnClickListener {
viewModel.onDisconnect()
}
val layoutManager = LinearLayoutManager(requireContext())
val dividerItemDecoration = DividerItemDecoration(
requireContext(),
layoutManager.orientation
)
binding.resourcesList.addItemDecoration(dividerItemDecoration)
binding.resourcesList.adapter = resourcesAdapter
binding.resourcesList.layoutManager = layoutManager
}
private fun setupObservers() {
viewModel.actionLiveData.observe(viewLifecycleOwner) { action ->
when (action) {
SessionViewModel.ViewAction.NavigateToSignInFragment ->
@@ -36,11 +59,15 @@ internal class SessionFragment : Fragment(R.layout.fragment_session) {
SessionViewModel.ViewAction.ShowError -> showError()
}
}
}
private fun setupButtonListeners() {
binding.btSignOut.setOnClickListener {
viewModel.onDisconnect()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.resources?.let {
resourcesAdapter.updateResources(it)
}
}
}
}
}

View File

@@ -1,31 +1,92 @@
package dev.firezone.android.features.session.ui
import dev.firezone.android.features.session.backend.SessionManager
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.firezone.android.tunnel.callback.TunnelListener
import dev.firezone.android.tunnel.TunnelManager
import dev.firezone.android.tunnel.model.Resource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
internal class SessionViewModel @Inject constructor(
private val sessionManager: SessionManager
private val tunnelManager: TunnelManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState
private val actionMutableLiveData = MutableLiveData<ViewAction>()
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
private val tunnelListener = object: TunnelListener {
override fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
dnsAddress: String,
dnsFallbackStrategy: String
) {
//TODO("Not yet implemented")
}
override fun onTunnelReady(): Boolean {
//TODO("Not yet implemented")
return true
}
override fun onAddRoute(cidrAddress: String) {
//TODO("Not yet implemented")
}
override fun onRemoveRoute(cidrAddress: String) {
//TODO("Not yet implemented")
}
override fun onUpdateResources(resources: List<Resource>) {
//TODO("Not yet implemented")
Log.d("TunnelManager", "onUpdateResources: $resources")
_uiState.value = _uiState.value.copy (
resources = resources
)
}
override fun onDisconnect(error: String?): Boolean {
//TODO("Not yet implemented")
return true
}
override fun onError(error: String): Boolean {
//TODO("Not yet implemented")
return true
}
}
fun startSession() {
viewModelScope.launch {
sessionManager.connect()
tunnelManager.addListener(tunnelListener)
tunnelManager.connect()
}
}
override fun onCleared() {
super.onCleared()
tunnelManager.removeListener(tunnelListener)
}
fun onDisconnect() {
actionMutableLiveData.postValue(ViewAction.NavigateToSignInFragment)
}
internal data class UiState(
val resources: List<Resource>? = null,
)
internal sealed class ViewAction {
object NavigateToSignInFragment : ViewAction()

View File

@@ -1,12 +0,0 @@
package dev.firezone.android.tunnel
class Tunnel(
val config: TunnelConfig,
var state: State = State.Down
) {
sealed interface State {
object Up: State
object Down: State
}
}

View File

@@ -1,66 +0,0 @@
package dev.firezone.android.tunnel
import android.net.VpnService
import android.util.Log
class TunnelCallbacks {
fun onUpdateResources(resourceListJSON: String) {
// TODO: Call into client app to update resources list and routing table
Log.d(TunnelCallbacks.TAG, "onUpdateResources: $resourceListJSON")
}
fun onSetInterfaceConfig(
tunnelAddressIPv4: String,
tunnelAddressIPv6: String,
dnsAddress: String,
dnsFallbackStrategy: String,
): Int {
Log.d(TunnelCallbacks.TAG, "onSetInterfaceConfig: [IPv4:$tunnelAddressIPv4] [IPv6:$tunnelAddressIPv6] [dns:$dnsAddress] [dnsFallbackStrategy:$dnsFallbackStrategy]")
return buildVpnService(tunnelAddressIPv4, tunnelAddressIPv6).establish()?.detachFd() ?: -1
}
fun onTunnelReady(): Boolean {
Log.d(TunnelCallbacks.TAG, "onTunnelReady")
return true
}
fun onError(error: String): Boolean {
Log.d(TunnelCallbacks.TAG, "onError: $error")
return true
}
fun onAddRoute(cidrAddress: String) {
Log.d(TunnelCallbacks.TAG, "onAddRoute: $cidrAddress")
}
fun onRemoveRoute(cidrAddress: String) {
Log.d(TunnelCallbacks.TAG, "onRemoveRoute: $cidrAddress")
}
fun onDisconnect(error: String?): Boolean {
Log.d(TunnelCallbacks.TAG, "onDisconnect $error")
return true
}
private fun buildVpnService(ipv4Address: String, ipv6Address: String): VpnService.Builder =
TunnelService().Builder().apply {
addAddress(ipv4Address, 32)
addAddress(ipv6Address, 128)
// TODO: These are the staging Resources. Remove these in favor of the onUpdateResources callback.
addRoute("172.31.93.123", 32)
addRoute("172.31.83.10", 32)
addRoute("172.31.82.179", 32)
setSession("Firezone VPN")
// TODO: Can we do better?
setMtu(1280)
}
companion object {
private const val TAG = "TunnelCallbacks"
}
}

View File

@@ -1,20 +1,45 @@
package dev.firezone.android.tunnel
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.provider.Settings
import android.util.Log
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dev.firezone.android.BuildConfig
import dev.firezone.android.core.domain.preference.GetConfigUseCase
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
import dev.firezone.android.tunnel.callback.ConnlibCallback
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.callback.TunnelListener
import dev.firezone.android.tunnel.model.Tunnel
import dev.firezone.android.tunnel.model.TunnelConfig
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Singleton
class TunnelManager {
@Singleton
@OptIn(ExperimentalStdlibApi::class)
internal class TunnelManager @Inject constructor(
private val appContext: Context,
private val getConfigUseCase: GetConfigUseCase,
private val saveIsConnectedUseCase: SaveIsConnectedUseCase,
private val moshi: Moshi,
) {
private var activeTunnel: Tunnel? = null
private val listeners: MutableSet<WeakReference<TunnelListener>> = mutableSetOf()
private val callback: TunnelListener = object: TunnelListener {
private val callback: ConnlibCallback = object: ConnlibCallback {
override fun onUpdateResources(resourceListJSON: String) {
// TODO: Call into client app to update resources list and routing table
Log.d(TAG, "onUpdateResources: $resourceListJSON")
listeners.onEach {
it.get()?.onUpdateResources(resourceListJSON)
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let { resources ->
listeners.onEach {
it.get()?.onUpdateResources(resources)
}
}
}
@@ -23,12 +48,20 @@ class TunnelManager {
tunnelAddressIPv6: String,
dnsAddress: String,
dnsFallbackStrategy: String
) {
): Int {
Log.d(TAG, "onSetInterfaceConfig: [IPv4:$tunnelAddressIPv4] [IPv6:$tunnelAddressIPv6] [dns:$dnsAddress] [dnsFallbackStrategy:$dnsFallbackStrategy]")
val tunnel = Tunnel(
config = TunnelConfig(
tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy
)
)
listeners.onEach {
it.get()?.onSetInterfaceConfig(tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy)
}
return buildVpnService(tunnelAddressIPv4, tunnelAddressIPv6).establish()?.detachFd() ?: -1
}
override fun onTunnelReady(): Boolean {
@@ -94,7 +127,81 @@ class TunnelManager {
}
}
companion object {
fun startVPN() {
val intent = Intent(appContext, TunnelService::class.java)
intent.action = TunnelService.ACTION_CONNECT
appContext.startService(intent)
}
fun stopVPN() {
val intent = Intent(appContext, TunnelService::class.java)
intent.action = TunnelService.ACTION_DISCONNECT
appContext.startService(intent)
}
fun connect() {
try {
val config = getConfigUseCase.sync()
Log.d("Connlib", "accountId: ${config.accountId}")
Log.d("Connlib", "token: ${config.token}")
if (config.accountId != null && config.token != null) {
Log.d("Connlib", "Attempting to establish TunnelSession...")
sessionPtr = TunnelSession.connect(
controlPlaneUrl = BuildConfig.CONTROL_PLANE_URL,
token = config.token,
externalId = Settings.Secure.ANDROID_ID,
callback = callback
)
Log.d("Connlib", "connlib session started! sessionPtr: ${sessionPtr}")
setConnectionStatus(true)
}
} catch (exception: Exception) {
Log.e("Connection error:", exception.message.toString())
}
}
fun disconnect() {
try {
TunnelSession.disconnect(sessionPtr!!)
setConnectionStatus(false)
} catch (exception: Exception) {
Log.e("Disconnection error:", exception.message.toString())
}
}
private fun setConnectionStatus(value: Boolean) {
saveIsConnectedUseCase.sync(value)
}
private fun buildVpnService(ipv4Address: String, ipv6Address: String): VpnService.Builder =
TunnelService().Builder().apply {
addAddress(ipv4Address, 32)
addAddress(ipv6Address, 128)
// TODO: These are the staging Resources. Remove these in favor of the onUpdateResources callback.
addRoute("172.31.93.123", 32)
addRoute("172.31.83.10", 32)
addRoute("172.31.82.179", 32)
setSession("Firezone VPN")
// TODO: Can we do better?
setMtu(1280)
}
internal companion object {
var sessionPtr: Long? = null
private const val TAG: String = "TunnelManager"
init {
Log.d("Connlib","Attempting to load library from main app...")
System.loadLibrary("connlib")
Log.d("Connlib","Library loaded from main app!")
TunnelLogger.init()
Log.d("Connlib","Connlib Logger initialized!")
}
}
}

View File

@@ -20,4 +20,8 @@ class TunnelService: VpnService() {
}
companion object {
val ACTION_CONNECT = "dev.firezone.android.tunnel.CONNECT"
val ACTION_DISCONNECT = "dev.firezone.android.tunnel.DISCONNECT"
}
}

View File

@@ -1,8 +1,7 @@
package dev.firezone.android.tunnel
package dev.firezone.android.tunnel.callback
interface TunnelListener {
fun onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String)
interface ConnlibCallback {
fun onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String): Int
fun onTunnelReady(): Boolean

View File

@@ -0,0 +1,20 @@
package dev.firezone.android.tunnel.callback
import dev.firezone.android.tunnel.model.Resource
interface TunnelListener {
fun onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String)
fun onTunnelReady(): Boolean
fun onAddRoute(cidrAddress: String)
fun onRemoveRoute(cidrAddress: String)
fun onUpdateResources(resources: List<Resource>)
fun onDisconnect(error: String?): Boolean
fun onError(error: String): Boolean
}

View File

@@ -0,0 +1,11 @@
package dev.firezone.android.tunnel.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Resource(
val type: String,
val id: String,
val address: String,
val name: String,
)

View File

@@ -0,0 +1,15 @@
package dev.firezone.android.tunnel.model
data class Tunnel(
val config: TunnelConfig,
var state: State = State.Down,
val routes: MutableList<String> = mutableListOf(),
val resources: MutableList<String> = mutableListOf(),
) {
sealed interface State {
object Up: State
object CONNECTING: State
object Down: State
}
}

View File

@@ -1,4 +1,4 @@
package dev.firezone.android.tunnel
package dev.firezone.android.tunnel.model
data class TunnelConfig (
val tunnelAddressIPv4: String,

View File

@@ -35,16 +35,20 @@
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvSignStatus"
style="@style/AppTheme.Base.HeaderText"
style="@style/AppTheme.Base.H5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_small"
android:text="@string/sign_out_activity_status_text"
app:layout_constraintBottom_toTopOf="@+id/btSignOut"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/resources"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/llContainer" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/resourcesList"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/tvSignStatus"
app:layout_constraintBottom_toTopOf="@id/btSignOut" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btSignOut"
android:layout_width="0dp"

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/spacing_small">
<TextView
android:id="@+id/resourceNameText"
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_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.chip.Chip
android:id="@+id/typeChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Type"
android:textAllCaps="true"
android:enabled="false"
app:layout_constraintTop_toTopOf="@id/resourceNameText"
app:layout_constraintBottom_toBottomOf="@id/resourceNameText"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/addressText"
style="@style/AppTheme.Base.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Resource Address"
app:layout_constraintTop_toBottomOf="@id/resourceNameText"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -12,7 +12,7 @@
<string name="sign_in_fragment_sign_status_text">Signed Out</string>
<!-- Sign Out Activity -->
<string name="sign_out_activity_status_text">Authenticated</string>
<string name="resources">Resources</string>
<string name="sign_out_activity_button_text">Sign Out</string>
<!-- Error Dialog -->

View File

@@ -26,4 +26,10 @@
<item name="android:textColorPrimary">@color/black_firezone</item>
</style>
<style name="AppTheme.Base.Body2">
<item name="fontFamily">@font/fira_sans_regular</item>
<item name="android:textSize">@dimen/text_body2</item>
<item name="android:textColorPrimary">@color/black_firezone</item>
</style>
</resources>