diff --git a/android/app/build.gradle b/android/app/build.gradle index f9d292809..3fd671b9b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -71,7 +71,7 @@ android { } defaultConfig { - applicationId "network.mysterium.vpn" + applicationId "network.mysterium.provider" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode getVersionCode() diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9620cbb77..a82e1bfdf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -90,6 +90,10 @@ android:name="updated.mysterium.vpn.ui.settings.SettingsActivity" android:screenOrientation="portrait" /> + + diff --git a/android/app/src/main/java/updated/mysterium/vpn/core/DeferredNode.kt b/android/app/src/main/java/updated/mysterium/vpn/core/DeferredNode.kt index 6cd30fafb..6fb52c35e 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/core/DeferredNode.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/core/DeferredNode.kt @@ -8,7 +8,6 @@ import mysterium.MobileNode // DeferredNode is a wrapper class which holds MobileNode instance promise. // This allows to load UI without waiting for node to start. class DeferredNode { - private companion object { const val TAG = "DeferredNode" } @@ -26,6 +25,7 @@ class DeferredNode { ) { if (!lock.tryAcquire()) { Log.i(TAG, "Node is already started or starting, skipping") + } else { val handler = CoroutineExceptionHandler { _, exception -> Log.e(TAG, exception.localizedMessage ?: exception.toString()) diff --git a/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumAndroidCoreService.kt b/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumAndroidCoreService.kt index 4f0e658b9..87a1a052e 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumAndroidCoreService.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumAndroidCoreService.kt @@ -25,12 +25,10 @@ import android.os.Binder import android.os.Bundle import android.os.IBinder import android.util.Log -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import mysterium.MobileNode import mysterium.Mysterium +import network.mysterium.vpn.BuildConfig import network.mysterium.vpn.R import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -75,6 +73,7 @@ class MysteriumAndroidCoreService : VpnService(), KoinComponent { private var currentState = ConnectionState.NOTCONNECTED private var vpnTimeSpent: Float? = null // time spent for last session in minutes private var secondsBetweenAnalyticEvent = 0 + private var isProviderActive = false override fun onDestroy() { stopMobileNode() @@ -89,11 +88,50 @@ class MysteriumAndroidCoreService : VpnService(), KoinComponent { return MysteriumCoreServiceBridge() } + private fun startMobileProviderService(active: Boolean) { + mobileNode?.let { + isProviderActive = active + try { + if (active) { + it.startProvider() + } else { + it.stopProvider() + } + + } catch (e: Exception) { + isProviderActive = !active + println(e) + } + } + } + + private fun innerStopConsumer() { + var c = currentState == ConnectionState.CONNECTED || + currentState == ConnectionState.CONNECTING || + currentState == ConnectionState.ON_HOLD || + currentState == ConnectionState.IP_NOT_CHANGED + + + GlobalScope.launch(Dispatchers.IO) { + if (c) { + connectionUseCase.disconnect() + + activeProposal = null + deferredNode = null + stopForeground(true) + } + } + } + private fun startMobileNode(filesPath: String): MobileNode { mobileNode?.let { return it } - mobileNode = Mysterium.newNode(filesPath, Mysterium.defaultNodeOptions()) + mobileNode = Mysterium.newNode(filesPath, Mysterium.defaultProviderNodeOptions()) + + val launcherVersion = String.format("%s/android", BuildConfig.VERSION_NAME) + Mysterium.setFlagLauncherVersion(launcherVersion) + mobileNode?.overrideWireguardConnection(WireguardAndroidTunnelSetup(this@MysteriumAndroidCoreService)) return mobileNode ?: MobileNode() } @@ -272,6 +310,16 @@ class MysteriumAndroidCoreService : VpnService(), KoinComponent { inner class MysteriumCoreServiceBridge : Binder(), MysteriumCoreService { + override fun stopConsumer() { + innerStopConsumer() + } + override fun startProvider(active: Boolean) { + startMobileProviderService(active) + } + override fun isProviderActive(): Boolean { + return isProviderActive + } + override fun getDeferredNode() = deferredNode override fun setDeferredNode(node: DeferredNode?) { diff --git a/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumCoreService.kt b/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumCoreService.kt index 4f1e98ed3..4be88f539 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumCoreService.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/core/MysteriumCoreService.kt @@ -26,6 +26,9 @@ import updated.mysterium.vpn.notification.NotificationFactory interface MysteriumCoreService : IBinder { suspend fun startNode(): MobileNode + fun isProviderActive(): Boolean + fun startProvider(provider: Boolean) + fun stopConsumer() fun stopNode() diff --git a/android/app/src/main/java/updated/mysterium/vpn/core/NodeRepository.kt b/android/app/src/main/java/updated/mysterium/vpn/core/NodeRepository.kt index 14f9916d9..4cd5e98f0 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/core/NodeRepository.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/core/NodeRepository.kt @@ -19,6 +19,7 @@ import updated.mysterium.vpn.model.identity.MigrateHermesStatus import updated.mysterium.vpn.model.identity.MigrateHermesStatusResponse import updated.mysterium.vpn.model.manual.connect.ConnectionState import updated.mysterium.vpn.model.manual.connect.CountryInfo +import updated.mysterium.vpn.model.manual.connect.ServiceStatus import updated.mysterium.vpn.model.nodes.ProposalItem import updated.mysterium.vpn.model.nodes.ProposalsResponse import updated.mysterium.vpn.model.payment.Order @@ -71,6 +72,16 @@ class NodeRepository(var deferredNode: DeferredNode) { } } + // Register service status callback. + suspend fun registerServiceStatusChangeCallback(cb: (stats: ServiceStatus) -> Unit) { + withContext(Dispatchers.IO) { + deferredNode.await() + .registerServiceStatusChangeCallback { service, status -> + cb(ServiceStatus(service, status)) + } + } + } + // Register statistics callback. suspend fun registerStatisticsChangeCallback(cb: (stats: Statistics) -> Unit) { withContext(Dispatchers.IO) { diff --git a/android/app/src/main/java/updated/mysterium/vpn/di/Modules.kt b/android/app/src/main/java/updated/mysterium/vpn/di/Modules.kt index 7ae92ddd1..91fb460cd 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/di/Modules.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/di/Modules.kt @@ -31,6 +31,7 @@ import updated.mysterium.vpn.ui.onboarding.OnboardingViewModel import updated.mysterium.vpn.ui.prepare.top.up.PrepareTopUpViewModel import updated.mysterium.vpn.ui.private.key.PrivateKeyViewModel import updated.mysterium.vpn.ui.profile.ProfileViewModel +import updated.mysterium.vpn.ui.provider.ProviderViewModel import updated.mysterium.vpn.ui.report.issue.ReportIssueViewModel import updated.mysterium.vpn.ui.search.SearchViewModel import updated.mysterium.vpn.ui.settings.SettingsViewModel @@ -158,6 +159,9 @@ object Modules { viewModel { SelectCountryViewModel(get()) } + single { + ProviderViewModel(get()) + } } fun provideDatabase(context: Context) = Room.databaseBuilder( diff --git a/android/app/src/main/java/updated/mysterium/vpn/model/manual/connect/ProviderState.kt b/android/app/src/main/java/updated/mysterium/vpn/model/manual/connect/ProviderState.kt new file mode 100644 index 000000000..e8a3f58c5 --- /dev/null +++ b/android/app/src/main/java/updated/mysterium/vpn/model/manual/connect/ProviderState.kt @@ -0,0 +1,10 @@ +package updated.mysterium.vpn.model.manual.connect + +data class ProviderState( + val active: Boolean, +) + +class ServiceStatus( + val service: String, + val status: String +) diff --git a/android/app/src/main/java/updated/mysterium/vpn/model/notification/NotificationChannels.kt b/android/app/src/main/java/updated/mysterium/vpn/model/notification/NotificationChannels.kt index 8d0853d73..581a0cac6 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/model/notification/NotificationChannels.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/model/notification/NotificationChannels.kt @@ -7,4 +7,5 @@ object NotificationChannels { const val BALANCE_NOTIFICATION_ID = 4 const val PRIVATE_KEY_NOTIFICATION_ID = 5 const val PAYMENT_STATUS_ID = 6 + const val PROVIDER_NOTIFICATION = 7 } diff --git a/android/app/src/main/java/updated/mysterium/vpn/network/usecase/ConnectionUseCase.kt b/android/app/src/main/java/updated/mysterium/vpn/network/usecase/ConnectionUseCase.kt index f1a917cec..322ebf0ff 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/network/usecase/ConnectionUseCase.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/network/usecase/ConnectionUseCase.kt @@ -3,10 +3,13 @@ package updated.mysterium.vpn.network.usecase import mysterium.ConnectRequest import mysterium.GetIdentityRequest import mysterium.RegisterIdentityRequest +import okhttp3.internal.format import updated.mysterium.vpn.core.DeferredNode import updated.mysterium.vpn.core.NodeRepository import updated.mysterium.vpn.database.preferences.SharedPreferencesList import updated.mysterium.vpn.database.preferences.SharedPreferencesManager +import updated.mysterium.vpn.model.manual.connect.ConnectionState +import updated.mysterium.vpn.model.manual.connect.ServiceStatus import updated.mysterium.vpn.model.statistics.Statistics import updated.mysterium.vpn.model.wallet.Identity @@ -77,6 +80,10 @@ class ConnectionUseCase( callback: (Statistics) -> Unit ) = nodeRepository.registerStatisticsChangeCallback(callback) + suspend fun serviceStatusChangeCallback( + callback: (ServiceStatus) -> Unit + ) = nodeRepository.registerServiceStatusChangeCallback(callback) + suspend fun connectionStatusCallback( callback: (String) -> Unit ) = nodeRepository.registerConnectionStatusChangeCallback(callback) diff --git a/android/app/src/main/java/updated/mysterium/vpn/notification/AppNotificationManager.kt b/android/app/src/main/java/updated/mysterium/vpn/notification/AppNotificationManager.kt index ab4bc2823..c23b5afe2 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/notification/AppNotificationManager.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/notification/AppNotificationManager.kt @@ -8,6 +8,7 @@ import androidx.core.app.NotificationCompat import network.mysterium.vpn.R import updated.mysterium.vpn.model.notification.NotificationChannels import updated.mysterium.vpn.ui.connection.ConnectionActivity +import updated.mysterium.vpn.ui.provider.ProviderActivity import updated.mysterium.vpn.ui.splash.SplashActivity import updated.mysterium.vpn.ui.splash.SplashActivity.Companion.REDIRECTED_FROM_PUSH_KEY @@ -19,6 +20,7 @@ class AppNotificationManager(private val notificationManager: NotificationManage const val ACTION_DISCONNECT = "DISCONNECT" } + private val providerChannel = "provider" private val statisticsChannel = "statistics" private val connLostChannel = "connectionlost" private val paymentStatusChannel = "paymentstatus" @@ -28,6 +30,7 @@ class AppNotificationManager(private val notificationManager: NotificationManage // pendingAppIntent is used to navigate back to MainActivity // when user taps on notification. private lateinit var pendingAppIntent: PendingIntent + private lateinit var pendingProviderIntent: PendingIntent fun init(ctx: Context) { context = ctx @@ -36,11 +39,17 @@ class AppNotificationManager(private val notificationManager: NotificationManage } pendingAppIntent = PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + val intentProvider = Intent(ctx, ProviderActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + pendingProviderIntent = PendingIntent.getActivity(ctx, 0, intentProvider, PendingIntent.FLAG_IMMUTABLE) + registerAllNotificationChannels(context) } private fun registerAllNotificationChannels(context: Context) { with(context) { + createChannel(providerChannel, getString(R.string.provider_notification_chanel)) createChannel(statisticsChannel, getString(R.string.statisctics_notification_chanel)) createChannel(connLostChannel, getString(R.string.connection_lost_notification_chanel)) createChannel(paymentStatusChannel, getString(R.string.payment_notification_chanel)) @@ -72,6 +81,19 @@ class AppNotificationManager(private val notificationManager: NotificationManage } } + fun createProviderNotification(): NotificationFactory { + return { + NotificationCompat.Builder(it, providerChannel) + .setSmallIcon(R.drawable.notification_logo) + .setContentTitle("Provider is active") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVibrate(LongArray(0)) + .setContentIntent(pendingProviderIntent) + .setOnlyAlertOnce(true) + .build() + } + } + fun showStatisticsNotification(title: String, content: String) { val disconnectIntent = Intent(context, AppBroadcastReceiver::class.java).apply { action = ACTION_DISCONNECT diff --git a/android/app/src/main/java/updated/mysterium/vpn/ui/base/BaseActivity.kt b/android/app/src/main/java/updated/mysterium/vpn/ui/base/BaseActivity.kt index 8ed47d6e7..cde19dce7 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/ui/base/BaseActivity.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/ui/base/BaseActivity.kt @@ -35,6 +35,9 @@ import updated.mysterium.vpn.ui.connection.ConnectionActivity import updated.mysterium.vpn.ui.custom.view.ConnectionToolbar import updated.mysterium.vpn.ui.home.selection.HomeSelectionActivity import updated.mysterium.vpn.ui.home.selection.HomeSelectionViewModel +import updated.mysterium.vpn.ui.menu.MenuActivity +import updated.mysterium.vpn.ui.provider.ProviderActivity + import java.util.* abstract class BaseActivity : AppCompatActivity() { @@ -266,7 +269,7 @@ abstract class BaseActivity : AppCompatActivity() { ) { Intent(this, ConnectionActivity::class.java) } else { - Intent(this, HomeSelectionActivity::class.java) + Intent(this, MenuActivity::class.java) } intent.apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK diff --git a/android/app/src/main/java/updated/mysterium/vpn/ui/menu/MenuActivity.kt b/android/app/src/main/java/updated/mysterium/vpn/ui/menu/MenuActivity.kt index 948b046b9..f5bda068a 100644 --- a/android/app/src/main/java/updated/mysterium/vpn/ui/menu/MenuActivity.kt +++ b/android/app/src/main/java/updated/mysterium/vpn/ui/menu/MenuActivity.kt @@ -22,6 +22,7 @@ import updated.mysterium.vpn.ui.monitoring.MonitoringActivity import updated.mysterium.vpn.ui.profile.ProfileActivity import updated.mysterium.vpn.ui.report.issue.ReportIssueActivity import updated.mysterium.vpn.ui.settings.SettingsActivity +import updated.mysterium.vpn.ui.provider.ProviderActivity import updated.mysterium.vpn.ui.terms.TermsOfUseActivity import updated.mysterium.vpn.ui.wallet.WalletActivity @@ -50,6 +51,10 @@ class MenuActivity : BaseActivity() { iconResId = R.drawable.menu_icon_settings, titleResId = R.string.menu_list_item_settings, ), + MenuItem( + iconResId = R.drawable.menu_icon_settings, + titleResId = R.string.menu_list_item_provider, + ), MenuItem( iconResId = R.drawable.menu_icon_referral_deactivated, titleResId = R.string.menu_item_referral_title, @@ -185,6 +190,9 @@ class MenuActivity : BaseActivity() { startActivity(Intent(this, SettingsActivity::class.java)) } 5 -> menuItem.onItemClickListener = { + startActivity(Intent(this, ProviderActivity::class.java)) + } + 6 -> menuItem.onItemClickListener = { // TODO("Implement navigation to Referral") } } diff --git a/android/app/src/main/java/updated/mysterium/vpn/ui/provider/ProviderActivity.kt b/android/app/src/main/java/updated/mysterium/vpn/ui/provider/ProviderActivity.kt new file mode 100644 index 000000000..b73a4cded --- /dev/null +++ b/android/app/src/main/java/updated/mysterium/vpn/ui/provider/ProviderActivity.kt @@ -0,0 +1,90 @@ +package updated.mysterium.vpn.ui.provider + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import network.mysterium.vpn.R +import network.mysterium.vpn.databinding.ActivityProviderBinding +import org.koin.android.ext.android.inject +import updated.mysterium.vpn.App +import updated.mysterium.vpn.notification.AppNotificationManager +import updated.mysterium.vpn.ui.base.BaseActivity + + +class ProviderActivity : BaseActivity() { + + private companion object { + const val TAG = "ProviderActivity" + } + + private lateinit var binding: ActivityProviderBinding + private val viewModel: ProviderViewModel by inject() + private val notificationManager: AppNotificationManager by inject() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityProviderBinding.inflate(layoutInflater) + setContentView(binding.root) + + viewModel.init( + deferredMysteriumCoreService = App.getInstance(this).deferredMysteriumCoreService, + notificationManager = notificationManager + ) + configure() + bindsAction() + } + + override fun showConnectionHint() { + binding.connectionHint.visibility = View.VISIBLE + baseViewModel.hintShown() + } + + private fun configure() { + initToolbar(binding.manualConnectToolbar) + + viewModel.providerUpdate.observe(this) { + // prevent triggering of switch handler + binding.providerModeSwitch.tag = true + binding.providerModeSwitch.isChecked = it.active + binding.providerModeSwitch.tag = null + } + + viewModel.providerServiceStatus.observe(this) { + fun getStatusTxt(a: Boolean): Int { + if (a) { + return R.string.service_active_title; + } + return R.string.service_idle_title + } + binding.titleSvcState1.setText(getStatusTxt(it.active[0])) + binding.titleSvcState2.setText(getStatusTxt(it.active[1])) + binding.titleSvcState3.setText(getStatusTxt(it.active[2])) + } + } + + private fun bindsAction() { + binding.manualConnectToolbar.onConnectClickListener { + navigateToConnectionIfConnectedOrHome() + } + binding.manualConnectToolbar.onLeftButtonClicked { + finish() + } + binding.providerModeSwitch.setOnCheckedChangeListener { _, isChecked -> + if (binding.providerModeSwitch.tag == null) { + viewModel.toggleProvider(isChecked) + } + } + binding.buttonUI.setOnClickListener { + try { + val myIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://localhost:4449")) + startActivity(myIntent) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + } + } + } + +} diff --git a/android/app/src/main/java/updated/mysterium/vpn/ui/provider/ProviderViewModel.kt b/android/app/src/main/java/updated/mysterium/vpn/ui/provider/ProviderViewModel.kt new file mode 100644 index 000000000..247475b1b --- /dev/null +++ b/android/app/src/main/java/updated/mysterium/vpn/ui/provider/ProviderViewModel.kt @@ -0,0 +1,122 @@ +package updated.mysterium.vpn.ui.provider + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.* +import updated.mysterium.vpn.core.DeferredNode +import updated.mysterium.vpn.core.MysteriumCoreService +import updated.mysterium.vpn.model.manual.connect.ProviderState +import updated.mysterium.vpn.model.notification.NotificationChannels +import updated.mysterium.vpn.network.provider.usecase.UseCaseProvider +import updated.mysterium.vpn.notification.AppNotificationManager + +data class ServiceState( + var active: Array, +) + +class ProviderViewModel(useCaseProvider: UseCaseProvider) : ViewModel() { + + private companion object { + const val TAG = "ProviderViewModel" + } + + + val providerUpdate: LiveData + get() = _providerUpdate + private val _providerUpdate = MutableLiveData() + + + private val servicesState = ServiceState( Array(3) {false} ) + val providerServiceStatus: LiveData + get() = _providerServiceStatus + private val _providerServiceStatus = MutableLiveData() + + + private var coreService: MysteriumCoreService? = null + private val connectionUseCase = useCaseProvider.connection() + private lateinit var appNotificationManager: AppNotificationManager + private var deferredNode = DeferredNode() + + val handler = CoroutineExceptionHandler { _, exception -> + Log.i(TAG, exception.localizedMessage ?: exception.toString()) + } + + fun init( + deferredMysteriumCoreService: CompletableDeferred, + notificationManager: AppNotificationManager, + ) { + viewModelScope.launch(handler) { + appNotificationManager = notificationManager + coreService = deferredMysteriumCoreService.await() + + // Restart a node in case of app crash (on activity restore), thus regaining control of the node + startDeferredNode() + + val initialState = ProviderState( + active = getIsProviderActive(), + ) + _providerUpdate.postValue(initialState) + + connectionUseCase.serviceStatusChangeCallback { + val running = (it.status == "Running") + when (it.service) { + "wireguard" -> servicesState.active[0] = running + "data_transfer" -> servicesState.active[1] = running + "scraping" -> servicesState.active[2] = running + } + _providerServiceStatus.postValue(servicesState) + + // make provider switch state "false" if all services are disabled + var someEnabled = false + for (x in servicesState.active) { + if (x) { + someEnabled = true + break + } + } + _providerUpdate.postValue(ProviderState(someEnabled)) + } + } + } + + fun toggleProvider(isChecked: Boolean) { + CoroutineScope(Dispatchers.IO).launch { + coreService?.let { + it.startProvider(isChecked) + if (isChecked) { + it.startForegroundWithNotification( + NotificationChannels.PROVIDER_NOTIFICATION, + appNotificationManager.createProviderNotification() + ) + } else { + it.stopForeground() + } + } + } + } + + private suspend fun startDeferredNode() { + if (deferredNode.startedOrStarting()) { + coreService?.getDeferredNode()?.let { + deferredNode = it + } + } else { + coreService?.let { + deferredNode.start(it) + } + } + + connectionUseCase.initDeferredNode(deferredNode) + connectionUseCase.getIdentity() + } + + private fun getIsProviderActive(): Boolean { + coreService?.let { + return it.isProviderActive() + } + return false + } +} diff --git a/android/app/src/main/res/layout/activity_profile.xml b/android/app/src/main/res/layout/activity_profile.xml index 8f8f38e02..115a11989 100644 --- a/android/app/src/main/res/layout/activity_profile.xml +++ b/android/app/src/main/res/layout/activity_profile.xml @@ -47,8 +47,7 @@ + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +