summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2019-03-18 15:53:03 -0300
committerJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2019-03-18 15:53:03 -0300
commit019cb8ae0e4c38680d82ad354e608fbd12ef4b47 (patch)
tree1d2e970be9a8882f968661bbac04dbe2d0e40443
parent59c0aa07d7208805fa46743e4d73b19cdd95ad1c (diff)
parent104db1a1a456f19d6f8760ee84b7bb06ca2c5f87 (diff)
downloadmullvadvpn-019cb8ae0e4c38680d82ad354e608fbd12ef4b47.tar.xz
mullvadvpn-019cb8ae0e4c38680d82ad354e608fbd12ef4b47.zip
Merge branch 'android-switch-location-screen'
-rw-r--r--android/build.gradle1
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ConnectFragment.kt19
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/MainActivity.kt2
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/SelectLocationFragment.kt50
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt6
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt13
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt52
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt62
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt14
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemDividerDecoration.kt23
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt109
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt7
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt269
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt3
-rw-r--r--android/src/main/res/anim/do_nothing.xml8
-rw-r--r--android/src/main/res/anim/fragment_enter_from_bottom.xml8
-rw-r--r--android/src/main/res/anim/fragment_exit_to_bottom.xml8
-rw-r--r--android/src/main/res/drawable/icon_chevron_expand.xml9
-rw-r--r--android/src/main/res/drawable/icon_close.xml14
-rw-r--r--android/src/main/res/drawable/icon_relay_active.xml8
-rw-r--r--android/src/main/res/drawable/icon_tick.xml14
-rw-r--r--android/src/main/res/layout/connect.xml2
-rw-r--r--android/src/main/res/layout/relay_list_item.xml50
-rw-r--r--android/src/main/res/layout/select_location.xml46
-rw-r--r--android/src/main/res/values/dimensions.xml6
-rw-r--r--android/src/main/res/values/strings.xml6
26 files changed, 808 insertions, 1 deletions
diff --git a/android/build.gradle b/android/build.gradle
index 43bfdb53c9..5be82b79d7 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -31,6 +31,7 @@ repositories {
dependencies {
implementation 'com.android.support:appcompat-v7:28.0.0'
+ implementation 'com.android.support:recyclerview-v7:28.0.0'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.3.21'
}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ConnectFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ConnectFragment.kt
index c38321dfc9..bbe43ede29 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ConnectFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ConnectFragment.kt
@@ -6,6 +6,7 @@ import android.support.v4.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.Button
class ConnectFragment : Fragment() {
private lateinit var actionButton: ConnectActionButton
@@ -38,6 +39,10 @@ class ConnectFragment : Fragment() {
): View {
val view = inflater.inflate(R.layout.connect, container, false)
+ view.findViewById<Button>(R.id.switch_location).setOnClickListener {
+ openSwitchLocationScreen()
+ }
+
headerBar = HeaderBar(view, context!!)
notificationBanner = NotificationBanner(view)
status = ConnectionStatus(view, context!!)
@@ -67,4 +72,18 @@ class ConnectFragment : Fragment() {
private fun connected() {
state = ConnectionState.Connected
}
+
+ private fun openSwitchLocationScreen() {
+ fragmentManager?.beginTransaction()?.apply {
+ setCustomAnimations(
+ R.anim.fragment_enter_from_bottom,
+ R.anim.do_nothing,
+ R.anim.do_nothing,
+ R.anim.fragment_exit_to_bottom
+ )
+ replace(R.id.main_fragment, SelectLocationFragment())
+ addToBackStack(null)
+ commit()
+ }
+ }
}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/MainActivity.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/MainActivity.kt
index b5fc9c3306..b7b0e8fa8c 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/MainActivity.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/MainActivity.kt
@@ -4,6 +4,8 @@ import android.os.Bundle
import android.support.v4.app.FragmentActivity
class MainActivity : FragmentActivity() {
+ var selectedRelayItemCode: String? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main)
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/SelectLocationFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/SelectLocationFragment.kt
new file mode 100644
index 0000000000..369e4d9289
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/SelectLocationFragment.kt
@@ -0,0 +1,50 @@
+package net.mullvad.mullvadvpn
+
+import android.os.Bundle
+import android.support.v4.app.Fragment
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageButton
+
+import net.mullvad.mullvadvpn.relaylist.RelayItemDividerDecoration
+import net.mullvad.mullvadvpn.relaylist.RelayListAdapter
+
+class SelectLocationFragment : Fragment() {
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val view = inflater.inflate(R.layout.select_location, container, false)
+
+ view.findViewById<ImageButton>(R.id.close).setOnClickListener { close() }
+
+ configureRelayList(view.findViewById<RecyclerView>(R.id.relay_list))
+
+ return view
+ }
+
+ fun close() {
+ activity?.onBackPressed()
+ }
+
+ private fun configureRelayList(relayList: RecyclerView) {
+ val parentActivity = activity as MainActivity?
+ val relayListAdapter = RelayListAdapter(parentActivity?.selectedRelayItemCode)
+
+ relayListAdapter.onSelect = { relayItemCode ->
+ parentActivity?.selectedRelayItemCode = relayItemCode
+ close()
+ }
+
+ relayList.apply {
+ layoutManager = LinearLayoutManager(context!!)
+ adapter = relayListAdapter
+
+ addItemDecoration(RelayItemDividerDecoration(context!!))
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt
new file mode 100644
index 0000000000..d443d30cfe
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/GetItemResult.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.relaylist
+
+sealed class GetItemResult {
+ data class Item(val item: RelayItem) : GetItemResult()
+ data class Count(val count: Int) : GetItemResult()
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt
new file mode 100644
index 0000000000..131f71df9d
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/Relay.kt
@@ -0,0 +1,13 @@
+package net.mullvad.mullvadvpn.relaylist
+
+data class Relay(override val name: String) : RelayItem {
+ override val code = name
+ override val type = RelayItemType.Relay
+ override val hasChildren = false
+
+ override val visibleChildCount = 0
+
+ override var expanded
+ get() = false
+ set(value) {}
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt
new file mode 100644
index 0000000000..182a0ae3e5
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCity.kt
@@ -0,0 +1,52 @@
+package net.mullvad.mullvadvpn.relaylist
+
+class RelayCity(
+ override val name: String,
+ override val code: String,
+ override var expanded: Boolean,
+ val relays: List<Relay>
+) : RelayItem {
+ override val type = RelayItemType.City
+ override val hasChildren
+ get() = relays.size > 1
+
+ override val visibleChildCount: Int
+ get() {
+ if (expanded) {
+ return relays.size
+ } else {
+ return 0
+ }
+ }
+
+ fun getItem(position: Int): GetItemResult {
+ if (position == 0) {
+ return GetItemResult.Item(this)
+ }
+
+ if (!expanded) {
+ return GetItemResult.Count(1)
+ }
+
+ val offset = position - 1
+ val relayCount = relays.size
+
+ if (offset >= relayCount) {
+ return GetItemResult.Count(1 + relayCount)
+ } else {
+ return GetItemResult.Item(relays[offset])
+ }
+ }
+
+ fun getItemCount(): Int {
+ if (expanded) {
+ return 1 + relays.size
+ } else {
+ return 1
+ }
+ }
+
+ fun getRelayCount(): Int = relays.size
+
+ fun findRelayByCode(code: String): RelayItem? = relays.find { relay -> relay.code == code }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt
new file mode 100644
index 0000000000..ab78172b51
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayCountry.kt
@@ -0,0 +1,62 @@
+package net.mullvad.mullvadvpn.relaylist
+
+class RelayCountry(
+ override val name: String,
+ override val code: String,
+ override var expanded: Boolean,
+ val cities: List<RelayCity>
+) : RelayItem {
+ override val type = RelayItemType.Country
+ override val hasChildren
+ get() = getRelayCount() > 1
+
+ override val visibleChildCount: Int
+ get() {
+ if (expanded) {
+ return cities.map { city -> city.visibleItemCount }.sum()
+ } else {
+ return 0
+ }
+ }
+
+ fun getItem(position: Int): GetItemResult {
+ if (position == 0) {
+ return GetItemResult.Item(this)
+ }
+
+ var itemCount = 1
+ var remaining = position - 1
+
+ if (expanded) {
+ for (city in cities) {
+ val itemOrCount = city.getItem(remaining)
+
+ when (itemOrCount) {
+ is GetItemResult.Item -> return itemOrCount
+ is GetItemResult.Count -> {
+ remaining -= itemOrCount.count
+ itemCount += itemOrCount.count
+ }
+ }
+ }
+ }
+
+ return GetItemResult.Count(itemCount)
+ }
+
+ fun getRelayCount(): Int = cities.map { city -> city.getRelayCount() }.sum()
+
+ fun findRelayItemByCode(cityCode: String, relayCode: String?): RelayItem? {
+ for (city in cities) {
+ if (city.code == cityCode) {
+ if (relayCode != null) {
+ return city.findRelayByCode("$cityCode-$relayCode")
+ } else {
+ return city
+ }
+ }
+ }
+
+ return null
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
new file mode 100644
index 0000000000..9f325e6377
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItem.kt
@@ -0,0 +1,14 @@
+package net.mullvad.mullvadvpn.relaylist
+
+interface RelayItem {
+ val type: RelayItemType
+ val name: String
+ val code: String
+ val hasChildren: Boolean
+ val visibleChildCount: Int
+
+ val visibleItemCount: Int
+ get() = visibleChildCount + 1
+
+ var expanded: Boolean
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemDividerDecoration.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemDividerDecoration.kt
new file mode 100644
index 0000000000..01e66e63ab
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemDividerDecoration.kt
@@ -0,0 +1,23 @@
+package net.mullvad.mullvadvpn.relaylist
+
+import android.content.Context
+import android.graphics.Rect
+import android.support.v7.widget.RecyclerView
+import android.support.v7.widget.RecyclerView.ItemDecoration
+import android.support.v7.widget.RecyclerView.State
+import android.view.View
+
+import net.mullvad.mullvadvpn.R
+
+class RelayItemDividerDecoration(private val context: Context) : ItemDecoration() {
+ private val dividerHeight = context.resources.getDimensionPixelSize(R.dimen.relay_list_divider)
+
+ override fun getItemOffsets(offsets: Rect, view: View, parent: RecyclerView, state: State) {
+ val position = parent.getChildAdapterPosition(view)
+ val lastItem = parent.adapter!!.itemCount - 1
+
+ if (position != lastItem) {
+ offsets.bottom = dividerHeight
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt
new file mode 100644
index 0000000000..b67c31e2a9
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemHolder.kt
@@ -0,0 +1,109 @@
+package net.mullvad.mullvadvpn.relaylist
+
+import android.support.v7.widget.RecyclerView.ViewHolder
+import android.view.View
+import android.widget.ImageButton
+import android.widget.TextView
+
+import net.mullvad.mullvadvpn.R
+
+class RelayItemHolder(
+ private val view: View,
+ private val adapter: RelayListAdapter,
+ var itemPosition: RelayListAdapterPosition
+) : ViewHolder(view) {
+ private val name: TextView = view.findViewById(R.id.name)
+ private val chevron: ImageButton = view.findViewById(R.id.chevron)
+ private val relayActive: View = view.findViewById(R.id.relay_active)
+ private val selectedIcon: View = view.findViewById(R.id.selected)
+
+ private val countryColor = view.context.getColor(R.color.blue)
+ private val cityColor = view.context.getColor(R.color.blue40)
+ private val relayColor = view.context.getColor(R.color.blue20)
+ private val selectedColor = view.context.getColor(R.color.green)
+
+ private val countryPadding = view.resources.getDimensionPixelSize(R.dimen.country_row_padding)
+ private val cityPadding = view.resources.getDimensionPixelSize(R.dimen.city_row_padding)
+ private val relayPadding = view.resources.getDimensionPixelSize(R.dimen.relay_row_padding)
+
+ var item: RelayItem? = null
+ set(value) {
+ field = value
+ updateView()
+ }
+
+ var selected = false
+ set(value) {
+ field = value
+ updateView()
+ }
+
+ init {
+ chevron.setOnClickListener { toggle() }
+ view.setOnClickListener { adapter.selectItem(item, this) }
+ }
+
+ private fun updateView() {
+ val item = this.item
+
+ if (item != null) {
+ name.text = item.name
+
+ if (selected) {
+ relayActive.visibility = View.INVISIBLE
+ selectedIcon.visibility = View.VISIBLE
+ } else {
+ relayActive.visibility = View.VISIBLE
+ selectedIcon.visibility = View.INVISIBLE
+ }
+
+ when (item.type) {
+ RelayItemType.Country -> setViewStyle(countryColor, countryPadding)
+ RelayItemType.City -> setViewStyle(cityColor, cityPadding)
+ RelayItemType.Relay -> setViewStyle(relayColor, relayPadding)
+ }
+
+ if (item.hasChildren) {
+ chevron.visibility = View.VISIBLE
+ } else {
+ chevron.visibility = View.GONE
+ }
+ } else {
+ name.text = ""
+ chevron.visibility = View.GONE
+ }
+ }
+
+ private fun setViewStyle(rowColor: Int, padding: Int) {
+ var backgroundColor = rowColor
+ val paddingLeft = padding
+ val paddingTop = view.paddingTop
+ val paddingRight = view.paddingRight
+ val paddingBottom = view.paddingBottom
+
+ if (selected) {
+ backgroundColor = selectedColor
+ }
+
+ view.apply {
+ setBackgroundColor(backgroundColor)
+ setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom)
+ }
+ }
+
+ private fun toggle() {
+ item?.let { item ->
+ if (!item.expanded) {
+ item.expanded = true
+ chevron.rotation = 180.0F
+ adapter.expandItem(itemPosition, item.visibleChildCount)
+ } else {
+ val childCount = item.visibleChildCount
+
+ item.expanded = false
+ chevron.rotation = 0.0F
+ adapter.collapseItem(itemPosition, childCount)
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt
new file mode 100644
index 0000000000..cdbd58b291
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayItemType.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.relaylist
+
+enum class RelayItemType {
+ Country,
+ City,
+ Relay,
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
new file mode 100644
index 0000000000..583df4899c
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapter.kt
@@ -0,0 +1,269 @@
+package net.mullvad.mullvadvpn.relaylist
+
+import java.lang.ref.WeakReference
+import java.util.LinkedList
+
+import android.support.v7.widget.RecyclerView.Adapter
+import android.view.LayoutInflater
+import android.view.ViewGroup
+
+import net.mullvad.mullvadvpn.R
+
+class RelayListAdapter(private val initialSelectedItemCode: String?) : Adapter<RelayItemHolder>() {
+ private val relayList = fakeRelayList
+ private val activeIndices = LinkedList<WeakReference<RelayListAdapterPosition>>()
+ private var selectedItem: RelayItem? = null
+ private var selectedItemHolder: RelayItemHolder? = null
+
+ var onSelect: ((String?) -> Unit)? = null
+
+ init {
+ initialSelectedItemCode?.let { code -> selectedItem = findRelayItemByCode(code) }
+ }
+
+ override fun onCreateViewHolder(parentView: ViewGroup, type: Int): RelayItemHolder {
+ val inflater = LayoutInflater.from(parentView.context)
+ val view = inflater.inflate(R.layout.relay_list_item, parentView, false)
+ val index = RelayListAdapterPosition(0)
+
+ activeIndices.add(WeakReference(index))
+
+ return RelayItemHolder(view, this, index)
+ }
+
+ override fun onBindViewHolder(holder: RelayItemHolder, position: Int) {
+ var remaining = position
+
+ for (country in relayList) {
+ val itemOrCount = country.getItem(remaining)
+
+ when (itemOrCount) {
+ is GetItemResult.Item -> {
+ bindHolderToItem(holder, itemOrCount.item, position)
+ return
+ }
+ is GetItemResult.Count -> remaining -= itemOrCount.count
+ }
+ }
+ }
+
+ override fun getItemCount() = relayList.map { country -> country.visibleItemCount }.sum()
+
+ fun selectItem(item: RelayItem?, holder: RelayItemHolder?) {
+ selectedItemHolder?.selected = false
+
+ selectedItem = item
+ selectedItemHolder = holder
+ selectedItemHolder?.apply { selected = true }
+
+ onSelect?.invoke(item?.code)
+ }
+
+ fun expandItem(itemIndex: RelayListAdapterPosition, childCount: Int) {
+ val position = itemIndex.position
+
+ updateActiveIndices(position, childCount)
+ notifyItemRangeInserted(position + 1, childCount)
+ }
+
+ fun collapseItem(itemIndex: RelayListAdapterPosition, childCount: Int) {
+ val position = itemIndex.position
+
+ updateActiveIndices(position, -childCount)
+ notifyItemRangeRemoved(position + 1, childCount)
+ }
+
+ private fun updateActiveIndices(position: Int, delta: Int) {
+ val activeIndicesIterator = activeIndices.iterator()
+
+ while (activeIndicesIterator.hasNext()) {
+ val index = activeIndicesIterator.next().get()
+
+ if (index == null) {
+ activeIndicesIterator.remove()
+ } else {
+ val indexPosition = index.position
+
+ if (indexPosition > position) {
+ index.position = indexPosition + delta
+ }
+ }
+ }
+ }
+
+ private fun bindHolderToItem(holder: RelayItemHolder, item: RelayItem, position: Int) {
+ holder.item = item
+ holder.itemPosition.position = position
+
+ if (selectedItem != null && selectedItem == item) {
+ holder.selected = true
+ selectedItemHolder = holder
+ } else {
+ holder.selected = false
+ }
+ }
+
+ private fun findRelayItemByCode(code: String): RelayItem? {
+ val codeParts = code.split('-')
+
+ for (country in relayList) {
+ if (country.code == codeParts[0]) {
+ if (codeParts.size == 1) {
+ return country
+ } else {
+ var relayCode: String? = null
+
+ if (codeParts.size == 3) {
+ relayCode = codeParts[2]
+ }
+
+ return country.findRelayItemByCode("${codeParts[0]}-${codeParts[1]}", relayCode)
+ }
+ }
+ }
+
+ return null
+ }
+}
+
+val fakeRelayList = listOf(
+ RelayCountry(
+ "Australia",
+ "au",
+ false,
+ listOf(
+ RelayCity(
+ "Brisbane",
+ "au-bne",
+ false,
+ listOf(Relay("au-bne-001"))
+ ),
+ RelayCity(
+ "Melbourne",
+ "au-mel",
+ false,
+ listOf(Relay("au-mel-002"), Relay("au-mel-003"), Relay("au-mel-004"))
+ ),
+ RelayCity(
+ "Perth",
+ "au-per",
+ false,
+ listOf(Relay("au-per-001"))
+ ),
+ RelayCity(
+ "Sydney",
+ "au-syd",
+ false,
+ listOf(
+ Relay("au1-wireguard"),
+ Relay("au-syd-001"),
+ Relay("au-syd-002"),
+ Relay("au-mel-003")
+ )
+ )
+ )
+ ),
+ RelayCountry(
+ "South Africa",
+ "za",
+ false,
+ listOf(
+ RelayCity(
+ "Johannesburg",
+ "za-jnb",
+ false,
+ listOf(Relay("za-jnb-001"))
+ )
+ )
+ ),
+ RelayCountry(
+ "Sweden",
+ "se",
+ false,
+ listOf(
+ RelayCity(
+ "Gothenburg",
+ "se-got",
+ false,
+ listOf(
+ Relay("se3-wireguard"),
+ Relay("se5-wireguard"),
+ Relay("se-got-001"),
+ Relay("se-got-002"),
+ Relay("se-got-003"),
+ Relay("se-got-004"),
+ Relay("se-got-005"),
+ Relay("se-got-006"),
+ Relay("se-got-007")
+ )
+ ),
+ RelayCity(
+ "Helsingborg",
+ "se-hel",
+ false,
+ listOf(
+ Relay("se-hel-001"),
+ Relay("se-hel-002"),
+ Relay("se-hel-003"),
+ Relay("se-hel-004"),
+ Relay("se-hel-007"),
+ Relay("se-hel-008")
+ )
+ ),
+ RelayCity(
+ "Malmö",
+ "se-mma",
+ false,
+ listOf(
+ Relay("se4-wireguard"),
+ Relay("se-mma-001"),
+ Relay("se-mma-002"),
+ Relay("se-mma-003"),
+ Relay("se-mma-004"),
+ Relay("se-mma-005"),
+ Relay("se-mma-006"),
+ Relay("se-mma-007"),
+ Relay("se-mma-008"),
+ Relay("se-mma-009"),
+ Relay("se-mma-010")
+ )
+ ),
+ RelayCity(
+ "Stockholm",
+ "se-sto",
+ false,
+ listOf(
+ Relay("se2-wireguard"),
+ Relay("se6-wireguard"),
+ Relay("se7-wireguard"),
+ Relay("se8-wireguard"),
+ Relay("se-sto-001"),
+ Relay("se-sto-002"),
+ Relay("se-sto-003"),
+ Relay("se-sto-004"),
+ Relay("se-sto-005"),
+ Relay("se-sto-006"),
+ Relay("se-sto-007"),
+ Relay("se-sto-008"),
+ Relay("se-sto-009"),
+ Relay("se-sto-010"),
+ Relay("se-sto-011"),
+ Relay("se-sto-012"),
+ Relay("se-sto-013"),
+ Relay("se-sto-014"),
+ Relay("se-sto-015"),
+ Relay("se-sto-016"),
+ Relay("se-sto-017"),
+ Relay("se-sto-018"),
+ Relay("se-sto-019"),
+ Relay("se-sto-020"),
+ Relay("se-sto-021"),
+ Relay("se-sto-022"),
+ Relay("se-sto-023"),
+ Relay("se-sto-024"),
+ Relay("se-sto-025")
+ )
+ )
+ )
+ )
+)
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt
new file mode 100644
index 0000000000..09dfafebc8
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListAdapterPosition.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.relaylist
+
+data class RelayListAdapterPosition(var position: Int)
diff --git a/android/src/main/res/anim/do_nothing.xml b/android/src/main/res/anim/do_nothing.xml
new file mode 100644
index 0000000000..6c552b6d11
--- /dev/null
+++ b/android/src/main/res/anim/do_nothing.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <translate
+ android:fromYDelta="0"
+ android:toYDelta="0"
+ android:duration="450"
+ />
+</set>
diff --git a/android/src/main/res/anim/fragment_enter_from_bottom.xml b/android/src/main/res/anim/fragment_enter_from_bottom.xml
new file mode 100644
index 0000000000..60a84de5e5
--- /dev/null
+++ b/android/src/main/res/anim/fragment_enter_from_bottom.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <translate
+ android:fromYDelta="100%p"
+ android:toYDelta="0"
+ android:duration="450"
+ />
+</set>
diff --git a/android/src/main/res/anim/fragment_exit_to_bottom.xml b/android/src/main/res/anim/fragment_exit_to_bottom.xml
new file mode 100644
index 0000000000..7f7aa49258
--- /dev/null
+++ b/android/src/main/res/anim/fragment_exit_to_bottom.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+ <translate
+ android:fromYDelta="0"
+ android:toYDelta="100%p"
+ android:duration="450"
+ />
+</set>
diff --git a/android/src/main/res/drawable/icon_chevron_expand.xml b/android/src/main/res/drawable/icon_chevron_expand.xml
new file mode 100644
index 0000000000..f31a8bcf19
--- /dev/null
+++ b/android/src/main/res/drawable/icon_chevron_expand.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rotate
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:fromDegrees="90"
+ android:toDegrees="90"
+ android:pivotX="50%"
+ android:pivotY="50%"
+ android:drawable="@drawable/icon_chevron"
+ />
diff --git a/android/src/main/res/drawable/icon_close.xml b/android/src/main/res/drawable/icon_close.xml
new file mode 100644
index 0000000000..7cab786403
--- /dev/null
+++ b/android/src/main/res/drawable/icon_close.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<vector
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0"
+ >
+ <group>
+ <path android:fillColor="#99FFFFFF"
+ android:pathData="M12,24 C5.37312,24 -3.2900871e-16,18.62688 -7.34788079e-16,12 C-1.14056745e-15,5.37312 5.37312,0 12,0 C18.62688,0 24,5.37312 24,12 C24,18.62688 18.62688,24 12,24 Z M13.5,12 L17.2947612,8.20523878 C17.6857559,7.81424414 17.6838785,7.18387854 17.293923,6.79392296 L17.206077,6.70607704 C16.8181114,6.31811142 16.1842538,6.31574616 15.7947612,6.70523878 L12,10.5 L8.20523878,6.70523878 C7.81574616,6.31574616 7.18188858,6.31811142 6.79392296,6.70607704 L6.70607704,6.79392296 C6.31612146,7.18387854 6.31424414,7.81424414 6.70523878,8.20523878 L10.5,12 L6.70523878,15.7947612 C6.31424414,16.1857559 6.31612146,16.8161215 6.70607704,17.206077 L6.79392296,17.293923 C7.18188858,17.6818886 7.81574616,17.6842538 8.20523878,17.2947612 L12,13.5 L15.7947612,17.2947612 C16.1842538,17.6842538 16.8181114,17.6818886 17.206077,17.293923 L17.293923,17.206077 C17.6838785,16.8161215 17.6857559,16.1857559 17.2947612,15.7947612 L13.5,12 L13.5,12 Z"
+ />
+ </group>
+</vector>
diff --git a/android/src/main/res/drawable/icon_relay_active.xml b/android/src/main/res/drawable/icon_relay_active.xml
new file mode 100644
index 0000000000..f6e70521f8
--- /dev/null
+++ b/android/src/main/res/drawable/icon_relay_active.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="oval"
+ >
+ <solid android:color="@color/green"/>
+ <size android:width="16dp" android:height="16dp"/>
+</shape>
diff --git a/android/src/main/res/drawable/icon_tick.xml b/android/src/main/res/drawable/icon_tick.xml
new file mode 100644
index 0000000000..5a4831dd94
--- /dev/null
+++ b/android/src/main/res/drawable/icon_tick.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<vector
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0"
+ >
+ <group>
+ <path android:fillColor="#FFFFFF"
+ android:pathData="M2.92646877,10.7979185 C2.25699855,10.1340272 1.17157288,10.1340272 0.502102661,10.7979185 C-0.167367554,11.4618098 -0.167367554,12.5381902 0.502102661,13.2020815 L7.35924552,20.0020815 C8.02871573,20.6659728 9.11414141,20.6659728 9.78361162,20.0020815 L23.4978973,6.40208153 C24.1673676,5.73819023 24.1673676,4.66180977 23.4978973,3.99791847 C22.8284271,3.33402718 21.7430014,3.33402718 21.0735312,3.99791847 L8.57142857,16.3958369 L2.92646877,10.7979185 Z"
+ />
+ </group>
+</vector>
diff --git a/android/src/main/res/layout/connect.xml b/android/src/main/res/layout/connect.xml
index 8163a9a297..19f9cb177e 100644
--- a/android/src/main/res/layout/connect.xml
+++ b/android/src/main/res/layout/connect.xml
@@ -136,7 +136,7 @@
android:orientation="vertical"
android:padding="24dp"
>
- <Button
+ <Button android:id="@+id/switch_location"
android:layout_marginVertical="16dp"
android:text="@string/switch_location"
android:drawableRight="@drawable/icon_chevron"
diff --git a/android/src/main/res/layout/relay_list_item.xml b/android/src/main/res/layout/relay_list_item.xml
new file mode 100644
index 0000000000..29ebf56762
--- /dev/null
+++ b/android/src/main/res/layout/relay_list_item.xml
@@ -0,0 +1,50 @@
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/main_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingLeft="@dimen/country_row_padding"
+ android:paddingRight="24dp"
+ android:paddingVertical="16dp"
+ android:background="@color/blue"
+ android:orientation="horizontal"
+ android:gravity="center"
+ >
+ <FrameLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:gravity="center"
+ >
+ <ImageView android:id="@+id/relay_active"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:src="@drawable/icon_relay_active"
+ />
+ <ImageView android:id="@+id/selected"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:src="@drawable/icon_tick"
+ android:visibility="invisible"
+ />
+ </FrameLayout>
+ <TextView android:id="@+id/name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginHorizontal="8dp"
+ android:textColor="@color/white"
+ android:textSize="20sp"
+ android:textStyle="bold"
+ android:text=""
+ />
+ <ImageButton android:id="@+id/chevron"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_weight="0"
+ android:background="?android:attr/selectableItemBackground"
+ android:src="@drawable/icon_chevron_expand"
+ />
+</LinearLayout>
diff --git a/android/src/main/res/layout/select_location.xml b/android/src/main/res/layout/select_location.xml
new file mode 100644
index 0000000000..a372b32a9f
--- /dev/null
+++ b/android/src/main/res/layout/select_location.xml
@@ -0,0 +1,46 @@
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/main_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/darkBlue"
+ android:orientation="vertical"
+ android:gravity="left"
+ android:elevation="1dp"
+ >
+ <ImageButton android:id="@+id/close"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:layout_margin="12dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:src="@drawable/icon_close"
+ />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:layout_marginVertical="4dp"
+ android:layout_marginHorizontal="24dp"
+ android:textColor="@color/white"
+ android:textSize="32sp"
+ android:textStyle="bold"
+ android:text="@string/select_location"
+ />
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_weight="0"
+ android:layout_marginHorizontal="24dp"
+ android:layout_marginBottom="24dp"
+ android:textColor="@color/white60"
+ android:textSize="13sp"
+ android:text="@string/select_location_description"
+ />
+ <android.support.v7.widget.RecyclerView android:id="@+id/relay_list"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:scrollbars="vertical"
+ />
+</LinearLayout>
diff --git a/android/src/main/res/values/dimensions.xml b/android/src/main/res/values/dimensions.xml
new file mode 100644
index 0000000000..379843b2bf
--- /dev/null
+++ b/android/src/main/res/values/dimensions.xml
@@ -0,0 +1,6 @@
+<resources>
+ <dimen name="country_row_padding">20dp</dimen>
+ <dimen name="city_row_padding">40dp</dimen>
+ <dimen name="relay_row_padding">60dp</dimen>
+ <dimen name="relay_list_divider">1dp</dimen>
+</resources>
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index b8d1cdb654..24f8c9867e 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -19,4 +19,10 @@
<string name="cancel">Cancel</string>
<string name="disconnect">Disconnect</string>
<string name="switch_location">Switch location</string>
+
+ <string name="select_location">Select location</string>
+ <string name="select_location_description">
+ While connected, your real location is masked with a private and secure location in the
+ selected region
+ </string>
</resources>