From 495aa8c1b6262de3da117256527f7f5033780dd9 Mon Sep 17 00:00:00 2001 From: anjmao Date: Tue, 7 Jan 2020 14:46:10 +0200 Subject: [PATCH 1/4] Run tests and build app in docker CI --- .dockerignore | 4 +++ .gitignore | 2 ++ .gitlab-ci.yml | 75 +++++++++++++++++++++++++++++++++++------ Dockerfile | 34 +++++++++++++++++++ ci/Dockerfile | 83 ---------------------------------------------- ci/packages.txt | 9 ----- fastlane/Fastfile | 50 ++++++++++------------------ fastlane/README.md | 33 +++++++++--------- 8 files changed, 139 insertions(+), 151 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 ci/Dockerfile delete mode 100644 ci/packages.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..811a6218f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +android/app/build/ +android/build +android/lint +android/app/lint diff --git a/.gitignore b/.gitignore index 79d39cc0a..b75b85027 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ build/ local.properties *.iml ios/Index/ +android/app/lint +android/lint # test .jest/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7606c0ac6..694a82ee1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,17 +1,72 @@ -image: mysteriumnetwork/mobile-ci:0.1.0 - stages: - - deploy + - environment + - build + - test + - internal -push-beta: - stage: deploy - when: manual +.updateContainerJob: + image: docker:stable + stage: environment + services: + - docker:dind + script: + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG || true + - docker build --cache-from $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + +updateContainer: + extends: .updateContainerJob only: - - master - - /^release-*/ + changes: + - Dockerfile + +ensureContainer: + extends: .updateContainerJob + allow_failure: true + before_script: + - "mkdir -p ~/.docker && echo '{\"experimental\": \"enabled\"}' > ~/.docker/config.json" + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + # Skip update container `script` if the container already exists + # via https://gitlab.com/gitlab-org/gitlab-ce/issues/26866#note_97609397 -> https://stackoverflow.com/a/52077071/796832 + - docker manifest inspect $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG > /dev/null && exit || true + +.build_job: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + stage: build + before_script: + - "export VERSION_CODE=$((100 + $CI_PIPELINE_IID)) && echo $VERSION_CODE" + - "export VERSION_SHA=`echo ${CI_COMMIT_SHA:0:8}` && echo $VERSION_SHA" + # Because we allow the MR creation to fail, just make sure we are back in the right repo state + - git checkout "$CI_COMMIT_SHA" + after_script: + - rm -f android-signing-keystore.jks || true + artifacts: + paths: + - app/build/outputs + +buildDebug: + extends: .build_job + script: + - bundle exec fastlane buildDebug + +testDebug: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + stage: test + dependencies: + - buildDebug script: + - bundle exec fastlane test + +publishInternal: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + stage: internal + when: manual + before_script: - echo "$FASTLANE_ANDROID_SIGNING_FILE_VALUE" | base64 --decode > "$FASTLANE_ANDROID_SIGNING_FILE_PATH" - echo "$FASTLANE_ANDROID_SECRET_JSON_VALUE" | base64 --decode > "$FASTLANE_ANDROID_SECRET_JSON_PATH" - echo "$GOOGLE_SERVICES_VALUE" | base64 --decode > "$GOOGLE_SERVICES_PATH" - - bundle update --bundler - - fastlane android beta + after_script: + - rm -f $GOOGLE_SERVICES_PATH $FASTLANE_ANDROID_SIGNING_FILE_PATH $FASTLANE_ANDROID_SECRET_JSON_PATH + script: + - bundle exec fastlane internal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..2a4397f1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM openjdk:8-jdk + +# Just matched `app/build.gradle` +ENV ANDROID_COMPILE_SDK "28" +# Just matched `app/build.gradle` +ENV ANDROID_BUILD_TOOLS "28.0.3" +# Version from https://developer.android.com/studio/releases/sdk-tools +ENV ANDROID_SDK_TOOLS "4333796" + +ENV ANDROID_HOME /android-sdk-linux +ENV PATH="${PATH}:/android-sdk-linux/platform-tools/" + +# Install OS packages +RUN apt-get --quiet update --yes +RUN apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1 build-essential ruby ruby-dev +# We use this for xxd hex->binary +RUN apt-get --quiet install --yes vim-common +# Install Android SDK +RUN wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/sdk-tools-linux-${ANDROID_SDK_TOOLS}.zip +RUN unzip -q android-sdk.zip -d "$ANDROID_HOME/" +# Accept Android SDK licenses +RUN yes | $ANDROID_HOME/tools/bin/sdkmanager --licenses + +RUN echo y | android-sdk-linux/tools/android update sdk --no-ui --all --filter android-${ANDROID_COMPILE_SDK} +RUN echo y | android-sdk-linux/tools/android update sdk --no-ui --all --filter platform-tools +RUN echo y | android-sdk-linux/tools/android update sdk --no-ui --all --filter build-tools-${ANDROID_BUILD_TOOLS} +RUN echo y | android-sdk-linux/tools/android update sdk --no-ui --all --filter extra-android-m2repository +RUN echo y | android-sdk-linux/tools/android update sdk --no-ui --all --filter extra-google-google_play_services +RUN echo y | android-sdk-linux/tools/android update sdk --no-ui --all --filter extra-google-m2repository +# install Fastlane +COPY Gemfile.lock . +COPY Gemfile . +RUN gem install bundle +RUN bundle install diff --git a/ci/Dockerfile b/ci/Dockerfile deleted file mode 100644 index 72f302db8..000000000 --- a/ci/Dockerfile +++ /dev/null @@ -1,83 +0,0 @@ -# -# GitLab CI react-native-android v0.1 -# -# https://hub.docker.com/r/webcuisine/gitlab-ci-react-native-android/ -# https://github.com/cuisines/gitlab-ci-react-native-android -# - -FROM ubuntu:18.04 -MAINTAINER Sascha-Matthias Kulawik - -RUN echo "Android SDK 26.1.1" -ENV VERSION_SDK_TOOLS "4333796" - -ENV ANDROID_HOME "/sdk" -ENV PATH "$PATH:${ANDROID_HOME}/tools" -ENV DEBIAN_FRONTEND noninteractive - -RUN apt-get -qq update && \ - apt-get install -qqy --no-install-recommends \ - bzip2 \ - curl \ - git-core \ - html2text \ - openjdk-8-jdk \ - libc6-i386 \ - lib32stdc++6 \ - lib32gcc1 \ - lib32z1 \ - gnupg2 \ - unzip \ - && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* - -RUN rm -f /etc/ssl/certs/java/cacerts; \ - /var/lib/dpkg/info/ca-certificates-java.postinst configure - -RUN curl -s https://dl.google.com/android/repository/sdk-tools-linux-${VERSION_SDK_TOOLS}.zip > /sdk.zip && \ - unzip /sdk.zip -d /sdk && \ - rm -v /sdk.zip - -RUN mkdir -p $ANDROID_HOME/licenses/ \ - && echo "8933bad161af4178b1185d1a37fbf41ea5269c55\nd56f5187479451eabf01fb78af6dfcb131a6481e" > $ANDROID_HOME/licenses/android-sdk-license \ - && echo "84831b9409646a918e30573bab4c9c91346d8abd" > $ANDROID_HOME/licenses/android-sdk-preview-license - -ADD packages.txt /sdk -RUN mkdir -p /root/.android && \ - touch /root/.android/repositories.cfg && \ - ${ANDROID_HOME}/tools/bin/sdkmanager --update - -RUN while read -r package; do PACKAGES="${PACKAGES}${package} "; done < /sdk/packages.txt && \ - yes | ${ANDROID_HOME}/tools/bin/sdkmanager ${PACKAGES} - -RUN echo "Installing Yarn Deb Source" \ - && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ - && echo "deb http://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list - -RUN echo "Installing Node.JS" \ - && curl -sL https://deb.nodesource.com/setup_12.x | bash - - -ENV BUILD_PACKAGES git yarn nodejs build-essential imagemagick librsvg2-bin ruby ruby-dev wget libcurl4-openssl-dev -RUN echo "Installing Additional Libraries" \ - && rm -rf /var/lib/gems \ - && apt-get update && apt-get install $BUILD_PACKAGES -qqy --no-install-recommends - -RUN echo "Installing Fastlane" \ - && gem install fastlane badge -N \ - && gem cleanup - -ENV GRADLE_HOME /opt/gradle -ENV GRADLE_VERSION 5.4.1 - -RUN echo "Downloading Gradle" \ - && wget --no-verbose --output-document=gradle.zip "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" - -RUN echo "Installing Gradle" \ - && unzip gradle.zip \ - && rm gradle.zip \ - && mv "gradle-${GRADLE_VERSION}" "${GRADLE_HOME}/" \ - && ln --symbolic "${GRADLE_HOME}/bin/gradle" /usr/bin/gradle - -ENV LC_ALL "en_US.UTF-8" -ENV LANG "en_US.UTF-8" - -WORKDIR /app \ No newline at end of file diff --git a/ci/packages.txt b/ci/packages.txt deleted file mode 100644 index a9373894c..000000000 --- a/ci/packages.txt +++ /dev/null @@ -1,9 +0,0 @@ -add-ons;addon-google_apis-google-24 -build-tools;28.0.3 -extras;android;m2repository -extras;google;m2repository -extras;google;google_play_services -extras;m2repository;com;android;support;constraint;constraint-layout;1.0.2 -extras;m2repository;com;android;support;constraint;constraint-layout-solver;1.0.2 -platform-tools -platforms;android-28 \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index d5926cc53..856fc1a5c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,25 +1,23 @@ -default_platform(:ios) +default_platform(:android) -platform :ios do - def bump_versions - increment_version_number(version_number: last_git_tag) - increment_build_number(build_number: number_of_commits) +platform :android do + desc "Builds the debug code" + lane :buildDebug do + gradle(task: "assembleDebug", project_dir: "android") end - - desc "Push a new beta build to TestFlight" - lane :beta do - bump_versions - build_app(scheme: "MysteriumVPN", project: "ios/MysteriumVPN.xcodeproj") - upload_to_testflight + + desc "Builds the release code" + lane :buildRelease do + gradle(task: "assembleRelease", project_dir: "android") end -end -platform :android do - def bundle_offline_js - sh("cd .. && yarn bundle-android-js") + desc "Runs all the tests" + lane :test do + gradle(task: "test", project_dir: "android") end - def build_release + desc "Build release build locally" + lane :build do gradle( task: "clean assembleRelease", project_dir: "android", @@ -35,14 +33,15 @@ platform :android do }) end - def bundle_release + desc "Push a new internal build to Play Store" + lane :internal do gradle( task: "clean bundle", build_type: "Release", project_dir: "android", print_command: false, # to prevent outputting passwords properties: { - 'versionCode' => 10699, + 'versionCode' => 107031, 'versionName' => last_git_tag, 'applyGoogleServices' => true, "android.injected.signing.store.file" => ENV["FASTLANE_ANDROID_SIGNING_FILE_PATH"], @@ -50,21 +49,6 @@ platform :android do "android.injected.signing.key.alias" => ENV["FASTLANE_ANDROID_SIGNING_KEY_ALIAS"], "android.injected.signing.key.password" => ENV["FASTLANE_ANDROID_SIGNING_KEY_PASS"], }) - end - - desc "Build release build locally" - lane :build do - build_release - end - - desc "Bundle release build locally" - lane :bundle do - bundle_release - end - - desc "Push a new beta build to Play Store" - lane :beta do - bundle_release upload_to_play_store( track: "internal", json_key: ENV["FASTLANE_ANDROID_SECRET_JSON_PATH"] diff --git a/fastlane/README.md b/fastlane/README.md index c718583e1..1c9af8db3 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -15,31 +15,32 @@ Install _fastlane_ using or alternatively using `brew cask install fastlane` # Available Actions -## iOS -### ios beta +## Android +### android buildDebug ``` -fastlane ios beta +fastlane android buildDebug ``` -Push a new beta build to TestFlight - ----- - -## Android +Builds the debug code +### android buildRelease +``` +fastlane android buildRelease +``` +Builds the release code +### android test +``` +fastlane android test +``` +Runs all the tests ### android build ``` fastlane android build ``` Build release build locally -### android bundle -``` -fastlane android bundle -``` -Bundle release build locally -### android beta +### android internal ``` -fastlane android beta +fastlane android internal ``` -Push a new beta build to Play Store +Push a new internal build to Play Store ---- From 4118aa5c5169bbd9eb8c4d5b8f19f09d822271da Mon Sep 17 00:00:00 2001 From: anjmao Date: Tue, 7 Jan 2020 14:47:24 +0200 Subject: [PATCH 2/4] Show no internet message when no internet is available --- .../java/network/mysterium/MainActivity.kt | 118 +++++++++++++++--- .../mysterium/ui/ConnectivityChecker.kt | 53 ++++++++ 2 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/java/network/mysterium/ui/ConnectivityChecker.kt diff --git a/android/app/src/main/java/network/mysterium/MainActivity.kt b/android/app/src/main/java/network/mysterium/MainActivity.kt index 62c6c7ecb..3f7224e61 100644 --- a/android/app/src/main/java/network/mysterium/MainActivity.kt +++ b/android/app/src/main/java/network/mysterium/MainActivity.kt @@ -18,17 +18,26 @@ package network.mysterium import android.app.Activity +import android.app.NotificationManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.net.ConnectivityManager import android.net.VpnService import android.os.Bundle import android.os.IBinder import android.util.Log +import android.view.View +import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.Observer import androidx.navigation.Navigation +import androidx.navigation.ui.setupWithNavController +import com.google.android.material.navigation.NavigationView import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,12 +45,16 @@ import kotlinx.coroutines.launch import network.mysterium.service.core.DeferredNode import network.mysterium.service.core.MysteriumAndroidCoreService import network.mysterium.service.core.MysteriumCoreService +import network.mysterium.ui.ConnState +import network.mysterium.ui.Screen +import network.mysterium.ui.navigateTo import network.mysterium.vpn.R class MainActivity : AppCompatActivity() { private lateinit var appContainer: AppContainer private var deferredNode = DeferredNode() private var deferredMysteriumCoreService = CompletableDeferred() + private lateinit var vpnNotInternetLayout: LinearLayout private val serviceConnection = object : ServiceConnection { override fun onServiceDisconnected(name: ComponentName?) { @@ -51,11 +64,6 @@ class MainActivity : AppCompatActivity() { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { Log.i(TAG, "Service connected") deferredMysteriumCoreService.complete(service as MysteriumCoreService) - deferredNode.start(service) {err -> - if (err != null) { - showNodeStarError() - } - } } } @@ -63,26 +71,33 @@ class MainActivity : AppCompatActivity() { setTheme(R.style.AppTheme) super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + vpnNotInternetLayout = findViewById(R.id.vpn_not_internet_layout) + + // Setup app drawer. + val drawerLayout = findViewById(R.id.drawer_layout) + setupDrawerMenu(drawerLayout) // Initialize app DI container. appContainer = (application as MainApplication).appContainer - appContainer.init(applicationContext, deferredNode, deferredMysteriumCoreService) + appContainer.init( + applicationContext, + deferredNode, + deferredMysteriumCoreService, + drawerLayout, + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ) + + // Setup notifications. + appContainer.appNotificationManager.init(this) // Bind VPN service. ensureVpnServicePermission() bindMysteriumService() - // Load initial state without blocking main UI thread. - CoroutineScope(Dispatchers.Main).launch { - // Load favorite proposals from local database. - val favoriteProposals = appContainer.proposalsViewModel.loadFavoriteProposals() - - // Load initial data like current location, statistics, active proposal (if any). - appContainer.sharedViewModel.load(favoriteProposals) - - // Load initial proposals. - appContainer.proposalsViewModel.load() - } + // Start network connectivity checker and handle connection change event to + // start mobile node and initial data when network is available. + appContainer.connectivityChecker.start(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager) + appContainer.connectivityChecker.connState.observe(this, Observer { handleConnChange(it) }) // Navigate to main vpn screen and check if terms are accepted in separate coroutine // so it does not block main thread. @@ -95,6 +110,16 @@ class MainActivity : AppCompatActivity() { } } + override fun onPause() { + super.onPause() + appContainer.connectivityChecker.stop() + } + + override fun onResume() { + super.onResume() + appContainer.connectivityChecker.start(getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager) + } + override fun onDestroy() { unbindMysteriumService() super.onDestroy() @@ -116,6 +141,51 @@ class MainActivity : AppCompatActivity() { } } + // Start node and load initial data when connected to internet. + private fun handleConnChange(state: ConnState) { + Log.i(TAG, "Internet connection changed: $state") + + vpnNotInternetLayout.visibility = if (state == ConnState.CONNECTED) { + View.GONE + } else { + View.VISIBLE + } + + // Skip if node is already started. + if (deferredNode.isStarted()) { + return + } + + // Skip if no internet connection. + if (state == ConnState.NO_INTERNET) { + return + } + + // Start node in separate thread and load initial data. + CoroutineScope(Dispatchers.Main).launch { + deferredNode.start(deferredMysteriumCoreService.await()) { err -> + if (err != null) { + showNodeStarError() + } + } + loadInitialData() + } + } + + private suspend fun loadInitialData() { + // Load account data. + appContainer.accountViewModel.load() + + // Load favorite proposals from local database. + val favoriteProposals = appContainer.proposalsViewModel.loadFavoriteProposals() + + // Load initial data like current location, statistics, active proposal (if any). + appContainer.sharedViewModel.load(favoriteProposals) + + // Load initial proposals. + appContainer.proposalsViewModel.load() + } + private fun showNodeStarError() { Toast.makeText(this, "Failed to initialize. Please relaunch app.", Toast.LENGTH_LONG).show() } @@ -137,6 +207,20 @@ class MainActivity : AppCompatActivity() { startActivityForResult(intent, VPN_SERVICE_REQUEST) } + private fun setupDrawerMenu(drawerLayout: DrawerLayout) { + val navController = Navigation.findNavController(this, R.id.nav_host_fragment) + val navView = findViewById(R.id.nav_view) + navView.setupWithNavController(navController) + navView.setNavigationItemSelectedListener { + drawerLayout.closeDrawer(GravityCompat.START) + when { + it.itemId == R.id.menu_item_account -> navigateTo(navController, Screen.ACCOUNT) + it.itemId == R.id.menu_item_feedback -> navigateTo(navController, Screen.FEEDBACK) + } + true + } + } + private fun navigate(destination: Int) { val navController = Navigation.findNavController(this, R.id.nav_host_fragment) val navGraph = navController.navInflater.inflate(R.navigation.nav_graph) diff --git a/android/app/src/main/java/network/mysterium/ui/ConnectivityChecker.kt b/android/app/src/main/java/network/mysterium/ui/ConnectivityChecker.kt new file mode 100644 index 000000000..3be0a0aa2 --- /dev/null +++ b/android/app/src/main/java/network/mysterium/ui/ConnectivityChecker.kt @@ -0,0 +1,53 @@ +package network.mysterium.ui + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.* + +enum class ConnState { + NO_INTERNET, + CONNECTED +} + +// ConnectivityChecker for internet connection and exposes LiveData +// which can be used in activity or fragment. +class ConnectivityChecker: ViewModel() { + val connState = MutableLiveData() + + private var job: Job? = null + private val checkDurationMS = 2_000L + + fun start(connectivityManager: ConnectivityManager) { + job?.cancel() + job = CoroutineScope(Dispatchers.Default).launch { + while (true) { + val activeNetwork: NetworkInfo? = connectivityManager.activeNetworkInfo + val isConnected: Boolean = activeNetwork?.isConnectedOrConnecting == true + + viewModelScope.launch { + val newValue = if (isConnected) { + ConnState.CONNECTED + } else { + ConnState.NO_INTERNET + } + if (connState.value != newValue) { + connState.value = newValue + } + } + + delay(checkDurationMS) + } + } + } + + fun isConnected(): Boolean { + return connState.value == ConnState.CONNECTED + } + + fun stop() { + job?.cancel() + } +} From 40d5f398103940e9e0517ab18e766c0dad938980 Mon Sep 17 00:00:00 2001 From: anjmao Date: Tue, 7 Jan 2020 14:47:44 +0200 Subject: [PATCH 3/4] Add some basic e2e UI tests --- .../java/network/mysterium/BasicFlowTest.kt | 38 +++++++++++++ .../BasicFlowWithRegistrationTest.kt | 53 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 android/app/src/androidTest/java/network/mysterium/BasicFlowTest.kt create mode 100644 android/app/src/androidTest/java/network/mysterium/BasicFlowWithRegistrationTest.kt diff --git a/android/app/src/androidTest/java/network/mysterium/BasicFlowTest.kt b/android/app/src/androidTest/java/network/mysterium/BasicFlowTest.kt new file mode 100644 index 000000000..24b432d03 --- /dev/null +++ b/android/app/src/androidTest/java/network/mysterium/BasicFlowTest.kt @@ -0,0 +1,38 @@ +package network.mysterium + +import androidx.test.espresso.action.ViewActions.* +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class BasicFlowTest { + + @Rule + @JvmField + var mActivityTestRule = ActivityTestRule(MainActivity::class.java) + + @Test + fun registeredIdentityFlowTest() { + Views.checkStatusLabel("Disconnected") + Views.selectProposalLayout.perform(click()) + + Views.proposalSearchInput.perform(replaceText("0xfbf"), closeSoftKeyboard()) + + Views.selectProposalItem(0) + + Views.connectionButton.perform(click()) + + Views.checkStatusLabel("Connected") + + Thread.sleep(5000) + + Views.connectionButton.perform(click()) + + Views.checkStatusLabel("Disconnected") + } +} diff --git a/android/app/src/androidTest/java/network/mysterium/BasicFlowWithRegistrationTest.kt b/android/app/src/androidTest/java/network/mysterium/BasicFlowWithRegistrationTest.kt new file mode 100644 index 000000000..0ac11b603 --- /dev/null +++ b/android/app/src/androidTest/java/network/mysterium/BasicFlowWithRegistrationTest.kt @@ -0,0 +1,53 @@ +package network.mysterium + +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.filters.LargeTest +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class BasicFlowWithRegistrationTest { + + @Rule + @JvmField + var mActivityTestRule = ActivityTestRule(MainActivity::class.java) + + // TODO: For some reasons this fails with Failed to grant permissions, see logcat for details error. + // @Rule + // @JvmField + // val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(android.Manifest.permission.BIND_VPN_SERVICE) + + @Test + fun basicFlowWithRegistrationTest() { + Views.acceptTermsButton.perform(click()) + + Views.balanceLabel.perform(click()) + + Views.registrationPleaseWaitLabel.check(matches(withText("Please wait"))) + + Views.topUpButton.check(matches(isDisplayed())) + + Views.navBackButton.perform(click()) + + Views.selectProposalLayout.perform(click()) + + Views.proposalSearchInput.perform(replaceText("0xfbf"), closeSoftKeyboard()) + + Views.selectProposalItem(0) + + Views.connectionButton.perform(click()) + + Views.checkStatusLabel("Connected") + + Views.connectionButton.perform(click()) + + Views.checkStatusLabel("Disconnected") + } +} From b3f16f75e36869e787ed389ee0ce982348dc14da Mon Sep 17 00:00:00 2001 From: anjmao Date: Tue, 7 Jan 2020 14:49:55 +0200 Subject: [PATCH 4/4] Add initial payments integration flow --- android/app/build.gradle | 30 +- .../java/network/mysterium/Helpers.kt | 43 +++ .../java/network/mysterium/Views.kt | 135 +++++++++ android/app/src/main/AndroidManifest.xml | 8 + .../network/mysterium/AppBroadcastReceiver.kt | 33 +++ .../java/network/mysterium/AppContainer.kt | 24 +- .../mysterium/AppNotificationManager.kt | 117 ++++++++ .../java/network/mysterium/MainApplication.kt | 1 + .../mysterium/service/core/DeferredNode.kt | 4 + .../core/MysteriumAndroidCoreService.kt | 77 +---- .../service/core/MysteriumCoreService.kt | 12 +- .../mysterium/service/core/NodeRepository.kt | 126 ++++++-- .../network/mysterium/ui/AccountFragment.kt | 146 ++++++++++ .../network/mysterium/ui/AccountViewModel.kt | 153 ++++++++++ .../network/mysterium/ui/FeedbackFragment.kt | 7 +- .../network/mysterium/ui/FeedbackViewModel.kt | 10 +- .../network/mysterium/ui/MainVpnFragment.kt | 94 ++++-- .../java/network/mysterium/ui/Navigation.kt | 13 +- .../network/mysterium/ui/ProposalViewItem.kt | 12 +- .../mysterium/ui/ProposalsViewModel.kt | 2 +- .../network/mysterium/ui/SharedViewModel.kt | 88 +++--- .../network/mysterium/ui/UnitFormatter.kt | 21 +- .../ic_account_balance_wallet_gray_24dp.xml | 5 + .../drawable/ic_account_circle_black_24dp.xml | 9 + .../res/drawable/ic_arrow_back_white_24dp.xml | 5 + .../res/drawable/ic_bug_report_black_24dp.xml | 9 + .../main/res/drawable/ic_close_black_24dp.xml | 9 + .../drawable/ic_help_outline_black_24dp.xml | 5 - .../src/main/res/drawable/ic_menu_32dp.xml | 5 + .../app/src/main/res/layout/activity_main.xml | 47 ++- .../src/main/res/layout/fragment_account.xml | 274 ++++++++++++++++++ .../src/main/res/layout/fragment_feedback.xml | 163 +++++------ .../src/main/res/layout/fragment_main_vpn.xml | 54 +++- .../app/src/main/res/menu/navigation_menu.xml | 12 + .../app/src/main/res/navigation/nav_graph.xml | 9 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/styles.xml | 3 +- .../app/src/test/java/UnitFormatterTest.kt | 44 --- .../network/mysterium/ui/TokenModelTest.kt | 29 ++ .../network/mysterium/ui/UnitFormatterTest.kt | 51 ++++ 40 files changed, 1509 insertions(+), 384 deletions(-) create mode 100644 android/app/src/androidTest/java/network/mysterium/Helpers.kt create mode 100644 android/app/src/androidTest/java/network/mysterium/Views.kt create mode 100644 android/app/src/main/java/network/mysterium/AppBroadcastReceiver.kt create mode 100644 android/app/src/main/java/network/mysterium/AppNotificationManager.kt create mode 100644 android/app/src/main/java/network/mysterium/ui/AccountFragment.kt create mode 100644 android/app/src/main/java/network/mysterium/ui/AccountViewModel.kt create mode 100644 android/app/src/main/res/drawable/ic_account_balance_wallet_gray_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_account_circle_black_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_bug_report_black_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_close_black_24dp.xml delete mode 100644 android/app/src/main/res/drawable/ic_help_outline_black_24dp.xml create mode 100644 android/app/src/main/res/drawable/ic_menu_32dp.xml create mode 100644 android/app/src/main/res/layout/fragment_account.xml create mode 100644 android/app/src/main/res/menu/navigation_menu.xml delete mode 100644 android/app/src/test/java/UnitFormatterTest.kt create mode 100644 android/app/src/test/java/network/mysterium/ui/TokenModelTest.kt create mode 100644 android/app/src/test/java/network/mysterium/ui/UnitFormatterTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index e02560d15..2d1de750b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -70,6 +70,8 @@ android { versionName getVersionName() multiDexEnabled true + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] @@ -116,12 +118,15 @@ android { } } } + + useLibrary 'android.test.runner' + useLibrary 'android.test.base' + useLibrary 'android.test.mock' } dependencies { - def nav_version = "2.1.0" - implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" - implementation "androidx.navigation:navigation-ui-ktx:$nav_version" + implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.1.0' implementation('com.crashlytics.sdk.android:crashlytics:2.9.6@aar') { transitive = true @@ -134,6 +139,7 @@ dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.core:core-ktx:1.1.0' + implementation 'com.android.support:support-compat:28.0.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' @@ -142,18 +148,24 @@ dependencies { implementation 'com.makeramen:roundedimageview:2.3.0' implementation 'com.beust:klaxon:5.0.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0' - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0" - implementation "org.jetbrains.kotlin:kotlin-reflect:1.3.50" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0' + implementation 'org.jetbrains.kotlin:kotlin-reflect:1.3.50' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' implementation 'androidx.room:room-runtime:2.2.2' implementation 'androidx.room:room-ktx:2.2.2' kapt 'androidx.room:room-compiler:2.2.2' testImplementation 'junit:junit:4.12' - - implementation 'network.mysterium:mobile-node:0.15.0' + androidTestImplementation 'androidx.test:core:1.2.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'com.android.support.test.espresso:espresso-contrib:3.0.2' + androidTestImplementation 'androidx.test:rules:1.3.0-alpha03' + + implementation 'network.mysterium:mobile-node:0.18.0' // Comment network.mysterium:mobile-node and replace with your local path to use local node build. - // compile files('/Users/anjmao/go/src/github.com/mysteriumnetwork/node/build/package/Mysterium.aar') + //compile files('/Users/anjmao/go/src/github.com/mysteriumnetwork/node/build/package/Mysterium.aar') } // Run this once to be able to run the application with BUCK diff --git a/android/app/src/androidTest/java/network/mysterium/Helpers.kt b/android/app/src/androidTest/java/network/mysterium/Helpers.kt new file mode 100644 index 000000000..9d1f62e7d --- /dev/null +++ b/android/app/src/androidTest/java/network/mysterium/Helpers.kt @@ -0,0 +1,43 @@ +package network.mysterium + +import android.view.View +import android.view.ViewGroup +import androidx.test.espresso.Espresso +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +fun onViewReady(viewMatcher: Matcher, count: Int = 3, sleepMillis: Long = 2000): ViewInteraction { + for (i in 1..count) { + try { + return Espresso.onView(viewMatcher).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + } catch (e: NoMatchingViewException) { + if (i == count) { + throw e + } + Thread.sleep(sleepMillis) + } + } + throw Throwable("view not found") +} + +fun childAtPosition( + parentMatcher: Matcher, position: Int): Matcher { + + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("Child at position $position in parent ") + parentMatcher.describeTo(description) + } + + public override fun matchesSafely(view: View): Boolean { + val parent = view.parent + return parent is ViewGroup && parentMatcher.matches(parent) + && view == parent.getChildAt(position) + } + } +} diff --git a/android/app/src/androidTest/java/network/mysterium/Views.kt b/android/app/src/androidTest/java/network/mysterium/Views.kt new file mode 100644 index 000000000..8fdfe7b0f --- /dev/null +++ b/android/app/src/androidTest/java/network/mysterium/Views.kt @@ -0,0 +1,135 @@ +package network.mysterium + +import android.widget.FrameLayout +import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import network.mysterium.ui.ProposalsListAdapter +import network.mysterium.vpn.R +import org.hamcrest.Matchers +import org.hamcrest.core.IsInstanceOf + +object Views { + val acceptTermsButton: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.terms_accept_button), ViewMatchers.withText("Accept"), + childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.frameLayout), + childAtPosition( + ViewMatchers.withId(R.id.nav_host_fragment), + 0)), + 1), + ViewMatchers.isDisplayed())) + } + + val balanceLabel: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_account_balance_layout), + childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_top_status_layout), + childAtPosition( + ViewMatchers.withId(R.id.linearLayout2), + 0)), + 1), + ViewMatchers.isDisplayed())) + } + + val registrationPleaseWaitLabel: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.account_identity_registration_card_title), ViewMatchers.withText("Please wait"), + childAtPosition( + childAtPosition( + IsInstanceOf.instanceOf(FrameLayout::class.java), + 0), + 0), + ViewMatchers.isDisplayed())) + } + + val topUpButton: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.account_topup_button), + childAtPosition( + childAtPosition( + ViewMatchers.withId(R.id.account_balance_card), + 0), + 3), + ViewMatchers.isDisplayed()), 20, 5000) + } + + val navBackButton: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.account_toolbar), + childAtPosition( + ViewMatchers.withClassName(Matchers.`is`("com.google.android.material.appbar.AppBarLayout")), + 0)), + 1), + ViewMatchers.isDisplayed())) + } + + + val selectProposalLayout: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_select_proposal_layout), + childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_proposal_picker_layout), + childAtPosition( + ViewMatchers.withId(R.id.vpn_picker_and_button_layout), + 0)), + 0), + ViewMatchers.isDisplayed())) + } + + val proposalSearchInput: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.proposals_search_input), + childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.proposals_header_layout), + childAtPosition( + ViewMatchers.withClassName(Matchers.`is`("androidx.constraintlayout.widget.ConstraintLayout")), + 0)), + 1), + ViewMatchers.isDisplayed())) + } + + + val connectionButton: ViewInteraction + get() { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_connection_button), + childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_picker_and_button_layout), + childAtPosition( + ViewMatchers.withId(R.id.vpn_center_bg_layout), + 1)), + 1), + ViewMatchers.isDisplayed())) + } + + fun checkStatusLabel(status: String): ViewInteraction { + return onViewReady( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_status_label), ViewMatchers.withText(status), + childAtPosition( + Matchers.allOf(ViewMatchers.withId(R.id.vpn_top_status_layout), + childAtPosition( + ViewMatchers.withId(R.id.linearLayout2), + 0)), + 2), + ViewMatchers.isDisplayed())) + } + + fun selectProposalItem(position: Int) { + // Sleep a bit for item to appear after search. + Thread.sleep(1000) + onViewReady(ViewMatchers.withId(R.id.proposals_list)) + .perform(RecyclerViewActions.actionOnItemAtPosition(position, ViewActions.click())) + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 17da84094..eb2e86e36 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + + + + + + + diff --git a/android/app/src/main/java/network/mysterium/AppBroadcastReceiver.kt b/android/app/src/main/java/network/mysterium/AppBroadcastReceiver.kt new file mode 100644 index 000000000..2a83cdcbf --- /dev/null +++ b/android/app/src/main/java/network/mysterium/AppBroadcastReceiver.kt @@ -0,0 +1,33 @@ +package network.mysterium + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import android.widget.Toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class AppBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == AppNotificationManager.ACTION_DISCONNECT) { + handleDisconnect(context) + } + } + + private fun handleDisconnect(context: Context) { + CoroutineScope(Dispatchers.Main).launch { + try { + val appContainer = (context.applicationContext as MainApplication).appContainer + appContainer.sharedViewModel.disconnect() + } catch (e: Exception) { + Log.e(TAG, "Failed to disconnect") + } + } + } + + companion object { + private const val TAG = "AppBroadcastReceiver" + } +} diff --git a/android/app/src/main/java/network/mysterium/AppContainer.kt b/android/app/src/main/java/network/mysterium/AppContainer.kt index d2eb717ab..370539231 100644 --- a/android/app/src/main/java/network/mysterium/AppContainer.kt +++ b/android/app/src/main/java/network/mysterium/AppContainer.kt @@ -17,7 +17,9 @@ package network.mysterium +import android.app.NotificationManager import android.content.Context +import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.FragmentActivity import androidx.room.Room import kotlinx.coroutines.CompletableDeferred @@ -26,9 +28,7 @@ import network.mysterium.logging.BugReporter import network.mysterium.service.core.DeferredNode import network.mysterium.service.core.MysteriumCoreService import network.mysterium.service.core.NodeRepository -import network.mysterium.ui.ProposalsViewModel -import network.mysterium.ui.SharedViewModel -import network.mysterium.ui.TermsViewModel +import network.mysterium.ui.* class AppContainer { lateinit var appDatabase: AppDatabase @@ -36,21 +36,35 @@ class AppContainer { lateinit var sharedViewModel: SharedViewModel lateinit var proposalsViewModel: ProposalsViewModel lateinit var termsViewModel: TermsViewModel + lateinit var accountViewModel: AccountViewModel lateinit var bugReporter: BugReporter lateinit var deferredMysteriumCoreService: CompletableDeferred + lateinit var drawerLayout: DrawerLayout + lateinit var connectivityChecker: ConnectivityChecker + lateinit var appNotificationManager: AppNotificationManager - fun init(ctx: Context, deferredNode: DeferredNode, mysteriumCoreService: CompletableDeferred) { + fun init( + ctx: Context, + deferredNode: DeferredNode, + mysteriumCoreService: CompletableDeferred, + appDrawerLayout: DrawerLayout, + notificationManager: NotificationManager + ) { appDatabase = Room.databaseBuilder( ctx, AppDatabase::class.java, "mysteriumvpn" ).build() + drawerLayout = appDrawerLayout deferredMysteriumCoreService = mysteriumCoreService bugReporter = BugReporter() nodeRepository = NodeRepository(deferredNode) - sharedViewModel = SharedViewModel(nodeRepository, bugReporter, deferredMysteriumCoreService) + appNotificationManager = AppNotificationManager(notificationManager, deferredMysteriumCoreService) + accountViewModel = AccountViewModel(nodeRepository, bugReporter) + sharedViewModel = SharedViewModel(nodeRepository, deferredMysteriumCoreService, appNotificationManager, accountViewModel) proposalsViewModel = ProposalsViewModel(sharedViewModel, nodeRepository, appDatabase) termsViewModel = TermsViewModel(appDatabase) + connectivityChecker = ConnectivityChecker() } companion object { diff --git a/android/app/src/main/java/network/mysterium/AppNotificationManager.kt b/android/app/src/main/java/network/mysterium/AppNotificationManager.kt new file mode 100644 index 000000000..436f55421 --- /dev/null +++ b/android/app/src/main/java/network/mysterium/AppNotificationManager.kt @@ -0,0 +1,117 @@ +package network.mysterium + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CompletableDeferred +import network.mysterium.service.core.MysteriumCoreService +import network.mysterium.vpn.R + +typealias NotificationFactory = (Context) -> Notification + +class AppNotificationManager( + private val notificationManager: NotificationManager, + private val mysteriumCoreService: CompletableDeferred +) { + private val statisticsChannel = "statistics" + private val connLostChannel = "connectionlost" + private val topUpBalanceChannel = "topupbalance" + + val defaultNotificationID = 1 + private val topupBalanceNotificationID = 2 + + // pendingAppIntent is used to navigate back to MainActivity + // when user taps on notification. + private lateinit var pendingAppIntent: PendingIntent + + fun init(ctx: Context) { + val intent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + pendingAppIntent = PendingIntent.getActivity(ctx, 0, intent, 0) + + createChannel(statisticsChannel) + createChannel(connLostChannel) + createChannel(topUpBalanceChannel) + } + + private fun createChannel(channelId: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val channel = NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_DEFAULT) + channel.enableVibration(false) + notificationManager.createNotificationChannel(channel) + } + + fun createConnectedToVPNNotification(): NotificationFactory { + return { + NotificationCompat.Builder(it, statisticsChannel) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle("Connected") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVibrate(LongArray(0)) + .setContentIntent(pendingAppIntent) + .setOnlyAlertOnce(true) + .build() + } + } + + suspend fun showStatisticsNotification(title: String, content: String) { + val ctx = mysteriumCoreService.await().getContext() + val disconnectIntent = Intent(ctx, AppBroadcastReceiver::class.java).apply { + action = ACTION_DISCONNECT + } + val disconnectPendingIntent: PendingIntent = PendingIntent.getBroadcast(ctx, 0, disconnectIntent, 0) + + val notification = NotificationCompat.Builder(ctx, statisticsChannel) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle(title) + .setAutoCancel(true) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVibrate(LongArray(0)) + .setContentIntent(pendingAppIntent) + .setOnlyAlertOnce(true) + .addAction(R.drawable.ic_close_black_24dp, "Disconnect", disconnectPendingIntent) + .build() + notificationManager.notify(defaultNotificationID, notification) + } + + suspend fun showConnectionLostNotification() { + val ctx = mysteriumCoreService.await().getContext() + val notification = NotificationCompat.Builder(ctx, connLostChannel) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle("Connection lost") + .setContentText("VPN connection was closed.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVibrate(LongArray(0)) + .setContentIntent(pendingAppIntent) + .setOnlyAlertOnce(true) + .build() + notificationManager.notify(defaultNotificationID, notification) + } + + suspend fun showTopUpBalanceNotification() { + val ctx = mysteriumCoreService.await().getContext() + val notification = NotificationCompat.Builder(ctx, topUpBalanceChannel) + .setSmallIcon(R.drawable.notification_icon) + .setContentTitle("Top-up balance") + .setContentText("You need to top-up your balance to continue using VPN service.") + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVibrate(LongArray(0)) + .setContentIntent(pendingAppIntent) + .setOnlyAlertOnce(true) + .build() + notificationManager.notify(topupBalanceNotificationID, notification) + } + + companion object { + const val ACTION_DISCONNECT = "DISCONNECT" + } +} diff --git a/android/app/src/main/java/network/mysterium/MainApplication.kt b/android/app/src/main/java/network/mysterium/MainApplication.kt index 52f2bb375..02ceee250 100644 --- a/android/app/src/main/java/network/mysterium/MainApplication.kt +++ b/android/app/src/main/java/network/mysterium/MainApplication.kt @@ -52,3 +52,4 @@ class MainApplication : MultiDexApplication() { private const val TAG = "MainApplication" } } + diff --git a/android/app/src/main/java/network/mysterium/service/core/DeferredNode.kt b/android/app/src/main/java/network/mysterium/service/core/DeferredNode.kt index 8564669e4..9f1ff52ca 100644 --- a/android/app/src/main/java/network/mysterium/service/core/DeferredNode.kt +++ b/android/app/src/main/java/network/mysterium/service/core/DeferredNode.kt @@ -16,6 +16,10 @@ class DeferredNode { return deferredNode.await() } + fun isStarted(): Boolean { + return deferredNode.isCompleted + } + fun start(service: MysteriumCoreService, done: (err: Exception?) -> Unit) { CoroutineScope(Dispatchers.Main).launch { try { diff --git a/android/app/src/main/java/network/mysterium/service/core/MysteriumAndroidCoreService.kt b/android/app/src/main/java/network/mysterium/service/core/MysteriumAndroidCoreService.kt index 29c0fd113..7681505a6 100644 --- a/android/app/src/main/java/network/mysterium/service/core/MysteriumAndroidCoreService.kt +++ b/android/app/src/main/java/network/mysterium/service/core/MysteriumAndroidCoreService.kt @@ -17,30 +17,18 @@ package network.mysterium.service.core -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.VpnService import android.os.Binder -import android.os.Build import android.os.IBinder import android.util.Log -import androidx.core.app.NotificationCompat import mysterium.MobileNode import mysterium.Mysterium -import network.mysterium.MainActivity -import network.mysterium.vpn.BuildConfig -import network.mysterium.vpn.R +import network.mysterium.NotificationFactory class MysteriumAndroidCoreService : VpnService() { private var mobileNode: MobileNode? = null - private val notificationsChannelId = BuildConfig.APPLICATION_ID - - // pendingAppIntent is used to navigate back to MainActivity - // when user taps on notification. - private lateinit var pendingAppIntent: PendingIntent fun startMobileNode(filesPath: String): MobileNode { if (mobileNode != null) { @@ -50,12 +38,9 @@ class MysteriumAndroidCoreService : VpnService() { val openvpnBridge = Openvpn3AndroidTunnelSetupBridge(this) val wireguardBridge = WireguardAndroidTunnelSetup(this) - val logOptions = Mysterium.defaultLogOptions() - logOptions.filepath = filesPath - logOptions.logHTTP = false val options = Mysterium.defaultNetworkOptions() - mobileNode = Mysterium.newNode(filesPath, logOptions, options) + mobileNode = Mysterium.newNode(filesPath, options) mobileNode?.overrideOpenvpnConnection(openvpnBridge) mobileNode?.overrideWireguardConnection(wireguardBridge) @@ -80,55 +65,9 @@ class MysteriumAndroidCoreService : VpnService() { } } - override fun onCreate() { - super.onCreate() - - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - pendingAppIntent = PendingIntent.getActivity(this, 0, intent, 0) - - createNotificationChannel() - } - override fun onDestroy() { super.onDestroy() stopMobileNode() - // TODO: Check if node is destroyed correctly. - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT < 26) { - return - } - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channel = NotificationChannel(notificationsChannelId, notificationsChannelId, NotificationManager.IMPORTANCE_DEFAULT) - channel.enableVibration(false) - notificationManager.createNotificationChannel(channel) - } - - // startForeground starts service with given notifications in foreground. - fun startForeground(title: String, content: String = "") { - if (Build.VERSION.SDK_INT < 26) { - return - } - val notification = NotificationCompat.Builder(this, notificationsChannelId) - .setSmallIcon(R.drawable.notification_icon) - .setContentTitle(title) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setVibrate(LongArray(0)) - .setContentIntent(pendingAppIntent) - .setOnlyAlertOnce(true) - - if (content != "") { - notification.setContentText(content) - } - - startForeground(1, notification.build()) - } - - fun stopForeground() { - stopForeground(true) } override fun onRevoke() { @@ -144,12 +83,16 @@ class MysteriumAndroidCoreService : VpnService() { stopMobileNode() } - override fun showNotification(title: String, content: String) { - startForeground(title, content) + override fun getContext(): Context { + return this@MysteriumAndroidCoreService } - override fun hideNotifications() { - stopForeground() + override fun startForegroundWithNotification(id: Int, notificationFactory: NotificationFactory) { + startForeground(id, notificationFactory(this@MysteriumAndroidCoreService)) + } + + override fun stopForeground() { + stopForeground(true) } } diff --git a/android/app/src/main/java/network/mysterium/service/core/MysteriumCoreService.kt b/android/app/src/main/java/network/mysterium/service/core/MysteriumCoreService.kt index 3580b2b11..fb666757f 100644 --- a/android/app/src/main/java/network/mysterium/service/core/MysteriumCoreService.kt +++ b/android/app/src/main/java/network/mysterium/service/core/MysteriumCoreService.kt @@ -17,15 +17,19 @@ package network.mysterium.service.core +import android.content.Context import android.os.IBinder import mysterium.MobileNode +import network.mysterium.NotificationFactory interface MysteriumCoreService : IBinder { - fun startNode(): MobileNode + fun startNode(): MobileNode - fun stopNode() + fun stopNode() - fun showNotification(title: String, content: String = "") + fun getContext(): Context - fun hideNotifications() + fun startForegroundWithNotification(id: Int, notificationFactory: NotificationFactory) + + fun stopForeground() } diff --git a/android/app/src/main/java/network/mysterium/service/core/NodeRepository.kt b/android/app/src/main/java/network/mysterium/service/core/NodeRepository.kt index 3fc61ba19..fc54335c7 100644 --- a/android/app/src/main/java/network/mysterium/service/core/NodeRepository.kt +++ b/android/app/src/main/java/network/mysterium/service/core/NodeRepository.kt @@ -1,14 +1,10 @@ package network.mysterium.service.core -import android.util.Log import com.beust.klaxon.Json import com.beust.klaxon.Klaxon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import mysterium.ConnectRequest -import mysterium.GetProposalRequest -import mysterium.GetProposalsRequest -import mysterium.SendFeedbackRequest +import mysterium.* class ProposalItem( @Json(name = "providerId") @@ -51,9 +47,28 @@ class Status( val serviceType: String ) +class Identity( + val address: String, + val channelAddress: String, + val registrationStatus: String +) + +class IdentityRegistrationFees( + val fee: Long +) + +// Wrapper around Go mobile node library bindings. It should not change any result +// returned from internal mobile node and instead all mappings should happen in +// ViewModels. class NodeRepository(private val deferredNode: DeferredNode) { - suspend fun getProposals(refresh: Boolean): List { + // Get available proposals for mobile. Internally on Go side + // proposals are fetched once and cached but it is possible to refresh cache by + // passing refresh flag. + // + // Note that this method need to deserialize JSON byte array since Go Mobile + // does not support passing complex slices via it's bridge. + suspend fun proposals(refresh: Boolean): List { val req = GetProposalsRequest() req.showOpenvpnProposals = true req.showWireguardProposals = true @@ -68,56 +83,111 @@ class NodeRepository(private val deferredNode: DeferredNode) { return proposalsResponse.proposals } - suspend fun getProposal(providerID: String, serviceType: String): ProposalItem? { + // Get proposal from cache by given providerID and serviceType. + // + // Note that this method need to deserialize JSON byte array since Go Mobile + // does not support passing complex slices via it's bridge. + suspend fun proposal(providerID: String, serviceType: String): ProposalItem? { val req = GetProposalRequest() req.providerID = providerID req.serviceType = serviceType - val bytes = getProposal(req) + val bytes = proposal(req) val proposalsResponse = parseProposal(bytes) return proposalsResponse?.proposal } + // Register connection status callback. suspend fun registerConnectionStatusChangeCallback(cb: (status: String) -> Unit) { deferredNode.await().registerConnectionStatusChangeCallback { status -> cb(status) } } + // Register statistics callback. suspend fun registerStatisticsChangeCallback(cb: (stats: Statistics) -> Unit) { deferredNode.await().registerStatisticsChangeCallback { duration, bytesReceived, bytesSent -> cb(Statistics(duration, bytesReceived, bytesSent)) } } - suspend fun connect(req: ConnectRequest) = withContext(Dispatchers.IO) { + // Register statistics callback. + suspend fun registerBalanceChangeCallback(cb: (balance: Long) -> Unit) { + deferredNode.await().registerBalanceChangeCallback { + _, balance -> cb(balance) + } + } + + // Register identity registration status callback. + suspend fun registerIdentityRegistrationChangeCallback(cb: (status: String) -> Unit) { + deferredNode.await().registerIdentityRegistrationChangeCallback { + _, status -> cb(status) + } + } + + // Connect to VPN service. + suspend fun connect(identityAddress: String, providerID: String, serviceType: String) = withContext(Dispatchers.IO) { + val req = ConnectRequest() + req.providerID = providerID + req.serviceType = serviceType + req.identityAddress = identityAddress deferredNode.await().connect(req) } + // Disconnect from VPN service. suspend fun disconnect() = withContext(Dispatchers.IO) { deferredNode.await().disconnect() } - suspend fun unlockIdentity(): String = withContext(Dispatchers.IO) { - deferredNode.await().unlockIdentity() + // Unlock identity and return it's address. Internally mobile node will create default identity + // if it is not created yet. + suspend fun getIdentity(): Identity = withContext(Dispatchers.IO) { + val res = deferredNode.await().identity + Identity(address = res.identityAddress, channelAddress = res.channelAddress, registrationStatus = res.registrationStatus) + } + + // Get registration fees. + suspend fun identityRegistrationFees() = withContext(Dispatchers.IO) { + val res = deferredNode.await().identityRegistrationFees + IdentityRegistrationFees(fee = res.fee) } - suspend fun getLocation(): Location { - val location = getLocationAsync() - return Location( - ip = location.ip, - countryCode = location.country + // Register identity with given fee. + suspend fun registerIdentity(identityAddress: String, fee: Long) = withContext(Dispatchers.IO) { + val req = RegisterIdentityRequest() + req.identityAddress = identityAddress + req.fee = fee + deferredNode.await().registerIdentity(req) + } + + // Top-up balance with myst tokens. + suspend fun topUpBalance(identityAddress: String) = withContext(Dispatchers.IO) { + val req = TopUpRequest() + req.identityAddress = identityAddress + deferredNode.await().topUp(req) + } + + // Get current location with country and IP. + suspend fun location() = withContext(Dispatchers.IO) { + val res = deferredNode.await().location + Location( + ip = res.ip, + countryCode = res.country ) } - suspend fun getStatus(): Status { - val status = getStatusAsync() - return Status( - state = status.state, - providerID = status.providerID, - serviceType = status.serviceType + // Get current connection status. + suspend fun status() = withContext(Dispatchers.IO) { + val res = deferredNode.await().status + Status( + state = res.state, + providerID = res.providerID, + serviceType = res.serviceType ) } - suspend fun sendFeedback(req: SendFeedbackRequest) = withContext(Dispatchers.IO) { + // Send user feedback. + suspend fun sendFeedback(description: String) = withContext(Dispatchers.IO) { + val req = SendFeedbackRequest() + req.description = description deferredNode.await().sendFeedback(req) } @@ -125,7 +195,7 @@ class NodeRepository(private val deferredNode: DeferredNode) { deferredNode.await().getProposals(req) } - private suspend fun getProposal(req: GetProposalRequest) = withContext(Dispatchers.IO) { + private suspend fun proposal(req: GetProposalRequest) = withContext(Dispatchers.IO) { deferredNode.await().getProposal(req) } @@ -136,12 +206,4 @@ class NodeRepository(private val deferredNode: DeferredNode) { private suspend fun parseProposal(bytes: ByteArray) = withContext(Dispatchers.Default) { Klaxon().parse(bytes.inputStream()) } - - private suspend fun getLocationAsync() = withContext(Dispatchers.IO) { - deferredNode.await().location - } - - private suspend fun getStatusAsync() = withContext(Dispatchers.IO) { - deferredNode.await().status - } } diff --git a/android/app/src/main/java/network/mysterium/ui/AccountFragment.kt b/android/app/src/main/java/network/mysterium/ui/AccountFragment.kt new file mode 100644 index 000000000..743394c03 --- /dev/null +++ b/android/app/src/main/java/network/mysterium/ui/AccountFragment.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2020 The "mysteriumnetwork/mysterium-vpn-mobile" Authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package network.mysterium.ui + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.Observer +import com.google.android.material.card.MaterialCardView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.mysterium.AppContainer +import network.mysterium.vpn.R +import android.content.Intent +import android.net.Uri + +class AccountFragment : Fragment() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var toolbar: Toolbar + private lateinit var accountBalanceCard: MaterialCardView + private lateinit var accountBalanceText: TextView + private lateinit var accountIdentityText: TextView + private lateinit var accountIdentityRegistrationLayout: ConstraintLayout + private lateinit var accountIdentityRegistrationLayoutCard: MaterialCardView + private lateinit var accountIdentityRegistrationLayoutRetryCard: MaterialCardView + private lateinit var accountIdentityChannelAddressText: TextView + private lateinit var accountTopUpButton: Button + private lateinit var accountIdentityRegistrationRetryButton: Button + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + + val root = inflater.inflate(R.layout.fragment_account, container, false) + accountViewModel = AppContainer.from(activity).accountViewModel + + // Initialize UI elements. + toolbar = root.findViewById(R.id.account_toolbar) + accountBalanceCard = root.findViewById(R.id.account_balance_card) + accountBalanceText = root.findViewById(R.id.account_balance_text) + accountIdentityText = root.findViewById(R.id.account_identity_text) + accountIdentityRegistrationLayout = root.findViewById(R.id.account_identity_registration_layout) + accountIdentityRegistrationLayoutCard = root.findViewById(R.id.account_identity_registration_layout_card) + accountIdentityRegistrationLayoutRetryCard = root.findViewById(R.id.account_identity_registration_layout_retry_card) + accountIdentityChannelAddressText = root.findViewById(R.id.account_identity_channel_address_text) + accountTopUpButton = root.findViewById(R.id.account_topup_button) + accountIdentityRegistrationRetryButton = root.findViewById(R.id.account_identity_registration_retry_button) + + // Handle back press. + toolbar.setNavigationOnClickListener { + navigateTo(root, Screen.MAIN) + } + + onBackPress { + navigateTo(root, Screen.MAIN) + } + + accountViewModel.identity.observe(this, Observer { + handleIdentityChange(it) + }) + + accountViewModel.balance.observe(this, Observer { + accountBalanceText.text = it.balance.displayValue + }) + + accountTopUpButton.setOnClickListener { handleTopUp(root) } + + accountIdentityChannelAddressText.setOnClickListener { openKovanChannelDetails() } + accountIdentityText.setOnClickListener { openKovanIdentityDetails() } + + accountIdentityRegistrationRetryButton.setOnClickListener { handleRegistrationRetry() } + + return root + } + + private fun handleRegistrationRetry() { + accountIdentityRegistrationRetryButton.isEnabled = false + CoroutineScope(Dispatchers.Main).launch { + accountViewModel.loadIdentity() + accountIdentityRegistrationRetryButton.isEnabled = true + } + } + + private fun openKovanChannelDetails() { + val channelAddress = accountViewModel.identity.value!!.channelAddress + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://goerli.etherscan.io/address/$channelAddress")) + startActivity(browserIntent) + } + + private fun openKovanIdentityDetails() { + val identityAddress = accountViewModel.identity.value!!.address + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://goerli.etherscan.io/address/$identityAddress")) + startActivity(browserIntent) + } + + private fun handleIdentityChange(identity: IdentityModel) { + accountIdentityText.text = identity.address + accountIdentityChannelAddressText.text = identity.channelAddress + + if (identity.registered) { + accountIdentityRegistrationLayout.visibility = View.GONE + accountBalanceCard.visibility = View.VISIBLE + } else { + accountIdentityRegistrationLayout.visibility = View.VISIBLE + accountIdentityRegistrationLayoutCard.visibility = View.VISIBLE + accountIdentityRegistrationLayoutRetryCard.visibility = View.GONE + accountBalanceCard.visibility = View.GONE + + // Show retry button. + if (identity.registrationFailed) { + accountIdentityRegistrationLayoutRetryCard.visibility = View.VISIBLE + accountIdentityRegistrationLayoutCard.visibility = View.GONE + } + } + } + + private fun handleTopUp(root: View) { + accountTopUpButton.isEnabled = false + CoroutineScope(Dispatchers.Main).launch { + accountViewModel.topUp() + showMessage(root.context, "Balance will be updated in a few moments.") + accountTopUpButton.isEnabled = true + } + } +} diff --git a/android/app/src/main/java/network/mysterium/ui/AccountViewModel.kt b/android/app/src/main/java/network/mysterium/ui/AccountViewModel.kt new file mode 100644 index 000000000..316d93301 --- /dev/null +++ b/android/app/src/main/java/network/mysterium/ui/AccountViewModel.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2020 The "mysteriumnetwork/mysterium-vpn-mobile" Authors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package network.mysterium.ui + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import network.mysterium.logging.BugReporter +import network.mysterium.service.core.NodeRepository + +enum class IdentityRegistrationStatus(val status: String) { + UNKNOWN("Unknown"), + REGISTERED_CONSUMER("RegisteredConsumer"), + UNREGISTERED("Unregistered"), + IN_PROGRESS("InProgress"), + PROMOTING("Promoting"), + REGISTRATION_ERROR("RegistrationError"); + + companion object { + fun parse(status: String): IdentityRegistrationStatus { + return values().find { it.status == status } ?: UNKNOWN + } + } +} + +class IdentityModel( + val address: String, + val channelAddress: String, + var status: IdentityRegistrationStatus +) { + val registered: Boolean + get() { + return status == IdentityRegistrationStatus.REGISTERED_CONSUMER + } + + val registrationFailed: Boolean + get() { + return status == IdentityRegistrationStatus.REGISTRATION_ERROR + } +} + +class BalanceModel(val balance: TokenModel) + +class TokenModel(token: Long = 0) { + var displayValue = "" + var value = 0.00 + + init { + value = token / 100_000_000.00 + displayValue = "%.3f MYSTT".format(value) + } +} + +class AccountViewModel(private val nodeRepository: NodeRepository, private val bugReporter: BugReporter) : ViewModel() { + val balance = MutableLiveData() + val identity = MutableLiveData() + + suspend fun load() { + initListeners() + loadIdentity() + } + + suspend fun topUp() { + try { + val currentIdentity = identity.value ?: return + nodeRepository.topUpBalance(currentIdentity.address) + } catch (e: Exception) { + Log.e(TAG, "Failed to top-up balance", e) + } + } + + fun needToTopUp(): Boolean { + if (balance.value == null) { + return false + } + return balance.value!!.balance.value < 0.01 + } + + fun isIdentityRegistered(): Boolean { + val currentIdentity = identity.value ?: return false + return currentIdentity.registered + } + + suspend fun loadIdentity() { + try { + // Load node identity and it's registration status. + val nodeIdentity = nodeRepository.getIdentity() + val identityResult = IdentityModel( + address = nodeIdentity.address, + channelAddress = nodeIdentity.channelAddress, + status = IdentityRegistrationStatus.parse(nodeIdentity.registrationStatus) + ) + identity.value = identityResult + bugReporter.setUserIdentifier(nodeIdentity.address) + Log.i(TAG, "Loaded identity ${nodeIdentity.address}, channel addr: ${nodeIdentity.channelAddress}") + + // Register identity if not registered or failed. + if (identityResult.status == IdentityRegistrationStatus.UNREGISTERED || identityResult.status == IdentityRegistrationStatus.REGISTRATION_ERROR) { + val registrationFees = nodeRepository.identityRegistrationFees() + val currentIdentity = identity.value ?: return + nodeRepository.registerIdentity(currentIdentity.address, registrationFees.fee) + } + } catch (e: Exception) { + identity.value = IdentityModel(address = "", channelAddress = "", status = IdentityRegistrationStatus.REGISTRATION_ERROR) + Log.e(TAG, "Failed to load account identity", e) + } + } + + private suspend fun initListeners() { + nodeRepository.registerBalanceChangeCallback { + handleBalanceChange(it) + } + + nodeRepository.registerIdentityRegistrationChangeCallback { + handleIdentityRegistrationChange(it) + } + } + + private fun handleIdentityRegistrationChange(status: String) { + val currentIdentity = identity.value ?: return + viewModelScope.launch { + currentIdentity.status = IdentityRegistrationStatus.parse(status) + identity.value = currentIdentity + } + } + + private fun handleBalanceChange(changedBalance: Long) { + viewModelScope.launch { + balance.value = BalanceModel(TokenModel(changedBalance)) + } + } + + companion object { + const val TAG = "AccountViewModel" + } +} diff --git a/android/app/src/main/java/network/mysterium/ui/FeedbackFragment.kt b/android/app/src/main/java/network/mysterium/ui/FeedbackFragment.kt index af820a47c..f90f8d1c4 100644 --- a/android/app/src/main/java/network/mysterium/ui/FeedbackFragment.kt +++ b/android/app/src/main/java/network/mysterium/ui/FeedbackFragment.kt @@ -7,6 +7,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import com.google.android.material.button.MaterialButton import kotlinx.coroutines.CoroutineScope @@ -19,11 +20,11 @@ import network.mysterium.vpn.R class FeedbackFragment : Fragment() { private lateinit var feedbackViewModel: FeedbackViewModel - private lateinit var feedbackBackButton: ImageView private lateinit var feedbackTypeSpinner: Spinner private lateinit var feedbackMessage: EditText private lateinit var feedbackSubmitButton: MaterialButton private lateinit var versionLabel: TextView + private lateinit var feedbackToolbar: Toolbar override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -32,16 +33,16 @@ class FeedbackFragment : Fragment() { val nodeRepository = AppContainer.from(activity).nodeRepository feedbackViewModel = FeedbackViewModel(nodeRepository) - feedbackBackButton = root.findViewById(R.id.feedback_back_button) feedbackTypeSpinner = root.findViewById(R.id.feedback_type_spinner) feedbackMessage = root.findViewById(R.id.feedback_message) feedbackSubmitButton = root.findViewById(R.id.feedback_submit_button) versionLabel = root.findViewById(R.id.vpn_version_label) + feedbackToolbar = root.findViewById(R.id.feedback_toolbar) updateVersionLabel() // Handle back press. - feedbackBackButton.setOnClickListener { + feedbackToolbar.setNavigationOnClickListener { hideKeyboard(root) navigateTo(root, Screen.MAIN) } diff --git a/android/app/src/main/java/network/mysterium/ui/FeedbackViewModel.kt b/android/app/src/main/java/network/mysterium/ui/FeedbackViewModel.kt index be2beef86..6d72a0c76 100644 --- a/android/app/src/main/java/network/mysterium/ui/FeedbackViewModel.kt +++ b/android/app/src/main/java/network/mysterium/ui/FeedbackViewModel.kt @@ -18,7 +18,6 @@ package network.mysterium.ui import androidx.lifecycle.ViewModel -import mysterium.SendFeedbackRequest import network.mysterium.service.core.NodeRepository enum class FeedbackType(val type: Int) { @@ -42,11 +41,11 @@ enum class FeedbackType(val type: Int) { } class FeedbackViewModel(private val nodeRepository: NodeRepository): ViewModel() { - private var feebackType = FeedbackType.BUG + private var feedbackType = FeedbackType.BUG private var message = "" fun setFeedbackType(type: Int) { - feebackType = FeedbackType.parse(type) + feedbackType = FeedbackType.parse(type) } fun setMessage(msg: String) { @@ -58,8 +57,7 @@ class FeedbackViewModel(private val nodeRepository: NodeRepository): ViewModel() } suspend fun submit() { - val req = SendFeedbackRequest() - req.description = "Platform: Android, Feedback Type: $feebackType, Message: $message" - nodeRepository.sendFeedback(req) + val description = "Platform: Android, Feedback Type: $feedbackType, Message: $message" + nodeRepository.sendFeedback(description) } } diff --git a/android/app/src/main/java/network/mysterium/ui/MainVpnFragment.kt b/android/app/src/main/java/network/mysterium/ui/MainVpnFragment.kt index 5e9cb0f11..a4008e564 100644 --- a/android/app/src/main/java/network/mysterium/ui/MainVpnFragment.kt +++ b/android/app/src/main/java/network/mysterium/ui/MainVpnFragment.kt @@ -24,21 +24,25 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.GravityCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import network.mysterium.MainApplication +import network.mysterium.AppContainer import network.mysterium.vpn.R class MainVpnFragment : Fragment() { private lateinit var sharedViewModel: SharedViewModel private lateinit var proposalsViewModel: ProposalsViewModel + private lateinit var accountViewModel: AccountViewModel + private lateinit var connectivityChecker: ConnectivityChecker private var job: Job? = null private lateinit var connStatusLabel: TextView @@ -57,13 +61,17 @@ class MainVpnFragment : Fragment() { private lateinit var vpnStatsBytesReceivedLabel: TextView private lateinit var vpnStatsBytesReceivedUnits: TextView private lateinit var vpnStatsBytesSentUnits: TextView + private lateinit var vpnAccountBalanceLabel: TextView + private lateinit var vpnAccountBalanceLayout: LinearLayout override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val appContainer = (activity!!.application as MainApplication).appContainer + val appContainer = AppContainer.from(activity) sharedViewModel = appContainer.sharedViewModel proposalsViewModel = appContainer.proposalsViewModel + accountViewModel = appContainer.accountViewModel + connectivityChecker = appContainer.connectivityChecker val root = inflater.inflate(R.layout.fragment_main_vpn, container, false) @@ -84,9 +92,16 @@ class MainVpnFragment : Fragment() { vpnStatsBytesSentLabel = root.findViewById(R.id.vpn_stats_bytes_sent) vpnStatsBytesReceivedUnits = root.findViewById(R.id.vpn_stats_bytes_received_units) vpnStatsBytesSentUnits = root.findViewById(R.id.vpn_stats_bytes_sent_units) + vpnAccountBalanceLabel = root.findViewById(R.id.vpn_account_balance_label) + vpnAccountBalanceLayout = root.findViewById(R.id.vpn_account_balance_layout) feedbackButton.setOnClickListener { - navigateTo(root, Screen.FEEDBACK) + val drawer = appContainer.drawerLayout + drawer.openDrawer(GravityCompat.START) + } + + vpnAccountBalanceLayout.setOnClickListener { + navigateTo(root, Screen.ACCOUNT) } selectProposalLayout.setOnClickListener { @@ -98,7 +113,7 @@ class MainVpnFragment : Fragment() { } connectionButton.setOnClickListener { - handleConnectionPress(root.context) + handleConnectionPress(root) } sharedViewModel.selectedProposal.observe(this, Observer { updateSelectedProposal(it) }) @@ -112,6 +127,8 @@ class MainVpnFragment : Fragment() { sharedViewModel.location.observe(this, Observer { updateLocation(it) }) + accountViewModel.balance.observe(this, Observer { updateBalance(it) }) + onBackPress { emulateHomePress() } return root @@ -122,33 +139,37 @@ class MainVpnFragment : Fragment() { job?.cancel() } - private fun updateLocation(it: LocationViewItem) { - conStatusIP.text = "IP: ${it.ip}" - if (it.countryFlagImage == null) { + private fun updateBalance(balance: BalanceModel) { + vpnAccountBalanceLabel.text = balance.balance.displayValue + } + + private fun updateLocation(location: LocationModel) { + conStatusIP.text = "IP: ${location.ip}" + if (location.countryFlagImage == null) { vpnStatusCountry.setImageResource(R.drawable.ic_public_black_24dp) } else { - vpnStatusCountry.setImageBitmap(it.countryFlagImage) + vpnStatusCountry.setImageBitmap(location.countryFlagImage) } } - private fun updateSelectedProposal(it: ProposalViewItem) { - vpnSelectedProposalCountryLabel.text = it.countryName - vpnSelectedProposalCountryIcon.setImageBitmap(it.countryFlagImage) - vpnSelectedProposalProviderLabel.text = it.providerID + private fun updateSelectedProposal(proposal: ProposalViewItem) { + vpnSelectedProposalCountryLabel.text = proposal.countryName + vpnSelectedProposalCountryIcon.setImageBitmap(proposal.countryFlagImage) + vpnSelectedProposalProviderLabel.text = proposal.providerID vpnSelectedProposalProviderLabel.visibility = View.VISIBLE - vpnProposalPickerFavoriteImage.setImageResource(it.isFavoriteResID) + vpnProposalPickerFavoriteImage.setImageResource(proposal.isFavoriteResID) } - private fun updateStatsLabels(it: StatisticsViewItem) { - vpnStatsDurationLabel.text = it.duration - vpnStatsBytesReceivedLabel.text = it.bytesReceived.value - vpnStatsBytesReceivedUnits.text = it.bytesReceived.units - vpnStatsBytesSentLabel.text = it.bytesSent.value - vpnStatsBytesSentUnits.text = it.bytesSent.units + private fun updateStatsLabels(stats: StatisticsModel) { + vpnStatsDurationLabel.text = stats.duration + vpnStatsBytesReceivedLabel.text = stats.bytesReceived.value + vpnStatsBytesReceivedUnits.text = stats.bytesReceived.units + vpnStatsBytesSentLabel.text = stats.bytesSent.value + vpnStatsBytesSentUnits.text = stats.bytesSent.units } - private fun updateConnStateLabel(it: ConnectionState) { - val connStateText = when (it) { + private fun updateConnStateLabel(state: ConnectionState) { + val connStateText = when (state) { ConnectionState.NOT_CONNECTED, ConnectionState.UNKNOWN -> getString(R.string.conn_state_not_connected) ConnectionState.CONNECTED -> getString(R.string.conn_state_connected) ConnectionState.CONNECTING -> getString(R.string.conn_state_connecting) @@ -187,35 +208,49 @@ class MainVpnFragment : Fragment() { } } - private fun updateConnButtonState(it: ConnectionState) { - connectionButton.text = when (it) { + private fun updateConnButtonState(state: ConnectionState) { + connectionButton.text = when (state) { ConnectionState.NOT_CONNECTED, ConnectionState.UNKNOWN -> getString(R.string.connect_button_connect) ConnectionState.CONNECTED -> getString(R.string.connect_button_disconnect) ConnectionState.CONNECTING -> getString(R.string.connect_button_cancel) ConnectionState.DISCONNECTING -> getString(R.string.connect_button_disconnecting) } - connectionButton.isEnabled = when (it) { + connectionButton.isEnabled = when (state) { ConnectionState.DISCONNECTING -> false else -> true } } - private fun handleConnectionPress(ctx: Context) { + private fun handleConnectionPress(root: View) { + if (!isAdded) { + return + } + + if (!connectivityChecker.isConnected()) { + showMessage(root.context, "Check internet connection.") + return + } + + if (!accountViewModel.isIdentityRegistered()) { + navigateTo(root, Screen.ACCOUNT) + return + } + if (sharedViewModel.canConnect()) { - connect(ctx) + connect(root.context, accountViewModel.identity.value!!.address) return } if (sharedViewModel.canDisconnect()) { - disconnect(ctx) + disconnect(root.context) return } cancel() } - private fun connect(ctx: Context) { + private fun connect(ctx: Context, identityAddress: String) { val proposal: ProposalViewItem? = sharedViewModel.selectedProposal.value if (proposal == null) { showMessage(ctx, getString(R.string.vpn_select_proposal_warning)) @@ -225,7 +260,8 @@ class MainVpnFragment : Fragment() { connectionButton.isEnabled = false job = CoroutineScope(Dispatchers.Main).launch { try { - sharedViewModel.connect(proposal.providerID, proposal.serviceType.type) + Log.i(TAG, "Connecting identity $identityAddress to provider ${proposal.providerID} with service ${proposal.serviceType.type}") + sharedViewModel.connect(identityAddress, proposal.providerID, proposal.serviceType.type) } catch (e: kotlinx.coroutines.CancellationException) { // Do nothing. } catch (e: Exception) { diff --git a/android/app/src/main/java/network/mysterium/ui/Navigation.kt b/android/app/src/main/java/network/mysterium/ui/Navigation.kt index e7f9b393b..a92d8b39e 100644 --- a/android/app/src/main/java/network/mysterium/ui/Navigation.kt +++ b/android/app/src/main/java/network/mysterium/ui/Navigation.kt @@ -4,25 +4,32 @@ import android.content.Intent import android.view.View import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment +import androidx.navigation.NavController import androidx.navigation.findNavController import network.mysterium.vpn.R enum class Screen { MAIN, FEEDBACK, - PROPOSALS + PROPOSALS, + ACCOUNT, } -fun navigateTo(view: View, destination: Screen) { - val navController = view.findNavController() +fun navigateTo(navController: NavController, destination: Screen) { val to = when(destination) { Screen.MAIN -> R.id.action_go_to_vpn_screen Screen.FEEDBACK -> R.id.action_go_to_feedback_screen Screen.PROPOSALS -> R.id.action_go_to_proposals_screen + Screen.ACCOUNT -> R.id.action_go_to_account_screen } navController.navigate(to) } +fun navigateTo(view: View, destination: Screen) { + val navController = view.findNavController() + navigateTo(navController, destination) +} + fun Fragment.onBackPress(cb: () -> Unit) { val callback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { diff --git a/android/app/src/main/java/network/mysterium/ui/ProposalViewItem.kt b/android/app/src/main/java/network/mysterium/ui/ProposalViewItem.kt index 53208499d..3e32090a0 100644 --- a/android/app/src/main/java/network/mysterium/ui/ProposalViewItem.kt +++ b/android/app/src/main/java/network/mysterium/ui/ProposalViewItem.kt @@ -46,12 +46,12 @@ class ProposalViewItem constructor( } companion object { - fun parse(it: ProposalItem, favoriteProposals: Map): ProposalViewItem { + fun parse(proposal: ProposalItem, favoriteProposals: Map): ProposalViewItem { val res = ProposalViewItem( - id = it.providerID+it.serviceType, - providerID = it.providerID, - serviceType = ServiceType.parse(it.serviceType), - countryCode = it.countryCode.toLowerCase()) + id = proposal.providerID+proposal.serviceType, + providerID = proposal.providerID, + serviceType = ServiceType.parse(proposal.serviceType), + countryCode = proposal.countryCode.toLowerCase()) if (Countries.bitmaps.contains(res.countryCode)) { res.countryFlagImage = Countries.bitmaps[res.countryCode] @@ -59,7 +59,7 @@ class ProposalViewItem constructor( } res.serviceTypeResID = mapServiceTypeResourceID(res.serviceType) - res.qualityLevel = QualityLevel.parse(it.qualityLevel) + res.qualityLevel = QualityLevel.parse(proposal.qualityLevel) res.qualityResID = mapQualityLevelResourceID(res.qualityLevel) res.isFavorite = favoriteProposals.containsKey(res.id) if (res.isFavorite) { diff --git a/android/app/src/main/java/network/mysterium/ui/ProposalsViewModel.kt b/android/app/src/main/java/network/mysterium/ui/ProposalsViewModel.kt index 2ad6e4de6..7e791e04b 100644 --- a/android/app/src/main/java/network/mysterium/ui/ProposalsViewModel.kt +++ b/android/app/src/main/java/network/mysterium/ui/ProposalsViewModel.kt @@ -194,7 +194,7 @@ class ProposalsViewModel(private val sharedViewModel: SharedViewModel, private v private suspend fun loadInitialProposals(refresh: Boolean = false) { try { - val nodeProposals = nodeRepository.getProposals(refresh) + val nodeProposals = nodeRepository.proposals(refresh) allProposals = nodeProposals.map { ProposalViewItem.parse(it, favoriteProposals) } proposalsCounts.value = ProposalsCounts( diff --git a/android/app/src/main/java/network/mysterium/ui/SharedViewModel.kt b/android/app/src/main/java/network/mysterium/ui/SharedViewModel.kt index 1924569bb..fbc942d7b 100644 --- a/android/app/src/main/java/network/mysterium/ui/SharedViewModel.kt +++ b/android/app/src/main/java/network/mysterium/ui/SharedViewModel.kt @@ -23,11 +23,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.* -import mysterium.ConnectRequest +import network.mysterium.AppNotificationManager import network.mysterium.db.FavoriteProposal import network.mysterium.service.core.NodeRepository import network.mysterium.service.core.Statistics -import network.mysterium.logging.BugReporter import network.mysterium.service.core.MysteriumCoreService import network.mysterium.service.core.Status @@ -45,22 +44,22 @@ enum class ConnectionState(val type: String) { } } -class LocationViewItem( +class LocationModel( val ip: String, val countryFlagImage: Bitmap? ) -class StatisticsViewItem( +class StatisticsModel( val duration: String, val bytesReceived: FormattedBytesViewItem, val bytesSent: FormattedBytesViewItem ) { companion object { - fun from(it: Statistics): StatisticsViewItem { - return StatisticsViewItem( - duration = UnitFormatter.timeDisplay(it.duration.toDouble()), - bytesReceived = UnitFormatter.bytesDisplay(it.bytesReceived.toDouble()), - bytesSent = UnitFormatter.bytesDisplay(it.bytesSent.toDouble()) + fun from(stats: Statistics): StatisticsModel { + return StatisticsModel( + duration = UnitFormatter.timeDisplay(stats.duration), + bytesReceived = UnitFormatter.bytesDisplay(stats.bytesReceived), + bytesSent = UnitFormatter.bytesDisplay(stats.bytesSent) ) } } @@ -68,19 +67,19 @@ class StatisticsViewItem( class SharedViewModel( private val nodeRepository: NodeRepository, - private val bugReporter: BugReporter, - private val mysteriumCoreService: CompletableDeferred + private val mysteriumCoreService: CompletableDeferred, + private val notificationManager: AppNotificationManager, + private val accountViewModel: AccountViewModel ) : ViewModel() { val selectedProposal = MutableLiveData() val connectionState = MutableLiveData() - val statistics = MutableLiveData() - val location = MutableLiveData() + val statistics = MutableLiveData() + val location = MutableLiveData() private var isConnected = false suspend fun load(favoriteProposals: Map) { - unlockIdentity() initListeners() loadLocation() val status = loadCurrentStatus() @@ -101,16 +100,19 @@ class SharedViewModel( return state != null && state == ConnectionState.CONNECTED } - suspend fun connect(providerID: String, serviceType: String) { + suspend fun connect(identityAddress: String, providerID: String, serviceType: String) { try { connectionState.value = ConnectionState.CONNECTING // Before doing actual connection add some delay to prevent // from trying to establish connection if user instantly clicks CANCEL. delay(1000) - val req = ConnectRequest() - req.providerID = providerID - req.serviceType = serviceType - nodeRepository.connect(req) + nodeRepository.connect(identityAddress, providerID, serviceType) + + // Force app to run in foreground while connected to VPN. + mysteriumCoreService.await().startForegroundWithNotification( + notificationManager.defaultNotificationID, + notificationManager.createConnectedToVPNNotification() + ) isConnected = true connectionState.value = ConnectionState.CONNECTED loadLocation() @@ -133,13 +135,13 @@ class SharedViewModel( connectionState.value = ConnectionState.NOT_CONNECTED throw e } finally { - mysteriumCoreService.await().hideNotifications() + mysteriumCoreService.await().stopForeground() } } private suspend fun loadCurrentStatus(): Status? { return try { - val status = nodeRepository.getStatus() + val status = nodeRepository.status() val state = ConnectionState.parse(status.state) connectionState.value = state status @@ -149,13 +151,13 @@ class SharedViewModel( } } - private suspend fun loadActiveProposal(it: Status?, favoriteProposals: Map) { - if (it == null || it.providerID == "" || it.serviceType == "") { + private suspend fun loadActiveProposal(status: Status?, favoriteProposals: Map) { + if (status == null || status.providerID == "" || status.serviceType == "") { return } try { - val proposal = nodeRepository.getProposal(it.providerID, it.serviceType) ?: return + val proposal = nodeRepository.proposal(status.providerID, status.serviceType) ?: return val proposalViewItem = ProposalViewItem.parse(proposal, favoriteProposals) selectProposal(proposalViewItem) } catch (e: Exception) { @@ -174,8 +176,8 @@ class SharedViewModel( } } - private fun handleConnectionStatusChange(it: String) { - val newState = ConnectionState.parse(it) + private fun handleConnectionStatusChange(status: String) { + val newState = ConnectionState.parse(status) val currentState = connectionState.value // Update all UI related state in new coroutine on UI thread. @@ -183,50 +185,46 @@ class SharedViewModel( // inside go node library. viewModelScope.launch { connectionState.value = newState + if (currentState == ConnectionState.CONNECTED && newState != currentState) { - mysteriumCoreService.await().showNotification("Connection lost", "VPN connection was closed.") + notificationManager.showConnectionLostNotification() resetStatistics() loadLocation() } } } - private fun handleStatisticsChange(it: Statistics) { + private fun handleStatisticsChange(stats: Statistics) { // Update all UI related state in new coroutine on UI thread. // This is needed since status change can be executed on separate // inside go node library. viewModelScope.launch { - val s = StatisticsViewItem.from(it) - statistics.value = StatisticsViewItem.from(it) + val s = StatisticsModel.from(stats) + statistics.value = StatisticsModel.from(stats) + + if (canDisconnect() && accountViewModel.needToTopUp()) { + notificationManager.showTopUpBalanceNotification() + } // Show global notification with connected country and statistics. // At this point we need to check if proposal is not null since // statistics event can fire sooner than proposal is loaded. - if (selectedProposal.value != null) { + if (selectedProposal.value != null && canDisconnect()) { val countryName = selectedProposal.value?.countryName val notificationTitle = "Connected to $countryName" val notificationContent = "Received ${s.bytesReceived.value} ${s.bytesReceived.units} | Send ${s.bytesSent.value} ${s.bytesSent.units}" - mysteriumCoreService.await().showNotification(notificationTitle, notificationContent) + notificationManager.showStatisticsNotification(notificationTitle, notificationContent) } } } - private suspend fun unlockIdentity() { - try { - val identity = nodeRepository.unlockIdentity() - bugReporter.setUserIdentifier(identity) - } catch (e: Exception) { - Log.e(TAG, "Failed not unlock identity", e) - } - } - private suspend fun loadLocation() { // Try to load location with few attempts. It can fail to load when connected to VPN. - location.value = LocationViewItem(ip = "Updating", countryFlagImage = null) + location.value = LocationModel(ip = "Updating", countryFlagImage = null) for (i in 1..3) { try { - val loc = nodeRepository.getLocation() - location.value = LocationViewItem(ip = loc.ip, countryFlagImage = Countries.bitmaps[loc.countryCode.toLowerCase()]) + val loc = nodeRepository.location() + location.value = LocationModel(ip = loc.ip, countryFlagImage = Countries.bitmaps[loc.countryCode.toLowerCase()]) break } catch (e: Exception) { delay(1000) @@ -236,7 +234,7 @@ class SharedViewModel( } private fun resetStatistics() { - statistics.value = StatisticsViewItem.from(Statistics(0, 0, 0)) + statistics.value = StatisticsModel.from(Statistics(0, 0, 0)) } companion object { diff --git a/android/app/src/main/java/network/mysterium/ui/UnitFormatter.kt b/android/app/src/main/java/network/mysterium/ui/UnitFormatter.kt index f7a70f7a3..024c5e653 100644 --- a/android/app/src/main/java/network/mysterium/ui/UnitFormatter.kt +++ b/android/app/src/main/java/network/mysterium/ui/UnitFormatter.kt @@ -20,7 +20,6 @@ package network.mysterium.ui import kotlin.math.floor import kotlin.math.roundToInt - class FormattedBytesViewItem(val value: String, val units: String) object UnitFormatter { @@ -28,33 +27,35 @@ object UnitFormatter { val MB = 1024 * KB val GB = 1024 * MB - fun bytesDisplay(bytes: Double): FormattedBytesViewItem { + fun bytesDisplay(bytes: Long): FormattedBytesViewItem { + val bytesDouble = bytes.toDouble() return when { - bytes < KB -> FormattedBytesViewItem("$bytes", "B") - bytes < MB -> FormattedBytesViewItem("%.2f".format(bytes / KB), "KB") - bytes < GB -> FormattedBytesViewItem("%.2f".format(bytes / MB), "MB") - else -> FormattedBytesViewItem("%.2f".format(bytes / GB), "GB") + bytesDouble < KB -> FormattedBytesViewItem("$bytesDouble", "B") + bytesDouble < MB -> FormattedBytesViewItem("%.2f".format(bytesDouble / KB), "KB") + bytesDouble < GB -> FormattedBytesViewItem("%.2f".format(bytesDouble / MB), "MB") + else -> FormattedBytesViewItem("%.2f".format(bytesDouble / GB), "GB") } } - fun timeDisplay(seconds: Double): String { + fun timeDisplay(seconds: Long): String { + val secondsDouble = seconds.toDouble() if (seconds < 0) { return "00:00:00" } - val h = floor(seconds / 3600).roundToInt() + val h = floor(secondsDouble / 3600).roundToInt() val hh = when { h > 9 -> h.toString() else -> "0$h" } - val m = floor((seconds % 3600) / 60).roundToInt() + val m = floor((secondsDouble % 3600) / 60).roundToInt() val mm = when { m > 9 -> m.toString() else -> "0$m" } - val s = floor(seconds % 60).roundToInt() + val s = floor(secondsDouble % 60).roundToInt() val ss = when { s > 9 -> s.toString() else -> "0$s" diff --git a/android/app/src/main/res/drawable/ic_account_balance_wallet_gray_24dp.xml b/android/app/src/main/res/drawable/ic_account_balance_wallet_gray_24dp.xml new file mode 100644 index 000000000..98932549a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_account_balance_wallet_gray_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_account_circle_black_24dp.xml b/android/app/src/main/res/drawable/ic_account_circle_black_24dp.xml new file mode 100644 index 000000000..76785806d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_account_circle_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml new file mode 100644 index 000000000..71d5bbd29 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_bug_report_black_24dp.xml b/android/app/src/main/res/drawable/ic_bug_report_black_24dp.xml new file mode 100644 index 000000000..4d83902b8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_bug_report_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_close_black_24dp.xml b/android/app/src/main/res/drawable/ic_close_black_24dp.xml new file mode 100644 index 000000000..ede4b7108 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_close_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_help_outline_black_24dp.xml b/android/app/src/main/res/drawable/ic_help_outline_black_24dp.xml deleted file mode 100644 index 850ca0eb7..000000000 --- a/android/app/src/main/res/drawable/ic_help_outline_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/android/app/src/main/res/drawable/ic_menu_32dp.xml b/android/app/src/main/res/drawable/ic_menu_32dp.xml new file mode 100644 index 000000000..029d1ba43 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_menu_32dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index daa7bc824..2303301c2 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -7,14 +7,43 @@ android:background="@color/ColorWhite" android:fitsSystemWindows="true"> - + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_account.xml b/android/app/src/main/res/layout/fragment_account.xml new file mode 100644 index 000000000..801b53177 --- /dev/null +++ b/android/app/src/main/res/layout/fragment_account.xml @@ -0,0 +1,274 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/fragment_feedback.xml b/android/app/src/main/res/layout/fragment_feedback.xml index b6339d945..e3e263b24 100644 --- a/android/app/src/main/res/layout/fragment_feedback.xml +++ b/android/app/src/main/res/layout/fragment_feedback.xml @@ -1,109 +1,94 @@ - + + android:layout_height="match_parent"> + + + + + + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingLeft="20dp" + android:paddingTop="10dp" + android:paddingRight="20dp"> - - - + app:layout_constraintTop_toBottomOf="@id/feedback_type_spinner_label" /> - - - + - + - + - + - + - + diff --git a/android/app/src/main/res/layout/fragment_main_vpn.xml b/android/app/src/main/res/layout/fragment_main_vpn.xml index d819a9d53..37c0926d4 100644 --- a/android/app/src/main/res/layout/fragment_main_vpn.xml +++ b/android/app/src/main/res/layout/fragment_main_vpn.xml @@ -6,35 +6,57 @@ android:background="#ffffff" android:orientation="vertical"> - - + android:layout_height="0dp"> + + + + + + + app:layout_constraintVertical_bias="0.60" /> + + + + + + + diff --git a/android/app/src/main/res/navigation/nav_graph.xml b/android/app/src/main/res/navigation/nav_graph.xml index 96fa4bbf5..aac8ef3ab 100644 --- a/android/app/src/main/res/navigation/nav_graph.xml +++ b/android/app/src/main/res/navigation/nav_graph.xml @@ -26,6 +26,12 @@ android:label="fragment_proposals" tools:layout="@layout/fragment_proposals" /> + + @@ -37,4 +43,7 @@ + + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 76fc0bdf2..e06fb3bea 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,4 +28,8 @@ Please select proposal to connect. Failed to connect. Please try again. Failed to disconnect. Please try again. + Feedback type + + + Hello blank fragment diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cbddab95c..08d1937d8 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -13,7 +13,8 @@ #622461 #ffffff #999999 - + #f9f9f9 + #dc3545