summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-07-01 17:47:45 -0300
committerJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-07-01 17:47:45 -0300
commit5902ecb40a11332764fbdbb5f902868f5b75211e (patch)
tree8b8e5e894a8810dfa012a962609e4cc48d30a799 /android
parentd8cbd703261fc60c0864264a0bd632f3823c723e (diff)
parent5bf48b1125b520ffa39ac13075c8d56fd5c91c6b (diff)
downloadmullvadvpn-5902ecb40a11332764fbdbb5f902868f5b75211e.tar.xz
mullvadvpn-5902ecb40a11332764fbdbb5f902868f5b75211e.zip
Merge branch 'in-app-notification-refactor'
Diffstat (limited to 'android')
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt26
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/NotificationBanner.kt284
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt46
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt45
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt78
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt58
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt21
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt25
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt6
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt102
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt53
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt127
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt18
-rw-r--r--android/src/main/res/layout/connect.xml61
-rw-r--r--android/src/main/res/layout/notification_banner.xml50
15 files changed, 647 insertions, 353 deletions
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt
index 550edd7e26..55144b8529 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt
@@ -8,6 +8,11 @@ import android.widget.ImageButton
import kotlinx.coroutines.delay
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification
+import net.mullvad.mullvadvpn.ui.notification.KeyStatusNotification
+import net.mullvad.mullvadvpn.ui.notification.TunnelStateNotification
+import net.mullvad.mullvadvpn.ui.notification.VersionInfoNotification
+import net.mullvad.mullvadvpn.ui.widget.NotificationBanner
import org.joda.time.DateTime
val KEY_IS_TUNNEL_INFO_EXPANDED = "is_tunnel_info_expanded"
@@ -43,7 +48,14 @@ class ConnectFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
headerBar = HeaderBar(view, resources)
- notificationBanner = NotificationBanner(view, parentActivity, appVersionInfoCache, daemon)
+ notificationBanner = view.findViewById<NotificationBanner>(R.id.notification_banner).apply {
+ notifications.apply {
+ register(TunnelStateNotification(parentActivity, connectionProxy))
+ register(KeyStatusNotification(parentActivity, daemon, keyStatusListener))
+ register(VersionInfoNotification(parentActivity, appVersionInfoCache))
+ register(AccountExpiryNotification(parentActivity, daemon, accountCache))
+ }
+ }
status = ConnectionStatus(view, resources)
@@ -69,12 +81,6 @@ class ConnectFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
notificationBanner.onResume()
- keyStatusListener.onKeyStatusChange.subscribe(this) { keyStatus ->
- jobTracker.newUiJob("updateKeyStatus") {
- notificationBanner.keyState = keyStatus
- }
- }
-
locationInfoCache.onNewLocation = { location ->
jobTracker.newUiJob("updateLocationInfo") {
locationInfo.location = location
@@ -98,10 +104,6 @@ class ConnectFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
} else if (expiry != null) {
scheduleNextAccountExpiryCheck(expiry)
}
-
- jobTracker.newUiJob("updateAccountExpiry") {
- notificationBanner.accountExpiry = expiry
- }
}
}
@@ -119,6 +121,7 @@ class ConnectFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
}
override fun onSafelyDestroyView() {
+ notificationBanner.onDestroy()
switchLocationButton.onDestroy()
}
@@ -128,7 +131,6 @@ class ConnectFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) {
}
private fun updateTunnelState(uiState: TunnelState, realState: TunnelState) {
- notificationBanner.tunnelState = realState
locationInfo.state = realState
headerBar.setState(realState)
status.setState(realState)
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/NotificationBanner.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/NotificationBanner.kt
deleted file mode 100644
index efb27bc4d7..0000000000
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/NotificationBanner.kt
+++ /dev/null
@@ -1,284 +0,0 @@
-package net.mullvad.mullvadvpn.ui
-
-import android.content.Context
-import android.content.Intent
-import android.graphics.drawable.Drawable
-import android.net.Uri
-import android.view.View
-import android.widget.ImageView
-import android.widget.TextView
-import kotlin.properties.Delegates.observable
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.dataproxy.AppVersionInfoCache
-import net.mullvad.mullvadvpn.model.KeygenEvent
-import net.mullvad.mullvadvpn.model.TunnelState
-import net.mullvad.mullvadvpn.service.MullvadDaemon
-import net.mullvad.mullvadvpn.util.TimeLeftFormatter
-import net.mullvad.talpid.tunnel.ActionAfterDisconnect
-import net.mullvad.talpid.tunnel.ErrorState
-import net.mullvad.talpid.tunnel.ErrorStateCause
-import net.mullvad.talpid.tunnel.ParameterGenerationError
-import org.joda.time.DateTime
-
-class NotificationBanner(
- val parentView: View,
- val context: Context,
- val versionInfoCache: AppVersionInfoCache,
- val daemon: MullvadDaemon
-) {
- enum class ExternalLink {
- BuyMoreTime,
- Download,
- KeyManagement
- }
-
- private val resources = context.resources
- private val timeLeftFormatter = TimeLeftFormatter(resources)
-
- private val buyMoreTimeUrl = context.getString(R.string.account_url)
- private val downloadUrl = Uri.parse(context.getString(R.string.download_url))
- private val keyManagementUrl = context.getString(R.string.wg_key_url)
-
- private val errorImage = resources.getDrawable(R.drawable.icon_notification_error, null)
- private val warningImage = resources.getDrawable(R.drawable.icon_notification_warning, null)
-
- private val banner: View = parentView.findViewById(R.id.notification_banner)
- private val status: ImageView = parentView.findViewById(R.id.notification_status)
- private val title: TextView = parentView.findViewById(R.id.notification_title)
- private val message: TextView = parentView.findViewById(R.id.notification_message)
- private val icon: View = parentView.findViewById(R.id.notification_icon)
-
- private var updateJob: Job? = null
-
- private var externalLink: ExternalLink? = null
- private var visible = false
-
- private val clickController = BlockingController(
- object : BlockableView {
- override fun setEnabled(enabled: Boolean) {
- if (enabled) {
- banner.setAlpha(1f)
- banner.setClickable(true)
- } else {
- banner.setAlpha(0.5f)
- banner.setClickable(false)
- }
- }
-
- override fun onClick() = GlobalScope.launch(Dispatchers.Default) {
- buildUrl()?.let { url ->
- context.startActivity(Intent(Intent.ACTION_VIEW, url))
- }
- }
-
- private fun buildUrl() = when (externalLink) {
- ExternalLink.BuyMoreTime -> Uri.parse(buyMoreTimeUrl + buildUrlTokenParameter())
- ExternalLink.Download -> downloadUrl
- ExternalLink.KeyManagement -> Uri.parse(keyManagementUrl + buildUrlTokenParameter())
- null -> null
- }
-
- private fun buildUrlTokenParameter() = "?token=${daemon.getWwwAuthToken()}"
- }
- )
-
- var accountExpiry by observable<DateTime?>(null) { _, _, _ -> update() }
- var keyState by observable<KeygenEvent?>(null) { _, _, _ -> update() }
- var tunnelState by observable<TunnelState>(TunnelState.Disconnected()) { _, _, _ -> update() }
-
- init {
- banner.setOnClickListener { clickController.action() }
- }
-
- fun onResume() {
- versionInfoCache.onUpdate = {
- updateJob = GlobalScope.launch(Dispatchers.Main) { update() }
- }
- }
-
- fun onPause() {
- versionInfoCache.onUpdate = null
- updateJob?.cancel()
- clickController.onPause()
- }
-
- private fun update() {
- externalLink = null
-
- updateBasedOnTunnelState() ||
- updateBasedOnKeyState() ||
- updateBasedOnVersionInfo() ||
- updateBasedOnAccountExpiry()
- }
-
- private fun updateBasedOnKeyState(): Boolean {
- val keyState = keyState
- when (keyState) {
- null -> return false
- is KeygenEvent.NewKey -> return false
- is KeygenEvent.TooManyKeys -> {
- externalLink = ExternalLink.KeyManagement
- showError(R.string.wireguard_error, R.string.too_many_keys)
- }
- is KeygenEvent.GenerationFailure -> {
- showError(R.string.wireguard_error, R.string.failed_to_generate_key)
- }
- }
-
- return true
- }
-
- private fun updateBasedOnTunnelState(): Boolean {
- val state = tunnelState
-
- when (state) {
- is TunnelState.Disconnecting -> {
- when (state.actionAfterDisconnect) {
- ActionAfterDisconnect.Nothing -> return false
- ActionAfterDisconnect.Block -> showBlocking(null)
- ActionAfterDisconnect.Reconnect -> showBlocking(null)
- }
- }
- is TunnelState.Disconnected -> return false
- is TunnelState.Connecting -> showBlocking(null)
- is TunnelState.Connected -> return false
- is TunnelState.Error -> showBlocking(state.errorState)
- }
-
- return true
- }
-
- private fun updateBasedOnVersionInfo(): Boolean {
- if (versionInfoCache.isOutdated || !versionInfoCache.isSupported) {
- val title: Int
- val statusImage: Drawable
- val template: Int
-
- if (versionInfoCache.isSupported) {
- title = R.string.update_available
- template = R.string.update_available_description
- statusImage = warningImage
- } else {
- title = R.string.unsupported_version
- template = R.string.unsupported_version_description
- statusImage = errorImage
- }
-
- val parameter = versionInfoCache.upgradeVersion
- val description = context.getString(template, parameter)
-
- externalLink = ExternalLink.Download
-
- show(statusImage, title, description)
-
- return true
- } else {
- return false
- }
- }
-
- private fun updateBasedOnAccountExpiry(): Boolean {
- val expiry = accountExpiry
- val threeDaysFromNow = DateTime.now().plusDays(3)
-
- if (expiry != null && expiry.isBefore(threeDaysFromNow)) {
- val timeLeft = timeLeftFormatter.format(expiry)
-
- externalLink = ExternalLink.BuyMoreTime
-
- show(warningImage, R.string.account_credit_expires_soon, timeLeft)
- } else {
- hide()
- }
-
- return true
- }
-
- private fun showBlocking(errorState: ErrorState?) {
- val cause = errorState?.cause
-
- val messageText = when (cause) {
- null -> null
- is ErrorStateCause.AuthFailed -> R.string.auth_failed
- is ErrorStateCause.Ipv6Unavailable -> R.string.ipv6_unavailable
- is ErrorStateCause.SetFirewallPolicyError -> R.string.set_firewall_policy_error
- is ErrorStateCause.SetDnsError -> R.string.set_dns_error
- is ErrorStateCause.StartTunnelError -> R.string.start_tunnel_error
- is ErrorStateCause.IsOffline -> R.string.is_offline
- is ErrorStateCause.TapAdapterProblem -> R.string.tap_adapter_problem
- is ErrorStateCause.TunnelParameterError -> {
- when (cause.error) {
- ParameterGenerationError.NoMatchingRelay -> R.string.no_matching_relay
- ParameterGenerationError.NoMatchingBridgeRelay -> {
- R.string.no_matching_bridge_relay
- }
- ParameterGenerationError.NoWireguardKey -> R.string.no_wireguard_key
- ParameterGenerationError.CustomTunnelHostResultionError -> {
- R.string.custom_tunnel_host_resolution_error
- }
- }
- }
- is ErrorStateCause.VpnPermissionDenied -> R.string.vpn_permission_denied_error
- }
-
- // if the error state is null, we can assume that we are secure
- if (errorState?.isBlocking ?: true) {
- showError(R.string.blocking_internet, messageText)
- } else {
- val updatedMessageText = when (cause) {
- is ErrorStateCause.VpnPermissionDenied -> messageText
- else -> R.string.failed_to_block_internet
- }
-
- showError(R.string.not_blocking_internet, updatedMessageText)
- }
- }
-
- private fun showError(titleText: Int, messageText: Int?) {
- showError(titleText, messageText?.let { context.getString(it) })
- }
-
- private fun showError(titleText: Int, messageText: String?) {
- show(errorImage, titleText, messageText)
- }
-
- private fun show(statusImage: Drawable, titleText: Int, messageText: String?) {
- if (!visible) {
- visible = true
- banner.visibility = View.VISIBLE
- banner.translationY = -banner.height.toFloat()
- banner.animate().translationY(0.0F).setDuration(350).start()
- }
-
- status.setImageDrawable(statusImage)
- title.setText(titleText)
-
- if (messageText == null) {
- message.visibility = View.GONE
- } else {
- message.setText(messageText)
- message.visibility = View.VISIBLE
- }
-
- if (externalLink == null) {
- banner.setClickable(false)
- icon.visibility = View.GONE
- } else {
- banner.setClickable(true)
- icon.visibility = View.VISIBLE
- }
- }
-
- private fun hide() {
- if (visible) {
- visible = false
- banner.animate().translationY(-banner.height.toFloat()).setDuration(350).withEndAction {
- banner.visibility = View.INVISIBLE
- }
- }
- }
-}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt
new file mode 100644
index 0000000000..c159ee9550
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt
@@ -0,0 +1,46 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import android.content.Context
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.service.AccountCache
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+import net.mullvad.mullvadvpn.util.TimeLeftFormatter
+import org.joda.time.DateTime
+
+class AccountExpiryNotification(
+ context: Context,
+ daemon: MullvadDaemon,
+ private val accountCache: AccountCache
+) : NotificationWithUrlWithToken(context, daemon, R.string.account_url) {
+ private val timeLeftFormatter = TimeLeftFormatter(context.resources)
+
+ init {
+ status = StatusLevel.Error
+ title = context.getString(R.string.account_credit_expires_soon)
+ }
+
+ override fun onResume() {
+ accountCache.onAccountExpiryChange.subscribe(this) { accountExpiry ->
+ jobTracker.newUiJob("updateAccountExpiry") {
+ updateAccountExpiry(accountExpiry)
+ }
+ }
+ }
+
+ override fun onPause() {
+ accountCache.onAccountExpiryChange.unsubscribe(this)
+ }
+
+ private fun updateAccountExpiry(expiry: DateTime?) {
+ val threeDaysFromNow = DateTime.now().plusDays(3)
+
+ if (expiry != null && expiry.isBefore(threeDaysFromNow)) {
+ message = timeLeftFormatter.format(expiry)
+ shouldShow = true
+ } else {
+ shouldShow = false
+ }
+
+ update()
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt
new file mode 100644
index 0000000000..aa58b0bbf5
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt
@@ -0,0 +1,45 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import net.mullvad.mullvadvpn.util.ChangeMonitor
+import net.mullvad.mullvadvpn.util.JobTracker
+
+abstract class InAppNotification {
+ private val changeMonitor = ChangeMonitor()
+ protected val jobTracker = JobTracker()
+
+ var controller: InAppNotificationController? = null
+
+ var status by changeMonitor.monitor(StatusLevel.Error)
+ protected set
+
+ var title by changeMonitor.monitor("")
+ protected set
+
+ var message by changeMonitor.monitor<String?>(null)
+ protected set
+
+ var onClick by changeMonitor.monitor<(suspend () -> Unit)?>(null)
+ protected set
+
+ var showIcon by changeMonitor.monitor(false)
+ protected set
+
+ var shouldShow by changeMonitor.monitor(false)
+ protected set
+
+ open fun onResume() {}
+ open fun onPause() {}
+
+ open fun onDestroy() {
+ jobTracker.cancelAllJobs()
+ }
+
+ protected fun update() {
+ val controller = this.controller
+
+ if (controller != null && changeMonitor.changed) {
+ controller.notificationChanged(this@InAppNotification)
+ changeMonitor.reset()
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt
new file mode 100644
index 0000000000..3c1a62d925
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotificationController.kt
@@ -0,0 +1,78 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import kotlin.properties.Delegates.observable
+
+class InAppNotificationController(private val onNotificationChanged: (InAppNotification?) -> Unit) {
+ private val indices = HashMap<InAppNotification, Int>()
+ private val notifications = ArrayList<InAppNotification>()
+
+ private var currentIndex: Int? = null
+
+ var current by observable<InAppNotification?>(null) { _, _, notification ->
+ onNotificationChanged?.invoke(notification)
+ }
+
+ fun register(notification: InAppNotification) {
+ notification.controller = this
+
+ indices.put(notification, notifications.size)
+ notifications.add(notification)
+
+ notificationChanged(notification)
+ }
+
+ fun onResume() {
+ for (notification in notifications) {
+ notification.onResume()
+ }
+ }
+
+ fun onPause() {
+ for (notification in notifications) {
+ notification.onPause()
+ }
+ }
+
+ fun onDestroy() {
+ for (notification in notifications) {
+ notification.onDestroy()
+ }
+ }
+
+ fun notificationChanged(notification: InAppNotification) {
+ if (notification.shouldShow) {
+ maybeShowNotification(notification)
+ } else {
+ maybeHideNotification(notification)
+ }
+ }
+
+ private fun maybeShowNotification(notification: InAppNotification) {
+ indices.get(notification)?.let { index ->
+ if (index <= (currentIndex ?: Int.MAX_VALUE)) {
+ current = notification
+ currentIndex = index
+ }
+ }
+ }
+
+ private fun maybeHideNotification(notification: InAppNotification) {
+ if (current == notification) {
+ val start = currentIndex!! + 1
+ val end = notifications.size
+
+ for (index in start until end) {
+ val candidate = notifications.get(index)
+
+ if (candidate.shouldShow) {
+ current = candidate
+ currentIndex = index
+ return
+ }
+ }
+
+ current = null
+ currentIndex = null
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt
new file mode 100644
index 0000000000..880cea9f8c
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/KeyStatusNotification.kt
@@ -0,0 +1,58 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import android.content.Context
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.model.KeygenEvent
+import net.mullvad.mullvadvpn.service.KeyStatusListener
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+
+class KeyStatusNotification(
+ context: Context,
+ daemon: MullvadDaemon,
+ private val keyStatusListener: KeyStatusListener
+) : NotificationWithUrlWithToken(context, daemon, R.string.wg_key_url) {
+ private val failedToGenerateKey = context.getString(R.string.failed_to_generate_key)
+ private val tooManyKeys = context.getString(R.string.too_many_keys)
+
+ init {
+ status = StatusLevel.Error
+ title = context.getString(R.string.wireguard_error)
+ }
+
+ override fun onResume() {
+ keyStatusListener.onKeyStatusChange.subscribe(this) { keyStatus ->
+ jobTracker.newUiJob("updateKeyStatus") {
+ updateKeyStatus(keyStatus)
+ }
+ }
+ }
+
+ override fun onPause() {
+ keyStatusListener.onKeyStatusChange.unsubscribe(this)
+ }
+
+ private fun updateKeyStatus(keyStatus: KeygenEvent?) {
+ when (keyStatus) {
+ null -> shouldShow = false
+ is KeygenEvent.NewKey -> shouldShow = false
+ is KeygenEvent.TooManyKeys -> showTooManyKeys()
+ is KeygenEvent.GenerationFailure -> showGenerationFailure()
+ }
+
+ update()
+ }
+
+ private fun showTooManyKeys() {
+ onClick = openUrl
+ message = tooManyKeys
+ showIcon = true
+ shouldShow = true
+ }
+
+ private fun showGenerationFailure() {
+ onClick = null
+ message = failedToGenerateKey
+ showIcon = false
+ shouldShow = true
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt
new file mode 100644
index 0000000000..4257f8d2a6
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrl.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+
+abstract class NotificationWithUrl(
+ protected val context: Context,
+ urlId: Int
+) : InAppNotification() {
+ private val url = Uri.parse(context.getString(urlId))
+
+ protected val openUrl: suspend () -> Unit = {
+ context.startActivity(Intent(Intent.ACTION_VIEW, url))
+ }
+
+ init {
+ onClick = openUrl
+ showIcon = true
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt
new file mode 100644
index 0000000000..2c8c713a83
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/NotificationWithUrlWithToken.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+
+abstract class NotificationWithUrlWithToken(
+ protected val context: Context,
+ protected val daemon: MullvadDaemon,
+ urlId: Int
+) : InAppNotification() {
+ private val url = context.getString(urlId)
+
+ protected val openUrl: suspend () -> Unit = {
+ context.startActivity(Intent(Intent.ACTION_VIEW, buildUrl()))
+ }
+
+ init {
+ onClick = openUrl
+ showIcon = true
+ }
+
+ private fun buildUrl() = Uri.parse("$url?token=${daemon.getWwwAuthToken()}")
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt
new file mode 100644
index 0000000000..e592e55647
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/StatusLevel.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+enum class StatusLevel {
+ Warning,
+ Error,
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt
new file mode 100644
index 0000000000..7c86abd113
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt
@@ -0,0 +1,102 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import android.content.Context
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.service.ConnectionProxy
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+import net.mullvad.talpid.tunnel.ErrorState
+import net.mullvad.talpid.tunnel.ErrorStateCause
+import net.mullvad.talpid.tunnel.ParameterGenerationError
+
+class TunnelStateNotification(
+ private val context: Context,
+ private val connectionProxy: ConnectionProxy
+) : InAppNotification() {
+ private val blockingTitle = context.getString(R.string.blocking_internet)
+ private val notBlockingTitle = context.getString(R.string.not_blocking_internet)
+
+ init {
+ status = StatusLevel.Error
+ onClick = null
+ showIcon = false
+ }
+
+ override fun onResume() {
+ connectionProxy.onStateChange.subscribe(this) { tunnelState ->
+ jobTracker.newUiJob("updateTunnelState") {
+ updateTunnelState(tunnelState)
+ }
+ }
+ }
+
+ override fun onPause() {
+ connectionProxy.onStateChange.unsubscribe(this)
+ }
+
+ private fun updateTunnelState(state: TunnelState) {
+ when (state) {
+ is TunnelState.Disconnecting -> {
+ when (state.actionAfterDisconnect) {
+ ActionAfterDisconnect.Nothing -> hide()
+ ActionAfterDisconnect.Block -> show(null)
+ ActionAfterDisconnect.Reconnect -> show(null)
+ }
+ }
+ is TunnelState.Disconnected -> hide()
+ is TunnelState.Connecting -> show(null)
+ is TunnelState.Connected -> hide()
+ is TunnelState.Error -> show(state.errorState)
+ }
+
+ update()
+ }
+
+ private fun show(error: ErrorState?) {
+ val cause = error?.cause
+
+ val messageText = when (cause) {
+ null -> null
+ is ErrorStateCause.AuthFailed -> R.string.auth_failed
+ is ErrorStateCause.Ipv6Unavailable -> R.string.ipv6_unavailable
+ is ErrorStateCause.SetFirewallPolicyError -> R.string.set_firewall_policy_error
+ is ErrorStateCause.SetDnsError -> R.string.set_dns_error
+ is ErrorStateCause.StartTunnelError -> R.string.start_tunnel_error
+ is ErrorStateCause.IsOffline -> R.string.is_offline
+ is ErrorStateCause.TapAdapterProblem -> R.string.tap_adapter_problem
+ is ErrorStateCause.TunnelParameterError -> {
+ when (cause.error) {
+ ParameterGenerationError.NoMatchingRelay -> R.string.no_matching_relay
+ ParameterGenerationError.NoMatchingBridgeRelay -> {
+ R.string.no_matching_bridge_relay
+ }
+ ParameterGenerationError.NoWireguardKey -> R.string.no_wireguard_key
+ ParameterGenerationError.CustomTunnelHostResultionError -> {
+ R.string.custom_tunnel_host_resolution_error
+ }
+ }
+ }
+ is ErrorStateCause.VpnPermissionDenied -> R.string.vpn_permission_denied_error
+ }
+
+ // if the error state is null, we can assume that we are secure
+ if (error?.isBlocking ?: true) {
+ title = blockingTitle
+ message = messageText?.let { id -> context.getString(id) }
+ } else {
+ val updatedMessageText = when (cause) {
+ is ErrorStateCause.VpnPermissionDenied -> messageText
+ else -> R.string.failed_to_block_internet
+ }
+
+ title = notBlockingTitle
+ message = updatedMessageText?.let { id -> context.getString(id) }
+ }
+
+ shouldShow = true
+ }
+
+ private fun hide() {
+ shouldShow = false
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt
new file mode 100644
index 0000000000..8c5fe7568e
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt
@@ -0,0 +1,53 @@
+package net.mullvad.mullvadvpn.ui.notification
+
+import android.content.Context
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.dataproxy.AppVersionInfoCache
+
+class VersionInfoNotification(
+ context: Context,
+ private val versionInfoCache: AppVersionInfoCache
+) : NotificationWithUrl(context, R.string.download_url) {
+ private val unsupportedVersion = context.getString(R.string.unsupported_version)
+ private val updateAvailable = context.getString(R.string.update_available)
+
+ override fun onResume() {
+ versionInfoCache.onUpdate = {
+ jobTracker.newUiJob("updateVersionInfo") {
+ updateVersionInfo(
+ versionInfoCache.isOutdated,
+ versionInfoCache.isSupported,
+ versionInfoCache.upgradeVersion
+ )
+ }
+ }
+ }
+
+ override fun onPause() {
+ versionInfoCache.onUpdate = null
+ }
+
+ private fun updateVersionInfo(isOutdated: Boolean, isSupported: Boolean, upgrade: String?) {
+ if (isOutdated || !isSupported) {
+ val template: Int
+
+ if (isSupported) {
+ status = StatusLevel.Warning
+ title = updateAvailable
+ template = R.string.update_available_description
+ } else {
+ status = StatusLevel.Error
+ title = unsupportedVersion
+ template = R.string.unsupported_version_description
+ }
+
+ message = context.getString(template, upgrade)
+
+ shouldShow = true
+ } else {
+ shouldShow = false
+ }
+
+ update()
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt
new file mode 100644
index 0000000000..2992ee94eb
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NotificationBanner.kt
@@ -0,0 +1,127 @@
+package net.mullvad.mullvadvpn.ui.widget
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.ui.notification.InAppNotification
+import net.mullvad.mullvadvpn.ui.notification.InAppNotificationController
+import net.mullvad.mullvadvpn.ui.notification.StatusLevel
+import net.mullvad.mullvadvpn.util.JobTracker
+
+class NotificationBanner : FrameLayout {
+ private val jobTracker = JobTracker()
+
+ private val container =
+ context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service ->
+ val inflater = service as LayoutInflater
+
+ inflater.inflate(R.layout.notification_banner, this)
+ }
+
+ private val errorImage = resources.getDrawable(R.drawable.icon_notification_error, null)
+ private val warningImage = resources.getDrawable(R.drawable.icon_notification_warning, null)
+
+ private val status: ImageView = container.findViewById(R.id.notification_status)
+ private val title: TextView = container.findViewById(R.id.notification_title)
+ private val message: TextView = container.findViewById(R.id.notification_message)
+ private val icon: View = container.findViewById(R.id.notification_icon)
+
+ val notifications = InAppNotificationController { notification ->
+ if (notification != null) {
+ update(notification)
+ }
+
+ animateChange()
+ }
+
+ constructor(context: Context) : super(context) {}
+ constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {}
+
+ constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) :
+ super(context, attributes, defaultStyleAttribute) {}
+
+ constructor(
+ context: Context,
+ attributes: AttributeSet,
+ defaultStyleAttribute: Int,
+ defaultStyleResource: Int
+ ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {}
+
+ init {
+ setBackgroundResource(R.color.darkBlue)
+
+ setOnClickListener {
+ jobTracker.newUiJob("click") { onClick() }
+ }
+ }
+
+ fun onResume() {
+ notifications.onResume()
+ }
+
+ fun onPause() {
+ notifications.onPause()
+ }
+
+ fun onDestroy() {
+ notifications.onDestroy()
+ }
+
+ private suspend fun onClick() {
+ notifications.current?.onClick?.let { action ->
+ alpha = 0.5f
+ setClickable(false)
+
+ jobTracker.runOnBackground(action)
+
+ setClickable(true)
+ alpha = 1.0f
+ }
+ }
+
+ private fun update(notification: InAppNotification) {
+ val notificationMessage = notification.message
+ val clickAction = notification.onClick
+
+ when (notification.status) {
+ StatusLevel.Error -> status.setImageDrawable(errorImage)
+ StatusLevel.Warning -> status.setImageDrawable(warningImage)
+ }
+
+ title.text = notification.title
+
+ if (notificationMessage != null) {
+ message.text = notificationMessage
+ message.visibility = View.VISIBLE
+ } else {
+ message.visibility = View.GONE
+ }
+
+ if (notification.showIcon) {
+ icon.visibility = View.VISIBLE
+ } else {
+ icon.visibility = View.GONE
+ }
+
+ setClickable(clickAction != null)
+ }
+
+ private fun animateChange() {
+ val shouldShow = notifications.current != null
+
+ if (shouldShow && visibility == View.INVISIBLE) {
+ visibility = View.VISIBLE
+ translationY = -height.toFloat()
+ animate().translationY(0.0F).setDuration(350).start()
+ } else if (!shouldShow && visibility == View.VISIBLE) {
+ animate().translationY(-height.toFloat()).setDuration(350).withEndAction {
+ visibility = View.INVISIBLE
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt
new file mode 100644
index 0000000000..398177db99
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/ChangeMonitor.kt
@@ -0,0 +1,18 @@
+package net.mullvad.mullvadvpn.util
+
+import kotlin.properties.Delegates.observable
+
+class ChangeMonitor {
+ var changed = false
+ private set
+
+ fun <T> monitor(initialValue: T) = observable(initialValue) { _, oldValue, newValue ->
+ if (oldValue != newValue) {
+ changed = true
+ }
+ }
+
+ fun reset() {
+ changed = false
+ }
+}
diff --git a/android/src/main/res/layout/connect.xml b/android/src/main/res/layout/connect.xml
index 9e33f21f1e..2d7877cf2b 100644
--- a/android/src/main/res/layout/connect.xml
+++ b/android/src/main/res/layout/connect.xml
@@ -36,63 +36,10 @@
</LinearLayout>
<android.support.design.widget.CoordinatorLayout android:layout_width="match_parent"
android:layout_height="match_parent">
- <FrameLayout android:id="@+id/notification_banner"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:background="@color/darkBlue"
- android:visibility="invisible"
- android:clickable="false"
- android:elevation="0.25dp">
- <RelativeLayout android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingVertical="8dp"
- android:paddingLeft="20dp"
- android:paddingRight="10dp"
- android:background="?android:attr/selectableItemBackground">
- <RelativeLayout android:id="@+id/notification_status_container"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentTop="true"
- android:layout_alignParentLeft="true"
- android:layout_alignBottom="@id/notification_title">
- <ImageView android:id="@+id/notification_status"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_centerInParent="true"
- android:src="@drawable/icon_notification_error" />
- </RelativeLayout>
- <TextView android:id="@+id/notification_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentTop="true"
- android:layout_toLeftOf="@id/notification_icon"
- android:layout_toRightOf="@id/notification_status_container"
- android:layout_marginLeft="7dp"
- android:textSize="13sp"
- android:textStyle="bold"
- android:text="@string/blocking_internet"
- android:textAllCaps="true" />
- <TextView android:id="@+id/notification_message"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignWithParentIfMissing="true"
- android:layout_toLeftOf="@id/notification_icon"
- android:layout_alignLeft="@id/notification_title"
- android:layout_below="@id/notification_title"
- android:textSize="13sp"
- android:textColor="@color/white60"
- android:text=""
- android:visibility="gone" />
- <ImageView android:id="@+id/notification_icon"
- android:layout_width="12dp"
- android:layout_height="12dp"
- android:layout_alignParentRight="true"
- android:layout_centerVertical="true"
- android:alpha="0.6"
- android:src="@drawable/icon_extlink"
- android:visibility="gone" />
- </RelativeLayout>
- </FrameLayout>
+ <net.mullvad.mullvadvpn.ui.widget.NotificationBanner android:id="@+id/notification_banner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:elevation="0.25dp" />
<ScrollView android:id="@+id/body"
android:layout_width="match_parent"
android:layout_height="match_parent"
diff --git a/android/src/main/res/layout/notification_banner.xml b/android/src/main/res/layout/notification_banner.xml
new file mode 100644
index 0000000000..82d3792f07
--- /dev/null
+++ b/android/src/main/res/layout/notification_banner.xml
@@ -0,0 +1,50 @@
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingVertical="8dp"
+ android:paddingLeft="20dp"
+ android:paddingRight="10dp"
+ android:background="?android:attr/selectableItemBackground">
+ <RelativeLayout android:id="@+id/notification_status_container"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_alignParentLeft="true"
+ android:layout_alignBottom="@id/notification_title">
+ <ImageView android:id="@+id/notification_status"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_centerInParent="true"
+ android:src="@drawable/icon_notification_error" />
+ </RelativeLayout>
+ <TextView android:id="@+id/notification_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_toLeftOf="@id/notification_icon"
+ android:layout_toRightOf="@id/notification_status_container"
+ android:layout_marginLeft="7dp"
+ android:textSize="13sp"
+ android:textStyle="bold"
+ android:text="@string/blocking_internet"
+ android:textAllCaps="true" />
+ <TextView android:id="@+id/notification_message"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignWithParentIfMissing="true"
+ android:layout_toLeftOf="@id/notification_icon"
+ android:layout_alignLeft="@id/notification_title"
+ android:layout_below="@id/notification_title"
+ android:textSize="13sp"
+ android:textColor="@color/white60"
+ android:text=""
+ android:visibility="gone" />
+ <ImageView android:id="@+id/notification_icon"
+ android:layout_width="12dp"
+ android:layout_height="12dp"
+ android:layout_alignParentRight="true"
+ android:layout_centerVertical="true"
+ android:alpha="0.6"
+ android:src="@drawable/icon_extlink"
+ android:visibility="gone" />
+</RelativeLayout>