diff options
| author | Janito Vaqueiro Ferreira Filho <janito@mullvad.net> | 2020-07-01 17:47:45 -0300 |
|---|---|---|
| committer | Janito Vaqueiro Ferreira Filho <janito@mullvad.net> | 2020-07-01 17:47:45 -0300 |
| commit | 5902ecb40a11332764fbdbb5f902868f5b75211e (patch) | |
| tree | 8b8e5e894a8810dfa012a962609e4cc48d30a799 /android/src/main | |
| parent | d8cbd703261fc60c0864264a0bd632f3823c723e (diff) | |
| parent | 5bf48b1125b520ffa39ac13075c8d56fd5c91c6b (diff) | |
| download | mullvadvpn-5902ecb40a11332764fbdbb5f902868f5b75211e.tar.xz mullvadvpn-5902ecb40a11332764fbdbb5f902868f5b75211e.zip | |
Merge branch 'in-app-notification-refactor'
Diffstat (limited to 'android/src/main')
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> |
