diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b0538cb9..ad8eac880 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,7 +97,11 @@ UploadToNexusPublic: - echo "$signKey" | base64 -d -i > $SIGN_KEY_PATH - export GRADLE_USER_HOME=$(pwd)/.gradle - chmod +x ./gradlew +<<<<<<< HEAD + - extras ndk -n 23.2.8568313 -c 3.22.1 +======= - extras ndk -n 27.2.12479018 -c 3.22.1 +>>>>>>> origin/develop - extras fastlane - apk --no-cache add swig - fastlane updateBuildVersion @@ -129,7 +133,11 @@ UploadToNexusPublic: - echo "$signKey" | base64 -d -i > $SIGN_KEY_PATH - export GRADLE_USER_HOME=$(pwd)/.gradle - chmod +x ./gradlew +<<<<<<< HEAD + - extras ndk -n 23.2.8568313 -c 3.22.1 +======= - extras ndk -n 27.2.12479018 -c 3.22.1 +>>>>>>> origin/develop - extras fastlane - apk --no-cache add swig - fastlane keepBuildVersion @@ -161,6 +169,9 @@ Slack: stage: Deploy before_script: - extras fastlane +<<<<<<< HEAD + - apk --no-cache add curl +======= - apk --no-cache add curl BuildStrongswan: @@ -241,3 +252,4 @@ BuildStrongswan: paths: - $CI_PROJECT_DIR/artifacts/strongswan-libs.zip expire_in: 1 week +>>>>>>> origin/develop diff --git a/base/build.gradle b/base/build.gradle index 62607e57f..ad77c0bce 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -5,7 +5,11 @@ apply from: "$rootDir/config/config.gradle" apply from: "$rootDir/depedencycheck.gradle" android { +<<<<<<< HEAD + ndkVersion "23.0.7599858" +======= ndkVersion "27.2.12479018" +>>>>>>> origin/develop compileOptions { sourceCompatibility rootProject.java targetCompatibility rootProject.java @@ -54,10 +58,17 @@ android { def version = details.requested.version def versionData = version.split('-') if (versionData.contains('jre')) { +<<<<<<< HEAD + details.useVersion "30.1.1-jre" + } + if (versionData.contains('android')) { + details.useVersion "30.1.1-android" +======= details.useVersion "32.1.2-jre" } if (versionData.contains('android')) { details.useVersion "32.1.2-android" +>>>>>>> origin/develop } } if (details.requested.group == 'junit') { @@ -99,7 +110,11 @@ dependencies { api project(path: ':wgtunnel') //Rxjava api 'io.reactivex.rxjava2:rxandroid:2.1.1' +<<<<<<< HEAD + api 'io.reactivex.rxjava2:rxjava:2.2.13' +======= api 'io.reactivex.rxjava2:rxjava:2.2.19' +>>>>>>> origin/develop //Retrofit implementation "com.squareup.retrofit2:retrofit:$retrofit" api "com.squareup.retrofit2:converter-gson:$retrofit" @@ -107,7 +122,11 @@ dependencies { implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0' //Okhttp implementation 'com.squareup.okhttp3:logging-interceptor:4.2.2' +<<<<<<< HEAD + implementation 'com.squareup.okhttp3:okhttp:4.10.0' +======= implementation 'com.squareup.okhttp3:okhttp:4.11.0' +>>>>>>> origin/develop api 'com.squareup.okhttp3:mockwebserver:4.9.3' //Logging api 'org.slf4j:slf4j-api:1.7.36' @@ -117,7 +136,10 @@ dependencies { } //Room api "androidx.room:room-rxjava2:$room" +<<<<<<< HEAD +======= api "androidx.room:room-ktx:$room" +>>>>>>> origin/develop api "androidx.room:room-runtime:$room" kapt "androidx.room:room-compiler:$room" implementation "androidx.room:room-testing:$room" @@ -140,14 +162,26 @@ dependencies { kapt "com.github.bumptech.glide:compiler:$glide" api 'commons-io:commons-io:2.11.0' api 'com.github.thoughtbot.expandable-recycler-view:expandablerecyclerview:v1.3' +<<<<<<< HEAD + api 'com.jakewharton:butterknife:10.2.0' + kapt 'com.jakewharton:butterknife-compiler:10.2.3' + api 'com.google.guava:guava:30.1.1-android' +======= api 'com.google.guava:guava:32.1.2-android' +>>>>>>> origin/develop api 'com.github.GCX-HCI:tray:v0.12.0' implementation 'org.isomorphism:token-bucket:1.6' // Test testImplementation "junit:junit:$JUnit" testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' testImplementation 'org.junit.jupiter:junit-jupiter' +<<<<<<< HEAD + // ECH Conscrypt + implementation 'info.guardianproject.conscrypt:conscrypt-android:2.6.alpha1638179154.job1828169525' + implementation 'commons-io:commons-io:2.11.0' +======= implementation 'commons-io:commons-io:2.13.0' +>>>>>>> origin/develop implementation 'commons-net:commons-net:3.6' // Google only dependencies googleApi "com.google.firebase:firebase-messaging:$fireBase" @@ -156,5 +190,8 @@ dependencies { googleApi 'com.android.billingclient:billing:7.0.0' googleApi "com.google.android.gms:play-services-appset:16.0.2" googleApi 'com.google.android.play:review-ktx:2.0.1' +<<<<<<< HEAD +======= googleApi 'com.google.android.gms:play-services-auth:20.4.1' +>>>>>>> origin/develop } diff --git a/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationComponent.kt b/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationComponent.kt index a7be2296b..d81ac51c1 100644 --- a/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationComponent.kt +++ b/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationComponent.kt @@ -22,7 +22,10 @@ import com.windscribe.vpn.mocklocation.MockLocationManager import com.windscribe.vpn.repository.* import com.windscribe.vpn.services.FirebaseManager import com.windscribe.vpn.services.ReceiptValidator +<<<<<<< HEAD +======= import com.windscribe.vpn.services.sso.GoogleSignInManager +>>>>>>> origin/develop import com.windscribe.vpn.state.* import com.windscribe.vpn.workers.WindScribeWorkManager import com.windscribe.vpn.workers.worker.* @@ -71,7 +74,10 @@ interface ApplicationComponent { val firebaseManager: FirebaseManager val proxyDNSManager: ProxyDNSManager val dynamicShortCutManager: DynamicShortcutManager +<<<<<<< HEAD +======= val googleSignInManager: GoogleSignInManager +>>>>>>> origin/develop //Repository val staticIpRepository: StaticIpRepository diff --git a/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationModule.kt b/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationModule.kt index eec104751..cbd4ecf93 100644 --- a/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationModule.kt +++ b/base/src/fdroid/java/com/windscribe/vpn/di/ApplicationModule.kt @@ -9,8 +9,11 @@ import com.windscribe.vpn.backend.AndroidDeviceIdentityImpl import com.windscribe.vpn.services.FirebaseManager import com.windscribe.vpn.services.ReceiptValidator import com.windscribe.vpn.services.firebasecloud.FirebaseManagerImpl +<<<<<<< HEAD +======= import com.windscribe.vpn.services.sso.GoogleSignInManager import com.windscribe.vpn.services.sso.GoogleSignInManagerImpl +>>>>>>> origin/develop import com.windscribe.vpn.workers.WindScribeWorkManager import dagger.Module import dagger.Provides @@ -39,10 +42,13 @@ class ApplicationModule(override var windscribeApp: Windscribe) : BaseApplicatio fun provideAndroidIdentity(): AndroidDeviceIdentity { return AndroidDeviceIdentityImpl() } +<<<<<<< HEAD +======= @Provides @Singleton fun providesGoogleSignInManager(): GoogleSignInManager { return GoogleSignInManagerImpl(windscribeApp) } +>>>>>>> origin/develop } \ No newline at end of file diff --git a/base/src/main/ws_launcher-playstore.png b/base/src/main/ic_launcher_mogavpn_playstore.png similarity index 100% rename from base/src/main/ws_launcher-playstore.png rename to base/src/main/ic_launcher_mogavpn_playstore.png diff --git a/base/src/main/ws_launcher-web.png b/base/src/main/ic_launcher_mogavpn_web.png similarity index 100% rename from base/src/main/ws_launcher-web.png rename to base/src/main/ic_launcher_mogavpn_web.png diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index ea073ca72..519d98692 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ + MogaVPN Empty username! Empty password! Enter username @@ -14,7 +15,7 @@ Your password must be at least 8 characters(ie. \"Hello1234\", \"Solyanka\"). The password you entered is commonly used and not secure. 8 or more characters with at least one uppercase and lowercase(ie. \"Hello1234\", \"Solyanka\"). - You appear to be on a highly restrictive network which is blocking Windscribe. Please use the \"Emergency Connect\" feature on the main screen and then try to signup or login again. If you\'re unsuccessful, please contact us. + You appear to be on a highly restrictive network which is blocking MogaVPN. Please use the \"Emergency Connect\" feature on the main screen and then try to signup or login again. If you\'re unsuccessful, please contact us. Restrictive Network Detected 2FA code is required Invalid 2FA code @@ -138,20 +139,20 @@ Authentication failed Waiting for usable network News Feed - You will find announcements and general Windscribe related news here. Perhaps even delicious cake, Everyone loves cake! - WELCOME TO WINDSCRIBE - Rate Windscribe + You will find announcements and general MogaVPN related news here. Perhaps even delicious cake, Everyone loves cake! + WELCOME TO MOGAVPN + Rate MogaVPN Maybe Later Go Away - Are you enjoying Windscribe? Sure you are. Rate us in the Play Store, and we’ll love you long time. + Are you enjoying MogaVPN? Sure you are. Rate us in the Play Store, and we’ll love you long time. This feature requires you to modify your android settings. Please follow the next few steps to activate it. Let\'s do it Unlock Developer Options Open Settings and look for the “About Phone” section. Find the build number inside “Software info” and tap it 7 times. Open Settings Select Mock Location App - Once unlocked, go to “Developer Options” in the Settings. Under “Debugging” choose Windscribe as a “Mock location app”. - Once unlocked, go to “Developer Options” in the Settings. Under “Location” choose Windscribe as a “Mock location app”. + Once unlocked, go to “Developer Options” in the Settings. Under “Debugging” choose MogaVPN as a “Mock location app”. + Once unlocked, go to “Developer Options” in the Settings. Under “Location” choose MogaVPN as a “Mock location app”. You’re Done! Your GPS location will be the same as your VPN location. Couldn’t enable GPS Spoofing @@ -207,7 +208,7 @@ Help Me! Log Out Preferences - Are you sure you want to log out of Windscribe? + Are you sure you want to log out of MogaVPN? Location Order Display Latency Language @@ -216,17 +217,17 @@ Haptic Feedback Version Location Load - Choose which apps to exclude or include from Windscribe when Connected + Choose which apps to exclude or include from MogaVPN when Connected Mode Split Tunneling Off On - Apps selected below will not go through Windscribe when connected - Apps selected below will go through Windscribe when connected + Apps selected below will not go through MogaVPN when connected + Apps selected below will go through MogaVPN when connected APPS Always on VPN \"Always On\" VPN allows Android to control the VPN connection and ensure that no data can leave the device unless the VPN tunnel is up. - Android 10+ devices require Always-On VPN to be configured for Windscribe to start on device boot. + Android 10+ devices require Always-On VPN to be configured for MogaVPN to start on device boot. Forget network Secured Unsecured @@ -240,7 +241,7 @@ Auto-connect On Boot Enable Start On Boot Grant Permission - Windscribe needs the location permission while it\'s running in the background in order to make the \"Network Whitelist\" feature work. This permission is required to access Wifi network names. This permission is used solely for this feature as well as GPS spoofing functionality. Your location data does not leave your device, and is not used for anything else. + MogaVPN needs the location permission while it\'s running in the background in order to make the \"Network Whitelist\" feature work. This permission is required to access Wifi network names. This permission is used solely for this feature as well as GPS spoofing functionality. Your location data does not leave your device, and is not used for anything else. Location permission required to set up preferred protocols and auto connect. Disclaimer Location permission @@ -249,7 +250,7 @@ No logging Unlock streaming Knowledge Base - All you need to know about Windscribe. + All you need to know about MogaVPN. Talk to Garry Need help? Garry can help you with most issues, go talk to him. Contact Humans @@ -344,7 +345,7 @@ Sign Up Lazy Login Go to https://windscribe.com/lazy on any device and enter the code below. - Using your Windscribe Android app on your phone or tablet, go to Preferences (Top left), under “Account” choose “Lazy Login” and enter the code below. + Using your MogaVPN Android app on your phone or tablet, go to Preferences (Top left), under “Account” choose “Lazy Login” and enter the code below. OR Generate Code Manual Login @@ -426,8 +427,8 @@ last chosen location. Protocol change is not available for Custom Configs. Disconnect Emergency Connect - Can’t access Windscribe? Connect to our servers to unblock your restrictive network. - You are now connected to Windscribe server. Try to login again. + Can’t access MogaVPN? Connect to our servers to unblock your restrictive network. + You are now connected to MogaVPN server. Try to login again. CURRENT NETWORK OTHER NETWORKS (Required) @@ -471,11 +472,11 @@ last chosen location. Failed to update Robert rules. Successfully updated Robert rules. Share App - "%s is inviting you to join Windscribe. Provide their username at signup and you’ll both get 1gb of free data added to your accounts. If you go pro, they’ll go pro too!\n%s" + "%s is inviting you to join MogaVPN. Provide their username at signup and you’ll both get 1gb of free data added to your accounts. If you go pro, they’ll go pro too!\n%s" “Allow all the time” Location Access Required! Enable Location Access Location permission required - Windscribe needs the location permission in order to make the \"Network Whitelist\" feature work. This permission is required to access Wifi network names. This permission is used solely for this feature as well as GPS spoofing functionality. Your location data does not leave your device, and is not used for anything else. + MogaVPN needs the location permission in order to make the \"Network Whitelist\" feature work. This permission is required to access Wifi network names. This permission is used solely for this feature as well as GPS spoofing functionality. Your location data does not leave your device, and is not used for anything else. Missing location permission Permissions > Location and select Allow all the time.]]> Circumvent Censorship @@ -488,7 +489,7 @@ last chosen location. Connected DNS IP/DNS-over-HTTPS/TLS Enter valid IP or Url - Select the DNS server while connected to Windscribe. + Select the DNS server while connected to MogaVPN. Quick Connect File Sharing Frowned Upon Voucher Code (Optional) @@ -500,7 +501,7 @@ last chosen location. Confirmed email is required. Voucher code is used already. Disable Battery Optimization - To maintain a stable connection, please remove Windscribe from battery optimization settings. This ensures uninterrupted service for IKEv2 connections. + To maintain a stable connection, please remove MogaVPN from battery optimization settings. This ensures uninterrupted service for IKEv2 connections. Don’t ask again Upgrade now to stay protected or wait until your bandwidth is reset on %s Changelog @@ -519,8 +520,8 @@ last chosen location. %s/month, Billed Annually Billed Annually Start using Pro - Welcome to Windscribe Pro! - Thanks for upgrading to Windscribe Pro! You now have access to even more powerful features. + Welcome to MogaVPN Pro! + Thanks for upgrading to MogaVPN Pro! You now have access to even more powerful features. Set Up on All Your Devices Connect to Any Location Unlimited Bandwidth diff --git a/fastlane/metadata/android/en-US/changelogs/1645.txt b/fastlane/metadata/android/en-US/changelogs/1645.txt new file mode 100644 index 000000000..9613c6bff --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1645.txt @@ -0,0 +1,5 @@ +v3.82(Mobile) +* Payment UI Upgrade +* Added json logging support. +* Added ec flag. +* Added show message to the user when static ip location is disabled. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1646.txt b/fastlane/metadata/android/en-US/changelogs/1646.txt new file mode 100644 index 000000000..1c2168383 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1646.txt @@ -0,0 +1,4 @@ +v3.82(TV) +* Payment UI Upgrade +* Added json logging support. +* Added ec flag. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1647.txt b/fastlane/metadata/android/en-US/changelogs/1647.txt new file mode 100644 index 000000000..70b8c65a4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1647.txt @@ -0,0 +1,2 @@ +v3.82(Mobile) +* Fixed login crash. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/1648.txt b/fastlane/metadata/android/en-US/changelogs/1648.txt new file mode 100644 index 000000000..4b6fab3f6 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1648.txt @@ -0,0 +1,2 @@ +v3.82(TV) +* Fixed login crash. \ No newline at end of file diff --git a/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/AccountStatusDialogTest.kt b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/AccountStatusDialogTest.kt new file mode 100644 index 000000000..d87b3f4d9 --- /dev/null +++ b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/AccountStatusDialogTest.kt @@ -0,0 +1,109 @@ +package com.windscribe.vpn.tests.dialogs + +import android.content.Intent +import androidx.fragment.app.FragmentOnAttachListener +import androidx.test.InstrumentationRegistry +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.RootMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import com.windscribe.mobile.R +import com.windscribe.mobile.dialogs.AccountStatusDialog +import com.windscribe.mobile.dialogs.AccountStatusDialogData +import com.windscribe.mobile.upgradeactivity.UpgradeActivity +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.di.TestConfiguration +import com.windscribe.vpn.tests.BaseTest +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AccountStatusDialogTest : BaseTest() { + + @Test + fun testAccountStatusDialog() { + countingIdlingResource.increment() + updatedUserConfiguration( + TestConfiguration( + accountStatus = 1, lastAccountStatus = 1 + ) + ) + val activity = ActivityTestRule(WindscribeActivity::class.java).launchActivity(Intent()) + val listener = FragmentOnAttachListener { _, fragment -> + if (fragment is AccountStatusDialog) { + countingIdlingResource.decrement() + } + } + activity.supportFragmentManager.addFragmentOnAttachListener(listener) + // Show expired account dialog + updatedUserConfiguration( + TestConfiguration( + accountStatus = 2, lastAccountStatus = 1 + ) + ) + var data = AccountStatusDialogData( + title = activity.getString(R.string.you_re_out_of_data), + icon = R.drawable.garry_nodata, + description = activity.getString(R.string.upgrade_to_stay_protected), + showSkipButton = true, + skipText = activity.getString(R.string.upgrade_later), + showUpgradeButton = true, + upgradeText = activity.getString(R.string.upgrade), + bannedLayout = false + ) + // Verify dialog data + verifyData(data) + Assert.assertFalse(activity.isFinishing) + onView(withId(R.id.userAccountStatusPrimaryButton)).inRoot(RootMatchers.isDialog()) + .perform(click()) + intended(hasComponent(UpgradeActivity::class.java.name)) + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + // Show banned account dialog + countingIdlingResource.increment() + updatedUserConfiguration( + TestConfiguration( + accountStatus = 3, lastAccountStatus = 1 + ) + ) + data = AccountStatusDialogData( + title = activity.getString(R.string.you_ve_been_banned), + icon = R.drawable.garry_angry, + description = activity.getString(R.string.you_ve_violated_our_terms), + showSkipButton = false, + skipText = "", + showUpgradeButton = true, + upgradeText = activity.getString(R.string.ok), + bannedLayout = true + ) + // Verify dialog data + verifyData(data) + onView(withId(R.id.userAccountStatusPrimaryButton)).inRoot(RootMatchers.isDialog()) + .perform(click()) + Assert.assertTrue(activity.isFinishing) + + activity.supportFragmentManager.removeFragmentOnAttachListener(listener) + activity.finish() + } + + private fun verifyData(data: AccountStatusDialogData) { + onView(withId(R.id.userAccountStatusIcon)).inRoot(RootMatchers.isDialog()) + .check(matches(isDisplayed())) + onView(withId(R.id.userAccountStatusTitle)).inRoot(RootMatchers.isDialog()) + .check(matches(withText(data.title))) + onView(withId(R.id.userAccountStatusDescription)).inRoot(RootMatchers.isDialog()) + .check(matches(withText(data.description))) + onView(withId(R.id.userAccountStatusPrimaryButton)).inRoot(RootMatchers.isDialog()) + .check(matches(withText(data.upgradeText))) + onView(withId(R.id.userAccountStatusSecondaryButton)).inRoot(RootMatchers.isDialog()) + .check(matches(withText(data.skipText))) + } +} \ No newline at end of file diff --git a/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/NodeStatusDialogTest.kt b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/NodeStatusDialogTest.kt new file mode 100644 index 000000000..24f9483f4 --- /dev/null +++ b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/NodeStatusDialogTest.kt @@ -0,0 +1,86 @@ +package com.windscribe.vpn.tests.dialogs + +import android.content.Intent +import androidx.fragment.app.FragmentOnAttachListener +import androidx.test.InstrumentationRegistry +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.RootMatchers +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import com.windscribe.mobile.R +import com.windscribe.mobile.dialogs.NodeStatusDialog +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.tests.BaseTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NodeStatusDialogTest : BaseTest() { + @Test + fun testDataNodeStatusDialog() { + updatedUserConfiguration() + val activity = ActivityTestRule(WindscribeActivity::class.java).launchActivity(Intent()) + val listener = FragmentOnAttachListener { _, fragment -> + if (fragment is NodeStatusDialog) { + countingIdlingResource.decrement() + } + } + activity.supportFragmentManager.addFragmentOnAttachListener(listener) + + countingIdlingResource.increment() + activity.setUpLayoutForNodeUnderMaintenance() + Espresso.onView(withId(R.id.nodeStatusTitle)).inRoot(RootMatchers.isDialog()).check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.under_maintenance)) + ) + ) + Espresso.onView(withId(R.id.nodeStatusDescription)).inRoot(RootMatchers.isDialog()).check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.check_status_description)) + ) + ) + Espresso.onView(withId(R.id.nodeStatusGaryIcon)).inRoot(RootMatchers.isDialog()).check( + ViewAssertions.matches( + ViewMatchers.isDisplayed() + ) + ) + Espresso.onView(withId(R.id.nodeStatusPrimaryButton)).inRoot(RootMatchers.isDialog()).check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.check_status)) + ) + ) + Espresso.onView(withId(R.id.nodeStatusSecondaryButton)).inRoot(RootMatchers.isDialog()) + .check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.back)) + ) + ) + + activity.supportFragmentManager.findFragmentByTag(NodeStatusDialog.tag)?.let { fragment -> + assert(fragment.isVisible) + } + Espresso.onView(withId(R.id.nodeStatusPrimaryButton)).inRoot(RootMatchers.isDialog()) + .perform(click()) + activity.supportFragmentManager.findFragmentByTag(NodeStatusDialog.tag)?.let { fragment -> + assert(!fragment.isVisible) + } + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + + countingIdlingResource.increment() + activity.setUpLayoutForNodeUnderMaintenance() + Espresso.onView(withId(R.id.nodeStatusSecondaryButton)).inRoot(RootMatchers.isDialog()) + .perform(click()) + activity.supportFragmentManager.findFragmentByTag(NodeStatusDialog.tag)?.let { fragment -> + assert(!fragment.isVisible) + } + + activity.supportFragmentManager.removeFragmentOnAttachListener(listener) + activity.finish() + } + +} \ No newline at end of file diff --git a/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/ShareAppLinkDialogTest.kt b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/ShareAppLinkDialogTest.kt new file mode 100644 index 000000000..857f83ec6 --- /dev/null +++ b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/ShareAppLinkDialogTest.kt @@ -0,0 +1,77 @@ +package com.windscribe.vpn.tests.dialogs + +import android.content.Intent +import android.content.Intent.EXTRA_TITLE +import androidx.fragment.app.FragmentOnAttachListener +import androidx.test.InstrumentationRegistry +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import com.windscribe.mobile.R +import com.windscribe.mobile.dialogs.ShareAppLinkDialog +import com.windscribe.mobile.mainmenu.MainMenuActivity +import com.windscribe.vpn.tests.BaseTest +import org.junit.Assert +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShareAppLinkDialogTest : BaseTest() { + + @Test + fun testDataShareAppLinkDialog() { + val activity = ActivityTestRule(MainMenuActivity::class.java).launchActivity(Intent()) + val listener = FragmentOnAttachListener { _, fragment -> + if (fragment is ShareAppLinkDialog) { + countingIdlingResource.decrement() + } + } + activity.supportFragmentManager.addFragmentOnAttachListener(listener) + countingIdlingResource.increment() + activity.showShareLinkDialog() + // Verify all the elements are correctly displayed + onView(withId(R.id.shareAppTitle)).inRoot(isDialog()).check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.share_windscribe_with_a_friend)) + ) + ) + onView(withId(R.id.shareAppExplainer)).inRoot(isDialog()).check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.referee_must_provide_your_username_at_sign_up_and_confirm_their_email_in_order_for_the_benefits_above_to_apply_to_your_account)) + ) + ) + onView(withId(R.id.shareAppIcon)).inRoot(isDialog()).check( + ViewAssertions.matches( + ViewMatchers.isDisplayed() + ) + ) + onView(withId(R.id.shareAppLinkButton)).inRoot(isDialog()).check( + ViewAssertions.matches( + ViewMatchers.withText(activity.getString(R.string.share_invite_link)) + ) + ) + // Verify the share link button works + onView(withId(R.id.shareAppLinkButton)).inRoot(isDialog()).perform(click()) + val title = IntentMatchers.hasExtra(EXTRA_TITLE, activity.getString(R.string.share_app)) + assertFalse(title == null) + Intents.intended(title) + Assert.assertTrue(activity.supportFragmentManager.findFragmentByTag(ShareAppLinkDialog.tag) == null) + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + // Verify the back button works + countingIdlingResource.increment() + activity.showShareLinkDialog() + onView(withId(R.id.shareAppNavButton)).inRoot(isDialog()).perform(click()) + Assert.assertTrue(activity.supportFragmentManager.findFragmentByTag(ShareAppLinkDialog.tag) == null) + activity.supportFragmentManager.removeFragmentOnAttachListener(listener) + activity.finish() + } +} \ No newline at end of file diff --git a/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/UnknownErrorDialogTest.kt b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/UnknownErrorDialogTest.kt new file mode 100644 index 000000000..2320b52fd --- /dev/null +++ b/mobile/src/androidTest/java/com/windscribe/vpn/tests/dialogs/UnknownErrorDialogTest.kt @@ -0,0 +1,89 @@ +package com.windscribe.vpn.tests.dialogs + +import android.content.Intent +import androidx.fragment.app.FragmentOnAttachListener +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import androidx.test.uiautomator.UiDevice +import com.windscribe.mobile.R +import com.windscribe.mobile.dialogs.UnknownErrorDialog +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.tests.BaseTest +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UnknownErrorDialogTest : BaseTest() { + + @Test + fun testUnknownErrorDialog() { + val activity = ActivityTestRule(WelcomeActivity::class.java).launchActivity(Intent()) + val listener = FragmentOnAttachListener { _, fragment -> + if (fragment is UnknownErrorDialog) { + countingIdlingResource.decrement() + } + } + activity.supportFragmentManager.addFragmentOnAttachListener(listener) + + countingIdlingResource.increment() + activity.showFailedAlert(activity.getString(R.string.failed_network_alert)) + + onView(withId(R.id.unknownErrorDescription)).inRoot(isDialog()).check( + ViewAssertions.matches( + withText(appContext.getString(R.string.failed_network_alert)) + ) + ) + onView(withId(R.id.unknownErrorIcon)).inRoot(isDialog()).check( + ViewAssertions.matches( + isDisplayed() + ) + ) + onView(withId(R.id.unknownErrorContactSupportButton)).inRoot(isDialog()).check( + ViewAssertions.matches( + withText(appContext.getString(R.string.contact_support)) + ) + ) + onView(withId(R.id.unknownErrorSendLogButton)).inRoot(isDialog()).check( + ViewAssertions.matches( + withText(appContext.getString(R.string.export_log)) + ) + ) + onView(withId(R.id.unknownErrorCancelButton)).inRoot(isDialog()).check( + ViewAssertions.matches( + withText(appContext.getString(R.string.close)) + ) + ) + + onView(withId(R.id.unknownErrorContactSupportButton)).inRoot(isDialog()).perform(click()) + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + + countingIdlingResource.increment() + activity.showFailedAlert(activity.getString(R.string.failed_network_alert)) + val intent = IntentMatchers.hasAction(Intent.ACTION_CHOOSER) + onView(withId(R.id.unknownErrorSendLogButton)).inRoot(isDialog()).perform(click()) + Intents.intended(intent) + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack() + + countingIdlingResource.increment() + activity.showFailedAlert(activity.getString(R.string.failed_network_alert)) + onView(withId(R.id.unknownErrorCancelButton)).inRoot(isDialog()).perform(click()) + onView(withId(R.id.unknownErrorCancelButton)).check { view, noViewFoundException -> + Assert.assertNotNull(noViewFoundException) + } + + activity.supportFragmentManager.removeFragmentOnAttachListener(listener) + activity.finish() + } +} \ No newline at end of file diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 8511801e3..558fcfafe 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -7,8 +7,8 @@ (R.id.alert_edit_view) + editText.filters = arrayOf(InputFilter.AllCaps(), InputFilter.LengthFilter(9)) + editText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + editText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(editable: Editable) { + if (editable.length == 9) { + alertDialog?.getButton(DialogInterface.BUTTON_POSITIVE)?.performClick() + } + } + + override fun beforeTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + editText?.text?.let { + val replaceIndex = editText.selectionEnd + if (replaceIndex == 4 && start == 3) { + editText.setText(String.format("%s-", editText.text.toString())) + editText.setSelection(editText.text?.length ?: 0) + } + if (replaceIndex == 5 && s[s.length - 1] != '-') { + it.insert(4, "-") + editText.setSelection(editText.text?.length ?: 0) + } + } + } + }) + alert.setTitle(R.string.enter_code) + alert.setView(view) + alert.setPositiveButton(R.string.enter) { _: DialogInterface, _: Int -> + val code = Objects.requireNonNull(editText.text).toString() + presenter.onCodeEntered(code) + } + alert.setNegativeButton(R.string.cancel) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + alertDialog = alert.create() + editText.requestFocus() + alertDialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + alertDialog?.show() + } + + override fun showEnterVoucherCodeDialog() { + val alert = AlertDialog.Builder(this, R.style.OverlayAlert) + val view = LayoutInflater.from(this).inflate(R.layout.alert_input_layout, null) + val editText = view.findViewById(R.id.alert_edit_view) + editText.filters = arrayOf(InputFilter.AllCaps()) + editText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + alert.setTitle(R.string.voucher_code) + alert.setView(view) + alert.setPositiveButton(R.string.enter) { _: DialogInterface, _: Int -> + val code = Objects.requireNonNull(editText.text).toString() + presenter.onVoucherCodeSubmitted(code) + } + alert.setNegativeButton(R.string.cancel) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + alertDialog = alert.create() + editText.requestFocus() + alertDialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) + alertDialog?.show() + } + + override fun showErrorDialog(error: String) { + ErrorDialog.show( + this, + error, + getColor(this, R.attr.overlayDialogBackgroundColor, R.color.colorDeepBlue90) + ) + } + + override fun showErrorMessage(errorMessage: String) { + Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show() + } + + override fun showProgress(progressText: String) { + mCustomProgressDialog.show() + (mCustomProgressDialog.findViewById(R.id.tv_dialog_header) as TextView).text = + progressText + } + + override fun showSuccessDialog(message: String) { + SuccessDialog.show( + this, + message, + getColor(this, R.attr.overlayDialogBackgroundColor, R.color.colorDeepBlue90) + ) + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, AccountActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/account/AccountPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/account/AccountPresenter.kt new file mode 100644 index 000000000..57456160a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/account/AccountPresenter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.account + +import android.content.Context + +interface AccountPresenter { + fun onAddEmailClicked(tvEmailText: String) + fun observeUserData(accountActivity: AccountActivity) + fun onCodeEntered(code: String) + fun onDestroy() + fun onEditAccountClicked() + fun onResendEmail() + fun onUpgradeClicked(textViewText: String) + fun onLazyLoginClicked() + fun setLayoutFromApiSession() + fun setTheme(context: Context) + fun onVoucherCodeClicked() + fun onVoucherCodeSubmitted(voucherCode: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/account/AccountPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/account/AccountPresenterImpl.kt new file mode 100644 index 000000000..6f38a4bf2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/account/AccountPresenterImpl.kt @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.account + +import android.content.Context +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.ClaimVoucherCodeResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.api.response.UserSessionResponse +import com.windscribe.vpn.api.response.VerifyExpressLoginResponse +import com.windscribe.vpn.api.response.WebSession +import com.windscribe.vpn.constants.NetworkErrorCodes +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.constants.NetworkKeyConstants.getWebsiteLink +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.constants.UserStatusConstants +import com.windscribe.vpn.errormodel.WindError.Companion.instance +import com.windscribe.vpn.model.User +import com.windscribe.vpn.model.User.EmailStatus +import com.windscribe.vpn.repository.CallResult +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import java.text.DecimalFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.Objects +import javax.inject.Inject + +class AccountPresenterImpl @Inject constructor( + private val accountView: AccountView, + private val interactor: ActivityInteractor +) : AccountPresenter { + private val logger = LoggerFactory.getLogger("basic") + private val successMessage = + """ + Sweet, you should be + all good to go now. + """ + + override fun onDestroy() { + if (interactor.getCompositeDisposable().isDisposed.not()) { + logger.info("Disposing observer on destroy...") + interactor.getCompositeDisposable().dispose() + } + } + + override fun onAddEmailClicked(tvEmailText: String) { + if (interactor.getResourceString(R.string.add_email) == tvEmailText) { + logger.info("Go to add Email activity") + accountView.goToEmailActivity() + } else { + logger.info("User already confirmed email...") + } + } + + override fun observeUserData(accountActivity: AccountActivity) { + interactor.getUserRepository().user.observe(accountActivity) { user: User -> + setUserInfo( + user + ) + } + } + + override fun onVoucherCodeClicked() { + accountView.showEnterVoucherCodeDialog() + } + + override fun onVoucherCodeSubmitted(voucherCode: String) { + accountView.showProgress("Claiming voucher code...") + logger.debug("Claiming voucher code.") + interactor.getCompositeDisposable() + .add(interactor.getApiCallManager().claimVoucherCode(voucherCode) + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : + DisposableSingleObserver>() { + override fun onSuccess(response: GenericResponseClass) { + accountView.hideProgress() + handleClaimVoucherResponse(response) + } + + override fun onError(e: Throwable) { + logger.error("Error claiming voucher code: ${e.localizedMessage}") + accountView.hideProgress() + accountView.showErrorDialog("Error applying voucher code. ${e.localizedMessage}") + } + }) + ) + } + + private fun handleClaimVoucherResponse(response: GenericResponseClass) { + when (val result = response.callResult()) { + is CallResult.Error -> { + logger.debug("Error applying Voucher Code: ${result.errorMessage}") + accountView.showErrorDialog(result.errorMessage) + } + + is CallResult.Success -> { + logger.debug("Claimed voucher code: {}", result.data) + if (result.data.isClaimed) { + accountView.showSuccessDialog( + interactor.getResourceString( + R.string.voucher_code_is_applied + ) + ) + interactor.getWorkManager().updateSession() + } else if (result.data.emailRequired == true) { + accountView.showErrorDialog(interactor.getResourceString(R.string.confirmed_email_required)) + } else if (result.data.isUsed) { + accountView.showErrorDialog(interactor.getResourceString(R.string.voucher_code_used_already)) + } else { + accountView.showErrorDialog(interactor.getResourceString(R.string.voucher_code_is_invalid)) + } + } + } + } + + override fun onCodeEntered(code: String) { + accountView.showProgress("Verifying code...") + logger.debug("verifying express login code.") + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().verifyExpressLoginCode(code) + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + logger.debug("Error verifying login code: ${e.localizedMessage}") + accountView.hideProgress() + accountView.showErrorDialog("Error verifying login code. Check your network connection.") + } + + override fun onSuccess( + response: GenericResponseClass + ) { + accountView.hideProgress() + when (val result = response.callResult()) { + is CallResult.Error -> { + if (response.errorClass != null) { + logger.debug("Error verifying login code: ${result.errorMessage}") + accountView.showErrorDialog(result.errorMessage) + } else { + logger.debug("Failed to verify lazy login code.") + accountView.showErrorDialog("Failed to verify lazy login code.") + } + } + + is CallResult.Success -> { + logger.debug("Successfully verified login code") + accountView.showSuccessDialog(successMessage.trimIndent()) + } + } + } + }) + ) + } + + override fun onEditAccountClicked() { + accountView.setWebSessionLoading(true) + logger.info("Opening My Account page in browser...") + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().getWebSession().subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()).subscribeWith(object : + DisposableSingleObserver?>() { + override fun onError(e: Throwable) { + accountView.setWebSessionLoading(false) + accountView.showErrorDialog( + "Unable to generate web session. Check your network connection." + ) + } + + override fun onSuccess( + webSession: GenericResponseClass + ) { + accountView.setWebSessionLoading(false) + when (val result = webSession.callResult()) { + is CallResult.Error -> { + if (result.code == NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + accountView.showErrorDialog( + "Unable to generate Web-Session. Check your network connection." + ) + } else { + accountView.showErrorDialog(result.errorMessage) + } + } + + is CallResult.Success -> { + accountView.openEditAccountInBrowser( + getWebsiteLink(NetworkKeyConstants.URL_MY_ACCOUNT) + result.data.tempSession + ) + } + } + } + }) + ) + } + + override fun onResendEmail() { + accountView.goToConfirmEmailActivity() + } + + override fun onUpgradeClicked(textViewText: String) { + if (interactor.getResourceString(R.string.upgrade_case_normal) == textViewText) { + logger.info("Showing upgrade dialog to the user...") + accountView.openUpgradeActivity() + } else { + logger.info("User is already pro no actions taken...") + } + } + + override fun onLazyLoginClicked() { + accountView.showEnterCodeDialog() + } + + override fun setLayoutFromApiSession() { + interactor.getCompositeDisposable().add( + interactor.getApiCallManager() + .getSessionGeneric(null) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver?>() { + override fun onError(e: Throwable) { + logger.debug("Error while making get session call:" + e.message) + } + + override fun onSuccess( + userSessionResponse: GenericResponseClass + ) { + if (userSessionResponse.dataClass != null) { + interactor.getUserRepository() + .reload(userSessionResponse.dataClass, null) + } else if (userSessionResponse.errorClass != null) { + //Server responded with error! + logger.debug( + "Server returned error during get session call." + + userSessionResponse.errorClass.toString() + ) + } + } + }) + ) + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + logger.debug("Setting theme to $savedThem") + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + private fun setUserInfo(user: User) { + accountView.setActivityTitle(interactor.getResourceString(R.string.my_account)) + if (user.isGhost) { + accountView.setupLayoutForGhostMode(user.isPro) + } else if (user.maxData != -1L) { + accountView.setupLayoutForFreeUser( + interactor.getResourceString(R.string.upgrade_case_normal), + interactor.getThemeColor(R.attr.wdSecondaryColor) + ) + } else if (user.isAlaCarteUnlimitedPlan) { + accountView.setupLayoutForPremiumUser( + interactor.getResourceString(R.string.a_la_carte_unlimited_plan), + interactor.getThemeColor(R.attr.wdSecondaryColor) + ) + } else { + accountView.setupLayoutForPremiumUser( + interactor.getResourceString(R.string.plan_pro), + interactor.getThemeColor(R.attr.wdActionColor) + ) + } + accountView.setUsername(user.userName) + when (user.emailStatus) { + EmailStatus.NoEmail -> accountView.setEmail( + interactor.getResourceString(R.string.add_email), + interactor.getResourceString( + R.string.get_10gb_data + ), + interactor.getThemeColor(R.attr.wdSecondaryColor), + interactor.getThemeColor(R.attr.wdActionColor), + interactor.getThemeColor(R.attr.wdPrimaryColor), + R.drawable.ic_email_attention, + R.drawable.confirmed_email_container_background + ) + + EmailStatus.EmailProvided -> user.email?.let { + accountView.setEmailConfirm( + it, interactor.getResourceString( + R.string.confirm_your_email + ), com.windscribe.vpn.commonutils.ThemeUtils.getColor( + appContext, R.attr.wdWarningColor50, R.color.colorYellow + ), com.windscribe.vpn.commonutils.ThemeUtils.getColor( + appContext, R.attr.wdWarningColor, R.color.colorYellow + ), R.drawable.ic_warning_icon, R.drawable.attention_container_background + ) + } + + EmailStatus.Confirmed -> user.email?.let { + accountView.setEmailConfirmed( + it, + interactor.getResourceString( + R.string.get_10gb_data + ), + interactor.getThemeColor(R.attr.wdSecondaryColor), + interactor.getThemeColor( + R.attr.wdPrimaryColor + ), + R.drawable.ic_email_attention, + R.drawable.confirmed_email_container_background + ) + } + } + if (user.maxData == -1L) { + accountView.setPlanName(interactor.getResourceString(R.string.unlimited_data)) + accountView.setDataLeft("") + } else { + val maxData = user.maxData / UserStatusConstants.GB_DATA + accountView.setPlanName("$maxData ${interactor.getResourceString(R.string.gb_per_month)}") + user.dataLeft?.let { data -> + if (data > 0) { + val formattedData = DecimalFormat("##.00").format(data) + accountView.setDataLeft("$formattedData GB") + } else { + accountView.setDataLeft("0.00 GB") + } + } + } + setExpiryOrResetDate(user) + } + + private fun setExpiryOrResetDate(user: User) { + val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + var date: String? = null + if ((user.isPro && user.expiryDate != null) || user.isAlaCarteUnlimitedPlan) { + date = user.expiryDate + } else if (user.resetDate != null) { + date = user.resetDate + } + if (date != null) { + try { + val lastResetDate = formatter.parse(date) + val c = Calendar.getInstance() + c.time = Objects.requireNonNull(lastResetDate) + if (!user.isPro && user.isAlaCarteUnlimitedPlan.not()) { + c.add(Calendar.MONTH, 1) + val nextResetDate = c.time + accountView.setResetDate( + interactor.getResourceString(R.string.reset_date), + formatter.format(nextResetDate) + ) + } else { + val nextResetDate = c.time + accountView.setResetDate( + interactor.getResourceString(R.string.expiry_date), + formatter.format(nextResetDate) + ) + } + } catch (e: ParseException) { + logger.debug("Could not parse date data. " + instance.convertErrorToString(e)) + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/account/AccountView.kt b/mobile/src/main/java/com/windscribe/mobile/account/AccountView.kt new file mode 100644 index 000000000..2b69049da --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/account/AccountView.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.account + +interface AccountView { + fun goToConfirmEmailActivity() + fun goToEmailActivity() + fun hideProgress() + fun openEditAccountInBrowser(url: String) + fun openUpgradeActivity() + fun setActivityTitle(title: String) + fun setEmail( + email: String, + warningText: String, + emailColor: Int, + warningColor: Int, + labelColor: Int, + infoIcon: Int, + containerBackground: Int + ) + + fun setEmailConfirm( + emailConfirm: String, + warningText: String, + emailColor: Int, + emailLabelColor: Int, + infoIcon: Int, + containerBackground: Int + ) + + fun setEmailConfirmed( + emailConfirm: String, + warningText: String, + emailColor: Int, + emailLabelColor: Int, + infoIcon: Int, + containerBackground: Int + ) + + fun setPlanName(planName: String) + fun setDataLeft(dataLeft: String) + fun setResetDate(resetDateLabel: String, resetDate: String) + fun setUsername(username: String) + fun setWebSessionLoading(show: Boolean) + fun setupLayoutForGhostMode(proUser: Boolean) + fun setupLayoutForFreeUser(upgradeText: String, color: Int) + fun setupLayoutForPremiumUser(upgradeText: String, color: Int) + fun showEnterCodeDialog() + fun showErrorDialog(error: String) + fun showErrorMessage(errorMessage: String) + fun showProgress(progressText: String) + fun showSuccessDialog(message: String) + fun showEnterVoucherCodeDialog() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/ConfigAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/ConfigAdapter.java new file mode 100644 index 000000000..c1aedb68c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/ConfigAdapter.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.holder.ConfigViewHolder; +import com.windscribe.mobile.holder.RemoveConfigHolder; +import com.windscribe.vpn.serverlist.entity.ConfigFile; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.entity.PingTime; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.List; + +public class ConfigAdapter extends RecyclerView.Adapter { + + private final List configFiles; + + private final ServerListData serverListData; + + private final ListViewClickListener listViewClickListener; + + public ConfigAdapter(List configFiles, ServerListData serverListData, + ListViewClickListener listViewClickListener) { + this.configFiles = configFiles; + this.listViewClickListener = listViewClickListener; + this.serverListData = serverListData; + } + + public List getConfigFiles() { + return configFiles; + } + + @Override + public int getItemCount() { + return configFiles.size(); + } + + @Override + public int getItemViewType(int position) { + return configFiles.get(position).getType(); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ConfigFile configFile = configFiles.get(holder.getAdapterPosition()); + if (holder instanceof ConfigViewHolder) { + ConfigViewHolder configViewHolder = (ConfigViewHolder) holder; + int pingTime = getPingTime(configFile); + configViewHolder.onBind(configFile, listViewClickListener, serverListData, pingTime); + } else { + RemoveConfigHolder removeConfigHolder = (RemoveConfigHolder) holder; + int pingTime = getPingTime(configFile); + removeConfigHolder.onBind(configFile, listViewClickListener, serverListData, pingTime); + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == 1) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.config_layout, parent, false); + return new ConfigViewHolder(view); + } else { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.remove_config_layout, parent, false); + return new RemoveConfigHolder(view); + } + + } + + private int getPingTime(ConfigFile city) { + for (PingTime pingTime : serverListData.getPingTimes()) { + if (city.getPrimaryKey() == pingTime.ping_id) { + return pingTime.getPingTime(); + } + } + return -1; + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/ExpandedAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/ExpandedAdapter.java new file mode 100644 index 000000000..83f16a3b0 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/ExpandedAdapter.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.List; + +public class ExpandedAdapter extends SearchRegionsAdapter { + + public ExpandedAdapter(List groups, ServerListData serverListData, + ListViewClickListener mListener) { + super(groups, serverListData, mListener); + } + + +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/FavouriteAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/FavouriteAdapter.java new file mode 100644 index 000000000..4737a33b9 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/FavouriteAdapter.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.os.Build; +import android.text.Html; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.holder.FavoriteViewHolder; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.constants.NetworkKeyConstants; +import com.windscribe.vpn.serverlist.entity.City; +import com.windscribe.vpn.serverlist.entity.Favourite; +import com.windscribe.vpn.serverlist.entity.PingTime; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.ArrayList; +import java.util.List; + +public class FavouriteAdapter extends RecyclerView.Adapter { + + public ServerListData dataDetails; + + private List mFavouriteList; + + private final ListViewClickListener mListener; + + + public FavouriteAdapter(List mFavouriteList, ServerListData dataDetails, ListViewClickListener mListener) { + this.mFavouriteList = mFavouriteList; + this.dataDetails = dataDetails; + this.mListener = mListener; + } + + public boolean enabledNode(City city) { + return (city.nodesAvailable() | (!dataDetails.isProUser() && city.getPro() == 1)); + } + + public City getCity(int cityId) { + for (City city : mFavouriteList) { + if (city.getId() == cityId) { + return city; + } + } + return null; + } + + @Override + public int getItemCount() { + return mFavouriteList != null ? mFavouriteList.size() : 0; + } + + public boolean isFavourite(City city) { + for (Favourite favourite : dataDetails.getFavourites()) { + if (favourite.getId() == city.getId()) { + return true; + } + } + return false; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + FavoriteViewHolder listViewHolder = (FavoriteViewHolder) holder; + bindCity(listViewHolder, mFavouriteList.get(holder.getAdapterPosition())); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.favorite_list_view_holder, parent, false); + return new FavoriteViewHolder(itemView); + } + + public void setDataDetails(ServerListData dataDetails) { + this.dataDetails = dataDetails; + List cities = new ArrayList<>(); + for (Favourite favourite : dataDetails.getFavourites()) { + City city = getCity(favourite.getId()); + if (city != null) { + cities.add(city); + } + } + mFavouriteList = cities; + } + + public void setFavourites(City city, FavoriteViewHolder holder) { + holder.tvFavoriteCityName.setAlpha(1f); + holder.tvFavouriteItemStrength.setAlpha(1f); + holder.imgFavoriteItemStrengthBar.setAlpha(1f); + + //Setup Pro icon for Unavailable locations + if (!enabledNode(city)) { + holder.imgFavorite.setImageResource(R.drawable.construction_icon); + holder.tvFavoriteCityName.setEnabled(false); + holder.imgFavorite.setSelected(false); + holder.tvFavoriteCityName.setAlpha(0.5f); + holder.tvFavouriteItemStrength.setAlpha(0.5f); + holder.imgFavoriteItemStrengthBar.setAlpha(0.5f); + } else if (isFavourite(city)) { + holder.imgFavorite.setImageResource(R.drawable.modal_add_to_favs); + holder.imgFavorite.setSelected(true); + } else { + holder.imgFavorite.setImageResource(R.drawable.modal_add_to_favs); + holder.imgFavorite.setSelected(false); + } + } + + private void bindCity(FavoriteViewHolder holder, City city) { + // Pings + int pingTime = getPingTime(city); + setPings(holder, pingTime); + + // Favourites + setFavourites(city, holder); + + // Set Node name/ Nick name + setNameAndNickName(city, holder); + + //setClickListeners + setClickListeners(city, holder); + + setTouchListener(holder); + + setLinkSpeed(city, holder); + //Set server load + setServerHealth(city, holder); + } + + private int getPingTime(City city) { + for (PingTime pingTime : dataDetails.getPingTimes()) { + if (city.getId() == pingTime.ping_id) { + return pingTime.getPingTime(); + } + } + return -1; + } + + private int getServerHealthColor(int health, Context context) { + if (health < 60) { + return context.getResources().getColor(R.color.colorNeonGreen); + } else if (health < 89) { + return context.getResources().getColor(R.color.colorYellow); + } else { + return context.getResources().getColor(R.color.colorRed); + } + } + + private void setClickListeners(City city, FavoriteViewHolder holder) { + holder.imgFavorite.setOnClickListener(v -> { + if (holder.getAdapterPosition() != -1) { + mListener.removeFromFavourite(mFavouriteList.get(holder.getAdapterPosition()).getId(), holder.getAdapterPosition(), this); + } + }); + + //On click item + holder.itemView.setOnClickListener(v -> { + if (!city.nodesAvailable() && city.getPro() != 1) { + mListener.onUnavailableRegion(false); + } else if (!city.nodesAvailable() && city.getPro() == 1 && dataDetails.isProUser()) { + mListener.onUnavailableRegion(false); + } else { + mListener.onCityClick(city.getId()); + } + }); + } + + private void setLinkSpeed(final City city, final FavoriteViewHolder holder) { + int visibility = "10000".equals(city.getLinkSpeed()) + ? View.VISIBLE : View.INVISIBLE; + holder.imgLinkSpeed.setVisibility(visibility); + } + + private void setNameAndNickName(City city, FavoriteViewHolder holder) { + String sourceString = "" + city.getNodeName() + " " + city.getNickName(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + holder.tvFavoriteCityName.setText(Html.fromHtml(sourceString, Html.FROM_HTML_MODE_LEGACY)); + } else { + holder.tvFavoriteCityName.setText(Html.fromHtml(sourceString)); + } + } + + private void setPings(FavoriteViewHolder holder, int pingResult) { + if (dataDetails.isShowLatencyInBar()) { + holder.tvFavouriteItemStrength.setVisibility(View.GONE); + holder.imgFavoriteItemStrengthBar.setVisibility(View.VISIBLE); + if (pingResult != -1) { + if (pingResult > -1 && pingResult < NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT) { + holder.imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_3_bar); + } else if (pingResult >= NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT) { + + holder.imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_2_bar); + + } else if (pingResult >= NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_1_BAR_UPPER_LIMIT) { + + holder.imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_1_bar); + + } else { + holder.imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_no_bar); + } + } + } else { + holder.tvFavouriteItemStrength.setVisibility(View.VISIBLE); + holder.imgFavoriteItemStrengthBar.setVisibility(View.GONE); + holder.tvFavouriteItemStrength.setText(pingResult != -1 ? String.valueOf(pingResult) : "--"); + } + } + + private void setServerHealth(final City city, final FavoriteViewHolder holder) { + int health = city.getHealth(); + if (dataDetails.isShowLocationHealthEnabled() && health > 0) { + Context context = holder.itemView.getContext(); + int color = getServerHealthColor(health, context); + holder.serverHealth.setIndicatorColor(color); + holder.serverHealth.setProgress(health); + holder.serverHealth.setVisibility(View.VISIBLE); + } else { + holder.serverHealth.setVisibility(View.GONE); + } + } + + private void setTextAndIconColors(FavoriteViewHolder holder, int selectedColor) { + holder.imgFavorite.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.imgFavoriteItemStrengthBar.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.imgLinkSpeed.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.tvFavoriteCityName.setTextColor(ColorStateList.valueOf(selectedColor)); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(FavoriteViewHolder holder) { + holder.itemView.setOnTouchListener((View v, MotionEvent event) -> { + int defaultColor = ThemeUtils.getColor(v.getContext(), R.attr.wdSecondaryColor, R.color.colorWhite50); + int selectedColor = ThemeUtils.getColor(v.getContext(), R.attr.wdPrimaryColor, R.color.colorWhite50); + if (event.getAction() == MotionEvent.ACTION_DOWN) { + setTextAndIconColors(holder, selectedColor); + } else { + setTextAndIconColors(holder, defaultColor); + } + return false; + }); + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/GpsSpoofingPagerAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/GpsSpoofingPagerAdapter.java new file mode 100644 index 000000000..f72f1ea1a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/GpsSpoofingPagerAdapter.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; +import androidx.viewpager2.adapter.FragmentStateAdapter; + +import com.windscribe.mobile.gpsspoofing.fragments.GpsSpoofingDeveloperSettings; +import com.windscribe.mobile.gpsspoofing.fragments.GpsSpoofingError; +import com.windscribe.mobile.gpsspoofing.fragments.GpsSpoofingMockSettings; +import com.windscribe.mobile.gpsspoofing.fragments.GpsSpoofingStart; +import com.windscribe.mobile.gpsspoofing.fragments.GpsSpoofingSuccess; + +public class GpsSpoofingPagerAdapter extends FragmentStateAdapter { + + public GpsSpoofingPagerAdapter(@NonNull FragmentManager fm, Lifecycle lifecycle) { + super(fm, lifecycle); + } + + @NonNull + @Override + public Fragment createFragment(int position) { + switch (position) { + case 0: + return new GpsSpoofingStart(); + case 1: + return new GpsSpoofingDeveloperSettings(); + case 2: + return new GpsSpoofingMockSettings(); + case 3: + return new GpsSpoofingSuccess(); + case 4: + return new GpsSpoofingError(); + } + return new GpsSpoofingStart(); + } + + @Override + public int getItemCount() { + return 5; + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/InstalledAppsAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/InstalledAppsAdapter.java new file mode 100644 index 000000000..f1cb65576 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/InstalledAppsAdapter.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.annotation.SuppressLint; +import android.content.res.Resources; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.res.ResourcesCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.vpn.Windscribe; +import com.windscribe.vpn.api.response.InstalledAppsData; + +import java.util.ArrayList; +import java.util.List; + +public class InstalledAppsAdapter extends RecyclerView.Adapter { + + private static class InstalledAppsViewHolder extends RecyclerView.ViewHolder { + + final ImageView imgAppLogo; + + final ImageView imgCheck; + + final TextView tvAppName; + + public InstalledAppsViewHolder(View itemView) { + super(itemView); + tvAppName = itemView.findViewById(R.id.app_name); + imgAppLogo = itemView.findViewById(R.id.app_logo); + imgCheck = itemView.findViewById(R.id.img_check); + } + } + + public interface InstalledAppListener { + + void onInstalledAppClick(InstalledAppsData updatedModel, boolean reloadAdapter); + } + + private final List copyItems = new ArrayList<>(); + + private final InstalledAppListener installedAppListener; + + private final List mAppsList; + + public InstalledAppsAdapter(List mAppsList, InstalledAppListener installedAppListener) { + this.mAppsList = mAppsList; + this.installedAppListener = installedAppListener; + copyItems.addAll(mAppsList); + } + + @SuppressLint("NotifyDataSetChanged") + public void filter(String text) { + mAppsList.clear(); + if (text.isEmpty()) { + mAppsList.addAll(copyItems); + } else { + String query = text.toLowerCase(); + for (InstalledAppsData app : copyItems) { + if (app.getAppName().toLowerCase().contains(query)) { + mAppsList.add(app); + } + } + } + notifyDataSetChanged(); + } + + @Override + public int getItemCount() { + return mAppsList != null ? mAppsList.size() : 0; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int i) { + InstalledAppsViewHolder listViewHolder = (InstalledAppsViewHolder) viewHolder; + InstalledAppsData installedAppsData = mAppsList.get(viewHolder.getAdapterPosition()); + listViewHolder.imgAppLogo.setImageDrawable(installedAppsData.getAppIconDrawable()); + listViewHolder.tvAppName.setText(installedAppsData.getAppName()); + Resources resources = viewHolder.itemView.getResources(); + if (installedAppsData.isChecked()) { + listViewHolder.imgCheck.setImageDrawable(ResourcesCompat + .getDrawable(resources, R.drawable.ic_checkmark_on, Windscribe.getAppContext().getTheme())); + } else { + listViewHolder.imgCheck.setImageDrawable(ResourcesCompat + .getDrawable(resources, R.drawable.ic_checkmark_off, Windscribe.getAppContext().getTheme())); + } + + listViewHolder.itemView.setOnClickListener(v -> { + installedAppsData.setChecked(!installedAppsData.isChecked()); + notifyItemChanged(listViewHolder.getAdapterPosition()); + installedAppListener.onInstalledAppClick(installedAppsData, false); + }); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + View itemView = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.installed_apps_viewholder, viewGroup, false); + return new InstalledAppsViewHolder(itemView); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/LogViewAdapter.kt b/mobile/src/main/java/com/windscribe/mobile/adapter/LogViewAdapter.kt new file mode 100644 index 000000000..005772f21 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/LogViewAdapter.kt @@ -0,0 +1,41 @@ +package com.windscribe.mobile.adapter + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import com.windscribe.mobile.R +import com.windscribe.mobile.holder.LogViewHolder + +class LogViewAdapter(private val logs : List): RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.debug_item_layout, parent, false) + return LogViewHolder(view) + } + + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + val log = logs[holder.adapterPosition] + holder.bind(log) + holder.itemView.setOnLongClickListener { + if(logs.isNotEmpty()){ + copyToClipBoard(it.context, logs) + } + return@setOnLongClickListener true + } + } + private fun copyToClipBoard(context: Context, logs : List ){ + val clipboard: ClipboardManager = context.getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager + val clip: ClipData = ClipData.newPlainText("DebugLog", logs.joinToString(separator = "\n")) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Log copied to clipboard", Toast.LENGTH_SHORT).show() + } + + override fun getItemCount(): Int { + return logs.size + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/NetworkListAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/NetworkListAdapter.java new file mode 100644 index 000000000..c8da97ec4 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/NetworkListAdapter.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.networksecurity.viewholder.NetworkAdapterActionListener; +import com.windscribe.mobile.networksecurity.viewholder.NetworkListViewHolder; +import com.windscribe.vpn.Windscribe; +import com.windscribe.vpn.localdatabase.tables.NetworkInfo; + +import java.util.List; + +public class NetworkListAdapter extends RecyclerView.Adapter { + + private NetworkAdapterActionListener mAdapterActionListener; + + private final List mNetList; + + + public NetworkListAdapter(List mNetList) { + this.mNetList = mNetList; + } + + @Override + public int getItemCount() { + return mNetList != null ? mNetList.size() : 0; + } + + @Override + public void onBindViewHolder(@NonNull final NetworkListViewHolder holder, int position) { + NetworkInfo networkInfo = mNetList.get(position); + holder.tvNetworkName.setText(networkInfo.getNetworkName()); + String protectionStatus = networkInfo.isAutoSecureOn() ? Windscribe.getAppContext() + .getText(R.string.network_secured).toString() + : Windscribe.getAppContext().getText(R.string.network_unsecured).toString(); + holder.tvProtection.setText(protectionStatus); + + holder.itemView.setOnClickListener(v -> mAdapterActionListener.onItemSelected(networkInfo)); + if(position == getItemCount()-1){ + holder.dividerView.setVisibility(View.GONE); + } + } + + @NonNull + @Override + public NetworkListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()).inflate(R.layout.network_list_card, parent, false); + return new NetworkListViewHolder(itemView); + } + + public void setAdapterActionListener(NetworkAdapterActionListener mListener) { + this.mAdapterActionListener = mListener; + } + +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/NewsFeedAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/NewsFeedAdapter.java new file mode 100644 index 000000000..7e12338ee --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/NewsFeedAdapter.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + + + +import android.text.Html; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.RotateAnimation; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.common.base.CharMatcher; +import com.windscribe.mobile.R; +import com.windscribe.mobile.newsfeedactivity.NewsFeedListener; +import com.windscribe.vpn.constants.AnimConstants; +import com.windscribe.vpn.localdatabase.tables.NewsfeedAction; +import com.windscribe.vpn.localdatabase.tables.WindNotification; + +import java.util.List; + +public class NewsFeedAdapter extends RecyclerView.Adapter { + + private class NewsFeedViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + + final TextView btnAction; + + final ConstraintLayout clBodyLayout; + + final ImageView imgCloseIcon; + + final ImageView imgReadIcon; + + final TextView tvBody; + + final TextView tvTitle; + + WindNotification windNotification; + + public NewsFeedViewHolder(View itemView) { + super(itemView); + clBodyLayout = itemView.findViewById(R.id.cl_notification_body); + tvTitle = itemView.findViewById(R.id.tv_welcome_title); + tvBody = itemView.findViewById(R.id.tv_body_message); + imgCloseIcon = itemView.findViewById(R.id.img_close_btn); + imgReadIcon = itemView.findViewById(R.id.img_read_icon); + tvBody.setMovementMethod(LinkMovementMethod.getInstance()); + btnAction = itemView.findViewById(R.id.action_label); + + tvTitle.setOnClickListener(this); + clBodyLayout.setOnClickListener(this); + imgCloseIcon.setOnClickListener(this); + btnAction.setOnClickListener(this); + + } + + @Override + public void onClick(View v) { + switch (v.getId()) { + case R.id.cl_notification_body: + break; + case R.id.tv_welcome_title: + case R.id.img_close_btn: + imgReadIcon.setVisibility(View.INVISIBLE); + onClickAnimation(clBodyLayout, imgCloseIcon, windNotification, tvTitle); + break; + case R.id.action_label: + newsFeedListener.onNotificationActionClick(windNotification); + break; + } + + } + + void bind(WindNotification windNotification) { + this.windNotification = windNotification; + } + } + + private final int firstItemToOpen; + + private final List mNotificationList; + + private final NewsFeedListener newsFeedListener; + + public NewsFeedAdapter(List mNotificationList, int firstItemToOpen, + NewsFeedListener newsFeedListener) { + this.mNotificationList = mNotificationList; + this.newsFeedListener = newsFeedListener; + this.firstItemToOpen = firstItemToOpen; + } + + @Override + public int getItemCount() { + return mNotificationList != null ? mNotificationList.size() : 0; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + final NewsFeedViewHolder newsFeedViewHolder = (NewsFeedViewHolder) holder; + //The first position will always be visible + WindNotification windNotification = mNotificationList.get(holder.getAdapterPosition()); + newsFeedViewHolder.bind(windNotification); + if (mNotificationList.get(holder.getAdapterPosition()).isRead()) { + newsFeedViewHolder.imgReadIcon.setVisibility(View.INVISIBLE); + } else { + newsFeedViewHolder.imgReadIcon.setVisibility(View.VISIBLE); + } + if (windNotification.getNotificationId() == firstItemToOpen) { + newsFeedViewHolder.clBodyLayout.setVisibility(View.VISIBLE); + newsFeedViewHolder.imgCloseIcon.setImageResource(R.drawable.ic_close_white); + newsFeedViewHolder.imgCloseIcon.setTag(1); + newsFeedViewHolder.imgReadIcon.setVisibility(View.INVISIBLE); + newsFeedListener.onNotificationExpand(windNotification); + } else { + newsFeedViewHolder.imgCloseIcon.setImageResource(R.drawable.ic_close_white_25_alpha); + newsFeedViewHolder.imgCloseIcon.setTag(0); + } + NewsfeedAction newsfeedAction = windNotification.getAction(); + if (newsfeedAction != null) { + // Remove last

container if it has a class name "ncta" + String message = windNotification.getNotificationMessage(); + int bodyEndIndex = message.lastIndexOf(" 0) { + String body = message.substring(0, bodyEndIndex); + String pTag = message.substring(bodyEndIndex, message.length() - 1); + if (pTag.contains("ncta")) { + windNotification.setNotificationMessage(body); + } + } + // Show action with label + newsFeedViewHolder.btnAction.setVisibility(View.VISIBLE); + newsFeedViewHolder.btnAction.setText(newsfeedAction.getLabel()); + } + // Clear any trailing whitespace. + Spanned htmlBody = Html.fromHtml(windNotification.getNotificationMessage()); + String htmlBodyWithoutSpace = CharMatcher.whitespace().trimTrailingFrom(htmlBody.toString()); + ((NewsFeedViewHolder) holder).tvBody.setText(htmlBody.subSequence(0, htmlBodyWithoutSpace.length())); + ((NewsFeedViewHolder) holder).tvTitle.setText(Html.fromHtml(mNotificationList.get(holder.getAdapterPosition()).getNotificationTitle().toUpperCase())); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new NewsFeedViewHolder( + LayoutInflater.from(parent.getContext()).inflate(R.layout.news_feed_view, parent, false)); + } + + private void onClickAnimation(final View clBodyLayout, final View imgCloseIcon, + WindNotification windNotification, final TextView tvTitle) { + if (clBodyLayout.getVisibility() == View.VISIBLE) { + tvTitle.animate().alpha(0.5f).setDuration(250); + clBodyLayout.animate().alpha(0).setDuration(250).withStartAction( + () -> rotateImageAnimation(imgCloseIcon, -45, true)).withEndAction( + () -> clBodyLayout.setVisibility(View.GONE)); + + } else { + newsFeedListener.onNotificationExpand(windNotification); + tvTitle.animate().alpha(0); + tvTitle.animate().alpha(1).setDuration(250); + clBodyLayout.setAlpha(0); + clBodyLayout.setVisibility(View.VISIBLE); + clBodyLayout.animate().alpha(1).setDuration(250).withStartAction( + () -> rotateImageAnimation(imgCloseIcon, 45, false)).start(); + } + } + + private void rotateImageAnimation(final View viewToRotate, float toDegrees, final boolean collapse) { + final RotateAnimation rotateAnimation = new RotateAnimation(0, toDegrees, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); + + rotateAnimation.setFillAfter(true); + rotateAnimation.setDuration(AnimConstants.RECYCLER_VIEW_UNDERLINE_ANIMATION_DURATION); + rotateAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + if (collapse) { + viewToRotate.clearAnimation(); + ((ImageView) viewToRotate).setImageResource(R.drawable.ic_close_white_25_alpha); + } else { + viewToRotate.clearAnimation(); + ((ImageView) viewToRotate).setImageResource(R.drawable.ic_close_white); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + + } + + @Override + public void onAnimationStart(Animation animation) { + } + }); + viewToRotate.setAnimation(rotateAnimation); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/ProtocolAdapter.kt b/mobile/src/main/java/com/windscribe/mobile/adapter/ProtocolAdapter.kt new file mode 100644 index 000000000..ec1d72ae6 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/ProtocolAdapter.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.adapter + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.ProtocolAdapter.ProtocolViewHolder +import com.windscribe.mobile.listeners.ProtocolClickListener +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.backend.utils.ProtocolConfig + +class ProtocolAdapter( + private val listener: ProtocolClickListener +) : RecyclerView.Adapter() { + var protocolConfigs: List = emptyList() + @SuppressLint("NotifyDataSetChanged") + set(value) { + field = value + notifyDataSetChanged() + } + class ProtocolViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val mProtocolTextView: TextView = itemView.findViewById(R.id.protocol) + fun bind(protocolConfig: ProtocolConfig) { + mProtocolTextView.text = protocolConfig.heading + if (adapterPosition == 0) { + mProtocolTextView.background = ResourcesCompat.getDrawable( + itemView.context.resources, + R.drawable.capsule_background_small, appContext.theme + ) + } else { + mProtocolTextView.background = null + } + } + } + + override fun getItemCount(): Int { + return protocolConfigs.size + } + + override fun onBindViewHolder(holder: ProtocolViewHolder, position: Int) { + val protocolConfig = protocolConfigs[holder.adapterPosition] + holder.bind(protocolConfig) + holder.itemView.setOnClickListener { + holder.mProtocolTextView.background = ResourcesCompat.getDrawable( + holder.itemView.context.resources, + R.drawable.capsule_background_small, + appContext.theme + ) + listener.onProtocolSelected(protocolConfig) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProtocolViewHolder { + val view = + LayoutInflater.from(parent.context).inflate(R.layout.protocol_layout, parent, false) + return ProtocolViewHolder(view) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/ProtocolInformationAdapter.kt b/mobile/src/main/java/com/windscribe/mobile/adapter/ProtocolInformationAdapter.kt new file mode 100644 index 000000000..a3f437823 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/ProtocolInformationAdapter.kt @@ -0,0 +1,281 @@ +package com.windscribe.mobile.adapter + +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.windscribe.mobile.R +import com.windscribe.vpn.autoconnection.ProtocolConnectionStatus +import com.windscribe.vpn.autoconnection.ProtocolInformation +import com.windscribe.vpn.backend.Util +import com.windscribe.vpn.commonutils.Ext.toPx +import com.windscribe.vpn.commonutils.ThemeUtils + +class ProtocolInformationAdapter( + val data: MutableList, + private val listener: ItemSelectListener +) : + RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ProtocolInformationViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.protocol_information, parent, false) + return ProtocolInformationViewHolder(view, listener) + } + + override fun onBindViewHolder(holder: ProtocolInformationViewHolder, position: Int) { + holder.bind(data[position]) + } + + override fun getItemCount(): Int { + return data.size + } + + fun update(updatedData: List) { + val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() { + override fun getOldListSize(): Int { + return data.size + } + + override fun getNewListSize(): Int { + return updatedData.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return data[oldItemPosition].protocol == updatedData[newItemPosition].protocol + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = data[oldItemPosition] + val newItem = updatedData[newItemPosition] + return oldItem.protocol == newItem.protocol && oldItem.description == newItem.description + && oldItem.type == newItem.type && oldItem.port == newItem.port + } + + }) + data.clear() + data.addAll(updatedData) + diff.dispatchUpdatesTo(this) + } +} + + +interface ItemSelectListener { + fun onItemSelect(protocolInformation: ProtocolInformation) +} + + +class ProtocolInformationViewHolder(val itemView: View, val listener: ItemSelectListener) : + RecyclerView.ViewHolder(itemView) { + private val protocolView: TextView = itemView.findViewById(R.id.protocol) + private val portView: TextView = itemView.findViewById(R.id.port) + private val descriptionView: TextView = itemView.findViewById(R.id.description) + private val dividerView: TextView = itemView.findViewById(R.id.divider) + private val statusView: TextView = itemView.findViewById(R.id.status) + private val checkView: ImageView = itemView.findViewById(R.id.check) + private val actionArrowView: ImageView = itemView.findViewById(R.id.action_arrow) + private val errorView: TextView = itemView.findViewById(R.id.error) + private val containerView: ConstraintLayout = itemView.findViewById(R.id.container) + private var protocolInformation: ProtocolInformation? = null + + init { + itemView.setOnClickListener { view -> + protocolInformation?.let { + if (it.type != ProtocolConnectionStatus.Failed && it.type != ProtocolConnectionStatus.Connected) { + listener.onItemSelect(it) + } + } + } + } + + fun bind(protocolInformation: ProtocolInformation) { + this.protocolInformation = protocolInformation + protocolInformation.apply { + protocolView.text = Util.getProtocolLabel(protocol) + portView.text = port + descriptionView.text = description + } + itemView.apply { + when (protocolInformation.type) { + ProtocolConnectionStatus.Connected -> { + containerView.background = AppCompatResources.getDrawable( + context, + R.drawable.protocol_connected_background + ) + protocolView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdActionColor, + R.color.colorNeonGreen + ) + ) + portView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdActionColor50, + R.color.colorNeonGreen50 + ) + ) + descriptionView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdActionColor50, + R.color.colorNeonGreen50 + ) + ) + dividerView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdActionColor25, + R.color.colorNeonGreen25 + ) + ) + statusView.text = "Connected to" + statusView.visibility = View.VISIBLE + checkView.visibility = View.VISIBLE + actionArrowView.visibility = View.GONE + errorView.visibility = View.GONE + val background = statusView.background as GradientDrawable + //The corners are ordered top-left, top-right, bottom-right, bottom-left + val radius = context.toPx(8F) + // background.cornerRadii = floatArrayOf(0F, 0F,context.toPx(7F),context.toPx(7F),radius, 0F, 0F,radius,radius) + val param = statusView.layoutParams as ViewGroup.MarginLayoutParams + val margin = context.toPx(2F).toInt() + // param.setMargins(0,0,margin,0) + statusView.layoutParams = param + } + ProtocolConnectionStatus.Disconnected -> { + containerView.background = AppCompatResources.getDrawable( + context, + R.drawable.protocol_disconnected_background + ) + protocolView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdPrimaryColor, + R.color.colorWhite + ) + ) + portView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + descriptionView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + dividerView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdPrimaryColor25, + R.color.colorWhite25 + ) + ) + statusView.visibility = View.GONE + checkView.visibility = View.GONE + actionArrowView.visibility = View.VISIBLE + errorView.visibility = View.GONE + } + ProtocolConnectionStatus.Failed -> { + containerView.background = AppCompatResources.getDrawable( + context, + R.drawable.protocol_failed_background + ) + protocolView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + portView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + descriptionView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + dividerView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdPrimaryColor25, + R.color.colorWhite25 + ) + ) + statusView.visibility = View.GONE + checkView.visibility = View.GONE + actionArrowView.visibility = View.GONE + errorView.visibility = View.VISIBLE + } + ProtocolConnectionStatus.NextUp -> { + containerView.background = AppCompatResources.getDrawable( + context, + R.drawable.protocol_disconnected_background + ) + protocolView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdPrimaryColor, + R.color.colorWhite + ) + ) + portView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + descriptionView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + dividerView.setTextColor( + ThemeUtils.getColor( + context, + R.attr.wdPrimaryColor25, + R.color.colorWhite25 + ) + ) + statusView.text = "NEXT UP IN ${protocolInformation.autoConnectTimeLeft}s" + statusView.visibility = View.VISIBLE + checkView.visibility = View.GONE + actionArrowView.visibility = View.VISIBLE + errorView.visibility = View.GONE + val background = statusView.background as GradientDrawable + //The corners are ordered top-left, top-right, bottom-right, bottom-left + val radius = context.toPx(8F) + background.cornerRadii = + floatArrayOf(0F, 0F, radius, radius, 0F, 0F, radius, radius) + val param = statusView.layoutParams as ViewGroup.MarginLayoutParams + param.setMargins(0, 0, 0, 0) + statusView.layoutParams = param + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/RegionsAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/RegionsAdapter.java new file mode 100644 index 000000000..478993848 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/RegionsAdapter.java @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; +import android.text.Html; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.core.content.res.ResourcesCompat; + +import com.thoughtbot.expandablerecyclerview.ExpandableRecyclerViewAdapter; +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup; +import com.windscribe.mobile.R; +import com.windscribe.mobile.holder.CityViewHolder; +import com.windscribe.mobile.holder.RegionViewHolder; +import com.windscribe.vpn.Windscribe; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.constants.NetworkKeyConstants; +import com.windscribe.vpn.serverlist.entity.City; +import com.windscribe.vpn.serverlist.entity.Favourite; +import com.windscribe.vpn.serverlist.entity.Group; +import com.windscribe.vpn.serverlist.entity.PingTime; +import com.windscribe.vpn.serverlist.entity.Region; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.ArrayList; +import java.util.List; + +public class RegionsAdapter extends ExpandableRecyclerViewAdapter { + + private ServerListData serverListData; + + private final long mLastClickTime = 0; + + private final ListViewClickListener mListener; + + public RegionsAdapter(List groups, ServerListData serverListData, + ListViewClickListener mListener) { + super(groups); + this.mListener = mListener; + this.serverListData = serverListData; + } + + public ServerListData getServerListData() { + return serverListData; + } + + public void setServerListData(ServerListData serverListData) { + this.serverListData = serverListData; + } + + public List getGroupsList() { + List groupList = new ArrayList<>(); + for (@SuppressWarnings("rawtypes") ExpandableGroup expandableGroup : getGroups()) { + Group group = (Group) expandableGroup; + groupList.add(group); + } + return groupList; + } + + //City View Holder + @Override + public void onBindChildViewHolder(final CityViewHolder holder, final int flatPosition, + final ExpandableGroup group, int childIndex) { + final City city = (City) group.getItems().get(childIndex); + bindCity(holder, city); + } + + // Group View holder + @Override + public void onBindGroupViewHolder(final RegionViewHolder holder, final int flatPosition, + final ExpandableGroup group) { + Group expandableGroup = (Group) group; + // Best Location and normal Location + if (((Group) group).getRegion() == null) { + bindBestLocation(holder); + } else { + bindRegion(expandableGroup.getRegion(), expandableGroup.getItems(), holder); + } + } + + @Override + public CityViewHolder onCreateChildViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.server_node_list_view_holder, parent, false); + return new CityViewHolder(view); + } + + @Override + public RegionViewHolder onCreateGroupViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.server_locations_view_holder, parent, false); + return new RegionViewHolder(view); + } + + public void setExpandStatus(RegionViewHolder holder) { + if (!isGroupExpanded(holder.getAdapterPosition())) { + holder.imgAnimationLine.setVisibility(View.GONE); + holder.imgDropDown.setImageResource(ThemeUtils + .getResourceId(holder.itemView.getContext(), R.attr.close_list_icon, + R.drawable.ic_location_dropdown_collapse)); + int color = ThemeUtils + .getColor(holder.itemView.getContext(), R.attr.nodeListGroupTextColor, R.color.colorWhite); + holder.tvCountryName.setTextColor(color); + } else { + holder.imgDropDown.setImageResource(ThemeUtils + .getResourceId(holder.itemView.getContext(), R.attr.expand_list_icon, + R.drawable.ic_location_drop_down_expansion)); + int color = ThemeUtils.getColor(holder.itemView.getContext(), R.attr.nodeListGroupTextColorSelected, + R.color.colorWhite); + holder.tvCountryName.setTextColor(color); + holder.imgAnimationLine.setVisibility(View.VISIBLE); + } + } + + public void setGroupClickListener(Region region, RegionViewHolder holder) { + holder.itemView.setOnClickListener(view -> { + if (serverListData.isProUser() + && region.getStatus() == NetworkKeyConstants.SERVER_STATUS_TEMPORARILY_UNAVAILABLE) { + mListener.onUnavailableRegion(false); + } else { + holder.onClick(view); + } + }); + + //Drop down image + holder.imgDropDown.setOnClickListener(view -> { + if (serverListData.isProUser() + && region.getStatus() == NetworkKeyConstants.SERVER_STATUS_TEMPORARILY_UNAVAILABLE) { + mListener.onUnavailableRegion(false); + } else { + holder.onClick(view); + } + }); + } + + public void setPremiumStatus(int isPro, RegionViewHolder holder) { + if (isPro == 1 && !serverListData.isProUser()) { + int drawable = ThemeUtils + .getResourceId(holder.itemView.getContext(), R.attr.proBadge, R.drawable.ic_hs_pro_badge); + holder.imgProBadge.setImageResource(drawable); + } else { + holder.imgProBadge.setImageDrawable(null); + } + } + + private void bindBestLocation(RegionViewHolder holder) { + if (serverListData.getBestLocation().getRegion() != null) { + holder.tvCountryName.setText(holder.itemView.getContext().getString(R.string.best_location)); + setFlags(serverListData.getBestLocation().getRegion().getCountryCode(), holder); + setPremiumStatus(0, holder); + setExpandStatus(holder); + holder.imgDropDown.setVisibility(View.GONE); + holder.itemView.setOnClickListener( + v -> mListener.onCityClick(serverListData.getBestLocation().getCity().getId())); + setGroupHealth(serverListData.getBestLocation().getCity().getHealth(), holder); + } + } + + private void bindCity(CityViewHolder holder, City city) { + //setClickListeners + setClickListeners(city, holder); + + setTouchListener(holder); + + // Pings + int pingTime = getPingTime(city); + setPings(holder, pingTime); + + // Favourites + setFavourites(city, holder); + + // Set Node name/ Nick name + setNameAndNickName(city, holder); + + //Set Link Speed + setLinkSpeed(city, holder); + + //Set server load + setServerHealth(city, holder); + } + + private void bindRegion(Region region, List cities, RegionViewHolder holder) { + // Group Name + holder.setGroupName(region.getName()); + // Expand or collapsed status + setExpandStatus(holder); + // On Item Click + setGroupClickListener(region, holder); + // Disabled Whole group Highly unlikely + noCityAvailable(region, holder); + //Setup flag if present + setFlags(region.getCountryCode(), holder); + // if All sub locations are premium + setPremiumStatus(region.getPremium(), holder); + // Setup force expand + holder.tvCountryName.setTag(R.string.force_expand, 1); + // Show expand icon + holder.imgDropDown.setVisibility(View.VISIBLE); + int averageHealth = 0; + int numberOfCities = 0; + for (City city : cities) { + if (city.getHealth() > 0) { + numberOfCities++; + averageHealth = averageHealth + city.getHealth(); + } + } + if (averageHealth > 0 && numberOfCities > 0) { + averageHealth = averageHealth / numberOfCities; + } + int finalAverageHealth = averageHealth; + holder.setItemExpandListener(new RegionViewHolder.ItemExpandListener() { + @Override + public void onItemExpand() { + int scrollTo = holder.getAdapterPosition() + (Math.min(cities.size(), 5)); + mListener.setScrollTo(scrollTo); + } + @Override + public void onItemCollapse() { + setGroupHealth(finalAverageHealth, holder); + } + }); + setGroupHealth(averageHealth, holder); + holder.imgP2pBadge.setVisibility(region.getP2p() == 0 ? View.VISIBLE : View.INVISIBLE); + holder.imgP2pBadge.setOnClickListener(v -> Toast.makeText(v.getContext(), v.getContext().getString(R.string.file_sharing_frowned_upon), Toast.LENGTH_SHORT).show()); + } + + private boolean enabledNode(City city) { + return (city.nodesAvailable() || (!serverListData.isProUser() && city.getPro() == 1)); + } + + private int getPingTime(City city) { + for (PingTime pingTime : serverListData.getPingTimes()) { + if (city.getId() == pingTime.ping_id) { + return pingTime.getPingTime(); + } + } + return -1; + + } + + private int getServerHealthColor(int health, Context context) { + if (health < 60) { + return context.getResources().getColor(R.color.colorNeonGreen); + } else if (health < 89) { + return context.getResources().getColor(R.color.colorYellow); + } else { + return context.getResources().getColor(R.color.colorRed); + } + } + + private boolean isFavourite(City city) { + for (Favourite favourite : serverListData.getFavourites()) { + if (favourite.getId() == city.getId()) { + return true; + } + } + return false; + } + + private void noCityAvailable(Region region, RegionViewHolder holder) { + if (serverListData.isProUser() + && region.getStatus() == NetworkKeyConstants.SERVER_STATUS_TEMPORARILY_UNAVAILABLE) { + Drawable drawable = ResourcesCompat + .getDrawable(holder.itemView.getResources(), R.drawable.construction_icon, + Windscribe.getAppContext().getTheme()); + holder.imgDropDown.setImageDrawable(drawable); + } + } + + private void setClickListeners(City city, CityViewHolder holder) { + // Add and remove from favourite + if (city.nodesAvailable()) { + holder.imgFavorite.setOnClickListener(v -> { + if (isFavourite(city)) { + mListener.removeFromFavourite(city.getId(), holder.getAdapterPosition(), this); + } else { + mListener.addToFavourite(city.getId(), holder.getAdapterPosition(), this); + } + }); + } + + //On City Click + holder.itemView.setOnClickListener(view -> { + if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) { + return; + } + if (!city.nodesAvailable() && city.getPro() != 1) { + mListener.onUnavailableRegion(false); + } else if (!city.nodesAvailable() && city.getPro() == 1 && serverListData.isProUser()) { + mListener.onUnavailableRegion(false); + } else { + mListener.onCityClick(city.getId()); + } + }); + } + + private void setFavourites(City city, CityViewHolder holder) { + // Reset alpha + holder.nodeGroupName.setAlpha(1f); + holder.imgSignalStrengthBar.setAlpha(1f); + holder.tvSignalStrength.setAlpha(1f); + + //Setup Pro icon for Unavailable locations + if (city.getPro() == 1 && !serverListData.isProUser()) { + holder.imgFavorite.setImageResource(R.drawable.pro_loc_icon); + holder.imgFavorite.setTag(2); + holder.imgFavorite.setClickable(false); + } else if (!enabledNode(city)) { + //holder.nodeGroupName.setTextColor(holder.itemView.getResources().getColor(R.color.textColorWhite50)); + Drawable drawable = ResourcesCompat + .getDrawable(holder.itemView.getResources(), R.drawable.construction_icon, + Windscribe.getAppContext().getTheme()); + holder.imgFavorite.setImageDrawable(drawable); + holder.nodeGroupName.setEnabled(false); + holder.imgFavorite.setSelected(false); + holder.nodeGroupName.setAlpha(0.5f); + holder.imgSignalStrengthBar.setAlpha(0.5f); + holder.tvSignalStrength.setAlpha(0.5f); + } else if (isFavourite(city)) { + holder.imgFavorite.setImageResource(R.drawable.modal_add_to_favs); + holder.imgFavorite.setSelected(true); + holder.imgFavorite.setClickable(true); + } else { + holder.imgFavorite.setImageResource(R.drawable.modal_add_to_favs); + holder.imgFavorite.setSelected(false); + holder.imgFavorite.setClickable(true); + } + } + + private void setFlags(String countryCode, RegionViewHolder holder) { + Integer iconId = serverListData.getFlags().get(countryCode); + if (iconId != null) { + holder.imgCountryFlag.setImageResource(iconId); + } else { + holder.imgCountryFlag.setImageDrawable(null); + } + } + + private void setGroupHealth(final int health, final RegionViewHolder holder) { + if (!isGroupExpanded(holder.getAdapterPosition()) && serverListData.isShowLocationHealthEnabled() + && health > 0) { + Context context = holder.itemView.getContext(); + int color = getServerHealthColor(health, context); + holder.serverLoadBar.setIndicatorColor(color); + holder.serverLoadBar.setProgress(health); + holder.serverLoadBar.setVisibility(View.VISIBLE); + } else { + holder.serverLoadBar.setVisibility(View.GONE); + } + } + + private void setLinkSpeed(final City city, final CityViewHolder holder) { + int visibility = "10000".equals(city.getLinkSpeed()) ? View.VISIBLE : View.INVISIBLE; + holder.imgLinkSpeed.setVisibility(visibility); + } + + private void setNameAndNickName(City city, CityViewHolder holder) { + String sourceString = "" + city.getNodeName() + " " + city.getNickName(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + holder.nodeGroupName.setText(Html.fromHtml(sourceString, Html.FROM_HTML_MODE_LEGACY)); + } else { + holder.nodeGroupName.setText(Html.fromHtml(sourceString)); + } + } + + private void setPings(CityViewHolder holder, int pingResult) { + if (serverListData.isShowLatencyInBar()) { + holder.tvSignalStrength.setVisibility(View.GONE); + holder.imgSignalStrengthBar.setVisibility(View.VISIBLE); + if (pingResult != -1) { + if (pingResult > -1 && pingResult < NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT) { + holder.imgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_3_bar); + } else if (pingResult >= NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT) { + holder.imgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_2_bar); + } else if (pingResult >= NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_1_BAR_UPPER_LIMIT) { + holder.imgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_1_bar); + } else { + holder.imgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_no_bar); + } + } + } else { + holder.tvSignalStrength.setVisibility(View.VISIBLE); + holder.imgSignalStrengthBar.setVisibility(View.GONE); + holder.tvSignalStrength.setText(pingResult != -1 ? String.valueOf(pingResult) : "--"); + } + } + + private void setServerHealth(final City city, final CityViewHolder holder) { + int health = city.getHealth(); + if (serverListData.isShowLocationHealthEnabled() && health > 0) { + Context context = holder.itemView.getContext(); + int color = getServerHealthColor(health, context); + holder.serverHealth.setIndicatorColor(color); + holder.serverHealth.setProgress(health); + holder.serverHealth.setVisibility(View.VISIBLE); + } else { + holder.serverHealth.setVisibility(View.GONE); + } + } + + private void setTextAndIconColors(CityViewHolder holder, int selectedColor) { + holder.imgFavorite.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.imgSignalStrengthBar.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.imgLinkSpeed.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.nodeGroupName.setTextColor(ColorStateList.valueOf(selectedColor)); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(CityViewHolder holder) { + holder.itemView.setOnTouchListener((View v, MotionEvent event) -> { + int defaultColor = ThemeUtils.getColor(v.getContext(), R.attr.wdSecondaryColor, R.color.colorWhite50); + int selectedColor = ThemeUtils.getColor(v.getContext(), R.attr.wdPrimaryColor, R.color.colorWhite50); + if (event.getAction() == MotionEvent.ACTION_DOWN) { + setTextAndIconColors(holder, selectedColor); + } else { + setTextAndIconColors(holder, defaultColor); + } + return false; + }); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/RobertSettingsAdapter.kt b/mobile/src/main/java/com/windscribe/mobile/adapter/RobertSettingsAdapter.kt new file mode 100644 index 000000000..895105102 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/RobertSettingsAdapter.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.RobertSettingsAdapter.RobertSettingsViewHolder +import com.windscribe.vpn.api.response.RobertFilter +import com.windscribe.vpn.commonutils.ThemeUtils + +data class RobertSetting(var filter: String, var enabled: Boolean) + +interface RobertAdapterListener { + fun settingChanged(originalList: List, filter: RobertFilter, position: Int) +} + +class RobertSettingsAdapter(private val robertAdapterListener: RobertAdapterListener) : + RecyclerView.Adapter() { + + var data: List = mutableListOf() + var settingUpdateInProgress = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RobertSettingsViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.robert_setting_item_view, parent, false) + return RobertSettingsViewHolder(view) + } + + override fun onBindViewHolder(holder: RobertSettingsViewHolder, position: Int) { + holder.toggle.setOnClickListener { + if (settingUpdateInProgress) return@setOnClickListener + val originalList = ArrayList(data.map { it.copy() }) + if( data[holder.adapterPosition].status== 1){ + data[holder.adapterPosition].status = 0 + }else{ + data[holder.adapterPosition].status = 1 + } + settingUpdateInProgress = true + robertAdapterListener.settingChanged(originalList,data[holder.adapterPosition], holder.adapterPosition) + notifyItemChanged(holder.adapterPosition) + } + holder.bind(data[position]) + } + + override fun getItemCount(): Int { + return data.size + } + + class RobertSettingsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + var iconMap = mapOf( + Pair("malware", R.drawable.ic_malware), + Pair("ads", R.drawable.ic_ads), + Pair("social", R.drawable.ic_social), + Pair("porn", R.drawable.ic_porn), + Pair("gambling", R.drawable.ic_gambling), + Pair("fakenews", R.drawable.ic_fake_news), + Pair("competitors", R.drawable.ic_other_vpn), + Pair("cryptominers", R.drawable.ic_crypto) + ) + + var toggle: ImageView = itemView.findViewById(R.id.toggle) + var icon: ImageView = itemView.findViewById(R.id.icon) + var filter: TextView = itemView.findViewById(R.id.filter) + var allow: TextView = itemView.findViewById(R.id.allow) + fun bind(robertSetting: RobertFilter) { + if (robertSetting.status == 1) { + toggle.setImageResource(R.drawable.ic_toggle_button_on) + allow.setText(R.string.blocking) + allow.setTextColor( + ThemeUtils.getColor( + itemView.context, + R.attr.wdActionColor, + R.color.colorNeonGreen + ) + ) + } else { + toggle.setImageResource(R.drawable.ic_toggle_button_off) + allow.setText(R.string.allowing) + allow.setTextColor( + ThemeUtils.getColor( + itemView.context, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + } + iconMap[robertSetting.id]?.let { + icon.setImageResource(it) + }?: kotlin.run { + icon.setImageResource(R.drawable.ic_preference_placeholder) + } + filter.text = robertSetting.title + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/SearchRegionsAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/SearchRegionsAdapter.java new file mode 100644 index 000000000..7644f6a44 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/SearchRegionsAdapter.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.List; + +public class SearchRegionsAdapter extends RegionsAdapter { + + + public SearchRegionsAdapter(List groups, ServerListData serverListData, + ListViewClickListener mListener) { + super(groups, serverListData, mListener); + } + +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/ServerListFragmentPager.java b/mobile/src/main/java/com/windscribe/mobile/adapter/ServerListFragmentPager.java new file mode 100644 index 000000000..f298df187 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/ServerListFragmentPager.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.os.Bundle; +import android.os.Parcelable; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapter; + +import com.windscribe.mobile.fragments.ServerListFragment; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.List; + +public class ServerListFragmentPager extends FragmentStatePagerAdapter { + + private final List mFragmentList; + + public ServerListFragmentPager(FragmentManager fm, List mFragmentList) { + super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.mFragmentList = mFragmentList; + } + + @Override + public int getCount() { + return mFragmentList != null ? mFragmentList.size() : 0; + } + + @NotNull + @Override + public Fragment getItem(int i) { + return mFragmentList.get(i); + } + + @Override + public Parcelable saveState() { + Bundle bundle = (Bundle) super.saveState(); + if (bundle != null) { + Parcelable[] states = bundle.getParcelableArray("states"); + if (states != null) { + states = Arrays.copyOfRange(states, states.length > 3 ? states.length - 3 : 0, states.length - 1); + } + bundle.putParcelableArray("states", states); + } else { + bundle = new Bundle(); + } + return bundle; + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/StaticRegionAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/StaticRegionAdapter.java new file mode 100644 index 000000000..180c7d2b5 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/StaticRegionAdapter.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import android.annotation.SuppressLint; +import android.content.res.ColorStateList; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.holder.StaticRegionHolder; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.constants.NetworkKeyConstants; +import com.windscribe.vpn.serverlist.entity.PingTime; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.entity.StaticRegion; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.List; + + +public class StaticRegionAdapter extends RecyclerView.Adapter { + + private final ListViewClickListener mListener; + private ServerListData dataDetails; + private List mStaticIpList; + + + public StaticRegionAdapter(List mStaticIpList, ServerListData dataDetails, + ListViewClickListener mListener) { + this.mStaticIpList = mStaticIpList; + this.dataDetails = dataDetails; + this.mListener = mListener; + } + + @Override + public int getItemCount() { + return mStaticIpList != null ? mStaticIpList.size() : 0; + } + + @Override + public void onBindViewHolder(@NonNull StaticRegionHolder staticRegionHolder, int i) { + //Setup icon + StaticRegion region = mStaticIpList.get(staticRegionHolder.getAdapterPosition()); + if (region.getStatus() != null && region.getStatus() == 0) { + staticRegionHolder.mImageIpType.setImageResource(R.drawable.ic_under_construction); + } else if (NetworkKeyConstants.STATIC_IP_TYPE_DATA_CENTER.equals(region.getType())) { + staticRegionHolder.mImageIpType.setImageResource(R.drawable.ic_datacenter_ip_icon); + } else { + staticRegionHolder.mImageIpType.setImageResource(R.drawable.ic_residential_ip_icon); + } + + staticRegionHolder.mIpCityName + .setText(mStaticIpList.get(staticRegionHolder.getAdapterPosition()).getCityName()); + staticRegionHolder.mStaticIp + .setText(mStaticIpList.get(staticRegionHolder.getAdapterPosition()).getStaticIp()); + + int pingResult = getPingTime(mStaticIpList.get(staticRegionHolder.getAdapterPosition()).getId()); + if (dataDetails.isShowLatencyInBar()) { + staticRegionHolder.mTextViewPing.setVisibility(View.GONE); + staticRegionHolder.mImgSignalStrengthBar.setVisibility(View.VISIBLE); + if (pingResult != -1) { + if (pingResult > -1 && pingResult < NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT) { + staticRegionHolder.mImgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_3_bar); + } else if (pingResult >= NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT) { + staticRegionHolder.mImgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_2_bar); + + } else if (pingResult >= NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_1_BAR_UPPER_LIMIT) { + + staticRegionHolder.mImgSignalStrengthBar.setImageResource(R.drawable.ic_network_ping_black_1_bar); + + } else { + staticRegionHolder.mImgSignalStrengthBar + .setImageResource(R.drawable.ic_network_ping_black_no_bar); + } + } + } else { + staticRegionHolder.mTextViewPing.setVisibility(View.VISIBLE); + staticRegionHolder.mImgSignalStrengthBar.setVisibility(View.GONE); + if (pingResult != -1) { + staticRegionHolder.mTextViewPing.setText(String.valueOf(pingResult)); + } else { + staticRegionHolder.mTextViewPing.setText("--"); + } + } + + staticRegionHolder.itemView.setOnClickListener(v -> { + StaticRegion staticRegion = mStaticIpList.get(staticRegionHolder.getAdapterPosition()); + if (staticRegion.getStatus() != null && staticRegion.getStatus() == 0) { + mListener.onUnavailableRegion(true); + } else { + mListener.onStaticIpClick(staticRegion.getId()); + } + }); + setTouchListener(staticRegionHolder); + } + + @NonNull + @Override + public StaticRegionHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + View view = LayoutInflater.from(viewGroup.getContext()) + .inflate(R.layout.static_ip_list_view_holder, viewGroup, false); + return new StaticRegionHolder(view); + } + + public void setDataDetails(ServerListData dataDetails) { + this.dataDetails = dataDetails; + } + + public void setStaticIpList(List mStaticIpList) { + this.mStaticIpList = mStaticIpList; + } + + private int getPingTime(int id) { + for (PingTime pingTime : dataDetails.getPingTimes()) { + if (id == pingTime.ping_id) { + return pingTime.getPingTime(); + } + } + return -1; + } + + private void setTextAndIconColors(StaticRegionHolder holder, int selectedColor) { + holder.mImgSignalStrengthBar.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.mImageIpType.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.mIpCityName.setTextColor(ColorStateList.valueOf(selectedColor)); + holder.mStaticIp.setTextColor(ColorStateList.valueOf(selectedColor)); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(StaticRegionHolder holder) { + holder.itemView.setOnTouchListener((View v, MotionEvent event) -> { + int defaultColor = ThemeUtils.getColor(v.getContext(), R.attr.wdSecondaryColor, R.color.colorWhite50); + int selectedColor = ThemeUtils.getColor(v.getContext(), R.attr.wdPrimaryColor, R.color.colorWhite50); + if (event.getAction() == MotionEvent.ACTION_DOWN) { + setTextAndIconColors(holder, selectedColor); + } else { + setTextAndIconColors(holder, defaultColor); + } + return false; + }); + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/adapter/StreamingNodeAdapter.java b/mobile/src/main/java/com/windscribe/mobile/adapter/StreamingNodeAdapter.java new file mode 100644 index 000000000..b517cd20c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/adapter/StreamingNodeAdapter.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.adapter; + +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup; +import com.windscribe.mobile.holder.RegionViewHolder; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.List; + +public class StreamingNodeAdapter extends RegionsAdapter { + + public StreamingNodeAdapter(List groups, ServerListData serverListData, + ListViewClickListener mListener) { + super(groups, serverListData, mListener); + } + + @Override + public void setPremiumStatus(int isPro, RegionViewHolder holder) { + holder.imgProBadge.setImageDrawable(null); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamPresenter.kt new file mode 100644 index 000000000..6600ed109 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamPresenter.kt @@ -0,0 +1,7 @@ +package com.windscribe.mobile.advance + +interface AdvanceParamPresenter { + fun setup() + fun saveAdvanceParams(text: String) + fun clearAdvanceParams() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamView.kt b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamView.kt new file mode 100644 index 000000000..b67f003d2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamView.kt @@ -0,0 +1,7 @@ +package com.windscribe.mobile.advance + +interface AdvanceParamView { + fun setupActivityTitle() + fun showToast(message: String) + fun setAdvanceParamsText(text: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamsActivity.kt b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamsActivity.kt new file mode 100644 index 000000000..d591aa29a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamsActivity.kt @@ -0,0 +1,66 @@ +package com.windscribe.mobile.advance + +import android.os.Bundle +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.widget.AppCompatEditText +import androidx.test.espresso.action.EditorAction +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.di.DaggerActivityComponent +import com.windscribe.vpn.Windscribe +import com.windscribe.vpn.commonutils.WindUtilities +import javax.inject.Inject + +class AdvanceParamsActivity : BaseActivity(), AdvanceParamView { + + @Inject + lateinit var presenter: AdvanceParamPresenter + + @BindView(R.id.nav_title) + lateinit var titleView: TextView + + @BindView(R.id.advance_params_text) + lateinit var advanceParamsText: AppCompatEditText + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_advance_params, true) + presenter.setup() + } + + override fun setupActivityTitle() { + titleView.text = getString(R.string.advance) + } + + @OnClick(R.id.nav_button) + fun onBackButtonClick(){ + super.onBackPressed() + } + + @OnClick(R.id.saveAdvanceParams) + fun onSavedAdvanceParamsClick(){ + advanceParamsText.onEditorAction(EditorInfo.IME_ACTION_DONE) + presenter.saveAdvanceParams(advanceParamsText.text.toString()) + } + + @OnClick(R.id.clearAdvanceParams) + fun onClearAdvanceParamsClick(){ + advanceParamsText.onEditorAction(EditorInfo.IME_ACTION_DONE) + presenter.clearAdvanceParams() + } + + override fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + override fun setAdvanceParamsText(text: String) { + advanceParamsText.setText(text) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamsPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamsPresenterImpl.kt new file mode 100644 index 000000000..86a2bca4b --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/advance/AdvanceParamsPresenterImpl.kt @@ -0,0 +1,39 @@ +package com.windscribe.mobile.advance + +import com.windscribe.vpn.apppreference.PreferencesHelper +import com.windscribe.vpn.repository.AdvanceParameterRepository +import javax.inject.Inject + +class AdvanceParamsPresenterImpl @Inject constructor(private var advanceParamsView: AdvanceParamView, private val preferencesHelper: PreferencesHelper, private val advanceParameterRepository: AdvanceParameterRepository) : AdvanceParamPresenter { + override fun setup() { + advanceParamsView.setupActivityTitle() + advanceParamsView.setAdvanceParamsText(preferencesHelper.advanceParamText) + } + + override fun saveAdvanceParams(text: String) { + val lineCount = text.split("\n").count { it.isNotEmpty() } + if (lineCount == 0 || text.isEmpty()) { + advanceParamsView.showToast("Nothing to save!! Please add at least 1 key=value pair.") + return + } + val lines = text.split("\n").filter { it.isNotEmpty() } + val invalidLines = lines.filter { + val kv = it.split("=") + kv.count() != 2 || (kv.count() == 2 && (kv[0].isEmpty() || kv[1].isEmpty())) + } + if (invalidLines.isNotEmpty()) { + val error = invalidLines.joinToString(prefix = "Invalid key/value: ", separator = ",") + advanceParamsView.showToast(error) + return + } + preferencesHelper.advanceParamText = text + advanceParamsView.showToast("Saved successfully.") + advanceParameterRepository.reload() + } + + override fun clearAdvanceParams() { + preferencesHelper.advanceParamText = "" + advanceParamsView.setAdvanceParamsText("") + advanceParameterRepository.reload() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/base/BaseActivity.kt b/mobile/src/main/java/com/windscribe/mobile/base/BaseActivity.kt new file mode 100644 index 000000000..d0c127b30 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/base/BaseActivity.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.base + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.PixelFormat +import android.graphics.Rect +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import butterknife.ButterKnife +import com.windscribe.mobile.R +import com.windscribe.mobile.di.ActivityComponent +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.commonutils.WindUtilities +import com.windscribe.vpn.constants.PreferencesKeyConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean + +abstract class BaseActivity : AppCompatActivity() { + val coldLoad = AtomicBoolean() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setWindow() + window.setFormat(PixelFormat.RGBA_8888) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Set a new pixel format for the window to use for rendering + val window = window + window.setFormat(PixelFormat.RGBA_8888) + var boundingRect: List = ArrayList() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val displayCutout = getWindow().decorView.rootWindowInsets.displayCutout + if (displayCutout != null) { + boundingRect = displayCutout.boundingRects + } + } + if (boundingRect.isNotEmpty()) { + val boundingRectHeight = boundingRect[0].height() + if (this is WindscribeActivity) { + this.adjustToolBarHeight(boundingRectHeight / 2) + } else { + val backButton = findViewById(R.id.nav_bar) + backButton?.setPaddingRelative( + backButton.paddingStart, + backButton.paddingTop + boundingRectHeight / 2, backButton.paddingEnd, + backButton.paddingBottom + ) + } + } + } + + open fun setTheme(context: Context) { + val savedThem = appContext.preference.selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + open fun setWindow() { + val statusBarColor = resources.getColor(android.R.color.transparent) + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + window.statusBarColor = statusBarColor + } + + + val isConnectedToNetwork: Boolean + get() = WindUtilities.isOnline() + + fun openURLInBrowser(urlToOpen: String?) { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlToOpen)) + if (browserIntent.resolveActivity(packageManager) != null) { + startActivity(browserIntent) + } else { + Toast.makeText( + this, + "No available browser found to open the desired url!", + Toast.LENGTH_SHORT + ).show() + } + } + + protected fun setActivityModule(activityModule: ActivityModule?): ActivityComponent { + return com.windscribe.mobile.di.DaggerActivityComponent.builder().activityModule(activityModule) + .applicationComponent( + appContext + .applicationComponent + ).build() + } + + protected fun setContentLayout(layoutID: Int, setTheme: Boolean = true) { + setContentLayout(setTheme) + setContentView(layoutID) + ButterKnife.bind(this) + } + + protected fun setContentLayout(setTheme: Boolean = true) { + if (setTheme) { + setTheme(this) + } + setLanguage() + coldLoad.set(true) + } + + fun setLanguage() { + val newLocale = appContext.getSavedLocale() + Locale.setDefault(newLocale) + val config = Configuration() + config.locale = newLocale + appContext.resources.updateConfiguration(config, baseContext.resources.displayMetrics) + resources.updateConfiguration(config, baseContext.resources.displayMetrics) + } + + fun activityScope(block: suspend CoroutineScope.() -> Unit) { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED, block) + } + } + + companion object { + const val FILE_PICK_REQUEST = 204 + const val CONNECTED_FLAG_PATH_PICK_REQUEST = 205 + const val DISCONNECTED_FLAG_PATH_PICK_REQUEST = 206 + const val REQUEST_BACKGROUND_PERMISSION = 207 + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmActivity.kt b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmActivity.kt new file mode 100644 index 000000000..4e13742c0 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmActivity.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.confirmemail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.work.Data +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.email.AddEmailActivity +import com.windscribe.vpn.Windscribe +import javax.inject.Inject + +class ConfirmActivity : BaseActivity(), ConfirmEmailView { + + @Inject + lateinit var presenter: ConfirmEmailPresenter + + @BindView(R.id.description) + lateinit var descriptionView: TextView + + @BindView(R.id.progress_view) + lateinit var progressView: FrameLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_confirm, true) + presenter.init(intent.getStringExtra(ReasonToConfirmEmail)) + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun finishActivity() { + Windscribe.appContext.workManager.updateSession(Data.EMPTY) + finish() + } + + @OnClick(R.id.change_email) + fun onChangeEmailClicked() { + startActivity(Intent(this, AddEmailActivity::class.java)) + finish() + } + + @OnClick(R.id.close) + fun onCloseClicked() { + finishActivity() + } + + @OnClick(R.id.resend_email) + fun onResendEmailClicked() { + presenter.resendVerificationEmail() + } + + override fun setReasonToConfirmEmail(reasonForConfirmEmail: String) { + descriptionView.text = reasonForConfirmEmail + } + + override fun showEmailConfirmProgress(show: Boolean) { + runOnUiThread { progressView.visibility = if (show) View.VISIBLE else View.GONE } + } + + override fun showToast(toast: String) { + runOnUiThread { Toast.makeText(this@ConfirmActivity, toast, Toast.LENGTH_SHORT).show() } + } + + companion object { + const val ReasonToConfirmEmail = "reasonToConfirmEmail" + @JvmStatic + fun getStartIntent(context: Context?): Intent { + return Intent(context, ConfirmActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailPresenter.kt new file mode 100644 index 000000000..f1084089d --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailPresenter.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.confirmemail + +interface ConfirmEmailPresenter { + fun init(reasonToConfirmEmail: String?) + fun onDestroy() + fun resendVerificationEmail() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailPresenterImp.kt b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailPresenterImp.kt new file mode 100644 index 000000000..a82d29522 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailPresenterImp.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.confirmemail + +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.AddEmailResponse +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.constants.UserStatusConstants +import com.windscribe.vpn.repository.CallResult +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class ConfirmEmailPresenterImp @Inject constructor( + private var confirmEmailView: ConfirmEmailView, + private var interactor: ActivityInteractor +) : ConfirmEmailPresenter { + private val mPresenterLog = LoggerFactory.getLogger("basic") + override fun onDestroy() { + if (interactor.getCompositeDisposable().isDisposed.not()) { + interactor.getCompositeDisposable().dispose() + } + } + + override fun init(reasonToConfirmEmail: String?) { + reasonToConfirmEmail?.let { + confirmEmailView.setReasonToConfirmEmail(it) + } ?: kotlin.run { + val proUser = (interactor.getAppPreferenceInterface().userStatus + == UserStatusConstants.USER_STATUS_PREMIUM) + val reasonForConfirmEmail = interactor + .getResourceString(if (proUser) R.string.pro_reason_to_confirm else R.string.free_reason_to_confirm) + confirmEmailView.setReasonToConfirmEmail(reasonForConfirmEmail) + } + } + + override fun resendVerificationEmail() { + confirmEmailView.showEmailConfirmProgress(true) + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().resendUserEmailAddress(null).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()).subscribeWith(object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + confirmEmailView.showToast(interactor.getResourceString(R.string.error_sending_email)) + confirmEmailView.showEmailConfirmProgress(false) + } + + override fun onSuccess( + postEmailResponseClass: GenericResponseClass + ) { + confirmEmailView.showEmailConfirmProgress(false) + when (val result = postEmailResponseClass.callResult()) { + is CallResult.Error -> { + confirmEmailView.showToast(result.errorMessage) + mPresenterLog.debug("Server returned error. $result") + } + is CallResult.Success -> { + confirmEmailView.showToast(interactor.getResourceString(R.string.email_confirmation_sent_successfully)) + mPresenterLog.info("Email confirmation sent successfully...") + confirmEmailView.finishActivity() + } + } + } + }) + ) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailView.kt b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailView.kt new file mode 100644 index 000000000..e2c868a3d --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/confirmemail/ConfirmEmailView.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.confirmemail + +interface ConfirmEmailView { + fun finishActivity() + fun setReasonToConfirmEmail(reasonForConfirmEmail: String) + fun showEmailConfirmProgress(show: Boolean) + fun showToast(toast: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionmode/AllProtocolFailedFragment.kt b/mobile/src/main/java/com/windscribe/mobile/connectionmode/AllProtocolFailedFragment.kt new file mode 100644 index 000000000..d8579a425 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionmode/AllProtocolFailedFragment.kt @@ -0,0 +1,56 @@ +package com.windscribe.mobile.connectionmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ProgressBar +import androidx.fragment.app.DialogFragment +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.vpn.autoconnection.AutoConnectionModeCallback + +class AllProtocolFailedFragment : DialogFragment() { + + @BindView(R.id.progressBar) + lateinit var progressView: ProgressBar + + @BindView(R.id.send_debug_log) + lateinit var sendDebugLog: Button + + private var autoConnectionModeCallback: AutoConnectionModeCallback? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.all_protocol_failed, container, false) + ButterKnife.bind(this, view) + return view + } + + @OnClick(R.id.cancel, R.id.img_close_btn) + fun onCancelClick() { + dismiss() + autoConnectionModeCallback?.onCancel() + } + + @OnClick(R.id.send_debug_log) + fun onSendLogClick() { + sendDebugLog.visibility = View.INVISIBLE + progressView.visibility = View.VISIBLE + autoConnectionModeCallback?.onSendLogClicked() + } + + companion object { + fun newInstance(autoConnectionModeCallback: AutoConnectionModeCallback): AllProtocolFailedFragment { + val fragment = AllProtocolFailedFragment() + fragment.autoConnectionModeCallback = autoConnectionModeCallback + return fragment + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionmode/ConnectionChangeFragment.kt b/mobile/src/main/java/com/windscribe/mobile/connectionmode/ConnectionChangeFragment.kt new file mode 100644 index 000000000..a733ec569 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionmode/ConnectionChangeFragment.kt @@ -0,0 +1,75 @@ +package com.windscribe.mobile.connectionmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.ItemSelectListener +import com.windscribe.mobile.adapter.ProtocolInformationAdapter +import com.windscribe.vpn.autoconnection.AutoConnectionModeCallback +import com.windscribe.vpn.autoconnection.ProtocolInformation + +class ConnectionChangeFragment : DialogFragment(), ItemSelectListener { + + @BindView(R.id.protocol_list) + lateinit var protocolListView: RecyclerView + private var protocolInformation: List? = null + private var autoConnectionModeCallback: AutoConnectionModeCallback? = null + + + var adapter: ProtocolInformationAdapter? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.connection_change, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + adapter = ProtocolInformationAdapter(mutableListOf(), this) + protocolListView.layoutManager = LinearLayoutManager(context) + protocolListView.adapter = adapter + protocolInformation?.let { + adapter?.update(it) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.FullScreenDialog) + } + + @OnClick(R.id.cancel, R.id.img_close_btn) + fun onCancelClick() { + dismissAllowingStateLoss() + autoConnectionModeCallback?.onCancel() + } + + override fun onItemSelect(protocolInformation: ProtocolInformation) { + dismissAllowingStateLoss() + autoConnectionModeCallback?.onProtocolSelect(protocolInformation) + } + + companion object { + fun newInstance( + protocolInformation: List, + autoConnectionModeCallback: AutoConnectionModeCallback + ): ConnectionChangeFragment { + val fragment = ConnectionChangeFragment() + fragment.protocolInformation = protocolInformation + fragment.autoConnectionModeCallback = autoConnectionModeCallback + return fragment + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionmode/ConnectionFailureFragment.kt b/mobile/src/main/java/com/windscribe/mobile/connectionmode/ConnectionFailureFragment.kt new file mode 100644 index 000000000..78c894fe3 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionmode/ConnectionFailureFragment.kt @@ -0,0 +1,103 @@ +package com.windscribe.mobile.connectionmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.ItemSelectListener +import com.windscribe.mobile.adapter.ProtocolInformationAdapter +import com.windscribe.vpn.autoconnection.AutoConnectionModeCallback +import com.windscribe.vpn.autoconnection.ProtocolInformation +import com.windscribe.vpn.commonutils.Ext.launchPeriodicAsync +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.cancel + +class ConnectionFailureFragment : DialogFragment(), ItemSelectListener { + + + @BindView(R.id.protocol_list) + lateinit var protocolListView: RecyclerView + private var scope = CoroutineScope(Main) + private var adapter: ProtocolInformationAdapter? = null + private var protocolInformation: List? = null + private var autoConnectionModeCallback: AutoConnectionModeCallback? = null + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.connection_failure, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + adapter = ProtocolInformationAdapter(mutableListOf(), this) + protocolListView.layoutManager = LinearLayoutManager(context) + protocolListView.adapter = adapter + protocolInformation?.let { + adapter?.update(it) + startAutoSelectTimer() + } + } + + private fun startAutoSelectTimer() { + scope.launchPeriodicAsync(1000) { + adapter?.data?.let { + if (it[0].autoConnectTimeLeft > 0) { + it[0].autoConnectTimeLeft = it[0].autoConnectTimeLeft - 1 + } + adapter?.notifyItemChanged(0) + if (it[0].autoConnectTimeLeft <= 0) { + onItemSelect(it[0]) + } + } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.FullScreenDialog) + } + + @OnClick(R.id.cancel, R.id.img_close_btn) + fun onCancelClick() { + scope.cancel() + dismissAllowingStateLoss() + autoConnectionModeCallback?.onCancel() + } + + override fun onItemSelect(protocolInformation: ProtocolInformation) { + dismissAllowingStateLoss() + scope.cancel() + autoConnectionModeCallback?.onProtocolSelect(protocolInformation) + } + + companion object { + fun newInstance( + protocolInformationList: List, + autoConnectionModeCallback: AutoConnectionModeCallback + ): DialogFragment { + val fragment = ConnectionFailureFragment() + fragment.protocolInformation = protocolInformationList + fragment.autoConnectionModeCallback = autoConnectionModeCallback + return fragment + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionmode/DebugLogSentFragment.kt b/mobile/src/main/java/com/windscribe/mobile/connectionmode/DebugLogSentFragment.kt new file mode 100644 index 000000000..a637b99c3 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionmode/DebugLogSentFragment.kt @@ -0,0 +1,47 @@ +package com.windscribe.mobile.connectionmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.vpn.autoconnection.AutoConnectionModeCallback +import com.windscribe.vpn.constants.NetworkKeyConstants + +class DebugLogSentFragment : DialogFragment() { + + private var autoConnectionModeCallback: AutoConnectionModeCallback? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.debug_log_sent, container, false) + ButterKnife.bind(this, view) + return view + } + + @OnClick(R.id.cancel, R.id.img_close_btn) + fun onCancelClick() { + dismiss() + autoConnectionModeCallback?.onCancel() + } + + @OnClick(R.id.contact_support) + fun onContactSupportClick() { + dismiss() + val activity = activity as? BaseActivity + activity?.openURLInBrowser(NetworkKeyConstants.getWebsiteLink(NetworkKeyConstants.URL_HELP_ME)) + } + + companion object { + fun newInstance(autoConnectionModeCallback: AutoConnectionModeCallback): DebugLogSentFragment { + val fragment = DebugLogSentFragment() + fragment.autoConnectionModeCallback = autoConnectionModeCallback + return fragment + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionmode/SetupPreferredProtocolFragment.kt b/mobile/src/main/java/com/windscribe/mobile/connectionmode/SetupPreferredProtocolFragment.kt new file mode 100644 index 000000000..694210d76 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionmode/SetupPreferredProtocolFragment.kt @@ -0,0 +1,67 @@ +package com.windscribe.mobile.connectionmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.vpn.autoconnection.AutoConnectionModeCallback +import com.windscribe.vpn.autoconnection.ProtocolInformation +import com.windscribe.vpn.backend.Util + +class SetupPreferredProtocolFragment : DialogFragment() { + + @BindView(R.id.title) + lateinit var titleView: TextView + + private var protocolInformation: ProtocolInformation? = null + private var autoConnectionModeCallback: AutoConnectionModeCallback? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.setup_preferred_protocol, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + protocolInformation?.let { + titleView.text = getString( + R.string.set_this_protocol_as_preferred, Util.getProtocolLabel(it.protocol) + ) + } ?: kotlin.run { + dismiss() + } + } + + @OnClick(R.id.cancel, R.id.img_close_btn) + fun onCancelClick() { + dismiss() + autoConnectionModeCallback?.onCancel() + } + + @OnClick(R.id.set_as_preferred) + fun onSetAsPreferredProtocolClick() { + dismiss() + autoConnectionModeCallback?.onSetAsPreferredClicked() + } + + companion object { + fun newInstance( + protocolInformation: ProtocolInformation?, + autoConnectionModeCallback: AutoConnectionModeCallback + ): SetupPreferredProtocolFragment { + val fragment = SetupPreferredProtocolFragment() + fragment.protocolInformation = protocolInformation + fragment.autoConnectionModeCallback = autoConnectionModeCallback + return fragment + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsActivity.kt b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsActivity.kt new file mode 100644 index 000000000..eca6ec09c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsActivity.kt @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionsettings + +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.ConstraintLayout +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.preferences.* +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.dialogs.* +import com.windscribe.mobile.gpsspoofing.GpsSpoofingSettingsActivity +import com.windscribe.mobile.networksecurity.NetworkSecurityActivity +import com.windscribe.mobile.splittunneling.SplitTunnelingActivity +import com.windscribe.mobile.utils.PermissionManager +import com.windscribe.mobile.utils.UiUtil +import com.windscribe.vpn.constants.FeatureExplainer +import org.slf4j.LoggerFactory +import java.util.zip.GZIPOutputStream +import javax.inject.Inject + +class ConnectionSettingsActivity : BaseActivity(), ConnectionSettingsView, ExtraDataUseWarningDialogCallBack { + + private val logger = LoggerFactory.getLogger(TAG) + + @Inject + lateinit var presenter: ConnectionSettingsPresenter + + @BindView(R.id.nav_title) + lateinit var activityTitleView: TextView + + @BindView(R.id.connection_parent) + lateinit var constraintLayoutConnection: ConstraintLayout + + @BindView(R.id.split_tunnel_status) + lateinit var splitTunnelStatusView: TextView + + @BindView(R.id.cl_boot_settings) + lateinit var autoStartToggleView: ToggleView + + @BindView(R.id.cl_lan_settings) + lateinit var allowLanToggleView: ToggleView + + @BindView(R.id.cl_gps_settings) + lateinit var gpsToggleView: ToggleView + + @BindView(R.id.cl_connection_mode) + lateinit var connectionModeDropDownView: ExpandableDropDownView + + @BindView(R.id.cl_dns_mode) + lateinit var dnsModeDropDownView: ExpandableDropDownView + + @BindView(R.id.cl_packet_size) + lateinit var packetSizeModeDropDownView: ExpandableDropDownView + + @BindView(R.id.cl_keep_alive) + lateinit var keepAliveExpandableView: ExpandableDropDownView + + @BindView(R.id.cl_decoy_traffic) + lateinit var decoyTrafficToggleView: ExpandableToggleView + + @BindView(R.id.cl_anti_censorship) + lateinit var clAntiCensorshipToggleView: ToggleView + + @BindView(R.id.cl_auto_connect) + lateinit var clAutoConnectToggleView: ToggleView + + @BindView(R.id.split_tunnel_title) + lateinit var splitTunnelLabel: TextView + + @BindView(R.id.split_tunnel_right_icon) + lateinit var splitTunnelArrow: ImageView + + @BindView(R.id.network_options_title) + lateinit var networkOptionsLabel: TextView + + @BindView(R.id.network_options_right_icon) + lateinit var networkOptionsArrow: ImageView + + @Inject + lateinit var permissionManager: PermissionManager + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.connection_layout, true) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + setupCustomViewDelegates() + logger.info("Setting up layout based on saved mode settings...") + presenter.init() + activityTitleView.text = getString(R.string.connection) + permissionManager.register(this) + } + + override fun onStart() { + super.onStart() + presenter.onStart() + } + + override fun onResume() { + super.onResume() + presenter.onHotStart() + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun gotoSplitTunnelingSettings() { + val intent = SplitTunnelingActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + private fun setupCustomViewDelegates() { + allowLanToggleView.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + logger.info("User clicked on allow lan...") + presenter.onAllowLanClicked() + } + + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.ALLOW_LAN) + } + } + gpsToggleView.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + logger.info("User clicked on gps spoof.") + presenter.onGpsSpoofingClick() + } + + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.GPS_SPOOFING) + } + } + autoStartToggleView.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + logger.info("User clicked on auto start on boot.") + presenter.onAutoStartOnBootClick() + } + + override fun onExplainClick() {} + } + decoyTrafficToggleView.delegate = object : ExpandableToggleView.Delegate { + override fun onToggleClick() { + presenter.onDecoyTrafficClick() + } + + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.DECOY_TRAFFIC) + } + } + val decoyTrafficView = decoyTrafficToggleView.childView as DecoyTrafficView? + decoyTrafficView?.delegate = object : DecoyTrafficView.Delegate { + override fun onDecoyTrafficVolumeChanged(volume: String) { + presenter.onFakeTrafficVolumeSelected(volume) + } + } + connectionModeDropDownView.delegate = object : ExpandableDropDownView.Delegate { + override fun onItemSelect(position: Int) { + if (position == 0) { + presenter.onConnectionModeAutoClicked() + } else { + presenter.onConnectionModeManualClicked() + } + } + + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.CONNECTION_MODE) + } + } + dnsModeDropDownView.delegate = object : ExpandableDropDownView.Delegate { + override fun onItemSelect(position: Int) { + if (position == 0) { + presenter.onRobertDnsModeSelected() + } else { + presenter.onCustomDnsModeSelected() + } + } + + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.CUSTOM_DNS_MODE) + } + } + val connectionModeView = connectionModeDropDownView.childView as ConnectionModeView? + connectionModeView?.delegate = object : ConnectionModeView.Delegate { + override fun onProtocolSelected(protocol: String) { + presenter.onProtocolSelected(protocol) + } + + override fun onPortSelected(protocol: String, port: String) { + presenter.onPortSelected(protocol, port) + } + } + val dnsModeView = dnsModeDropDownView.childView as DnsModeView? + dnsModeView?.delegate = object : DnsModeView.Delegate { + override fun onCustomDnsChanged(dns: String) { + presenter.onCustomDnsChanged(dns) + } + } + packetSizeModeDropDownView.delegate = object : ExpandableDropDownView.Delegate { + override fun onItemSelect(position: Int) { + if (position == 0) { + presenter.onPacketSizeAutoModeClicked() + } else { + presenter.onPacketSizeManualModeClicked() + } + } + + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.PACKET_SIZE) + } + } + val packetSizeView = packetSizeModeDropDownView.childView as PacketSizeView + packetSizeView.delegate = object : PacketSizeView.Delegate { + override fun onAutoFillButtonClick() { + presenter.onAutoFillPacketSizeClicked() + } + + override fun onPacketSizeChanged(packetSize: String) { + presenter.setPacketSize(packetSize) + } + } + keepAliveExpandableView.delegate = object : ExpandableDropDownView.Delegate { + override fun onItemSelect(position: Int) { + if (position == 0) { + presenter.onKeepAliveAutoModeClicked() + } else { + presenter.onKeepAliveManualModeClicked() + } + } + + override fun onExplainClick() {} + } + val keepAliveView = keepAliveExpandableView.childView as KeepAliveView + keepAliveView.delegate = object : KeepAliveView.Delegate { + override fun onKeepAliveTimeChanged(time: String) { + presenter.saveKeepAlive(time) + } + + } + splitTunnelArrow.tag = R.drawable.ic_forward_arrow_settings + networkOptionsArrow.tag = R.drawable.ic_forward_arrow_settings + UiUtil.setupOnTouchListener( + textViewContainer = splitTunnelLabel, + iconView = splitTunnelArrow, + textView = splitTunnelLabel + ) + UiUtil.setupOnTouchListener( + textViewContainer = networkOptionsLabel, + iconView = networkOptionsArrow, + textView = networkOptionsLabel + ) + clAntiCensorshipToggleView.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.onAntiCensorshipClick() + } + override fun onExplainClick() { + openURLInBrowser(FeatureExplainer.CIRCUMVENT_CENSORSHIP) + } + } + clAutoConnectToggleView.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.onAutoConnectClick() + } + override fun onExplainClick() {} + } + } + + @OnClick(R.id.nav_button) + fun onBackButtonClicked() { + logger.info("User clicked on back arrow...") + onBackPressed() + } + + @OnClick(R.id.split_tunnel_title, R.id.split_tunnel_status, R.id.split_tunnel_right_icon) + fun onSplitTunnelingClick() { + logger.info("User clicked on split tunneling...") + presenter.onSplitTunnelingOptionClicked() + } + + @OnClick(R.id.open_always_setting) + fun openAlwaysVPNSettingsClick() { + logger.info("User clicked on open VPN Settings...") + goToSettings() + } + + private fun goToSettings() { + val intent = Intent("android.net.vpn.SETTINGS") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + if (intent.resolveActivity(packageManager) != null) { + startActivity(intent) + } + } + + @OnClick(R.id.network_options_right_icon, R.id.network_options_title) + fun onWhitelistClick() { + presenter.onNetworkOptionsClick() + } + + override fun openGpsSpoofSettings() { + startActivity(GpsSpoofingSettingsActivity.getStartIntent(this)) + } + + override fun packetSizeDetectionProgress(progress: Boolean) { + (packetSizeModeDropDownView.childView as PacketSizeView).packetSizeDetectionProgress( + progress + ) + } + + override fun setAutoStartOnBootToggle(toggleDrawable: Int) { + autoStartToggleView.setToggleImage(toggleDrawable) + } + + override fun setGpsSpoofingToggle(toggleDrawable: Int) { + gpsToggleView.setToggleImage(toggleDrawable) + } + + override fun setKeepAlive(keepAlive: String) { + (keepAliveExpandableView.childView as KeepAliveView).setKeepAlive(keepAlive) + } + + override fun setLanBypassToggle(toggleDrawable: Int) { + allowLanToggleView.setToggleImage(toggleDrawable) + } + + override fun setPacketSize(size: String) { + val packetSizeView = packetSizeModeDropDownView.childView as PacketSizeView + packetSizeView.setPacketSize(size) + } + + override fun setSplitTunnelText(onOff: String, color: Int) { + logger.info("Setting tunnel status $onOff") + splitTunnelStatusView.text = onOff + splitTunnelStatusView.setTextColor(color) + } + + override fun setupPacketSizeModeAdapter(savedValue: String, types: Array) { + packetSizeModeDropDownView.setAdapter(savedValue, types) + } + + override fun setKeepAliveModeAdapter(savedValue: String, types: Array) { + keepAliveExpandableView.setAdapter(savedValue, types) + } + + override fun setupPortMapAdapter(port: String, portMap: List) { + val connectionModeView = connectionModeDropDownView.childView as ConnectionModeView + connectionModeView.sePortAdapter(port, portMap) + } + + override fun setupProtocolAdapter(protocol: String, protocols: Array) { + val connectionModeView = connectionModeDropDownView.childView as ConnectionModeView + connectionModeView.seProtocolAdapter(protocol, protocols) + } + + override fun showGpsSpoofing() { + gpsToggleView.visibility = View.VISIBLE + } + + override fun showToast(toastString: String) { + runOnUiThread { + Toast.makeText(this, toastString, Toast.LENGTH_SHORT).show() + } + } + + override fun showExtraDataUseWarning() { + ExtraDataUseWarningDialog.show(this) + } + + override fun turnOnDecoyTraffic() { + presenter.turnOnDecoyTraffic() + } + + override fun showAutoStartOnBoot() { + autoStartToggleView.visibility = View.VISIBLE + } + + override fun setKeepAliveContainerVisibility(isAutoKeepAlive: Boolean) { + keepAliveExpandableView.visibility = + if (isAutoKeepAlive) View.VISIBLE else View.GONE + } + + override fun setupConnectionModeAdapter(savedValue: String, connectionModes: Array) { + connectionModeDropDownView.setAdapter(savedValue, connectionModes) + } + override fun setupDNSModeAdapter(savedValue: String, dnsModes: Array) { + dnsModeDropDownView.setAdapter(savedValue, dnsModes) + } + + override fun setupFakeTrafficVolumeAdapter(selectedValue: String, values: Array) { + (decoyTrafficToggleView.childView as DecoyTrafficView).setAdapter( + selectedValue, + values + ) + } + + override fun setPotentialTrafficUse(value: String) { + (decoyTrafficToggleView.childView as DecoyTrafficView).setPotentialTraffic(value) + } + + override fun setDecoyTrafficToggle(toggleDrawable: Int) { + decoyTrafficToggleView.setToggleImage(toggleDrawable) + } + + override fun setAntiCensorshipToggle(toggleDrawable: Int) { + clAntiCensorshipToggleView.setToggleImage(toggleDrawable) + } + + override fun setAutoConnectToggle(toggleDrawable: Int) { + clAutoConnectToggleView.setToggleImage(toggleDrawable) + } + + override fun goToNetworkSecurity() { + startActivity(NetworkSecurityActivity.getStartIntent(this)) + } + + override fun setCustomDnsAddress(dnsAddress: String) { + val dnsModeView = dnsModeDropDownView.childView as DnsModeView + dnsModeView.setCustomDns(dnsAddress) + } + + companion object { + private const val TAG = "conn_settings_a" + fun getStartIntent(context: Context?): Intent { + return Intent(context, ConnectionSettingsActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsPresenter.kt new file mode 100644 index 000000000..08a805354 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsPresenter.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionsettings + +import android.content.Context + +interface ConnectionSettingsPresenter { + fun init() + fun onAllowLanClicked() + fun onAutoFillPacketSizeClicked() + fun onAutoStartOnBootClick() + fun onConnectionModeAutoClicked() + fun onConnectionModeManualClicked() + fun onDestroy() + fun onGpsSpoofingClick() + fun onHotStart() + fun onKeepAliveAutoModeClicked() + fun onKeepAliveManualModeClicked() + fun onManualLayoutSetupCompleted() + fun onPacketSizeAutoModeClicked() + fun onPacketSizeManualModeClicked() + fun onPermissionProvided() + fun onPortSelected(heading: String, port: String) + fun onProtocolSelected(heading: String) + fun onSplitTunnelingOptionClicked() + fun onStart() + fun saveKeepAlive(keepAlive: String) + fun setKeepAlive(keepAlive: String) + fun setPacketSize(size: String) + fun setTheme(context: Context) + fun onDecoyTrafficClick() + fun turnOnDecoyTraffic() + fun onFakeTrafficVolumeSelected(label: String) + fun onNetworkOptionsClick() + fun onAntiCensorshipClick() + fun onAutoConnectClick() + fun onRobertDnsModeSelected() + fun onCustomDnsModeSelected() + fun onCustomDnsChanged(dns: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsPresenterImpl.kt new file mode 100644 index 000000000..f3c0cad4f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsPresenterImpl.kt @@ -0,0 +1,800 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionsettings + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.os.Build +import androidx.core.content.ContextCompat +import com.windscribe.mobile.R +import com.windscribe.mobile.utils.PermissionManager +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.ActivityInteractorImpl.PortMapLoadCallback +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.api.response.PortMapResponse +import com.windscribe.vpn.api.response.PortMapResponse.PortMap +import com.windscribe.vpn.backend.ProxyDNSManager +import com.windscribe.vpn.commonutils.Ext.getFakeTrafficVolumeOptions +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.constants.PreferencesKeyConstants.CONNECTION_MODE_AUTO +import com.windscribe.vpn.constants.PreferencesKeyConstants.CONNECTION_MODE_MANUAL +import com.windscribe.vpn.constants.PreferencesKeyConstants.DNS_MODE_CUSTOM +import com.windscribe.vpn.constants.PreferencesKeyConstants.DNS_MODE_ROBERT +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_IKev2 +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_STEALTH +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_TCP +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_UDP +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_WIRE_GUARD +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_WS_TUNNEL +import com.windscribe.vpn.decoytraffic.FakeTrafficVolume +import com.windscribe.vpn.mocklocation.MockLocationManager.Companion.isAppSelectedInMockLocationList +import com.windscribe.vpn.mocklocation.MockLocationManager.Companion.isDevModeOn +import com.windscribe.vpn.services.AutoConnectService +import com.windscribe.vpn.services.canAccessNetworkName +import com.windscribe.vpn.services.startAutoConnectService +import com.windscribe.vpn.services.stopAutoConnectService +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableObserver +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.launch +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import java.io.IOException +import java.net.NetworkInterface +import java.nio.charset.StandardCharsets +import java.util.* +import javax.inject.Inject + +class ConnectionSettingsPresenterImpl @Inject constructor( + private var connSettingsView: ConnectionSettingsView, + private var interactor: ActivityInteractor, + private val permissionManager: PermissionManager, + private val proxyDNSManager: ProxyDNSManager +) : ConnectionSettingsPresenter { + private val logger = LoggerFactory.getLogger("basic") + private var currentPoint = 1500 + override fun onStart() { + //Set split tunnel text view + if (interactor.getAppPreferenceInterface().splitTunnelToggle) { + //Split tunnel is on + logger.info("Split tunnel settings is ON") + connSettingsView.setSplitTunnelText( + interactor.getResourceString(R.string.on), + interactor.getThemeColor(R.attr.wdActionColor) + ) + } else { + logger.info("Split tunnel settings is OFF") + connSettingsView.setSplitTunnelText( + interactor.getResourceString(R.string.off), + interactor.getThemeColor(R.attr.wdSecondaryColor) + ) + } + setAutoStartMenu() + if (interactor.getAppPreferenceInterface().lanByPass) { + connSettingsView.setLanBypassToggle(R.drawable.ic_toggle_button_on) + } else { + connSettingsView.setLanBypassToggle(R.drawable.ic_toggle_button_off) + } + if (interactor.getAppPreferenceInterface().isDecoyTrafficOn) { + connSettingsView.setDecoyTrafficToggle(R.drawable.ic_toggle_button_on) + } else { + connSettingsView.setDecoyTrafficToggle(R.drawable.ic_toggle_button_off) + } + setDecoyTrafficParameters() + if (interactor.getAppPreferenceInterface().isAntiCensorshipOn) { + connSettingsView.setAntiCensorshipToggle(R.drawable.ic_toggle_button_on) + } else { + connSettingsView.setAntiCensorshipToggle(R.drawable.ic_toggle_button_off) + } + if (interactor.getAppPreferenceInterface().autoConnect) { + connSettingsView.setAutoConnectToggle(R.drawable.ic_toggle_button_on) + } else { + connSettingsView.setAutoConnectToggle(R.drawable.ic_toggle_button_off) + } + } + + override fun onDestroy() { + interactor.getDecoyTrafficController().load() + if (interactor.getCompositeDisposable().isDisposed.not()) { + logger.info("Disposing observer...") + interactor.getCompositeDisposable().dispose() + } + } + + override fun init() { + setupLayoutBasedOnConnectionMode() + setUpAutoModePorts() + setupPacketSizeMode() + setUpKeepAlive() + setupLayoutBasedOnDnsMode() + } + + override fun onAllowLanClicked() { + if (interactor.getAppPreferenceInterface().lanByPass) { + connSettingsView.setLanBypassToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().lanByPass = false + logger.info("Setting lan bypass to true") + } else { + connSettingsView.setLanBypassToggle(R.drawable.ic_toggle_button_on) + interactor.getAppPreferenceInterface().lanByPass = true + logger.info("Setting lan bypass to false") + } + } + + override fun onAutoFillPacketSizeClicked() { + mtuSizeFromNetworkInterface + } + + override fun onAutoStartOnBootClick() { + if (interactor.getAppPreferenceInterface().autoStartOnBoot) { + connSettingsView.setAutoStartOnBootToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().autoStartOnBoot = false + logger.info("Setting auto start on boot to false") + } else { + connSettingsView.setAutoStartOnBootToggle(R.drawable.ic_toggle_button_on) + interactor.getAppPreferenceInterface().autoStartOnBoot = true + logger.info("Setting auto start on boot to true") + } + } + + override fun onDecoyTrafficClick() { + if (interactor.getAppPreferenceInterface().isDecoyTrafficOn) { + connSettingsView.setDecoyTrafficToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().isDecoyTrafficOn = false + logger.info("Setting decoy traffic to false") + interactor.getDecoyTrafficController().stop() + } else { + connSettingsView.showExtraDataUseWarning() + } + } + + override fun turnOnDecoyTraffic() { + connSettingsView.setDecoyTrafficToggle(R.drawable.ic_toggle_button_on) + interactor.getAppPreferenceInterface().isDecoyTrafficOn = true + logger.info("Setting decoy traffic to true") + if (interactor.getVpnConnectionStateManager().isVPNConnected()) { + interactor.getDecoyTrafficController().load() + interactor.getDecoyTrafficController().start() + } + } + + private fun setDecoyTrafficParameters() { + val multiplierOptions = getFakeTrafficVolumeOptions() + val lowerLimit = + interactor.getAppPreferenceInterface().fakeTrafficVolume.name + connSettingsView.setupFakeTrafficVolumeAdapter(lowerLimit, multiplierOptions) + resetPotentialTrafficInfo() + } + + override fun onFakeTrafficVolumeSelected(label: String) { + interactor.getAppPreferenceInterface().fakeTrafficVolume = + FakeTrafficVolume.valueOf(label) + resetPotentialTrafficInfo() + } + + override fun onConnectionModeAutoClicked() { + //Save connection mode to preference only if manual mode is selected + if (CONNECTION_MODE_AUTO != interactor.getSavedConnectionMode()) { + interactor.saveConnectionMode(CONNECTION_MODE_AUTO) + interactor.getAppPreferenceInterface().setChosenProtocol(null) + setUpAutoModePorts() + interactor.getAutoConnectionManager().reset() + } + } + + override fun onConnectionModeManualClicked() { + //Save connection mode to preference only if a different connection mode is selected + if (CONNECTION_MODE_MANUAL != interactor.getSavedConnectionMode()) { + interactor.saveConnectionMode(CONNECTION_MODE_MANUAL) + connSettingsView.setKeepAliveContainerVisibility(interactor.getAppPreferenceInterface().savedProtocol == PROTO_IKev2) + } + } + + override fun onGpsSpoofingClick() { + permissionManager.withBackgroundLocationPermission { error -> + if (error != null) { + logger.debug(error) + } else { + onPermissionProvided() + } + } + } + + override fun onHotStart() { + setGpsSpoofingMenu() + } + + override fun onKeepAliveAutoModeClicked() { + val keepAliveSizeModeAuto = + interactor.getAppPreferenceInterface().isKeepAliveModeAuto + if (!keepAliveSizeModeAuto) { + interactor.getAppPreferenceInterface().isKeepAliveModeAuto = true + connSettingsView.setKeepAliveModeAdapter( + interactor.getResourceString(R.string.auto), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } + } + + override fun onKeepAliveManualModeClicked() { + val keepAliveSizeModeAuto = + interactor.getAppPreferenceInterface().isKeepAliveModeAuto + if (keepAliveSizeModeAuto) { + setKeepAlive(interactor.getAppPreferenceInterface().keepAlive) + interactor.getAppPreferenceInterface().isKeepAliveModeAuto = false + connSettingsView.setKeepAliveModeAdapter( + interactor.getResourceString(R.string.manual), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } + } + + override fun onManualLayoutSetupCompleted() { + logger.info("Manual layout setup is completed...") + setProtocolAdapter() + } + + override fun onPacketSizeAutoModeClicked() { + val packetSizeModeAuto = + interactor.getAppPreferenceInterface().isPackageSizeModeAuto + if (!packetSizeModeAuto) { + interactor.getAppPreferenceInterface().setPacketSizeModeToAuto(true) + } + } + + override fun onPacketSizeManualModeClicked() { + val packetSizeModeAuto = + interactor.getAppPreferenceInterface().isPackageSizeModeAuto + if (packetSizeModeAuto) { + interactor.getAppPreferenceInterface().setPacketSizeModeToAuto(false) + } + } + + override fun onPermissionProvided() { + if (isAppSelectedInMockLocationList(appContext) + && isDevModeOn(appContext) + ) { + if (interactor.getAppPreferenceInterface().isGpsSpoofingOn) { + connSettingsView.setGpsSpoofingToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().setGpsSpoofing(false) + logger.info("Setting gps spoofing to true") + } else { + connSettingsView.setGpsSpoofingToggle(R.drawable.ic_toggle_button_on) + interactor.getAppPreferenceInterface().setGpsSpoofing(true) + logger.info("Setting gps spoofing to false") + } + } else { + connSettingsView.setGpsSpoofingToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().setGpsSpoofing(false) + connSettingsView.openGpsSpoofSettings() + } + } + + override fun onPortSelected(heading: String, port: String) { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + when (getProtocolFromHeading(portMapResponse, heading)) { + PROTO_IKev2 -> { + logger.info("Saving selected IKev2 port...") + interactor.getAppPreferenceInterface().saveIKEv2Port(port) + } + + PROTO_UDP -> { + logger.info("Saving selected udp port...") + interactor.saveUDPPort(port) + } + + PROTO_TCP -> { + logger.info("Saving selected tcp port...") + interactor.saveTCPPort(port) + } + + PROTO_STEALTH -> { + logger.info("Saving selected stealth port...") + interactor.saveSTEALTHPort(port) + } + + PROTO_WS_TUNNEL -> { + logger.info("Saving selected ws tunnel port...") + interactor.saveWSTunnelPort(port) + } + + PROTO_WIRE_GUARD -> { + logger.info("Saving selected wire guard port...") + interactor.getAppPreferenceInterface().saveWireGuardPort(port) + } + + else -> { + logger.info("Saving default port (udp)...") + interactor.saveUDPPort(port) + } + } + interactor.getAutoConnectionManager().reset() + } + }) + } + + override fun onProtocolSelected(heading: String) { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + val protocol = getProtocolFromHeading(portMapResponse, heading) + val savedProtocol = interactor.getSavedProtocol() + if (savedProtocol != protocol) { + logger.info("Saving selected protocol...") + interactor.saveProtocol(protocol) + setPortMapAdapter(heading) + interactor.getAutoConnectionManager().reset() + } + } + }) + } + + override fun onSplitTunnelingOptionClicked() { + logger.info("Opening split tunnel settings activity..") + connSettingsView.gotoSplitTunnelingSettings() + } + + override fun saveKeepAlive(keepAlive: String) { + interactor.getAppPreferenceInterface().keepAlive = keepAlive + } + + override fun setKeepAlive(keepAlive: String) { + interactor.getAppPreferenceInterface().keepAlive = keepAlive + } + + override fun setPacketSize(size: String) { + interactor.getAppPreferenceInterface().packetSize = size.toInt() + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + logger.debug("Setting theme to $savedThem") + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + private fun setUpAutoModePorts() { + logger.debug("Setting auto mode ports.") + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + for (portMap in portMapResponse.portmap) { + if (portMap.protocol == PROTO_IKev2) { + interactor.getIKev2Port() + } + if (portMap.protocol == PROTO_UDP) { + interactor.getSavedUDPPort() + } + if (portMap.protocol == PROTO_TCP) { + interactor.getSavedTCPPort() + } + if (portMap.protocol == PROTO_STEALTH) { + interactor.getSavedSTEALTHPort() + } + if (portMap.protocol == PROTO_WS_TUNNEL) { + interactor.getSavedWSTunnelPort() + } + if (portMap.protocol == PROTO_WIRE_GUARD) { + interactor.getWireGuardPort() + } + } + val savedProtocol = interactor.getSavedProtocol() + val savedConnectionMode = interactor.getSavedConnectionMode() + connSettingsView.setKeepAliveContainerVisibility( + savedProtocol == PROTO_IKev2 && savedConnectionMode == CONNECTION_MODE_MANUAL + ) + setUpKeepAlive() + } + }) + } + + fun setUpKeepAlive() { + val isKeepAliveModeAuto = + interactor.getAppPreferenceInterface().isKeepAliveModeAuto + if (isKeepAliveModeAuto) { + connSettingsView.setKeepAliveModeAdapter( + interactor.getResourceString(R.string.auto), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } else { + connSettingsView.setKeepAliveModeAdapter( + interactor.getResourceString(R.string.manual), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } + val keepAliveTime = interactor.getAppPreferenceInterface().keepAlive + connSettingsView.setKeepAlive(keepAliveTime) + } + + private fun setupLayoutBasedOnConnectionMode() { + if (interactor.getSavedConnectionMode() == CONNECTION_MODE_AUTO) { + connSettingsView.setupConnectionModeAdapter( + interactor.getResourceString(R.string.auto), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } else { + connSettingsView.setupConnectionModeAdapter( + interactor.getResourceString(R.string.manual), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } + setProtocolAdapter() + } + + private fun setupLayoutBasedOnDnsMode() { + val dnsMode = interactor.getAppPreferenceInterface().dnsMode + if (dnsMode == DNS_MODE_ROBERT) { + connSettingsView.setupDNSModeAdapter(interactor.getResourceString(R.string.auto), arrayOf(interactor.getResourceString(R.string.auto), interactor.getResourceString(R.string.custom))) + } else { + connSettingsView.setupDNSModeAdapter(interactor.getResourceString(R.string.custom), arrayOf(interactor.getResourceString(R.string.auto), interactor.getResourceString(R.string.custom))) + } + val dnsAddress = interactor.getAppPreferenceInterface().dnsAddress + if(dnsAddress != null) { + connSettingsView.setCustomDnsAddress(dnsAddress) + } + } + + private fun setupPacketSizeMode() { + val packetSizeModeAuto = + interactor.getAppPreferenceInterface().isPackageSizeModeAuto + if (packetSizeModeAuto) { + connSettingsView.setupPacketSizeModeAdapter( + interactor.getResourceString(R.string.auto), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } else { + connSettingsView.setupPacketSizeModeAdapter( + interactor.getResourceString(R.string.manual), + arrayOf( + interactor.getResourceString(R.string.auto), + interactor.getResourceString(R.string.manual) + ) + ) + } + val packetSize = interactor.getAppPreferenceInterface().packetSize + connSettingsView.setPacketSize(packetSize.toString()) + } + + private fun setProtocolAdapter() { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + val savedProtocol = interactor.getSavedProtocol() + var selectedPortMap: PortMap? = null + val protocols: MutableList = ArrayList() + for (portMap in portMapResponse.portmap) { + if (portMap.protocol == savedProtocol) { + selectedPortMap = portMap + } + protocols.add(portMap.heading) + } + selectedPortMap = selectedPortMap ?: portMapResponse.portmap[0] + if (selectedPortMap != null) { + connSettingsView.setupProtocolAdapter( + selectedPortMap.heading, + protocols.toTypedArray() + ) + setPortMapAdapter(selectedPortMap.heading) + } + } + }) + }// check network first + + // MTU detection experimental feature + private val mtuSizeFromNetworkInterface: Unit + get() { + // check network first + if (interactor.getVpnConnectionStateManager().isVPNConnected()) { + connSettingsView.showToast(interactor.getResourceString(R.string.disconnect_from_vpn)) + return + } + val manager = appContext + .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (manager.activeNetworkInfo == null || manager.activeNetworkInfo?.isConnected != true) { + connSettingsView.showToast(interactor.getResourceString(R.string.no_network_detected)) + return + } + connSettingsView.packetSizeDetectionProgress(true) + connSettingsView.setPacketSize(interactor.getResourceString(R.string.auto_detecting_packet_size)) + var prop: LinkProperties? = null + val iFace: NetworkInterface + val networks = manager.allNetworks + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + prop = manager.getLinkProperties(manager.activeNetwork) + } else { + for (network in networks) { + val networkInfo = manager.activeNetworkInfo + if (networkInfo?.isConnected == true) { + prop = manager.getLinkProperties(network) + } + } + } + try { + if (prop != null) { + iFace = NetworkInterface.getByName(prop.interfaceName) + currentPoint = iFace.mtu + } else { + currentPoint = 1500 + } + repeatPing() + } catch (e: IOException) { + e.printStackTrace() + currentPoint = 1500 + repeatPing() + } + } + + private fun getProtocolFromHeading(portMapResponse: PortMapResponse, heading: String): String { + for (map in portMapResponse.portmap) { + if (map.heading == heading) { + return map.protocol + } + } + return PROTO_IKev2 + } + + private fun getSavedPort(protocol: String): String { + return when (protocol) { + PROTO_IKev2 -> interactor.getIKev2Port() + PROTO_UDP -> interactor.getSavedUDPPort() + PROTO_TCP -> interactor.getSavedTCPPort() + PROTO_STEALTH -> interactor.getSavedSTEALTHPort() + PROTO_WS_TUNNEL -> interactor.getSavedWSTunnelPort() + PROTO_WIRE_GUARD -> interactor.getWireGuardPort() + else -> "443" + } + } + + private fun isMtuSmallEnough(response: String): Boolean { + return !response.contains("100% packet loss") + } + + /** + * + */ + private val isPermissionProvided: Boolean + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + (ContextCompat + .checkSelfPermission( + appContext, + Manifest.permission.ACCESS_FINE_LOCATION + ) + == PackageManager.PERMISSION_GRANTED) + } else { + true + } + + private fun ping(value: Int): String? { + val size = value.toString() + val runtime = Runtime.getRuntime() + return try { + val process = runtime + .exec("/system/bin/ping -c 2 -s $size -i 0.5 -W 3 -M do checkip.windscribe.com") + val inputStream = process.inputStream + if (inputStream != null) { + IOUtils.toString(inputStream, StandardCharsets.UTF_8) + } else { + showMtuFailed() + null + } + } catch (e: IOException) { + e.printStackTrace() + null + } + } + + private fun repeatPing() { + interactor.getCompositeDisposable() + .add( + Observable.fromCallable { ping(currentPoint) }.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableObserver() { + override fun onComplete() { + dispose() + } + + override fun onError(e: Throwable) { + showMtuFailed() + dispose() + } + + override fun onNext(s: String) { + if (isMtuSmallEnough(s)) { + showMtuResult() + } else { + if (currentPoint > 10) { + currentPoint -= 10 + repeatPing() + } + } + } + }) + ) + } + + private fun setAutoStartMenu() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + connSettingsView.showAutoStartOnBoot() + } + if (interactor.getAppPreferenceInterface().autoStartOnBoot) { + connSettingsView.setAutoStartOnBootToggle(R.drawable.ic_toggle_button_on) + } else { + interactor.getAppPreferenceInterface().autoStartOnBoot = false + connSettingsView.setAutoStartOnBootToggle(R.drawable.ic_toggle_button_off) + } + } + + private fun setGpsSpoofingMenu() { + connSettingsView.showGpsSpoofing() + if (!isAppSelectedInMockLocationList(appContext.applicationContext) + or !isDevModeOn(appContext) or permissionManager.isBackgroundPermissionGranted().not() + ) { + interactor.getAppPreferenceInterface().setGpsSpoofing(false) + } + // Gps spoofing + if (interactor.getAppPreferenceInterface().isGpsSpoofingOn) { + connSettingsView.setGpsSpoofingToggle(R.drawable.ic_toggle_button_on) + } else { + connSettingsView.setGpsSpoofingToggle(R.drawable.ic_toggle_button_off) + } + } + + private fun setPortMapAdapter(heading: String) { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + val protocol = getProtocolFromHeading(portMapResponse, heading) + val savedPort = getSavedPort(protocol) + for (portMap in portMapResponse.portmap) { + if (portMap.protocol == protocol) { + connSettingsView.setupPortMapAdapter(savedPort, portMap.ports) + } + } + val savedProtocol = interactor.getSavedProtocol() + val savedConnectionMode = interactor.getSavedConnectionMode() + connSettingsView.setKeepAliveContainerVisibility( + savedProtocol == PROTO_IKev2 && savedConnectionMode == CONNECTION_MODE_MANUAL + ) + } + }) + } + + private fun showMtuFailed() { + connSettingsView.setPacketSize("") + connSettingsView.packetSizeDetectionProgress(false) + connSettingsView.showToast(interactor.getResourceString(R.string.auto_package_size_detecting_failed)) + logger.info("Error getting optimal MTU size.") + } + + private fun showMtuResult() { + connSettingsView.setPacketSize(currentPoint.toString()) + interactor.getAppPreferenceInterface().packetSize = currentPoint + connSettingsView.showToast(interactor.getResourceString(R.string.package_size_detected_successfully)) + connSettingsView.packetSizeDetectionProgress(false) + currentPoint = 1500 + } + + private fun resetPotentialTrafficInfo() { + val trafficVolume = interactor.getAppPreferenceInterface().fakeTrafficVolume + if (trafficVolume === FakeTrafficVolume.Low) { + connSettingsView.setPotentialTrafficUse( + String.format( + Locale.getDefault(), + "%dMB/Hour", + 1737 + ) + ) + } else if (trafficVolume === FakeTrafficVolume.Medium) { + connSettingsView.setPotentialTrafficUse( + String.format( + Locale.getDefault(), + "%dMB/Hour", + 6948 + ) + ) + } else { + connSettingsView.setPotentialTrafficUse( + String.format( + Locale.getDefault(), + "%dMB/Hour", + 16572 + ) + ) + } + } + + override fun onNetworkOptionsClick() { + permissionManager.withBackgroundLocationPermission { error -> + if (error != null) { + logger.debug(error) + } else { + connSettingsView.goToNetworkSecurity() + } + } + } + + override fun onAntiCensorshipClick() { + if (interactor.getAppPreferenceInterface().isAntiCensorshipOn) { + connSettingsView.setAntiCensorshipToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().isAntiCensorshipOn = false + } else { + connSettingsView.setAntiCensorshipToggle(R.drawable.ic_toggle_button_on) + interactor.getAppPreferenceInterface().isAntiCensorshipOn = true + } + interactor.setAntiCensorship(interactor.getAppPreferenceInterface().isAntiCensorshipOn) + interactor.getPreferenceChangeObserver().postAntiCensorShipStatusChange() + } + + override fun onAutoConnectClick() { + val status = interactor.getAppPreferenceInterface().autoConnect + if (status) { + connSettingsView.setAutoConnectToggle(R.drawable.ic_toggle_button_off) + interactor.getAppPreferenceInterface().autoConnect = false + } else { + connSettingsView.setAutoConnectToggle(R.drawable.ic_toggle_button_on) + interactor.getAppPreferenceInterface().autoConnect = true + } + val updatedStatus = status.not() + interactor.getMainScope().launch { + if (interactor.getVpnConnectionStateManager().isVPNConnected().not()) { + if (updatedStatus && appContext.canAccessNetworkName() && AutoConnectService.isAutoConnectingServiceRunning.not()) { + appContext.startAutoConnectService() + } else if (updatedStatus.not() && AutoConnectService.isAutoConnectingServiceRunning) { + appContext.stopAutoConnectService() + } + } + } + } + + override fun onRobertDnsModeSelected() { + if (DNS_MODE_ROBERT != interactor.getAppPreferenceInterface().dnsMode) { + interactor.getAppPreferenceInterface().dnsMode = DNS_MODE_ROBERT + proxyDNSManager.invalidConfig = true + } + } + + override fun onCustomDnsModeSelected() { + if (DNS_MODE_CUSTOM != interactor.getAppPreferenceInterface().dnsMode) { + interactor.getAppPreferenceInterface().dnsMode = DNS_MODE_CUSTOM + proxyDNSManager.invalidConfig = true + } + } + + override fun onCustomDnsChanged(dns: String) { + if (dns != interactor.getAppPreferenceInterface().dnsAddress){ + interactor.getAppPreferenceInterface().dnsAddress = dns + proxyDNSManager.invalidConfig = true + interactor.getMainScope().launch { + if (!interactor.getVpnConnectionStateManager().isVPNConnected()){ + proxyDNSManager.stopControlD() + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsView.kt b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsView.kt new file mode 100644 index 000000000..60fbeab68 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionsettings/ConnectionSettingsView.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionsettings + +import android.os.Build +import androidx.annotation.RequiresApi + +interface ConnectionSettingsView { + fun gotoSplitTunnelingSettings() + fun goToNetworkSecurity() + fun openGpsSpoofSettings() + fun packetSizeDetectionProgress(progress: Boolean) + fun setAutoStartOnBootToggle(toggleDrawable: Int) + fun setGpsSpoofingToggle(toggleDrawable: Int) + fun setKeepAlive(keepAlive: String) + fun setLanBypassToggle(toggleDrawable: Int) + fun setPacketSize(size: String) + fun setSplitTunnelText(onOff: String, color: Int) + fun setupConnectionModeAdapter(savedValue: String, connectionModes: Array) + fun setupPacketSizeModeAdapter(savedValue: String, types: Array) + fun setKeepAliveModeAdapter(savedValue: String, types: Array) + fun setupPortMapAdapter(port: String, portMap: List) + fun setupProtocolAdapter(protocol: String, protocols: Array) + fun showGpsSpoofing() + fun showToast(toastString: String) + fun setDecoyTrafficToggle(toggleDrawable: Int) + fun showExtraDataUseWarning() + fun setupFakeTrafficVolumeAdapter(selectedValue: String, values: Array) + fun setPotentialTrafficUse(value: String) + fun showAutoStartOnBoot() + fun setKeepAliveContainerVisibility(isAutoKeepAlive: Boolean) + fun setAntiCensorshipToggle(toggleDrawable: Int) + fun setAutoConnectToggle(toggleDrawable: Int) + fun setupDNSModeAdapter(savedValue: String, dnsModes: Array) + fun setCustomDnsAddress(dnsAddress: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectedAnimationState.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectedAnimationState.kt new file mode 100644 index 000000000..445fdb33d --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectedAnimationState.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import androidx.constraintlayout.widget.ConstraintSet +import com.windscribe.mobile.R +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +open class ConnectedAnimationState( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : ConnectionUiState(lastSelectedLocation, connectionOptions, context) { + override val badgeViewAlpha: Float + get() = 1.0f + override val connectionStateStatusEndColor: Int + get() = getColorResource(R.color.colorNeonGreen) + override val connectionStateStatusStartColor: Int + get() = getColorResource(R.color.colorLightBlue) + override val connectionStateStatusText: String + get() = "ON" + override val connectionStatusBackground: Drawable? + get() = getDrawable(R.drawable.ic_connected_status_bg) + override val flagGradientEndColor: Int + get() = getColorResource(R.color.colorPrimary) + override val flagGradientStartColor: Int + get() = getColorResource(R.color.colorFlagGradient) + override val headerBackgroundLeft: Drawable? + get() = getDrawable(if (isCustomBackgroundEnabled) R.drawable.header_left_connected_custom else R.drawable.header_left_connected) + override val headerBackgroundRight: Drawable? + get() = getDrawable(if (isCustomBackgroundEnabled) R.drawable.header_right_connected_custom else R.drawable.header_right_connected) + override val lockIconResource: Int + get() = R.drawable.ic_safe + override val onOffButtonResource: Int + get() = R.drawable.on_button + override val portAndProtocolEndTextColor: Int + get() = getColorResource(R.color.colorNeonGreen) + override val preferredProtocolStatusDrawable: Drawable? + get() { + return if (connectionOptions.isPreferred) { + getDrawable(R.drawable.ic_preferred_protocol_status_enabled) + } else null + } + override val progressRingResource: Drawable? + get() { + val splitRouting = appContext.preference.lastConnectedUsingSplit + return getDrawable(if (splitRouting) R.drawable.ic_connected_split_ring else R.drawable.ic_connected_ring) + } + + override val progressRingTag : Int + get() { + val splitRouting = appContext.preference.lastConnectedUsingSplit + return if (splitRouting) R.drawable.ic_connected_split_ring else R.drawable.ic_connected_ring + } + override val progressRingVisibility: Int + get() = ConstraintSet.VISIBLE + + override val decoyTrafficBadgeVisibility: Int + get(){ + val decoyTrafficOn = appContext.preference.isDecoyTrafficOn + return if(decoyTrafficOn){ + View.VISIBLE + } else { + View.GONE + } + } + + override val antiCensorShipStatusDrawable + get() = getDrawable(R.drawable.ic_anti_censorship_enabled) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectedState.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectedState.kt new file mode 100644 index 000000000..76da30dab --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectedState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import androidx.constraintlayout.widget.ConstraintSet +import com.windscribe.mobile.R +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +class ConnectedState( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : ConnectedAnimationState(lastSelectedLocation, connectionOptions, context) { + override val badgeViewAlpha: Float + get() = 1.0f + override val connectedCenterIconVisibility: Int + get() = ConstraintSet.VISIBLE + override val lockIconResource: Int + get() = R.drawable.ic_safe + override val progressRingVisibility: Int + get() = ConstraintSet.VISIBLE +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectingAnimationState.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectingAnimationState.kt new file mode 100644 index 000000000..08cb9cd95 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectingAnimationState.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.constraintlayout.widget.ConstraintSet +import com.windscribe.mobile.R +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +open class ConnectingAnimationState( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : ConnectionUiState(lastSelectedLocation, connectionOptions, context) { + override val connectingIconVisibility: Int + get() = ConstraintSet.VISIBLE + override val connectionStateStatusEndColor: Int + get() = getColorResource(R.color.colorLightBlue) + override val connectionStateStatusText: String + get() = " " + override val connectionStatusBackground: Drawable? + get() = getDrawable(R.drawable.ic_connecting_status_bg) + override val flagGradientEndColor: Int + get() = if (isCustomBackgroundEnabled) { + getColorResource(R.color.colorFlagGradient50) + } else { + getColorResource(R.color.colorFlagGradient) + } + override val headerBackgroundLeft: Drawable? + get() = getDrawable(if (isCustomBackgroundEnabled) R.drawable.header_left_connected_custom else R.drawable.header_left_connected) + override val headerBackgroundRight: Drawable? + get() = getDrawable(if (isCustomBackgroundEnabled) R.drawable.header_right_connected_custom else R.drawable.header_right_connected) + override val onOffButtonResource: Int + get() = R.drawable.on_button + override val portAndProtocolEndTextColor: Int + get() = getColorResource(R.color.colorLightBlue) + override val preferredProtocolStatusDrawable: Drawable? + get() { + return if (connectionOptions.isPreferred) { + getDrawable(R.drawable.ic_preferred_protocol_status_enabling) + } else null + } + override val progressRingVisibility: Int + get() = ConstraintSet.VISIBLE + + override val antiCensorShipStatusDrawable + get() = getDrawable(R.drawable.ic_anti_censorship_enabling) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectingState.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectingState.kt new file mode 100644 index 000000000..8d4cf100e --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectingState.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +class ConnectingState( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : ConnectingAnimationState(lastSelectedLocation, connectionOptions, context) \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionOptions.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionOptions.kt new file mode 100644 index 000000000..d5003ec40 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionOptions.kt @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + + +class ConnectionOptions internal constructor(var isPreferred: Boolean) \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionOptionsBuilder.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionOptionsBuilder.kt new file mode 100644 index 000000000..50673204e --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionOptionsBuilder.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +class ConnectionOptionsBuilder { + private var isPreferred: Boolean = false + fun build(): ConnectionOptions { + return ConnectionOptions(isPreferred) + } + + fun setIsPreferred(isPreferred: Boolean): ConnectionOptionsBuilder { + this.isPreferred = isPreferred + return this + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionUiState.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionUiState.kt new file mode 100644 index 000000000..683c6808a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/ConnectionUiState.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.res.ResourcesCompat +import com.windscribe.mobile.R +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.backend.utils.LastSelectedLocation +import com.windscribe.vpn.commonutils.FlagIconResource + +open class ConnectionUiState internal constructor( + private val savedLocation: LastSelectedLocation?, + var connectionOptions: ConnectionOptions, + private val context: Context +) { + private val flagIcons: Map = FlagIconResource.flagIcons + open val badgeViewAlpha: Float + get() = 0.3f + + fun getColorResource(color: Int): Int { + return context.resources.getColor(color) + } + + open val connectedCenterIconVisibility: Int + get() = ConstraintSet.GONE + val connectedFlagPath: String? + get() = appContext.preference.connectedFlagPath + val disconnectedFlagPath: String? + get() = appContext.preference.disConnectedFlagPath + open val connectingIconVisibility: Int + get() = View.GONE + open val connectionStateStatusEndColor: Int + get() = getColorResource(R.color.colorWhite) + open val connectionStateStatusStartColor: Int + get() = getColorResource(R.color.colorWhite50) + open val connectionStateStatusText: String + get() = "OFF" + open val connectionStatusBackground: Drawable? + get() = getDrawable(R.drawable.ic_disconnected_status_bg) + open val connectionStatusIcon: Drawable? + get() = getDrawable(R.drawable.connection_icon_drawable) + + fun getDrawable(drawable: Int): Drawable? { + return ResourcesCompat.getDrawable(context.resources, drawable, context.theme) + } + + val flag: Int + get() = if (savedLocation != null && flagIcons.containsKey( + savedLocation.countryCode + ) + ) { + flagIcons[savedLocation.countryCode]!! + } else R.drawable.dummy_flag + open val flagGradientEndColor: Int + get() = getColorResource(R.color.colorDeepBlue) + open val flagGradientStartColor: Int + get() = getColorResource(R.color.colorDeepBlue) + open val headerBackgroundLeft: Drawable? + get() = getDrawable(if (isCustomBackgroundEnabled) R.drawable.header_left_disconnected_custom else R.drawable.header_left_disconnected) + open val headerBackgroundRight: Drawable? + get() = getDrawable(if (isCustomBackgroundEnabled) R.drawable.header_right_disconnected_custom else R.drawable.header_right_disconnected) + open val lockIconResource: Int + get() = R.drawable.ic_unsafe + open val onOffButtonResource: Int + get() = R.drawable.off_button + open val portAndProtocolEndTextColor: Int + get() = getColorResource(R.color.colorWhite50) + open val preferredProtocolStatusDrawable: Drawable? + get() { + return if (connectionOptions.isPreferred) { + getDrawable(R.drawable.ic_preferred_protocol_status_disabled) + } else null + } + open val preferredProtocolStatusVisibility: Int + get() { + return if (connectionOptions.isPreferred) { + View.VISIBLE + } else View.GONE + } + open val progressRingResource: Drawable? + get() = getDrawable(R.drawable.progressbardrawble) + open val progressRingVisibility: Int + get() = View.INVISIBLE + + open val progressRingTag : Int = R.drawable.progressbardrawble + + open fun rotateConnectingIcon(): Boolean { + return true + } + open fun setConnectedPortAndProtocol(protocol: TextView, port: TextView) {} + + val isCustomBackgroundEnabled: Boolean + get() = appContext.preference.isCustomBackground + + open val decoyTrafficBadgeVisibility: Int = View.GONE + + open val antiCensorShipStatusVisibility: Int + get() { + return if (appContext.preference.isAntiCensorshipOn) { + View.VISIBLE + } else { + View.GONE + } + } + + open val antiCensorShipStatusDrawable: Drawable? + get() = getDrawable(R.drawable.ic_anti_censorship_disabled) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/DisconnectedState.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/DisconnectedState.kt new file mode 100644 index 000000000..c8b1bb38a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/DisconnectedState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import android.graphics.drawable.Drawable +import com.windscribe.mobile.R +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +class DisconnectedState( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : ConnectionUiState(lastSelectedLocation, connectionOptions, context) { + + override val connectionStatusBackground: Drawable? + get() = getDrawable(R.drawable.ic_disconnected_status_bg) + + override val flagGradientEndColor: Int + get() = if (isCustomBackgroundEnabled) { + getColorResource(R.color.colorDeepBlue50) + } else { + getColorResource(R.color.colorDeepBlue0) + } + + override val flagGradientStartColor: Int + get() = if (isCustomBackgroundEnabled) { + getColorResource(R.color.colorDeepBlue50) + } else { + getColorResource(R.color.colorDeepBlue0) + } + + override val preferredProtocolStatusDrawable: Drawable? + get() { + return if (connectionOptions.isPreferred) { + getDrawable(R.drawable.ic_preferred_protocol_status_disabled) + } else { + null + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/FailedProtocol.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/FailedProtocol.kt new file mode 100644 index 000000000..54a2330d2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/FailedProtocol.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import android.graphics.drawable.Drawable +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintSet +import com.windscribe.mobile.R +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +open class FailedProtocol( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : ConnectionUiState(lastSelectedLocation, connectionOptions, context) { + + override val badgeViewAlpha: Float + get() = 0.3f + + override val connectedCenterIconVisibility: Int + get() = ConstraintSet.VISIBLE + + override val connectingIconVisibility: Int + get() = ConstraintSet.VISIBLE + + override val connectionStateStatusText: String + get() = "" + + override val connectionStatusBackground: Drawable? + get() = getDrawable(R.drawable.ic_disconnected_status_bg) + + override val connectionStatusIcon: Drawable? + get() = getDrawable(R.drawable.failed_protcol_icon_drawable) + + override val lockIconResource: Int + get() = R.drawable.ic_unsafe + + override val onOffButtonResource: Int + get() = R.drawable.on_button + + override val portAndProtocolEndTextColor: Int + get() = getColorResource(R.color.colorYellow) + + override val progressRingResource: Drawable? + get() = getDrawable(R.drawable.progressbardrawble_error) + + override val progressRingTag : Int = R.drawable.progressbardrawble_error + + override val progressRingVisibility: Int + get() = ConstraintSet.VISIBLE + + override fun rotateConnectingIcon(): Boolean { + return false + } + + override fun setConnectedPortAndProtocol(protocol: TextView, port: TextView) { + val selectedProtocol = appContext.preference.selectedProtocol + val selectedPort = "FAILED" + protocol.text = selectedProtocol + port.text = selectedPort + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/connectionui/UnsecuredProtocol.kt b/mobile/src/main/java/com/windscribe/mobile/connectionui/UnsecuredProtocol.kt new file mode 100644 index 000000000..6d3e90407 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/connectionui/UnsecuredProtocol.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.connectionui + +import android.content.Context +import android.graphics.drawable.Drawable +import com.windscribe.mobile.R +import com.windscribe.vpn.backend.utils.LastSelectedLocation + +class UnsecuredProtocol( + lastSelectedLocation: LastSelectedLocation, + connectionOptions: ConnectionOptions, + context: Context +) : FailedProtocol(lastSelectedLocation, connectionOptions, context) { + + override val connectionStatusIcon: Drawable? + get() = getDrawable(R.drawable.ic_wifi_unsecure_yellow) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/CustomDialog.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/CustomDialog.kt new file mode 100644 index 000000000..dd33e8b40 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/CustomDialog.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.custom_view + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.Window +import com.windscribe.mobile.R + +class CustomDialog(context: Context) : Dialog(context) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.custom_dialog) + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setCanceledOnTouchOutside(false) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/CustomDrawableCrossFadeFactory.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/CustomDrawableCrossFadeFactory.kt new file mode 100644 index 000000000..59dddbd01 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/CustomDrawableCrossFadeFactory.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.custom_view + +import android.graphics.drawable.Drawable +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.request.transition.DrawableCrossFadeTransition +import com.bumptech.glide.request.transition.Transition +import com.bumptech.glide.request.transition.TransitionFactory + +open class CustomDrawableCrossFadeFactory protected constructor( + private val duration: Int, private val isCrossFadeEnabled: Boolean +) : TransitionFactory { + /** + * A Builder for [CustomDrawableCrossFadeFactory]. + */ + class Builder(private val durationMillis: Int) { + private var isCrossFadeEnabled = false + fun build(): CustomDrawableCrossFadeFactory { + return CustomDrawableCrossFadeFactory(durationMillis, isCrossFadeEnabled) + } + + fun setCrossFadeEnabled(isCrossFadeEnabled: Boolean): Builder { + this.isCrossFadeEnabled = isCrossFadeEnabled + return this + } + } + + private var resourceTransition: DrawableCrossFadeTransition? = null + get() { + if (field == null) { + field = DrawableCrossFadeTransition(duration, isCrossFadeEnabled) + } + return field + } + + override fun build(dataSource: DataSource, isFirstResource: Boolean): Transition? { + return resourceTransition + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/PlanUpgradeGradientButton.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/PlanUpgradeGradientButton.kt new file mode 100644 index 000000000..99bdae2cd --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/PlanUpgradeGradientButton.kt @@ -0,0 +1,70 @@ +package com.windscribe.mobile.custom_view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatButton + +class PlanUpgradeGradientButton @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyle: Int = 0 +) : AppCompatButton(context, attrs, defStyle) { + + private val gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val gradientColors = intArrayOf( + Color.rgb(217, 211, 255), // #D9D3FF + Color.rgb(219, 204, 247), // #DBCCF7 + Color.rgb(242, 227, 240), // #F2E3F0 + Color.rgb(218, 214, 235), // #DAD6EB + Color.rgb(201, 227, 242), // #C9E3F2 + Color.rgb(195, 229, 237), // #C3E5ED + Color.rgb(189, 237, 237), // #BDEDED + Color.rgb(194, 232, 240), // #C2E8F0 + Color.rgb(202, 222, 242) // #CADEF2 + ) + + private var rect: RectF? = null + + private val gradientPositions = + floatArrayOf(0.05f, 0.17f, 0.38f, 0.45f, 0.51f, 0.56f, 0.59f, 0.67f, 0.76f) + + private var gradientShader: LinearGradient? = null + + init { + isAllCaps = false + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + gradientShader = LinearGradient( + width * 0.21f, -height * 2.86f, + width * 0.77f, height * 4.21f, + gradientColors, gradientPositions, + Shader.TileMode.CLAMP + ) + gradientPaint.shader = gradientShader + rect = RectF(0f, 0f, width.toFloat(), height.toFloat()) + } + + override fun onDraw(canvas: Canvas) { + rect?.let { + val cornerRadius = height / 2f + canvas.drawRoundRect(it, cornerRadius, cornerRadius, gradientPaint) + } + super.onDraw(canvas) + } + + override fun setPressed(pressed: Boolean) { + super.setPressed(pressed) + alpha = if (pressed) 0.8f else 1.0f + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + alpha = if (enabled) 1.0f else 0.5f + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/PlanUpgradeStarsBackgroundView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/PlanUpgradeStarsBackgroundView.kt new file mode 100644 index 000000000..60ee18f7c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/PlanUpgradeStarsBackgroundView.kt @@ -0,0 +1,165 @@ +package com.windscribe.mobile.custom_view + +import android.content.Context +import android.graphics.BlurMaskFilter +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import android.util.AttributeSet +import androidx.core.content.ContextCompat +import com.windscribe.mobile.R +import com.windscribe.mobile.utils.UiUtil +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +class PlanUpgradeStarsBackgroundView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : androidx.appcompat.widget.AppCompatImageView(context, attrs) { + + private var glowRect: RectF? = null + private var starPadding = 0F + private val stars = mutableListOf() + private var innerRect: RectF? = null + private val cornerRadius = resources.getDimension(R.dimen.reg_12dp) + private val gradientTopColor = ContextCompat.getColor(context, R.color.colorPowderBlue14) + private val gradientBottomColor = ContextCompat.getColor(context, R.color.colorPowderBlue0) + private var gradient: LinearGradient? = null + var active = true + private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ContextCompat.getColor(context, R.color.colorPowderBlue) + strokeWidth = 3f + style = Paint.Style.STROKE + maskFilter = BlurMaskFilter(10f, BlurMaskFilter.Blur.NORMAL) + } + private val activeStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ContextCompat.getColor(context, R.color.colorPaleBlue) + style = Paint.Style.STROKE + strokeWidth = resources.getDimensionPixelSize(R.dimen.reg_2dp).toFloat() + } + private val strokePaint = activeStrokePaint.apply { + strokeWidth = resources.getDimensionPixelSize(R.dimen.reg_1dp).toFloat() + color = ContextCompat.getColor(context, R.color.colorWhite50) + } + private val gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + createRect() + createGradient() + generateStars() + } + + private fun createRect() { + val left = paddingLeft.toFloat() + val top = paddingTop.toFloat() + val right = width - paddingRight.toFloat() + val bottom = height - paddingBottom.toFloat() + val strokeWidth = resources.getDimensionPixelSize(R.dimen.reg_2dp).toFloat() + glowRect = RectF(left, top, right, bottom) + innerRect = + RectF(left + strokeWidth, top + strokeWidth, right - strokeWidth, bottom - strokeWidth) + starPadding = paddingLeft.toFloat() + strokeWidth + } + + private fun createGradient() { + gradient = LinearGradient( + 0f, + 0f, + 0f, + height.toFloat(), + gradientTopColor, + gradientBottomColor, + Shader.TileMode.CLAMP + ) + gradientPaint.shader = gradient + } + + private fun generateStars() { + stars.clear() + val starCount = UiUtil.getStartCount(resources, width, height) + for (i in 0 until starCount) { + val angle = Random.nextFloat() * 360 // Random movement direction + val speed = Random.nextFloat() * 0.8f + 0.2f // Random speed + + val x = + starPadding + Random.nextFloat() * (width - 2 * starPadding) // Keep within width bounds + val y = + starPadding + Random.nextFloat() * (height - 2 * starPadding) // Keep within height bounds + + stars.add( + Star( + x = x, + y = y, + size = Random.nextFloat() * 1.2f, + alpha = Random.nextFloat() * 255, + dx = cos(Math.toRadians(angle.toDouble())).toFloat() * speed, + dy = sin(Math.toRadians(angle.toDouble())).toFloat() * speed + ) + ) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (active) { + addGradient(canvas) + addStroke(canvas, activeStrokePaint) + addGlow(canvas) + updateStarts(canvas) + } else { + addStroke(canvas, strokePaint) + } + invalidate() + } + + private fun updateStarts(canvas: Canvas) { + for (star in stars) { + starPaint.alpha = star.alpha.toInt() + canvas.drawCircle(star.x, star.y, star.size, starPaint) + star.x += star.dx + star.y += star.dy + if (star.x <= starPadding || star.x >= width - starPadding) { + star.dx = -star.dx // Reverse X direction + star.x = star.x.coerceIn(starPadding, width - starPadding) // Keep inside bounds + } + if (star.y <= starPadding || star.y >= height - starPadding) { + star.dy = -star.dy // Reverse Y direction + star.y = star.y.coerceIn(starPadding, height - starPadding) // Keep inside bounds + } + star.alpha -= 0.5f + if (star.alpha <= 50) star.alpha = 255f + } + } + + private fun addGlow(canvas: Canvas) { + glowRect?.let { + canvas.drawRoundRect(it, cornerRadius, cornerRadius, glowPaint) + } + } + + private fun addStroke(canvas: Canvas, paint: Paint) { + innerRect?.let { + canvas.drawRoundRect(it, cornerRadius, cornerRadius, paint) + } + } + + private fun addGradient(canvas: Canvas) { + innerRect?.let { + canvas.drawRoundRect(it, cornerRadius, cornerRadius, gradientPaint) + } + } + + data class Star( + var x: Float, var y: Float, var size: Float, var alpha: Float, var dx: Float, var dy: Float + ) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/StartsView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/StartsView.kt new file mode 100644 index 000000000..1d86f3eac --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/StartsView.kt @@ -0,0 +1,47 @@ +package com.windscribe.mobile.custom_view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import com.windscribe.mobile.utils.UiUtil +import kotlin.random.Random + +class StarsView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : View(context, attrs) { + + private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + + private val stars = mutableListOf() + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + generateStars() + } + + private fun generateStars() { + stars.clear() + val starCount = UiUtil.getStartCount(resources, width, height) + repeat(starCount) { + val x = Random.nextFloat() * width + val y = Random.nextFloat() * height + val size = Random.nextFloat() * 1.2f + stars.add(Star(x, y, size)) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + for (star in stars) { + canvas.drawCircle(star.x, star.y, star.size, starPaint) + } + } + + data class Star(val x: Float, val y: Float, val size: Float) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/AppBackgroundView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/AppBackgroundView.kt new file mode 100644 index 000000000..6bd481bbe --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/AppBackgroundView.kt @@ -0,0 +1,104 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.content.Context +import android.transition.Fade +import android.transition.Transition +import android.transition.TransitionManager +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.* +import com.windscribe.mobile.R + + +class AppBackgroundView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), AdapterView.OnItemSelectedListener { + + interface Delegate { + fun onItemSelect(value: String) + fun onFirstRightIconClick() + fun onSecondRightIconClick() + } + var delegate: Delegate? = null + private var spinner: Spinner? = null + private var current: TextView? = null + private val view: View = View.inflate(context, R.layout.app_background_view, this) + private var keys: Array? = null + + init { + spinner = view.findViewById(R.id.spinner) + current = view.findViewById(R.id.current) + view.findViewById(R.id.first_item_right_icon).setOnClickListener { delegate?.onFirstRightIconClick() } + view.findViewById(R.id.second_item_right_icon).setOnClickListener { delegate?.onSecondRightIconClick() } + view.findViewById(R.id.clickable_area).setOnClickListener { spinner?.performClick() } + spinner?.onItemSelectedListener = this + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + view?.findViewById(R.id.tv_drop_down)?.text = "" + spinner?.selectedItem.toString().let { + current?.text = it + delegate?.onItemSelect(keys?.get(position) ?: "") + animateVisibilityChange(position) + } + } + + private fun animateVisibilityChange(position: Int) { + val visibility = if (position == 1) { + VISIBLE + } else { + GONE + } + val transition: Transition = Fade() + transition.duration = 300 + TransitionManager.beginDelayedTransition(parent as ViewGroup?, transition) + view.findViewById(R.id.divider1).visibility = visibility + view.findViewById(R.id.first_item_title).visibility = visibility + view.findViewById(R.id.first_item_description).visibility = visibility + view.findViewById(R.id.first_item_right_icon).visibility = visibility + view.findViewById(R.id.divider2).visibility = visibility + view.findViewById(R.id.second_item_title).visibility = visibility + view.findViewById(R.id.second_item_description).visibility = visibility + view.findViewById(R.id.second_item_right_icon).visibility = visibility + view.findViewById(R.id.label1).visibility = visibility + view.findViewById(R.id.label2).visibility = visibility + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + + fun setTitle(value: String){ + view.findViewById(R.id.label).text = value + } + + fun setFirstItemTitle(value: String){ + view.findViewById(R.id.first_item_title).text = value + } + + fun setSecondItemTitle(value: String){ + view.findViewById(R.id.second_item_title).text = value + } + + fun setFirstItemDescription(value: String) { + if (value.isNotEmpty()) view.findViewById(R.id.first_item_description).text = + value + } + + fun setSecondItemDescription(value: String) { + if (value.isNotEmpty()) view.findViewById(R.id.second_item_description).text = + value + } + + fun setAdapter(localiseValues: Array, selectedKey: String, keys: Array) { + this.keys = keys + val selectionAdapter: ArrayAdapter = ArrayAdapter( + context, R.layout.drop_down_layout, R.id.tv_drop_down, localiseValues + ) + spinner?.adapter = selectionAdapter + spinner?.isSelected = false + spinner?.setSelection(keys.indexOf(selectedKey)) + current?.text = localiseValues[keys.indexOf(selectedKey)] + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/BaseView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/BaseView.kt new file mode 100644 index 000000000..e461f7417 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/BaseView.kt @@ -0,0 +1,16 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.EditText + +open class BaseView(val view: View) { + fun setVisibility(visibility: Int){ + view.visibility = visibility + } + fun showKeyboard(editText: EditText) { + val keyboard = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + keyboard.showSoftInput(editText, 0) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ConnectionModeView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ConnectionModeView.kt new file mode 100644 index 000000000..199504ebb --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ConnectionModeView.kt @@ -0,0 +1,71 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.view.View +import android.widget.* +import com.windscribe.mobile.R + +class ConnectionModeView(childView: View) : BaseView(childView) { + interface Delegate { + fun onProtocolSelected(protocol: String) + fun onPortSelected(protocol: String, port: String) + } + + var delegate: Delegate? = null + private var protocolSpinner: Spinner? = null + private var protocolCurrent: TextView? = null + private var portSpinner: Spinner? = null + private var portCurrent: TextView? = null + + init { + protocolCurrent = childView.findViewById(R.id.tv_current_protocol) + protocolSpinner = childView.findViewById(R.id.spinner_protocol) + portCurrent = childView.findViewById(R.id.tv_current_port) + portSpinner = childView.findViewById(R.id.spinner_port) + portCurrent?.setOnClickListener { portSpinner?.performClick() } + childView.findViewById(R.id.img_port_drop_down_btn).setOnClickListener { portSpinner?.performClick() } + protocolCurrent?.setOnClickListener { protocolSpinner?.performClick() } + childView.findViewById(R.id.img_protocol_drop_down_btn).setOnClickListener { protocolSpinner?.performClick() } + } + + fun seProtocolAdapter(savedSelection: String, selections: Array) { + val selectionAdapter: ArrayAdapter = ArrayAdapter(view.context, R.layout.drop_down_layout, + R.id.tv_drop_down, selections) + protocolSpinner?.adapter = selectionAdapter + protocolSpinner?.isSelected = false + protocolSpinner?.setSelection(selectionAdapter.getPosition(savedSelection)) + protocolCurrent?.text = savedSelection + protocolSpinner?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + view?.findViewById(R.id.tv_drop_down)?.text = "" + protocolSpinner?.selectedItem.toString().let { + protocolCurrent?.text = it + delegate?.onProtocolSelected(it) + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + fun sePortAdapter(savedSelection: String, selections: List) { + val selectionAdapter: ArrayAdapter = ArrayAdapter(view.context, R.layout.drop_down_layout, + R.id.tv_drop_down, selections) + portSpinner?.adapter = selectionAdapter + portSpinner?.isSelected = false + portSpinner?.setSelection(selectionAdapter.getPosition(savedSelection)) + portCurrent?.text = savedSelection + portSpinner?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + view?.findViewById(R.id.tv_drop_down)?.text = "" + portSpinner?.selectedItem.toString().let { + portCurrent?.text = it + protocolCurrent?.text?.let { protocol -> + delegate?.onPortSelected(protocol.toString(), it) + } + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DecoyTrafficView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DecoyTrafficView.kt new file mode 100644 index 000000000..a0a2433af --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DecoyTrafficView.kt @@ -0,0 +1,46 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.view.View +import android.widget.* +import com.windscribe.mobile.R + +class DecoyTrafficView(private val childView: View) : BaseView(childView), AdapterView.OnItemSelectedListener { + private val potentialTrafficVolume = childView.findViewById(R.id.tv_current_potential_traffic) + private val spinner = childView.findViewById(R.id.spinner_fake_traffic_volume) + private val current = childView.findViewById(R.id.tv_current_fake_traffic_volume) + private val clickableArea = childView.findViewById(R.id.clickable_area) + + interface Delegate { + fun onDecoyTrafficVolumeChanged(volume: String) + } + + var delegate: Delegate? = null + + init { + clickableArea.setOnClickListener { spinner?.performClick() } + spinner?.onItemSelectedListener = this + } + + fun setPotentialTraffic(trafficVolume: String) { + potentialTrafficVolume.text = trafficVolume + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + view?.findViewById(R.id.tv_drop_down)?.text = "" + spinner?.selectedItem.toString().let { + current?.text = it + delegate?.onDecoyTrafficVolumeChanged(it) + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + + fun setAdapter(savedSelection: String, selections: Array) { + val selectionAdapter: ArrayAdapter = ArrayAdapter(childView.context, R.layout.drop_down_layout, + R.id.tv_drop_down, selections) + spinner?.adapter = selectionAdapter + spinner?.isSelected = false + spinner?.setSelection(selectionAdapter.getPosition(savedSelection)) + current?.text = savedSelection + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DnsModeView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DnsModeView.kt new file mode 100644 index 000000000..f4df1c280 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DnsModeView.kt @@ -0,0 +1,66 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import com.windscribe.mobile.R +import com.windscribe.vpn.commonutils.InputFilterMinMax + +class DnsModeView(childView: View) : BaseView(childView) { + private val customDns: EditText = childView.findViewById(R.id.custom_dns) + private val editCustomDns: ImageView = childView.findViewById(R.id.custom_dns_edit) + private val submitCustomDns: ImageView = childView.findViewById(R.id.custom_dns_check) + private val cancelCustomDns: ImageView = childView.findViewById(R.id.custom_dns_cancel) + interface Delegate { + fun onCustomDnsChanged(dns: String) + } + var delegate: Delegate? = null + + init { + editCustomDns.setOnClickListener { + customDns.isEnabled = true + customDns.requestFocus() + customDns.setSelection(customDns.text.length) + showKeyboard(customDns) + editCustomDns.visibility = View.GONE + submitCustomDns.visibility = View.VISIBLE + cancelCustomDns.visibility = View.VISIBLE + } + cancelCustomDns.setOnClickListener { + submitCustomDns.visibility = View.GONE + cancelCustomDns.visibility = View.GONE + editCustomDns.visibility = View.VISIBLE + customDns.clearFocus() + customDns.isEnabled = false + } + submitCustomDns.setOnClickListener { + val dnsAddress = customDns.text.toString() + if (!isValid(dnsAddress)) { + Toast.makeText(it.context, it.context.getString(R.string.enter_valid_ip_or_url), Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + submitCustomDns.visibility = View.INVISIBLE + cancelCustomDns.visibility = View.INVISIBLE + editCustomDns.visibility = View.VISIBLE + customDns.clearFocus() + customDns.isEnabled = false + delegate?.onCustomDnsChanged(dnsAddress) + } + } + fun setCustomDns(customDNS: String) { + customDns.setText(customDNS) + } + + private fun isValid(customDNS: String): Boolean { + if (customDNS.isBlank() || customDNS.isEmpty() || customDNS.length < 7) { + return false + } + val ipAddressRegex = """^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$""".toRegex() + val urlOrDomainRegex = """^(https?|h3)://([\w-]+(\.[\w-]+)+)(/[\w- ./?%&=]*)?$|^([\w-]+(\.[\w-]+)+)$""".toRegex() + val sdnsRegex = """^sdns://[A-Za-z0-9_-]+$""".toRegex() + return customDNS.matches(ipAddressRegex) || customDNS.matches(urlOrDomainRegex) || customDNS.matches(sdnsRegex) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DropDownView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DropDownView.kt new file mode 100644 index 000000000..9ca9cf0f9 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/DropDownView.kt @@ -0,0 +1,78 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.View +import android.widget.* +import androidx.core.content.res.getResourceIdOrThrow +import com.windscribe.mobile.R +import java.util.concurrent.atomic.AtomicBoolean + +class DropDownView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), AdapterView.OnItemSelectedListener { + + interface Delegate { + fun onItemSelect(value: String) + fun onExplainClick() + } + var delegate: Delegate? = null + private var spinner: Spinner? = null + private var current: TextView? = null + private val attributes: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.DropDownView) + private val view: View = View.inflate(context, R.layout.drop_down_view, this) + private var keys: Array? = null + private var ignoreInitialEvent = AtomicBoolean(false) + init { + attributes.getString(R.styleable.DropDownView_DropDownDescription)?.let { + view.findViewById(R.id.description).text = it + } + view.findViewById(R.id.label).text = attributes.getString(R.styleable.DropDownView_DropDownTitle) + val leftIcon = attributes.getResourceIdOrThrow(R.styleable.DropDownView_DropDownLeftIcon) + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + spinner = view.findViewById(R.id.spinner) + current = view.findViewById(R.id.current) + view.findViewById(R.id.right_icon).setOnClickListener { delegate?.onExplainClick() } + view.findViewById(R.id.clickable_area).setOnClickListener { spinner?.performClick() } + if(attributes.getBoolean(R.styleable.DropDownView_DropDownShowRightIcon, true).not()){ + view.findViewById(R.id.right_icon).visibility = GONE + } + spinner?.onItemSelectedListener = this + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + if (ignoreInitialEvent.getAndSet(true)) { + view?.findViewById(R.id.tv_drop_down)?. text = "" + spinner?.selectedItem.toString().let { + current?.text = it + delegate?.onItemSelect(keys?.get(position) ?: "") + } + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + + fun setCurrentValue(value: String) { + view.findViewById(R.id.current).text = value + } + + fun setTitle(value: String) { + view.findViewById(R.id.label).text = value + } + + fun setAdapter(localiseValues: Array, selectedKey: String, keys: Array) { + this.keys = keys + val selectionAdapter: ArrayAdapter = ArrayAdapter( + context, R.layout.drop_down_layout, R.id.tv_drop_down, localiseValues + ) + spinner?.adapter = selectionAdapter + spinner?.isSelected = false + spinner?.setSelection(keys.indexOf(selectedKey)) + if (keys.indexOf(selectedKey) != -1) { + current?.text = localiseValues[keys.indexOf(selectedKey)] + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ExpandableDropDownView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ExpandableDropDownView.kt new file mode 100644 index 000000000..d3ad2c487 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ExpandableDropDownView.kt @@ -0,0 +1,104 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.content.Context +import android.content.res.TypedArray +import android.transition.Fade +import android.transition.Transition +import android.transition.TransitionManager +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.core.content.res.getResourceIdOrThrow +import com.windscribe.mobile.R + + +class ExpandableDropDownView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), AdapterView.OnItemSelectedListener { + + interface Delegate { + fun onItemSelect(position: Int) + fun onExplainClick() + } + enum class ChildType { + ConnectionMode, PacketSize, KeepAliveMode, DnsMode + } + var delegate: Delegate? = null + private var spinner: Spinner? = null + private var current: TextView? = null + private val attributes: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.ExpandableDropDownView) + private val view: View = View.inflate(context, R.layout.expandable_dropdown_view, this) + var childView: BaseView? = null + + init { + spinner = view.findViewById(R.id.spinner) + current = view.findViewById(R.id.current) + attributes.getString(R.styleable.ExpandableDropDownView_ExpandableDropDownDescription)?.let { + view.findViewById(R.id.description).text = it + } + view.findViewById(R.id.label).text = attributes.getString(R.styleable.ExpandableDropDownView_ExpandableDropDownTitle) + val leftIcon = attributes.getResourceIdOrThrow(R.styleable.ExpandableDropDownView_ExpandableDropDownLeftIcon) + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + val showRightIcon = attributes.getBoolean(R.styleable.ExpandableDropDownView_ExpandableDropDownShowRightIcon, true) + view.findViewById(R.id.right_icon).visibility = if (showRightIcon) { VISIBLE} else INVISIBLE + view.findViewById(R.id.right_icon).setOnClickListener { delegate?.onExplainClick() } + view.findViewById(R.id.clickable_area).setOnClickListener { spinner?.performClick() } + spinner?.onItemSelectedListener = this + attachChildView() + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + view?.findViewById(R.id.tv_drop_down)?. text = "" + spinner?.selectedItem.toString().let { + current?.text = it + delegate?.onItemSelect(position) + animateVisibilityChange(position) + } + } + + private fun animateVisibilityChange(position: Int){ + val visibility = if (position == 0){ + GONE + } else { + VISIBLE + } + val transition: Transition = Fade() + transition.duration = 300 + TransitionManager.beginDelayedTransition(parent as ViewGroup?, transition) + childView?.setVisibility(visibility) + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + + fun setAdapter(savedSelection: String, selections: Array) { + val selectionAdapter: ArrayAdapter = ArrayAdapter(context, R.layout.drop_down_layout, + R.id.tv_drop_down, selections) + spinner?.adapter = selectionAdapter + spinner?.isSelected = false + spinner?.setSelection(selectionAdapter.getPosition(savedSelection)) + current?.text = savedSelection + } + + private fun attachChildView(){ + val childType = attributes.getString(R.styleable.ExpandableDropDownView_ExpandableDropDownChildType)?.let { ChildType.valueOf(it) } + ?:ChildType. ConnectionMode + val placeHolder = view.findViewById(R.id.place_holder_view) + childView = when (childType) { + ChildType.PacketSize -> { + PacketSizeView(inflate(context, R.layout.packet_size_view, placeHolder)) + } + ChildType.ConnectionMode -> { + ConnectionModeView(inflate(context, R.layout.auto_manual_mode_view, placeHolder)) + } + ChildType.DnsMode -> { + DnsModeView(inflate(context, R.layout.dns_mode_view, placeHolder)) + } + else -> { + KeepAliveView(inflate(context, R.layout.connection_keep_alive_tab, placeHolder)) + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ExpandableToggleView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ExpandableToggleView.kt new file mode 100644 index 000000000..5195b5870 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ExpandableToggleView.kt @@ -0,0 +1,120 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.content.Context +import android.content.res.TypedArray +import android.transition.Fade +import android.transition.Transition +import android.transition.TransitionManager +import android.transition.Visibility +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.content.res.getResourceIdOrThrow +import com.windscribe.mobile.R + + +class ExpandableToggleView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr){ + + interface Delegate { + fun onToggleClick() + fun onExplainClick() + } + + enum class ChildType { + DecoyTraffic, PreferredProtocol, SplitTunnelMode + } + + var delegate: Delegate? = null + private var toggle: ImageView? = null + private val attributes: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.ExpandableToggleView) + private val view: View = inflate(context, R.layout.expandable_toggle_view, this) + var childView: BaseView? = null + + init { + view.findViewById(R.id.description).text = attributes.getString(R.styleable.ExpandableToggleView_ExpandableToggleViewDescription) + toggle = view.findViewById(R.id.toggle) + view.findViewById(R.id.label).text = attributes.getString(R.styleable.ExpandableToggleView_ExpandableToggleViewTitle) + val leftIcon = attributes.getResourceIdOrThrow(R.styleable.ExpandableToggleView_ExpandableToggleViewLeftIcon) + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + view.findViewById(R.id.right_icon).setOnClickListener { + delegate?.onExplainClick() + } + toggle?.setOnClickListener { + delegate?.onToggleClick() + } + if (attributes.getBoolean(R.styleable.ExpandableToggleView_ExpandableToggleShowRightIcon, true).not()) { + view.findViewById(R.id.right_icon).visibility = INVISIBLE + } + attachChildView() + } + + private fun animateVisibilityChange(active: Boolean) { + val hideExplainViewOnCollapse = attributes.getBoolean(R.styleable.ExpandableToggleView_ExpandableToggleHideExplainViewOnCollapse, false) + val visibility = if (active) { + VISIBLE + } else { + GONE + } + val transition: Transition = Fade() + transition.duration = if (hideExplainViewOnCollapse) { + 0 + } else { + 300 + } + TransitionManager.beginDelayedTransition(parent as ViewGroup?, transition) + childView?.setVisibility(visibility) + if (hideExplainViewOnCollapse) { + setExplainViewVisibility(visibility, active) + if (attributes.getBoolean(R.styleable.ExpandableToggleView_ExpandableToggleShowRightIcon, true).not()) { + view.findViewById(R.id.right_icon).visibility = INVISIBLE + } + } + } + + private fun setExplainViewVisibility(visibility: Int, active: Boolean){ + view.findViewById(R.id.description).visibility = visibility + view.findViewById(R.id.right_icon).visibility = visibility + view.findViewById(R.id.bottom_background).visibility = visibility + view.findViewById(R.id.top_background).visibility = visibility + view.findViewById(R.id.clip_corner_background).visibility = visibility + view.findViewById(R.id.background).visibility = if (active) { + GONE + } else { + VISIBLE + } + } + + fun setToggleImage(resourceId: Int) { + toggle?.setImageResource(resourceId) + animateVisibilityChange(resourceId == R.drawable.ic_toggle_button_on) + } + + fun setDescription(resourceId: Int) { + view.findViewById(R.id.description).text = context.getString(resourceId) + } + + private fun attachChildView() { + val placeHolder = view.findViewById(R.id.place_holder_view) + val type = attributes.getString(R.styleable.ExpandableToggleView_ExpandableToggleViewChildType)?.let { ChildType.valueOf(it) } + ?: ChildType.DecoyTraffic + childView = when (type) { + ChildType.DecoyTraffic -> { + DecoyTrafficView(inflate(context, R.layout.connection_decoy_traffic_tab, placeHolder)) + } + ChildType.PreferredProtocol -> { + ConnectionModeView(inflate(context, R.layout.auto_manual_mode_view, placeHolder)) + } + else -> { + SplitRoutingModeView(inflate(context, R.layout.split_routing_mode_view, placeHolder)) + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/IconLinkView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/IconLinkView.kt new file mode 100644 index 000000000..e376ff482 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/IconLinkView.kt @@ -0,0 +1,70 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.widget.TextViewCompat +import com.windscribe.mobile.R +import com.windscribe.mobile.utils.UiUtil + + +@SuppressLint("ClickableViewAccessibility") +class IconLinkView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val attributes: TypedArray = + context.obtainStyledAttributes(attrs, R.styleable.ItemLinkView) + private val view: View = View.inflate(context, R.layout.icon_link_item_view, this) + var text: String + get() { + return view.findViewById(R.id.title).text.toString() + } + set(value) { + view.findViewById(R.id.title).text = value + } + + init { + val titleTextView = view.findViewById(R.id.title) + titleTextView.text = + attributes.getString(R.styleable.ItemLinkView_ItemLinkViewTitle) + val leftIcon = attributes.getResourceId(R.styleable.ItemLinkView_ItemLinkViewLeftIcon, -1) + if (leftIcon == -1) { + view.findViewById(R.id.left_icon).visibility = GONE + } else { + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + } + val rightIcon = attributes.getResourceId(R.styleable.ItemLinkView_ItemLinkViewRightIcon, -1) + if (rightIcon != -1) { + view.findViewById(R.id.right_icon).setImageResource(rightIcon) + view.findViewById(R.id.right_icon).tag = rightIcon + } + val isCommunityLink = attributes.getBoolean(R.styleable.ItemLinkView_CommunityLinkLabel, false) + if (isCommunityLink) { + titleTextView.apply { + TextViewCompat.setTextAppearance(this, R.style.CommunityLinkLabel) + gravity = Gravity.START or Gravity.CENTER_VERTICAL + } + } + if (!isCommunityLink) { + UiUtil.setupOnTouchListener( + container = view.findViewById(R.id.container), + textView = view.findViewById(R.id.title), + iconView = view.findViewById(R.id.right_icon) + ) + } + } + + fun onClick(click: OnClickListener) { + view.findViewById(R.id.container).setOnClickListener(click) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/KeepAliveView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/KeepAliveView.kt new file mode 100644 index 000000000..ade7551f9 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/KeepAliveView.kt @@ -0,0 +1,57 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ImageView +import com.windscribe.mobile.R + +class KeepAliveView(childView: View) : BaseView(childView) { + private val keepAliveEditText: EditText = childView.findViewById(R.id.keep_alive_edit_view) + private val keepAliveButton: ImageView = childView.findViewById(R.id.keep_alive_edit_button) + interface Delegate { + fun onKeepAliveTimeChanged(time: String) + } + var delegate: Delegate? = null + + init { + setEditTextListener() + keepAliveButton.setOnClickListener { + keepAliveEditText.isEnabled = true + keepAliveEditText.requestFocus() + keepAliveEditText.setSelection(keepAliveEditText.text.length) + showKeyboard(keepAliveEditText) + } + } + + private fun setEditTextListener() { + keepAliveEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + if (keepAliveEditText.text.toString().trim().isNotEmpty()) { + delegate?.onKeepAliveTimeChanged(keepAliveEditText.text.toString().trim()) + } + keepAliveEditText.clearFocus() + keepAliveEditText.isEnabled = false + return@setOnEditorActionListener false + } + false + } + + keepAliveEditText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable) { + if (keepAliveEditText.text.toString().trim().isNotEmpty()) { + delegate?.onKeepAliveTimeChanged(keepAliveEditText.text.toString().trim()) + } + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + }) + } + + fun setKeepAlive(keepAliveTime: String) { + keepAliveEditText.setText(keepAliveTime) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/MultipleLinkExplainView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/MultipleLinkExplainView.kt new file mode 100644 index 000000000..7b6605ffd --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/MultipleLinkExplainView.kt @@ -0,0 +1,63 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.content.res.getResourceIdOrThrow +import com.windscribe.mobile.R +import com.windscribe.mobile.utils.UiUtil + + +@SuppressLint("ClickableViewAccessibility") +class MultipleLinkExplainView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val attributes: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.MultipleLinkExplainView) + private val view: View = View.inflate(context, R.layout.multiple_link_explain_view, this) + + init { + view.findViewById(R.id.title).text = + attributes.getString(R.styleable.MultipleLinkExplainView_MultiLinkTitle) + view.findViewById(R.id.description).text = + attributes.getString(R.styleable.MultipleLinkExplainView_MultiLinkDescription) + val leftIcon = + attributes.getResourceIdOrThrow(R.styleable.MultipleLinkExplainView_MultiLinkLeftIcon) + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + view.findViewById(R.id.first_item_title).text = + attributes.getString(R.styleable.MultipleLinkExplainView_FirstItemTitle) + view.findViewById(R.id.second_item_title).text = + attributes.getString(R.styleable.MultipleLinkExplainView_SecondItemTitle) + view.findViewById(R.id.first_item_right_icon).tag = + R.drawable.ic_forward_arrow_settings + view.findViewById(R.id.second_item_right_icon).tag = + R.drawable.ic_forward_arrow_settings + UiUtil.setupOnTouchListener( + imageViewContainer = view.findViewById(R.id.first_item_tap_area), + iconView = view.findViewById(R.id.first_item_right_icon), + textView = view.findViewById(R.id.first_item_title) + ) + UiUtil.setupOnTouchListener( + imageViewContainer = view.findViewById(R.id.second_item_tap_area), + iconView = view.findViewById(R.id.second_item_right_icon), + textView = view.findViewById(R.id.second_item_title) + ) + } + + fun onFirstItemClick(click: OnClickListener){ + view.findViewById(R.id.first_item_tap_area).setOnClickListener(click) + } + + fun onSecondItemClick(click: OnClickListener){ + view.findViewById(R.id.second_item_tap_area).setOnClickListener(click) + view.findViewById(R.id.clip_corner_background).setOnClickListener(click) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/PacketSizeView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/PacketSizeView.kt new file mode 100644 index 000000000..2cc7e3909 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/PacketSizeView.kt @@ -0,0 +1,66 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.view.View +import android.widget.EditText +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import com.windscribe.mobile.R +import com.windscribe.vpn.commonutils.InputFilterMinMax + +class PacketSizeView(childView: View) : BaseView(childView) { + private val edPackageSize: EditText = childView.findViewById(R.id.edit_packet_size) + private val makePacketSizeEditable: ImageView = childView.findViewById(R.id.make_packet_size_editable) + private val autoFillBtn: ImageView = childView.findViewById(R.id.img_auto_fill_packet_size) + private val progressBar: ProgressBar = childView.findViewById(R.id.progress_packet_size) + private val autoFillProgress: TextView = childView.findViewById(R.id.edit_packet_progress) + interface Delegate { + fun onAutoFillButtonClick() + fun onPacketSizeChanged(packetSize: String) + } + var delegate: Delegate? = null + + init { + setEditTextListener() + makePacketSizeEditable.setOnClickListener { + edPackageSize.isEnabled = true + edPackageSize.requestFocus() + edPackageSize.setSelection(edPackageSize.text.length) + showKeyboard(edPackageSize) + } + autoFillBtn.setOnClickListener { delegate?.onAutoFillButtonClick() } + } + + private fun setEditTextListener() { + edPackageSize.filters = arrayOf(InputFilterMinMax("0", "2000")) + edPackageSize.setOnEditorActionListener { _, actionId, _ -> + if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE) { + if (edPackageSize.text.toString().trim().isNotEmpty()) { + delegate?.onPacketSizeChanged(edPackageSize.text.toString().trim()) + } + edPackageSize.clearFocus() + edPackageSize.isEnabled = false + return@setOnEditorActionListener false + } + false + } + } + + fun setPacketSize(packetSize: String) { + edPackageSize.setText(packetSize) + } + + fun packetSizeDetectionProgress(progress: Boolean) { + if (progress) { + autoFillBtn.visibility = View.INVISIBLE + progressBar.visibility = View.VISIBLE + edPackageSize.visibility = View.INVISIBLE + autoFillProgress.visibility = View.VISIBLE + } else { + progressBar.visibility = View.INVISIBLE + autoFillBtn.visibility = View.VISIBLE + autoFillProgress.visibility = View.INVISIBLE + edPackageSize.visibility = View.VISIBLE + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/SingleLinkExplainView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/SingleLinkExplainView.kt new file mode 100644 index 000000000..bf04fffea --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/SingleLinkExplainView.kt @@ -0,0 +1,67 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updateLayoutParams +import com.windscribe.mobile.R +import com.windscribe.mobile.utils.UiUtil + + +@SuppressLint("ClickableViewAccessibility") +class SingleLinkExplainView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val attributes: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.SingleLinkExplainView) + private val view: View = View.inflate(context, R.layout.lable_link_explain_view, this) + + init { + view.findViewById(R.id.title).text = + attributes.getString(R.styleable.SingleLinkExplainView_Title) + view.findViewById(R.id.description).text = + attributes.getString(R.styleable.SingleLinkExplainView_Description) + val leftIcon = attributes.getResourceId(R.styleable.SingleLinkExplainView_LeftIcon, -1) + if (leftIcon == -1) { + view.findViewById(R.id.left_icon).visibility = GONE + } else { + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + } + val rightIcon = view.findViewById(R.id.right_icon) + rightIcon.tag = R.drawable.ic_forward_arrow_settings + UiUtil.setupOnTouchListener( + container = view.findViewById(R.id.container), + textView = view.findViewById(R.id.title), + iconView = view.findViewById(R.id.right_icon) + ) + UiUtil.setupOnTouchListener( + imageViewContainer = view.findViewById(R.id.clip_corner_background), + textView = view.findViewById(R.id.title) + ) + val rightMargin = attributes.getFloat(R.styleable.SingleLinkExplainView_RightMargin, 11F) + val rightMarginPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + rightMargin, + resources.displayMetrics + ).toInt() + rightIcon.updateLayoutParams { + val params = this as ConstraintLayout.LayoutParams + params.marginEnd = rightMarginPx + params.rightMargin = rightMarginPx + } + } + + fun onClick(click: OnClickListener){ + view.findViewById(R.id.container).setOnClickListener(click) + view.findViewById(R.id.clip_corner_background).setOnClickListener(click) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/SplitRoutingModeView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/SplitRoutingModeView.kt new file mode 100644 index 000000000..9329af89f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/SplitRoutingModeView.kt @@ -0,0 +1,44 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.view.View +import android.widget.* +import com.windscribe.mobile.R + +class SplitRoutingModeView(private val childView: View) : BaseView(childView) , AdapterView.OnItemSelectedListener{ + private var spinner: Spinner? = null + private var current: TextView? = null + + interface Delegate { + fun onModeSelect(mode: String) + } + var delegate: Delegate? = null + var values: Array? = null + + init { + spinner = view.findViewById(R.id.spinner) + current = view.findViewById(R.id.current) + view.findViewById(R.id.clickable_area).setOnClickListener { spinner?.performClick() } + spinner?.onItemSelectedListener = this + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + view?.findViewById(R.id.tv_drop_down)?.text = "" + spinner?.selectedItem.toString().let { + delegate?.onModeSelect(values?.get(position) ?: "") + current?.text = it + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + + fun setAdapter(selectedValues: String, values: Array, localizeValues: Array) { + this.values = values + val modesAdapter: ArrayAdapter = ArrayAdapter( + childView.context, R.layout.drop_down_layout, R.id.tv_drop_down, localizeValues + ) + spinner?.adapter = modesAdapter + spinner?.isSelected = false + spinner?.setSelection(values.indexOf(selectedValues)) + current?.text = selectedValues + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ToggleView.kt b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ToggleView.kt new file mode 100644 index 000000000..35339a6ec --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/preferences/ToggleView.kt @@ -0,0 +1,47 @@ +package com.windscribe.mobile.custom_view.preferences + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.View +import android.widget.* +import androidx.core.content.res.getResourceIdOrThrow +import com.windscribe.mobile.R + +class ToggleView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr){ + + interface Delegate { + fun onToggleClick() + fun onExplainClick() + } + var delegate: Delegate? = null + var toggle: ImageView? = null + private val attributes: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.ToggleView) + private val view: View = View.inflate(context, R.layout.toggle_view, this) + + init { + attributes.getString(R.styleable.ToggleView_ToggleDescription)?.let { + view.findViewById(R.id.description).text = it + } + view.findViewById(R.id.label).text = attributes.getString(R.styleable.ToggleView_ToggleTitle) + val leftIcon = attributes.getResourceIdOrThrow(R.styleable.ToggleView_ToggleLeftIcon) + view.findViewById(R.id.left_icon).setImageResource(leftIcon) + view.findViewById(R.id.right_icon).setOnClickListener { delegate?.onExplainClick() } + view.findViewById(R.id.clickable_area).setOnClickListener {delegate?.onToggleClick() } + if(attributes.getBoolean(R.styleable.ToggleView_ToggleShowRightIcon, true).not()){ + view.findViewById(R.id.right_icon).visibility = INVISIBLE + } + } + + fun setTitle(value: String){ + view.findViewById(R.id.label).text = value + } + + fun setToggleImage(resourceId: Int) { + view.findViewById(R.id.toggle).setImageResource(resourceId) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/IDragDistanceConverter.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/IDragDistanceConverter.java new file mode 100644 index 000000000..17c256019 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/IDragDistanceConverter.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + +public interface IDragDistanceConverter { + + /** + * @param scrollDistance the distance between the ACTION_DOWN point and the ACTION_MOVE point + * @param refreshDistance the distance between the refresh point and the start point + * @return the real distance of the refresh view moved + */ + float convert(float scrollDistance, float refreshDistance); +} diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/IRefreshStatus.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/IRefreshStatus.java new file mode 100644 index 000000000..2e74455a4 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/IRefreshStatus.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + +public interface IRefreshStatus { + + /** + * @param pullDistance The drop-down distance of the refresh View + * @param pullProgress The drop-down progress of the refresh View and the pullProgress may be more than 1.0f + * pullProgress = pullDistance / refreshTargetOffset + */ + void pullProgress(float pullDistance, float pullProgress); + + /** + * Refresh View is dropped down to the refresh point + */ + void pullToRefresh(); + + /** + * refresh has been completed + */ + void refreshComplete(); + + /** + * Refresh View is refreshing + */ + void refreshing(); + + /** + * When the content view has reached to the start point and refresh has been completed, view will be reset. + */ + void reset(); +} diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/MaterialDragDistanceConverter.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/MaterialDragDistanceConverter.java new file mode 100644 index 000000000..8f5e41534 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/MaterialDragDistanceConverter.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + + +public class MaterialDragDistanceConverter implements IDragDistanceConverter { + + @Override + public float convert(float scrollDistance, float refreshDistance) { + float originalDragPercent = scrollDistance / refreshDistance; + float dragPercent = Math.min(1.0f, Math.abs(originalDragPercent)); + float extraOS = Math.abs(scrollDistance) - refreshDistance; + float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, refreshDistance * 2.0f) / refreshDistance); + float tensionPercent = (float) ((tensionSlingshotPercent / 4) - + Math.pow((tensionSlingshotPercent / 4), 2)) * 2f; + float extraMove = (refreshDistance) * tensionPercent * 2; + + return (int) ((refreshDistance * dragPercent) + extraMove); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RecyclerRefreshLayout.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RecyclerRefreshLayout.java new file mode 100644 index 000000000..87e7f6a63 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RecyclerRefreshLayout.java @@ -0,0 +1,1192 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.view.animation.Transformation; + +import androidx.annotation.NonNull; +import androidx.core.view.NestedScrollingChild; +import androidx.core.view.NestedScrollingChildHelper; +import androidx.core.view.NestedScrollingParent; +import androidx.core.view.NestedScrollingParentHelper; +import androidx.core.view.ViewCompat; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; + +/** + * The RecyclerRefreshLayout should be used whenever the user can refresh the + * contents of a view via a vertical swipe gesture. The activity that + * instantiates this view should add an OnRefreshListener to be notified + * whenever the swipe to refresh gesture is completed. The RecyclerRefreshLayout + * will notify the listener each and every time the gesture is completed again; + * the listener is responsible for correctly determining when to actually + * initiate a refresh of its content. If the listener determines there should + * not be a refresh, it must call setRefreshing(false) to cancel any visual + * indication of a refresh. If an activity wishes to show just the progress + * animation, it should call setRefreshing(true). To disable the gesture and + * progress animation, call setEnabled(false) on the view. + *

+ * Maybe you need a custom refresh components, can be implemented by call + * the function {@link #setRefreshView(View, ViewGroup.LayoutParams)} + *

+ */ +public class RecyclerRefreshLayout extends ViewGroup + implements NestedScrollingParent, NestedScrollingChild { + + /** + * Per-child layout information for layouts that support margins. + */ + public static class LayoutParams extends MarginLayoutParams { + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + } + + public interface OnRefreshListener { + + void onRefresh(); + } + + public enum RefreshStyle { + NORMAL, + PINNED, + FLOAT + } + + private static final int INVALID_INDEX = -1; + + private static final int INVALID_POINTER = -1; + + //the default height of the RefreshView + private static final int DEFAULT_REFRESH_SIZE_DP = 30; + + //the animation duration of the RefreshView scroll to the refresh point or the start point + private static final int DEFAULT_ANIMATE_DURATION = 300; + + // the threshold of the trigger to refresh + private static final int DEFAULT_REFRESH_TARGET_OFFSET_DP = 50; + + private static final float DECELERATE_INTERPOLATION_FACTOR = 2.0f; + + private int mActivePointerId = INVALID_POINTER; + + private final Interpolator mAnimateToRefreshInterpolator + = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); + + private final Interpolator mAnimateToStartInterpolator + = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); + + private float mCurrentTouchOffsetY; + + private boolean mDispatchTargetTouchDown; + + private IDragDistanceConverter mDragDistanceConverter; + + private int mFrom; + + private float mInitialDownY; + + private float mInitialMotionY; + + private float mInitialScrollY; + + //whether to remind the callback listener(OnRefreshListener) + private boolean mIsAnimatingToStart; + + private boolean mIsBeingDragged; + + private boolean mIsFitRefresh; + + private boolean mIsRefreshing; + + private boolean mNestedScrollInProgress; + + private final NestedScrollingChildHelper mNestedScrollingChildHelper; + + private final NestedScrollingParentHelper mNestedScrollingParentHelper; + + private boolean mNotifyListener; + + private OnRefreshListener mOnRefreshListener; + + private final int[] mParentOffsetInWindow = new int[2]; + + private final int[] mParentScrollConsumed = new int[2]; + + private float mRefreshInitialOffset; + + private IRefreshStatus mRefreshStatus; + + private final RefreshStyle mRefreshStyle = RefreshStyle.NORMAL; + + private float mRefreshTargetOffset; + + private View mRefreshView; + + private int mRefreshViewIndex = INVALID_INDEX; + + // Whether or not the RefreshView has been measured. + private boolean mRefreshViewMeasured = false; + + private final int mRefreshViewSize; + + private final Animation.AnimationListener mRefreshingListener = new Animation.AnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + if (mNotifyListener) { + if (mOnRefreshListener != null) { + mOnRefreshListener.onRefresh(); + } + } + + mIsAnimatingToStart = false; + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationStart(Animation animation) { + mIsAnimatingToStart = true; + mRefreshStatus.refreshing(); + } + }; + + private View mTarget; + + private float mTargetOrRefreshViewOffsetY; + + private final Animation mAnimateToRefreshingAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + animateToTargetOffset(mRefreshTargetOffset, mTarget.getTop(), interpolatedTime); + } + }; + + private final Animation mAnimateToStartAnimation = new Animation() { + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + animateToTargetOffset(0.0f, mTarget.getTop(), interpolatedTime); + } + }; + + private final Animation.AnimationListener mResetListener = new Animation.AnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + reset(); + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationStart(Animation animation) { + mIsAnimatingToStart = true; + mRefreshStatus.refreshComplete(); + } + }; + + // NestedScroll + private float mTotalUnconsumed; + + private final int mTouchSlop; + + // Whether the client has set a custom starting position; + private boolean mUsingCustomRefreshInitialOffset = false; + + public RecyclerRefreshLayout(Context context) { + this(context, null); + } + + public RecyclerRefreshLayout(Context context, AttributeSet attrs) { + super(context, attrs); + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + mRefreshViewSize = (int) (DEFAULT_REFRESH_SIZE_DP * metrics.density); + + mRefreshTargetOffset = DEFAULT_REFRESH_TARGET_OFFSET_DP * metrics.density; + + mTargetOrRefreshViewOffsetY = 0.0f; + mRefreshInitialOffset = 0.0f; + + mNestedScrollingParentHelper = new NestedScrollingParentHelper(this); + mNestedScrollingChildHelper = new NestedScrollingChildHelper(this); + + initRefreshView(); + initDragDistanceConverter(); + setNestedScrollingEnabled(true); + setChildrenDrawingOrderEnabled(true); + } + + @Override + public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { + return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedPreFling(float velocityX, float velocityY) { + return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); + } + + @Override + public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { + return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); + } + + @Override + public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, + int dyUnconsumed, int[] offsetInWindow) { + return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, + dxUnconsumed, dyUnconsumed, offsetInWindow); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + final int action = ev.getAction(); + switch (action) { + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + // support compile sdk version < 23 + onStopNestedScroll(this); + break; + default: + break; + } + return super.dispatchTouchEvent(ev); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + public int getNestedScrollAxes() { + return mNestedScrollingParentHelper.getNestedScrollAxes(); + } + + @Override + public boolean hasNestedScrollingParent() { + return mNestedScrollingChildHelper.hasNestedScrollingParent(); + } + + @Override + public boolean isNestedScrollingEnabled() { + return mNestedScrollingChildHelper.isNestedScrollingEnabled(); + } + + @Override + public void setNestedScrollingEnabled(boolean enabled) { + mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + ensureTarget(); + if (mTarget == null) { + return false; + } + + if (mRefreshStyle == RefreshStyle.FLOAT) { + if (!isEnabled() || canChildScrollUp(mTarget) + || mIsRefreshing || mNestedScrollInProgress) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + } else { + if ((!isEnabled() || (canChildScrollUp(mTarget) && !mDispatchTargetTouchDown))) { + return false; + } + } + + final int action = ev.getAction(); + + switch (action) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = ev.getPointerId(0); + mIsBeingDragged = false; + + float initialDownY = getMotionEventY(ev, mActivePointerId); + if (initialDownY == -1) { + return false; + } + + // Animation.AnimationListener.onAnimationEnd() can't be ensured to be called + if (mAnimateToRefreshingAnimation.hasEnded() && mAnimateToStartAnimation.hasEnded()) { + mIsAnimatingToStart = false; + } + + mInitialDownY = initialDownY; + mInitialScrollY = mTargetOrRefreshViewOffsetY; + mDispatchTargetTouchDown = false; + break; + + case MotionEvent.ACTION_MOVE: + if (mActivePointerId == INVALID_POINTER) { + return false; + } + + float activeMoveY = getMotionEventY(ev, mActivePointerId); + if (activeMoveY == -1) { + return false; + } + + initDragStatus(activeMoveY); + break; + + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + break; + default: + break; + } + + return mIsBeingDragged; + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + ensureTarget(); + if (mTarget == null) { + return; + } + + measureTarget(); + measureRefreshView(widthMeasureSpec, heightMeasureSpec); + + if (!mRefreshViewMeasured && !mUsingCustomRefreshInitialOffset) { + switch (mRefreshStyle) { + case PINNED: + mTargetOrRefreshViewOffsetY = mRefreshInitialOffset = 0.0f; + break; + case FLOAT: + mTargetOrRefreshViewOffsetY = mRefreshInitialOffset = -mRefreshView.getMeasuredHeight(); + break; + default: + mTargetOrRefreshViewOffsetY = 0.0f; + mRefreshInitialOffset = -mRefreshView.getMeasuredHeight(); + break; + } + } + if (!mRefreshViewMeasured) { + if (mRefreshTargetOffset < mRefreshView.getMeasuredHeight()) { + mRefreshTargetOffset = mRefreshView.getMeasuredHeight(); + } + } + + mRefreshViewMeasured = true; + + mRefreshViewIndex = -1; + for (int index = 0; index < getChildCount(); index++) { + if (getChildAt(index) == mRefreshView) { + mRefreshViewIndex = index; + break; + } + } + + } + + @Override + public boolean onNestedFling(@NotNull View target, float velocityX, float velocityY, + boolean consumed) { + return dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean onNestedPreFling(@NotNull View target, float velocityX, + float velocityY) { + return dispatchNestedPreFling(velocityX, velocityY); + } + + @Override + public void onNestedPreScroll(@NotNull View target, int dx, int dy, @NotNull int[] consumed) { + // If we are in the middle of consuming, a scroll, then we want to move the spinner back up + // before allowing the list to scroll + if (dy > 0 && mTotalUnconsumed > 0) { + if (dy > mTotalUnconsumed) { + consumed[1] = dy - (int) mTotalUnconsumed; + mTotalUnconsumed = 0; + } else { + mTotalUnconsumed -= dy; + consumed[1] = dy; + + } + RefreshLogger.i("pre scroll"); + moveSpinner(mTotalUnconsumed); + } + + // Now let our nested parent consume the leftovers + final int[] parentConsumed = mParentScrollConsumed; + if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) { + consumed[0] += parentConsumed[0]; + consumed[1] += parentConsumed[1]; + } + } + + @Override + public void onNestedScroll(@NotNull final View target, final int dxConsumed, final int dyConsumed, + final int dxUnconsumed, final int dyUnconsumed) { + // Dispatch up to the nested parent first + dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, + mParentOffsetInWindow); + + // This is a bit of a hack. Nested scrolling works from the bottom up, and as we are + // sometimes between two nested scrolling views, we need a way to be able to know when any + // nested scrolling parent has stopped handling events. We do that by using the + // 'offset in window 'functionality to see if we have been moved from the event. + // This is a decent indication of whether we should take over the event stream or not. + final int dy = dyUnconsumed + mParentOffsetInWindow[1]; + if (dy < 0) { + mTotalUnconsumed += Math.abs(dy); + RefreshLogger.i("nested scroll"); + moveSpinner(mTotalUnconsumed); + } + } + + @Override + public void onNestedScrollAccepted(@NotNull View child, @NotNull View target, int axes) { + // Reset the counter of how much leftover scroll needs to be consumed. + mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); + // Dispatch up to the nested parent + startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL); + mTotalUnconsumed = 0; + mNestedScrollInProgress = true; + } + + // NestedScrollingParent + @Override + public boolean onStartNestedScroll(@NotNull View child, @NotNull View target, int nestedScrollAxes) { + if (mRefreshStyle == RefreshStyle.FLOAT) { + return isEnabled() && canChildScrollUp(mTarget) && !mIsRefreshing + && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; + } + return isEnabled() && canChildScrollUp(mTarget) + && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; + } + + @Override + public void onStopNestedScroll(@NotNull View target) { + mNestedScrollingParentHelper.onStopNestedScroll(target); + mNestedScrollInProgress = false; + // Finish the spinner for nested scrolling if we ever consumed any + // unconsumed nested scroll + if (mTotalUnconsumed > 0) { + finishSpinner(); + mTotalUnconsumed = 0; + } + // Dispatch up our nested parent + stopNestedScroll(); + } + + // NestedScrollingChild + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent ev) { + ensureTarget(); + if (mTarget == null) { + return false; + } + + if (mRefreshStyle == RefreshStyle.FLOAT) { + if (!isEnabled() || canChildScrollUp(mTarget) || mNestedScrollInProgress) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + } else { + if ((!isEnabled() || (canChildScrollUp(mTarget) && !mDispatchTargetTouchDown))) { + return false; + } + } + + if (mRefreshStyle == RefreshStyle.FLOAT && (canChildScrollUp(mTarget) || mNestedScrollInProgress)) { + return false; + } + + final int action = ev.getAction(); + + switch (action) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = ev.getPointerId(0); + mIsBeingDragged = false; + break; + + case MotionEvent.ACTION_MOVE: { + if (mActivePointerId == INVALID_POINTER) { + return false; + } + + final float activeMoveY = getMotionEventY(ev, mActivePointerId); + if (activeMoveY == -1) { + return false; + } + + float overScrollY; + if (mIsAnimatingToStart) { + overScrollY = getTargetOrRefreshViewTop(); + + mInitialMotionY = activeMoveY; + mInitialScrollY = overScrollY; + + RefreshLogger.i("animatetostart overscrolly " + overScrollY + " -- " + mInitialMotionY); + } else { + overScrollY = activeMoveY - mInitialMotionY + mInitialScrollY; + RefreshLogger + .i("overscrolly " + overScrollY + " --" + mInitialMotionY + " -- " + mInitialScrollY); + } + + if (mIsRefreshing) { + //note: float style will not come here + if (overScrollY <= 0) { + if (mDispatchTargetTouchDown) { + mTarget.dispatchTouchEvent(ev); + } else { + MotionEvent obtain = MotionEvent.obtain(ev); + obtain.setAction(MotionEvent.ACTION_DOWN); + mDispatchTargetTouchDown = true; + mTarget.dispatchTouchEvent(obtain); + } + } else if (overScrollY > 0 && overScrollY < mRefreshTargetOffset) { + if (mDispatchTargetTouchDown) { + MotionEvent obtain = MotionEvent.obtain(ev); + obtain.setAction(MotionEvent.ACTION_CANCEL); + mDispatchTargetTouchDown = false; + mTarget.dispatchTouchEvent(obtain); + } + } + RefreshLogger.i("moveSpinner refreshing -- " + mInitialScrollY + " -- " + (activeMoveY + - mInitialMotionY)); + moveSpinner(overScrollY); + } else { + if (mIsBeingDragged) { + if (overScrollY > 0) { + moveSpinner(overScrollY); + } else { + return false; + } + } else { + initDragStatus(activeMoveY); + } + } + break; + } + + case MotionEvent.ACTION_POINTER_DOWN: { + onNewerPointerDown(ev); + break; + } + + case MotionEvent.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + if (mActivePointerId == INVALID_POINTER + || getMotionEventY(ev, mActivePointerId) == -1) { + resetTouchEvent(); + return false; + } + + if (mIsRefreshing || mIsAnimatingToStart) { + if (mDispatchTargetTouchDown) { + mTarget.dispatchTouchEvent(ev); + } + resetTouchEvent(); + return false; + } + + resetTouchEvent(); + finishSpinner(); + return false; + } + default: + break; + } + + return true; + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean b) { + // if this is a List < L or another view that doesn't support nested + // scrolling, ignore this request so that the vertical scroll event + // isn't stolen + if (mTarget == null || ViewCompat.isNestedScrollingEnabled(mTarget)) { + super.requestDisallowInterceptTouchEvent(b); + } + + } + + /** + * Set the listener to be notified when a refresh is triggered via the swipe + * gesture. + */ + public void setOnRefreshListener(OnRefreshListener listener) { + mOnRefreshListener = listener; + } + + /** + * @param refreshInitialOffset the top position of the {@link #mRefreshView} relative to its parent. + */ + public void setRefreshInitialOffset(float refreshInitialOffset) { + mRefreshInitialOffset = refreshInitialOffset; + mUsingCustomRefreshInitialOffset = true; + requestLayout(); + } + + /** + * @param refreshView must implements the interface IRefreshStatus + * @param layoutParams the with is always the match_parent, no matter how you set + * the height you need to set a specific value + */ + public void setRefreshView(@NonNull View refreshView, ViewGroup.LayoutParams layoutParams) { + if (mRefreshView == refreshView) { + return; + } + + if (mRefreshView != null && mRefreshView.getParent() != null) { + ((ViewGroup) mRefreshView.getParent()).removeView(mRefreshView); + } + + if (refreshView instanceof IRefreshStatus) { + mRefreshStatus = (IRefreshStatus) refreshView; + } else { + throw new ClassCastException("the refreshView must implement the interface IRefreshStatus"); + } + refreshView.setVisibility(View.GONE); + removeView(refreshView); + addView(refreshView, layoutParams); + + mRefreshView = refreshView; + } + + /** + * Notify the widget that refresh state has changed. Do not call this when + * refresh is triggered by a swipe gesture. + * + * @param refreshing Whether or not the view should show refresh progress. + */ + public void setRefreshing(boolean refreshing) { + if (refreshing && !mIsRefreshing) { + mIsRefreshing = true; + mNotifyListener = false; + + animateToRefreshingPosition((int) mTargetOrRefreshViewOffsetY, mRefreshingListener); + } else { + setRefreshing(refreshing, false); + } + } + + @Override + public boolean startNestedScroll(int axes) { + return mNestedScrollingChildHelper.startNestedScroll(axes); + } + + @Override + public void stopNestedScroll() { + mNestedScrollingChildHelper.stopNestedScroll(); + } + + @Override + protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { + return p instanceof LayoutParams; + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + } + + @Override + protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + return new LayoutParams(p); + } + + @Override + protected int getChildDrawingOrder(int childCount, int i) { + if (mRefreshStyle == RefreshStyle.FLOAT) { + if (mRefreshViewIndex < 0) { + return i; + } else if (i == childCount - 1) { + // Draw the selected child last + return mRefreshViewIndex; + } else if (i >= mRefreshViewIndex) { + // Move the children after the selected child earlier one + return i + 1; + } else { + // Keep the children before the selected child the same + return i; + } + } + if (mRefreshViewIndex < 0) { + return i; + } else if (i == 0) { + // Draw the selected child first + return mRefreshViewIndex; + } else if (i <= mRefreshViewIndex) { + // Move the children before the selected child earlier one + return i - 1; + } else { + return i; + } + } + + @Override + protected void onDetachedFromWindow() { + reset(); + clearAnimation(); + super.onDetachedFromWindow(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (getChildCount() == 0) { + return; + } + + ensureTarget(); + if (mTarget == null) { + return; + } + + final int width = getMeasuredWidth(); + final int height = getMeasuredHeight(); + final int targetTop = reviseTargetLayoutTop(getPaddingTop()); + final int targetLeft = getPaddingLeft(); + final int targetRight = targetLeft + width - getPaddingLeft() - getPaddingRight(); + final int targetBottom = targetTop + height - getPaddingTop() - getPaddingBottom(); + + try { + mTarget.layout(targetLeft, targetTop, targetRight, targetBottom); + } catch (Exception e) { + RefreshLogger.e("error: ignored=" + e + " " + Arrays.toString(e.getStackTrace())); + } + + int refreshViewLeft = (width - mRefreshView.getMeasuredWidth()) / 2; + int refreshViewTop = reviseRefreshViewLayoutTop((int) mRefreshInitialOffset); + int refreshViewRight = (width + mRefreshView.getMeasuredWidth()) / 2; + int refreshViewBottom = refreshViewTop + mRefreshView.getMeasuredHeight(); + + mRefreshView.layout(refreshViewLeft, refreshViewTop, refreshViewRight, refreshViewBottom); + + RefreshLogger.i("onLayout: " + left + " : " + top + " : " + right + " : " + bottom); + } + + private void animateOffsetToStartPosition(int from, Animation.AnimationListener listener) { + clearAnimation(); + + if (computeAnimateToStartDuration(from) <= 0) { + listener.onAnimationStart(null); + listener.onAnimationEnd(null); + return; + } + + mFrom = from; + mAnimateToStartAnimation.reset(); + mAnimateToStartAnimation.setDuration(computeAnimateToStartDuration(from)); + mAnimateToStartAnimation.setInterpolator(mAnimateToStartInterpolator); + if (listener != null) { + mAnimateToStartAnimation.setAnimationListener(listener); + } + + startAnimation(mAnimateToStartAnimation); + } + + private void animateToRefreshingPosition(int from, Animation.AnimationListener listener) { + clearAnimation(); + + if (computeAnimateToRefreshingDuration(from) <= 0) { + listener.onAnimationStart(null); + listener.onAnimationEnd(null); + return; + } + + mFrom = from; + mAnimateToRefreshingAnimation.reset(); + mAnimateToRefreshingAnimation.setDuration(computeAnimateToRefreshingDuration(from)); + mAnimateToRefreshingAnimation.setInterpolator(mAnimateToRefreshInterpolator); + + if (listener != null) { + mAnimateToRefreshingAnimation.setAnimationListener(listener); + } + + startAnimation(mAnimateToRefreshingAnimation); + } + + private void animateToTargetOffset(float targetEnd, float currentOffset, float interpolatedTime) { + int targetOffset = (int) (mFrom + (targetEnd - mFrom) * interpolatedTime); + + setTargetOrRefreshViewOffsetY((int) (targetOffset - currentOffset)); + } + + private boolean canChildScrollUp(View mTarget) { + if (mTarget == null) { + return false; + } + + if (mTarget instanceof ViewGroup) { + int childCount = ((ViewGroup) mTarget).getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = ((ViewGroup) mTarget).getChildAt(i); + if (canChildScrollUp(child)) { + return true; + } + } + } + + return canScrollVertically(-1); + } + + private int computeAnimateToRefreshingDuration(float from) { + RefreshLogger.i("from -- refreshing " + from); + + if (from < mRefreshInitialOffset) { + return 0; + } + + final int animateToRefreshDuration = DEFAULT_ANIMATE_DURATION; + if (mRefreshStyle == RefreshStyle.FLOAT) { + return (int) (Math.max(0.0f, Math.min(1.0f, + Math.abs(from - mRefreshInitialOffset - mRefreshTargetOffset) / mRefreshTargetOffset)) + * animateToRefreshDuration); + } + return (int) (Math.max(0.0f, Math.min(1.0f, Math.abs(from - mRefreshTargetOffset) / mRefreshTargetOffset)) + * animateToRefreshDuration); + } + + private int computeAnimateToStartDuration(float from) { + RefreshLogger.i("from -- start " + from); + + if (from < mRefreshInitialOffset) { + return 0; + } + + final int animateToStartDuration = DEFAULT_ANIMATE_DURATION; + if (mRefreshStyle == RefreshStyle.FLOAT) { + return (int) ( + Math.max(0.0f, Math.min(1.0f, Math.abs(from - mRefreshInitialOffset) / mRefreshTargetOffset)) + * animateToStartDuration); + } + return (int) (Math.max(0.0f, Math.min(1.0f, Math.abs(from) / mRefreshTargetOffset)) + * animateToStartDuration); + } + + private void ensureTarget() { + if (!isTargetValid()) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + if (!child.equals(mRefreshView)) { + mTarget = child; + break; + } + } + } + } + + private void finishSpinner() { + if (mIsRefreshing || mIsAnimatingToStart) { + return; + } + + float scrollY = getTargetOrRefreshViewOffset(); + if (scrollY > mRefreshTargetOffset) { + setRefreshing(true, true); + } else { + mIsRefreshing = false; + animateOffsetToStartPosition((int) mTargetOrRefreshViewOffsetY, mResetListener); + } + } + + private float getMotionEventY(MotionEvent ev, int activePointerId) { + final int index = ev.findPointerIndex(activePointerId); + if (index < 0) { + return -1; + } + return ev.getY(index); + } + + private int getTargetOrRefreshViewOffset() { + if (mRefreshStyle == RefreshStyle.FLOAT) { + return (int) (mRefreshView.getTop() - mRefreshInitialOffset); + } + return mTarget.getTop(); + } + + private int getTargetOrRefreshViewTop() { + if (mRefreshStyle == RefreshStyle.FLOAT) { + return mRefreshView.getTop(); + } + return mTarget.getTop(); + } + + private void initDragDistanceConverter() { + mDragDistanceConverter = new MaterialDragDistanceConverter(); + } + + private void initDragStatus(float activeMoveY) { + float diff = activeMoveY - mInitialDownY; + if (mIsRefreshing && (diff > mTouchSlop || mTargetOrRefreshViewOffsetY > 0)) { + mIsBeingDragged = true; + mInitialMotionY = mInitialDownY + mTouchSlop; + //scroll direction: from up to down + } else if (!mIsBeingDragged && diff > mTouchSlop) { + mInitialMotionY = mInitialDownY + mTouchSlop; + mIsBeingDragged = true; + } + } + + private void initRefreshView() { + mRefreshView = new RefreshView(getContext()); + mRefreshView.setVisibility(View.GONE); + if (mRefreshView instanceof IRefreshStatus) { + mRefreshStatus = (IRefreshStatus) mRefreshView; + } else { + throw new ClassCastException("the refreshView must implement the interface IRefreshStatus"); + } + + LayoutParams layoutParams = new LayoutParams(mRefreshViewSize, mRefreshViewSize); + addView(mRefreshView, layoutParams); + } + + private boolean isTargetValid() { + for (int i = 0; i < getChildCount(); i++) { + if (mTarget == getChildAt(i)) { + return true; + } + } + + return false; + } + + private void measureRefreshView(int widthMeasureSpec, int heightMeasureSpec) { + final MarginLayoutParams lp = (MarginLayoutParams) mRefreshView.getLayoutParams(); + + final int childWidthMeasureSpec; + if (lp.width == LayoutParams.MATCH_PARENT) { + final int width = Math.max(0, getMeasuredWidth() - getPaddingLeft() - getPaddingRight() + - lp.leftMargin - lp.rightMargin); + childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY); + } else { + childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, + getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin, + lp.width); + } + + final int childHeightMeasureSpec; + if (lp.height == LayoutParams.MATCH_PARENT) { + final int height = Math.max(0, getMeasuredHeight() + - getPaddingTop() - getPaddingBottom() + - lp.topMargin - lp.bottomMargin); + childHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + height, MeasureSpec.EXACTLY); + } else { + childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, + getPaddingTop() + getPaddingBottom() + + lp.topMargin + lp.bottomMargin, + lp.height); + } + + mRefreshView.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + + private void measureTarget() { + mTarget.measure(MeasureSpec + .makeMeasureSpec(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), + MeasureSpec.EXACTLY)); + } + + /** + * @param targetOrRefreshViewOffsetY the top position of the target + * or the RefreshView relative to its parent. + */ + private void moveSpinner(float targetOrRefreshViewOffsetY) { + mCurrentTouchOffsetY = targetOrRefreshViewOffsetY; + + float convertScrollOffset; + float refreshTargetOffset; + if (!mIsRefreshing) { + if (mRefreshStyle == RefreshStyle.FLOAT) { + convertScrollOffset = mRefreshInitialOffset + + mDragDistanceConverter.convert(targetOrRefreshViewOffsetY, mRefreshTargetOffset); + } else { + convertScrollOffset = mDragDistanceConverter + .convert(targetOrRefreshViewOffsetY, mRefreshTargetOffset); + } + } else { + //The Float style will never come here + convertScrollOffset = Math.min(targetOrRefreshViewOffsetY, mRefreshTargetOffset); + + if (convertScrollOffset < 0.0f) { + convertScrollOffset = 0.0f; + } + + } + refreshTargetOffset = mRefreshTargetOffset; + + if (!mIsRefreshing) { + if (convertScrollOffset > refreshTargetOffset && !mIsFitRefresh) { + mIsFitRefresh = true; + mRefreshStatus.pullToRefresh(); + } else if (convertScrollOffset <= refreshTargetOffset && mIsFitRefresh) { + mIsFitRefresh = false; + } + } + + RefreshLogger.i(targetOrRefreshViewOffsetY + " -- " + refreshTargetOffset + " -- " + + convertScrollOffset + " -- " + mTargetOrRefreshViewOffsetY + " -- " + mRefreshTargetOffset); + + setTargetOrRefreshViewOffsetY((int) (convertScrollOffset - mTargetOrRefreshViewOffsetY)); + } + + private void onNewerPointerDown(MotionEvent ev) { + final int index = ev.getActionIndex(); + mActivePointerId = ev.getPointerId(index); + + mInitialMotionY = getMotionEventY(ev, mActivePointerId) - mCurrentTouchOffsetY; + + RefreshLogger.i(" onDown " + mInitialMotionY); + } + + private void onSecondaryPointerUp(MotionEvent ev) { + int pointerIndex = ev.getActionIndex(); + int pointerId = ev.getPointerId(pointerIndex); + + if (pointerId == mActivePointerId) { + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = ev.getPointerId(newPointerIndex); + } + + mInitialMotionY = getMotionEventY(ev, mActivePointerId) - mCurrentTouchOffsetY; + + RefreshLogger.i(" onUp " + mInitialMotionY); + } + + private void reset() { + setTargetOrRefreshViewToInitial(); + + mCurrentTouchOffsetY = 0.0f; + + mRefreshStatus.reset(); + mRefreshView.setVisibility(View.GONE); + + mIsRefreshing = false; + mIsAnimatingToStart = false; + } + + private void resetTouchEvent() { + mInitialScrollY = 0.0f; + + mIsBeingDragged = false; + mDispatchTargetTouchDown = false; + mActivePointerId = INVALID_POINTER; + } + + private int reviseRefreshViewLayoutTop(int layoutTop) { + if (mRefreshStyle == RefreshStyle.PINNED) { + return layoutTop; + }//not consider mRefreshResistanceRate < 1.0f + return layoutTop + (int) mTargetOrRefreshViewOffsetY; + } + + private int reviseTargetLayoutTop(int layoutTop) { + if (mRefreshStyle == RefreshStyle.FLOAT) { + return layoutTop; + }//not consider mRefreshResistanceRate < 1.0f + return layoutTop + (int) mTargetOrRefreshViewOffsetY; + } + + private void setRefreshing(boolean refreshing, final boolean notify) { + if (mIsRefreshing != refreshing) { + mNotifyListener = notify; + mIsRefreshing = refreshing; + if (refreshing) { + animateToRefreshingPosition((int) mTargetOrRefreshViewOffsetY, mRefreshingListener); + } else { + animateOffsetToStartPosition((int) mTargetOrRefreshViewOffsetY, mResetListener); + } + } + } + + private void setTargetOrRefreshViewOffsetY(int offsetY) { + if (mTarget == null) { + return; + } + + switch (mRefreshStyle) { + case FLOAT: + mRefreshView.offsetTopAndBottom(offsetY); + mTargetOrRefreshViewOffsetY = mRefreshView.getTop(); + break; + case PINNED: + mTarget.offsetTopAndBottom(offsetY); + mTargetOrRefreshViewOffsetY = mTarget.getTop(); + break; + default: + mTarget.offsetTopAndBottom(offsetY); + mRefreshView.offsetTopAndBottom(offsetY); + mTargetOrRefreshViewOffsetY = mTarget.getTop(); + break; + } + + RefreshLogger.i("current offset" + mTargetOrRefreshViewOffsetY); + + if (mRefreshStyle == RefreshStyle.FLOAT) { + mRefreshStatus.pullProgress(mTargetOrRefreshViewOffsetY, + (mTargetOrRefreshViewOffsetY - mRefreshInitialOffset) / mRefreshTargetOffset); + } else { + mRefreshStatus + .pullProgress(mTargetOrRefreshViewOffsetY, mTargetOrRefreshViewOffsetY / mRefreshTargetOffset); + } + + if (mRefreshView.getVisibility() != View.VISIBLE) { + mRefreshView.setVisibility(View.VISIBLE); + } + + invalidate(); + } + + private void setTargetOrRefreshViewToInitial() { + if (mRefreshStyle == RefreshStyle.FLOAT) { + setTargetOrRefreshViewOffsetY((int) (mRefreshInitialOffset - mTargetOrRefreshViewOffsetY)); + } else { + setTargetOrRefreshViewOffsetY((int) (0 - mTargetOrRefreshViewOffsetY)); + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshLogger.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshLogger.java new file mode 100644 index 000000000..d432865f1 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshLogger.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + +import android.util.Log; + +public final class RefreshLogger { + + private static final String TAG = "RefreshLayout"; + + private static final boolean mEnableDebug = false; + + public static void e(String msg) { + if (mEnableDebug) { + Log.e(TAG, msg); + } + } + + public static void i(String msg) { + if (mEnableDebug) { + Log.i(TAG, msg); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshView.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshView.java new file mode 100644 index 000000000..199fbe8ac --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshView.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; +import android.view.animation.LinearInterpolator; + + +/** + * the default implementation class of the interface IRefreshStatus, and the class should always be rewritten + */ +public class RefreshView extends View implements IRefreshStatus { + + private static final int MAX_ARC_DEGREE = 330; + + private static final int ANIMATION_DURATION = 888; + + private static final int DEFAULT_START_DEGREES = 285; + + private static final int DEFAULT_STROKE_WIDTH = 2; + + private final RectF mArcBounds = new RectF(); + + private boolean mHasTriggeredRefresh; + + private final Paint mPaint = new Paint(); + + private ValueAnimator mRotateAnimator; + + private float mStartDegrees; + + private float mStrokeWidth; + + private float mSwipeDegrees; + + public RefreshView(Context context) { + this(context, null); + } + + public RefreshView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public RefreshView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + initData(); + initPaint(); + } + + @Override + public void pullProgress(float pullDistance, float pullProgress) { + if (!mHasTriggeredRefresh) { + float swipeProgress = Math.min(1.0f, pullProgress); + setSwipeDegrees(swipeProgress * MAX_ARC_DEGREE); + } + } + + @Override + public void pullToRefresh() { + + } + + @Override + public void refreshComplete() { + + } + + @Override + public void refreshing() { + mHasTriggeredRefresh = true; + mSwipeDegrees = MAX_ARC_DEGREE; + + startAnimator(); + } + + @Override + public void reset() { + resetAnimator(); + + mHasTriggeredRefresh = false; + mStartDegrees = DEFAULT_START_DEGREES; + mSwipeDegrees = 0.0f; + } + + public void setSwipeDegrees(float swipeDegrees) { + this.mSwipeDegrees = swipeDegrees; + postInvalidate(); + } + + @Override + protected void onDetachedFromWindow() { + resetAnimator(); + super.onDetachedFromWindow(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + drawArc(canvas); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + float radius = Math.min(w, h) / 2.0f; + float centerX = w / 2.0f; + float centerY = h / 2.0f; + + mArcBounds.set(centerX - radius, centerY - radius, centerX + radius, centerY + radius); + mArcBounds.inset(mStrokeWidth / 2.0f, mStrokeWidth / 2.0f); + } + + private void drawArc(Canvas canvas) { + canvas.drawArc(mArcBounds, mStartDegrees, mSwipeDegrees, false, mPaint); + } + + private void initData() { + float density = getResources().getDisplayMetrics().density; + mStrokeWidth = density * DEFAULT_STROKE_WIDTH; + + mStartDegrees = DEFAULT_START_DEGREES; + mSwipeDegrees = 0.0f; + } + + private void initPaint() { + mPaint.setAntiAlias(true); + mPaint.setStyle(Paint.Style.STROKE); + mPaint.setStrokeWidth(mStrokeWidth); + mPaint.setColor(Color.parseColor("#FFD72263")); + } + + private void resetAnimator() { + if (mRotateAnimator != null) { + mRotateAnimator.cancel(); + mRotateAnimator.removeAllUpdateListeners(); + + mRotateAnimator = null; + } + } + + private void setStartDegrees(float startDegrees) { + mStartDegrees = startDegrees; + postInvalidate(); + } + + private void startAnimator() { + mRotateAnimator = ValueAnimator.ofFloat(0.0f, 1.0f); + mRotateAnimator.setInterpolator(new LinearInterpolator()); + mRotateAnimator.addUpdateListener(animation -> { + float rotateProgress = (float) animation.getAnimatedValue(); + setStartDegrees(DEFAULT_START_DEGREES + rotateProgress * 360); + }); + mRotateAnimator.setRepeatMode(ValueAnimator.RESTART); + mRotateAnimator.setRepeatCount(ValueAnimator.INFINITE); + mRotateAnimator.setDuration(ANIMATION_DURATION); + + mRotateAnimator.start(); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshViewEg.java b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshViewEg.java new file mode 100644 index 000000000..f5ef60de6 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/custom_view/refresh/RefreshViewEg.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.custom_view.refresh; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.animation.Animation; +import android.view.animation.LinearInterpolator; +import android.view.animation.RotateAnimation; + +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.content.res.ResourcesCompat; + +import com.windscribe.mobile.R; +import com.windscribe.vpn.Windscribe; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.commonutils.WindUtilities; + + +public class RefreshViewEg extends AppCompatImageView implements IRefreshStatus { + + int currentArrayIndex = 0; + + boolean isRefreshing = false; + + final Paint paint = new Paint(); + + final Paint textPaint = new Paint(); + + private int radius; + + public RefreshViewEg(Context context) { + this(context, null); + } + + public RefreshViewEg(Context context, AttributeSet attrs) { + super(context, attrs); + initView(); + initAnimation(); + } + + @Override + public void pullProgress(float pullDistance, float pullProgress) { + currentArrayIndex = getProgress(pullDistance); + postInvalidate(); + } + + @Override + public void pullToRefresh() { + clearAnimation(); + } + + @Override + public void refreshComplete() { + animate().cancel(); + currentArrayIndex = 0; + isRefreshing = false; + this.setImageDrawable(null); + } + + @Override + public void refreshing() { + animate().cancel(); + isRefreshing = true; + setImageDrawable(null); + boolean vpnDisconnected = !Windscribe.getAppContext().vpnConnectionStateManager.isVPNActive(); + boolean networkAvailable = WindUtilities.isOnline(); + if (vpnDisconnected && networkAvailable) { + startAnimation(); + } + } + + @Override + public void reset() { + currentArrayIndex = 0; + isRefreshing = false; + animate().cancel(); + this.setImageDrawable(null); + } + + @Override + protected void onDraw(Canvas canvas) { + boolean vpnDisconnected = !Windscribe.getAppContext().getVpnConnectionStateManager().isVPNActive(); + boolean networkAvailable = WindUtilities.isOnline(); + if (networkAvailable && vpnDisconnected) { + drawCircles(canvas); + } + super.onDraw(canvas); + } + + private void drawCircles(Canvas canvas) { + int width = getWidth(); + int height = getHeight(); + final RectF rect = new RectF(); + rect.set((float) width / 2 - radius, (float) height / 2 - radius, (float) width / 2 + radius, + (float) height / 2 + radius); + for (int i = 0; i < currentArrayIndex; i++) { + int textColor = ThemeUtils.getColor(getContext(), R.attr.nodeListGroupTextColor, R.color.colorWhite40); + paint.setColor(textColor); + int secLength = 50; + double secRot = Math.PI / 6 * (i - 3); + float secX = (float) Math.sin(secRot) * secLength; + float secY = (float) -Math.cos(secRot) * secLength; + float startX = (float) Math.sin(secRot) * 35; + float startY = (float) -Math.cos(secRot) * 35; + canvas.drawLine(rect.centerX() + startX, rect.centerY() + startY, rect.centerX() + secX, + rect.centerY() + secY, paint); + } + + } + + private int getProgress(float distance) { + float totalDistance = 200; + if (distance <= 0) { + return 0; + } + if (distance >= totalDistance | currentArrayIndex > 12) { + return 12; + } + float percent = distance / totalDistance * 12; + return Math.round(percent); + } + + private void initAnimation() { + Animation mRotateAnimation = new RotateAnimation(0, 360, Animation.RELATIVE_TO_SELF, 0.5f, + Animation.RELATIVE_TO_SELF, 0.5f); + mRotateAnimation.setDuration(10000); + mRotateAnimation.setRepeatCount(Animation.INFINITE); + + } + + private void initView() { + paint.setColor(Color.GRAY); + paint.setStyle(Paint.Style.FILL); + this.setScaleType(ScaleType.CENTER); + radius = Math.round(getResources().getDimension(R.dimen.reg_24dp)); + + int stroke = Math.round(getResources().getDimension(R.dimen.reg_2dp)); + paint.setAntiAlias(true); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setStrokeWidth(stroke); + int textColor = ThemeUtils.getColor(getContext(), R.attr.nodeListGroupTextColor, R.color.colorWhite40); + + Typeface textTypeFace = ResourcesCompat.getFont(getContext(), R.font.ibm_plex_sans_bold); + textPaint.setAntiAlias(true); + textPaint.setColor(textColor); + textPaint.setTypeface(textTypeFace); + float textSize = getResources().getDimension(R.dimen.text_size_14); + textPaint.setTextSize(textSize); + } + + private void startAnimation() { + Runnable runnable = new Runnable() { + @Override + public void run() { + if (isRefreshing) { + animate().rotationBy(360).withEndAction(this).setDuration(1000) + .setInterpolator(new LinearInterpolator()).start(); + } + } + }; + + animate().rotationBy(360).withEndAction(runnable).setDuration(1000).setInterpolator(new LinearInterpolator()) + .start(); + } + + +} diff --git a/mobile/src/main/java/com/windscribe/mobile/debug/DebugPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/debug/DebugPresenter.kt new file mode 100644 index 000000000..918cb26c5 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/debug/DebugPresenter.kt @@ -0,0 +1,5 @@ +package com.windscribe.mobile.debug + +interface DebugPresenter { + suspend fun init() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/debug/DebugPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/debug/DebugPresenterImpl.kt new file mode 100644 index 000000000..bbefb05ce --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/debug/DebugPresenterImpl.kt @@ -0,0 +1,15 @@ +package com.windscribe.mobile.debug + +import com.windscribe.mobile.adapter.LogViewAdapter +import com.windscribe.vpn.ActivityInteractor + +class DebugPresenterImpl(val view: DebugView, val activityInteractor: ActivityInteractor): DebugPresenter { + + override suspend fun init() { + view.showProgress(true) + val logs = activityInteractor.getPartialLog().takeLast(350) + val logViewAdapter = LogViewAdapter(logs) + view.setAdapter(logViewAdapter) + view.showProgress(false) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/debug/DebugView.kt b/mobile/src/main/java/com/windscribe/mobile/debug/DebugView.kt new file mode 100644 index 000000000..1f40d2dd2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/debug/DebugView.kt @@ -0,0 +1,8 @@ +package com.windscribe.mobile.debug + +import com.windscribe.mobile.adapter.LogViewAdapter + +interface DebugView { + fun showProgress(show: Boolean) + fun setAdapter(logViewAdapter: LogViewAdapter) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/debug/DebugViewActivity.kt b/mobile/src/main/java/com/windscribe/mobile/debug/DebugViewActivity.kt new file mode 100644 index 000000000..2e4411c6f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/debug/DebugViewActivity.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.debug + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.coroutineScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.LogViewAdapter +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.constants.PreferencesKeyConstants +import javax.inject.Inject + +class DebugViewActivity : BaseActivity(), DebugView { + @JvmField + @BindView(R.id.debugView) + var debugView: RecyclerView? = null + + @JvmField + @BindView(R.id.nav_title) + var activityTitleView: TextView? = null + + @JvmField + @BindView(R.id.progressView) + var progressView: ConstraintLayout? = null + private var charonLog = false + + @Inject + lateinit var debugPresenter: DebugPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_debug_view, true) + charonLog = intent.getBooleanExtra("charonLog", false) + setActivityTitle() + lifecycle.coroutineScope.launchWhenCreated { + debugPresenter.init() + } + } + + fun setActivityTitle() { + activityTitleView?.text = getString(R.string.view_log) + } + + override fun setTheme(context: Context) { + val savedThem = appContext.preference.selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + @OnClick(R.id.nav_button) + fun onBackCLicked() { + onBackPressed() + } + + override fun setAdapter(logViewAdapter: LogViewAdapter) { + debugView?.let { + it.layoutManager = LinearLayoutManager(this) + it.adapter = logViewAdapter + } + } + + override fun showProgress(show: Boolean) { + if (show) { + progressView?.visibility = View.VISIBLE + } else { + progressView?.visibility = View.GONE + } + } + + companion object { + @JvmStatic + fun getStartIntent(context: Context?, charonLog: Boolean): Intent { + return Intent(context, DebugViewActivity::class.java).putExtra("charonLog", charonLog) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/di/BaseActivityComponent.kt b/mobile/src/main/java/com/windscribe/mobile/di/BaseActivityComponent.kt new file mode 100644 index 000000000..2c1531b72 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/di/BaseActivityComponent.kt @@ -0,0 +1,47 @@ +package com.windscribe.mobile.di + +import com.windscribe.mobile.about.AboutActivity +import com.windscribe.mobile.account.AccountActivity +import com.windscribe.mobile.advance.AdvanceParamsActivity +import com.windscribe.mobile.confirmemail.ConfirmActivity +import com.windscribe.mobile.connectionsettings.ConnectionSettingsActivity +import com.windscribe.mobile.debug.DebugViewActivity +import com.windscribe.mobile.email.AddEmailActivity +import com.windscribe.mobile.fragments.ServerListFragment +import com.windscribe.mobile.generalsettings.GeneralSettingsActivity +import com.windscribe.mobile.gpsspoofing.GpsSpoofingSettingsActivity +import com.windscribe.mobile.help.HelpActivity +import com.windscribe.mobile.mainmenu.MainMenuActivity +import com.windscribe.mobile.networksecurity.NetworkSecurityActivity +import com.windscribe.mobile.networksecurity.networkdetails.NetworkDetailsActivity +import com.windscribe.mobile.newsfeedactivity.NewsFeedActivity +import com.windscribe.mobile.robert.RobertSettingsActivity +import com.windscribe.mobile.splash.SplashActivity +import com.windscribe.mobile.splittunneling.SplitTunnelingActivity +import com.windscribe.mobile.ticket.SendTicketActivity +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.mobile.windscribe.WindscribeActivity + +interface BaseActivityComponent { + fun inject(sendTicketActivity: SendTicketActivity) + fun inject(helpActivity: HelpActivity) + fun inject(confirmActivity: ConfirmActivity) + fun inject(splashActivity: SplashActivity) + fun inject(welcomeActivity: WelcomeActivity) + fun inject(networkDetailsActivity: NetworkDetailsActivity) + fun inject(windscribeActivity: WindscribeActivity) + fun inject(mainMenuActivity: MainMenuActivity) + fun inject(generalSettingsActivity: GeneralSettingsActivity) + fun inject(networkSecurityActivity: NetworkSecurityActivity) + fun inject(accountActivity: AccountActivity) + fun inject(newsFeedActivity: NewsFeedActivity) + fun inject(addEmailActivity: AddEmailActivity) + fun inject(settingsActivity: ConnectionSettingsActivity) + fun inject(splitTunnelingActivity: SplitTunnelingActivity) + fun inject(serverListFragment: ServerListFragment) + fun inject(gpsSpoofingSettingsActivity: GpsSpoofingSettingsActivity) + fun inject(aboutActivity: AboutActivity) + fun inject(robertSettingsActivity: RobertSettingsActivity) + fun inject(debugViewActivity: DebugViewActivity) + fun inject(advanceParamsActivity: AdvanceParamsActivity) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/di/BaseActivityModule.kt b/mobile/src/main/java/com/windscribe/mobile/di/BaseActivityModule.kt new file mode 100644 index 000000000..103fa0b69 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/di/BaseActivityModule.kt @@ -0,0 +1,425 @@ +package com.windscribe.mobile.di + +import android.animation.ArgbEvaluator +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.lifecycleScope +import com.windscribe.mobile.about.AboutPresenter +import com.windscribe.mobile.about.AboutPresenterImpl +import com.windscribe.mobile.about.AboutView +import com.windscribe.mobile.account.AccountPresenter +import com.windscribe.mobile.account.AccountPresenterImpl +import com.windscribe.mobile.account.AccountView +import com.windscribe.mobile.advance.AdvanceParamPresenter +import com.windscribe.mobile.advance.AdvanceParamView +import com.windscribe.mobile.advance.AdvanceParamsPresenterImpl +import com.windscribe.mobile.confirmemail.ConfirmEmailPresenter +import com.windscribe.mobile.confirmemail.ConfirmEmailPresenterImp +import com.windscribe.mobile.confirmemail.ConfirmEmailView +import com.windscribe.mobile.connectionsettings.ConnectionSettingsPresenter +import com.windscribe.mobile.connectionsettings.ConnectionSettingsPresenterImpl +import com.windscribe.mobile.connectionsettings.ConnectionSettingsView +import com.windscribe.mobile.custom_view.CustomDialog +import com.windscribe.mobile.debug.DebugPresenter +import com.windscribe.mobile.debug.DebugPresenterImpl +import com.windscribe.mobile.debug.DebugView +import com.windscribe.mobile.email.AddEmailPresenter +import com.windscribe.mobile.email.AddEmailPresenterImpl +import com.windscribe.mobile.email.AddEmailView +import com.windscribe.mobile.fragments.ServerListFragment +import com.windscribe.mobile.generalsettings.GeneralSettingsPresenter +import com.windscribe.mobile.generalsettings.GeneralSettingsPresenterImpl +import com.windscribe.mobile.generalsettings.GeneralSettingsView +import com.windscribe.mobile.gpsspoofing.GpsSpoofingPresenter +import com.windscribe.mobile.gpsspoofing.GpsSpoofingPresenterImp +import com.windscribe.mobile.gpsspoofing.GpsSpoofingSettingView +import com.windscribe.mobile.help.HelpPresenter +import com.windscribe.mobile.help.HelpPresenterImpl +import com.windscribe.mobile.help.HelpView +import com.windscribe.mobile.mainmenu.MainMenuPresenter +import com.windscribe.mobile.mainmenu.MainMenuPresenterImpl +import com.windscribe.mobile.mainmenu.MainMenuView +import com.windscribe.mobile.networksecurity.NetworkSecurityPresenter +import com.windscribe.mobile.networksecurity.NetworkSecurityPresenterImpl +import com.windscribe.mobile.networksecurity.NetworkSecurityView +import com.windscribe.mobile.networksecurity.networkdetails.NetworkDetailPresenter +import com.windscribe.mobile.networksecurity.networkdetails.NetworkDetailPresenterImp +import com.windscribe.mobile.networksecurity.networkdetails.NetworkDetailView +import com.windscribe.mobile.newsfeedactivity.NewsFeedPresenter +import com.windscribe.mobile.newsfeedactivity.NewsFeedPresenterImpl +import com.windscribe.mobile.newsfeedactivity.NewsFeedView +import com.windscribe.mobile.robert.RobertSettingsPresenter +import com.windscribe.mobile.robert.RobertSettingsPresenterImpl +import com.windscribe.mobile.robert.RobertSettingsView +import com.windscribe.mobile.splash.SplashPresenter +import com.windscribe.mobile.splash.SplashPresenterImpl +import com.windscribe.mobile.splash.SplashView +import com.windscribe.mobile.splittunneling.SplitTunnelingPresenter +import com.windscribe.mobile.splittunneling.SplitTunnelingPresenterImpl +import com.windscribe.mobile.splittunneling.SplitTunnelingView +import com.windscribe.mobile.ticket.SendTicketPresenter +import com.windscribe.mobile.ticket.SendTicketPresenterImpl +import com.windscribe.mobile.ticket.SendTicketView +import com.windscribe.mobile.utils.PermissionManager +import com.windscribe.mobile.utils.PermissionManagerImpl +import com.windscribe.mobile.welcome.WelcomePresenter +import com.windscribe.mobile.welcome.WelcomePresenterImpl +import com.windscribe.mobile.welcome.WelcomeView +import com.windscribe.mobile.welcome.viewmodal.EmergencyConnectViewModal +import com.windscribe.mobile.windscribe.WindscribePresenter +import com.windscribe.mobile.windscribe.WindscribePresenterImpl +import com.windscribe.mobile.windscribe.WindscribeView +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.ActivityInteractorImpl +import com.windscribe.vpn.api.IApiCallManager +import com.windscribe.vpn.apppreference.PreferencesHelper +import com.windscribe.vpn.autoconnection.AutoConnectionManager +import com.windscribe.vpn.backend.ProxyDNSManager +import com.windscribe.vpn.backend.TrafficCounter +import com.windscribe.vpn.backend.utils.WindVpnController +import com.windscribe.vpn.decoytraffic.DecoyTrafficController +import com.windscribe.vpn.localdatabase.LocalDbInterface +import com.windscribe.vpn.repository.AdvanceParameterRepository +import com.windscribe.vpn.repository.ConnectionDataRepository +import com.windscribe.vpn.repository.LatencyRepository +import com.windscribe.vpn.repository.LocationRepository +import com.windscribe.vpn.repository.NotificationRepository +import com.windscribe.vpn.repository.ServerListRepository +import com.windscribe.vpn.repository.StaticIpRepository +import com.windscribe.vpn.repository.UserRepository +import com.windscribe.vpn.services.FirebaseManager +import com.windscribe.vpn.state.NetworkInfoManager +import com.windscribe.vpn.state.PreferenceChangeObserver +import com.windscribe.vpn.state.VPNConnectionStateManager +import com.windscribe.vpn.services.ReceiptValidator +import com.windscribe.vpn.workers.WindScribeWorkManager +import dagger.Module +import dagger.Provides +import kotlinx.coroutines.CoroutineScope +import javax.inject.Named +@Module +open class BaseActivityModule { + lateinit var activity: AppCompatActivity + lateinit var confirmEmailView: ConfirmEmailView + lateinit var helpView: HelpView + lateinit var aboutView: AboutView + lateinit var accountView: AccountView + lateinit var connectionSettingsView: ConnectionSettingsView + lateinit var emailView: AddEmailView + lateinit var generalSettingsView: GeneralSettingsView + lateinit var gpsSpoofingSettingView: GpsSpoofingSettingView + lateinit var mainMenuView: MainMenuView + lateinit var networkDetailView: NetworkDetailView + lateinit var networkSecurityView: NetworkSecurityView + lateinit var newsFeedView: NewsFeedView + lateinit var robertSettingsView: RobertSettingsView + lateinit var splashView: SplashView + lateinit var splitTunnelingView: SplitTunnelingView + lateinit var windscribeView: WindscribeView + lateinit var sendTicketView: SendTicketView + lateinit var welcomeView: WelcomeView + lateinit var debugView: DebugView + lateinit var advanceParamView: AdvanceParamView + + @Provides + fun provideAboutPresenter(activityInteractor: ActivityInteractor): AboutPresenter { + return AboutPresenterImpl(aboutView, activityInteractor) + } + + @Provides + fun provideDebugPresenter(activityInteractor: ActivityInteractor): DebugPresenter { + return DebugPresenterImpl(debugView, activityInteractor) + } + + @Provides + fun provideAccountPresenter(activityInteractor: ActivityInteractor): AccountPresenter { + return AccountPresenterImpl(accountView, activityInteractor) + } + + @Provides + fun provideAccountView(): AccountView { + return accountView + } + + @Provides + fun provideDebugView(): DebugView { + return debugView + } + + @Provides + fun provideAdvanceParamsView(): AdvanceParamView { + return advanceParamView + } + + @Provides + fun provideActivity(): AppCompatActivity { + return activity + } + + @Provides + fun provideAddEmailPresenter(activityInteractor: ActivityInteractor): AddEmailPresenter { + return AddEmailPresenterImpl(emailView, activityInteractor) + } + + @Provides + fun provideConfirmEmailPresenter( + confirmEmailView: ConfirmEmailView, activityInteractor: ActivityInteractor + ): ConfirmEmailPresenter { + return ConfirmEmailPresenterImp(confirmEmailView, activityInteractor) + } + + @Provides + fun provideConfirmEmailView(): ConfirmEmailView { + return confirmEmailView + } + + @Provides + fun provideConnectionPresenter( + activityInteractor: ActivityInteractor, + permissionManager: PermissionManager, + proxyDNSManager: ProxyDNSManager + ): ConnectionSettingsPresenter { + return ConnectionSettingsPresenterImpl(connectionSettingsView, activityInteractor, permissionManager, proxyDNSManager) + } + + @Provides + fun provideConnectionSettingsView(): ConnectionSettingsView { + return connectionSettingsView + } + + @Provides + fun provideCustomDialog(): CustomDialog { + return CustomDialog(activity) + } + + @Provides + fun provideEmailView(): AddEmailView { + return emailView + } + + @Provides + fun provideGeneralSettingsPresenter( + activityInteractor: ActivityInteractor + ): GeneralSettingsPresenter { + return GeneralSettingsPresenterImpl(generalSettingsView, activityInteractor) + } + + @Provides + fun provideGeneralSettingsView(): GeneralSettingsView { + return generalSettingsView + } + + @Provides + fun provideGpsSpoofingPresenter(activityInteractor: ActivityInteractor): GpsSpoofingPresenter { + return GpsSpoofingPresenterImp(gpsSpoofingSettingView, activityInteractor) + } + + @Provides + fun provideGpsSpoofingView(): GpsSpoofingSettingView { + return gpsSpoofingSettingView + } + + @Provides + fun provideHelpPresenter(activityInteractor: ActivityInteractor): HelpPresenter { + return HelpPresenterImpl(helpView, activityInteractor) + } + + @Provides + fun provideHelpView(): HelpView { + return helpView + } + + @Provides + fun provideMainMenuView(): MainMenuView { + return mainMenuView + } + + @Provides + fun provideMenuPresenter( + activityInteractor: ActivityInteractor + ): MainMenuPresenter { + return MainMenuPresenterImpl(mainMenuView, activityInteractor) + } + + @Provides + fun provideNetworkDetailPresenter(activityInteractor: ActivityInteractor): NetworkDetailPresenter { + return NetworkDetailPresenterImp(networkDetailView, activityInteractor) + } + + @Provides + fun provideAdvanceParamsPresenter(preferencesHelper: PreferencesHelper, advanceParameterRepository: AdvanceParameterRepository): AdvanceParamPresenter { + return AdvanceParamsPresenterImpl(advanceParamView, preferencesHelper, advanceParameterRepository) + } + + @Provides + fun provideNetworkDetailView(): NetworkDetailView { + return networkDetailView + } + + @Provides + fun provideNewsPresenter(activityInteractor: ActivityInteractor): NewsFeedPresenter { + return NewsFeedPresenterImpl(newsFeedView, activityInteractor) + } + + @Provides + fun provideRobertSettingsPresenter(activityInteractor: ActivityInteractor): RobertSettingsPresenter { + return RobertSettingsPresenterImpl(robertSettingsView, activityInteractor) + } + + @Provides + fun provideSecurityPresenter(activityInteractor: ActivityInteractor): NetworkSecurityPresenter { + return NetworkSecurityPresenterImpl(networkSecurityView, activityInteractor) + } + + @Provides + fun provideSecurityView(): NetworkSecurityView { + return networkSecurityView + } + + @Provides + fun provideSendTicketPresenter(activityInteractor: ActivityInteractor): SendTicketPresenter { + return SendTicketPresenterImpl(sendTicketView, activityInteractor) + } + + @Provides + fun provideSendTicketView(): SendTicketView { + return sendTicketView + } + + @Named("serverListFragments") + @Provides + fun provideServerListFragments(): List { + val serverListFragments: MutableList = ArrayList() + for (counter in 0..4) { + serverListFragments.add(counter, ServerListFragment.newInstance(counter)) + } + return serverListFragments + } + + @Provides + fun provideSplashPresenter(activityInteractor: ActivityInteractor): SplashPresenter { + return SplashPresenterImpl(splashView, activityInteractor) + } + + @Provides + fun provideSplashView(): SplashView { + return splashView + } + + @Provides + fun provideSplitPresenter( + activityInteractor: ActivityInteractor + ): SplitTunnelingPresenter { + return SplitTunnelingPresenterImpl(splitTunnelingView, activityInteractor) + } + + @Provides + fun provideSplitTunnelingView(): SplitTunnelingView { + return splitTunnelingView + } + + @Provides + fun provideWelcomePresenter(activityInteractor: ActivityInteractor): WelcomePresenter { + return WelcomePresenterImpl(welcomeView, activityInteractor) + } + + @Provides + fun provideWelcomeView(): WelcomeView { + return welcomeView + } + + @Provides + fun provideWindscribePresenter( + activityInteractor: ActivityInteractor, + permissionManager: PermissionManager + ): WindscribePresenter { + return WindscribePresenterImpl(windscribeView, activityInteractor, permissionManager) + } + + @Provides + fun provideWindscribeView(): WindscribeView { + return windscribeView + } + + @Provides + fun providesArgbEvaluator(): ArgbEvaluator { + return ArgbEvaluator() + } + + @Provides + fun providesActivityScope(): LifecycleCoroutineScope { + return activity.lifecycleScope + } + + @Provides + @PerActivity + fun providesPermissionManager(): PermissionManager { + return PermissionManagerImpl(activity) + } + + @Provides + @PerActivity + fun provideActivityInteractor( + activityScope: LifecycleCoroutineScope, + coroutineScope: CoroutineScope, + prefHelper: PreferencesHelper, + apiCallManager: IApiCallManager, + localDbInterface: LocalDbInterface, + vpnConnectionStateManager: VPNConnectionStateManager, + userRepository: UserRepository, + networkInfoManager: NetworkInfoManager, + locationRepository: LocationRepository, + vpnController: WindVpnController, + connectionDataRepository: ConnectionDataRepository, + serverListRepository: ServerListRepository, + staticListUpdate: StaticIpRepository, + preferenceChangeObserver: PreferenceChangeObserver, + notificationRepository: NotificationRepository, + workManager: WindScribeWorkManager, + decoyTrafficController: DecoyTrafficController, + trafficCounter: TrafficCounter, + autoConnectionManager: AutoConnectionManager, + latencyRepository: LatencyRepository, + receiptValidator: ReceiptValidator, + firebaseManager: FirebaseManager, + advanceParameterRepository: AdvanceParameterRepository + ): ActivityInteractor { + return ActivityInteractorImpl( + activityScope, + coroutineScope, + prefHelper, + apiCallManager, + localDbInterface, + vpnConnectionStateManager, + userRepository, + networkInfoManager, + locationRepository, + vpnController, + connectionDataRepository, + serverListRepository, + staticListUpdate, + preferenceChangeObserver, + notificationRepository, + workManager, + decoyTrafficController, + trafficCounter, + autoConnectionManager, latencyRepository, receiptValidator, + firebaseManager, + advanceParameterRepository + ) + } + + @Provides + fun providesEmergencyConnectViewModal( + scope: CoroutineScope, + windVpnController: WindVpnController, + vpnConnectionStateManager: VPNConnectionStateManager + ): Lazy { + return activity.viewModels { + return@viewModels EmergencyConnectViewModal.provideFactory( + scope, windVpnController, vpnConnectionStateManager + ) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/di/DialogComponent.kt b/mobile/src/main/java/com/windscribe/mobile/di/DialogComponent.kt new file mode 100644 index 000000000..7e3dd865e --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/di/DialogComponent.kt @@ -0,0 +1,11 @@ +package com.windscribe.mobile.di + +import com.windscribe.mobile.dialogs.ShareAppLinkDialog +import com.windscribe.vpn.di.ApplicationComponent +import dagger.Component + +@PerDialog +@Component(dependencies = [ApplicationComponent::class], modules = [DialogModule::class]) +interface DialogComponent { + fun inject(shareAppLinkDialog: ShareAppLinkDialog) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/di/DialogModule.kt b/mobile/src/main/java/com/windscribe/mobile/di/DialogModule.kt new file mode 100644 index 000000000..b24add27a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/di/DialogModule.kt @@ -0,0 +1,7 @@ +package com.windscribe.mobile.di + +import androidx.fragment.app.DialogFragment +import dagger.Module + +@Module +class DialogModule(private var dialogFragment: DialogFragment) \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/di/PerActivity.kt b/mobile/src/main/java/com/windscribe/mobile/di/PerActivity.kt new file mode 100644 index 000000000..30bcc10a4 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/di/PerActivity.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.di + +import javax.inject.Scope + +@Scope +@kotlin.annotation.Retention(AnnotationRetention.RUNTIME) +annotation class PerActivity diff --git a/mobile/src/main/java/com/windscribe/mobile/di/PerDialog.kt b/mobile/src/main/java/com/windscribe/mobile/di/PerDialog.kt new file mode 100644 index 000000000..8c2ed0250 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/di/PerDialog.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.di + +import javax.inject.Scope + +@Scope +@Retention(AnnotationRetention.RUNTIME) +annotation class PerDialog diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/AccountStatusDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/AccountStatusDialog.kt new file mode 100644 index 000000000..730268f6a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/AccountStatusDialog.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.UserAccountStatusLayoutBinding + +interface AccountStatusDialogCallback { + fun onRenewPlanClick() +} + +data class AccountStatusDialogData( + val title: String, + val icon: Int, + val description: String, + val showSkipButton: Boolean, + val skipText: String, + val showUpgradeButton: Boolean, + val upgradeText: String, + val bannedLayout: Boolean = false +) : java.io.Serializable + +class AccountStatusDialog : FullScreenDialog() { + private var accountStatusDialogCallback: AccountStatusDialogCallback? = null + private var binding: UserAccountStatusLayoutBinding? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + accountStatusDialogCallback = context as? AccountStatusDialogCallback + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = UserAccountStatusLayoutBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val accountStatusDialogData = + arguments?.getSerializable(accountStatusDialogDataKey) as? AccountStatusDialogData + accountStatusDialogData?.let { + binding?.userAccountStatusIcon?.setImageResource(accountStatusDialogData.icon) + binding?.userAccountStatusTitle?.text = accountStatusDialogData.title + binding?.userAccountStatusDescription?.text = accountStatusDialogData.description + binding?.userAccountStatusPrimaryButton?.text = accountStatusDialogData.upgradeText + binding?.userAccountStatusSecondaryButton?.text = accountStatusDialogData.skipText + binding?.userAccountStatusSecondaryButton?.visibility = + if (accountStatusDialogData.showSkipButton) View.VISIBLE else View.GONE + binding?.userAccountStatusPrimaryButton?.visibility = + if (accountStatusDialogData.showUpgradeButton) View.VISIBLE else View.GONE + binding?.userAccountStatusSecondaryButton?.setOnClickListener { + dismiss() + } + binding?.userAccountStatusPrimaryButton?.setOnClickListener { + if (accountStatusDialogData.bannedLayout) { + dismiss() + } else { + accountStatusDialogCallback?.onRenewPlanClick() + dismiss() + } + } + } + } + + override fun dismiss() { + val accountStatusDialogData = + arguments?.getSerializable(accountStatusDialogDataKey) as? AccountStatusDialogData + accountStatusDialogData?.let { + if (accountStatusDialogData.bannedLayout) { + activity?.finish() + } + } + super.dismiss() + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + companion object { + const val tag = "AccountStatusDialog" + private const val accountStatusDialogDataKey = "accountStatusDialogData" + fun show(activity: AppCompatActivity, accountStatusDialogData: AccountStatusDialogData) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) return + activity.runOnUiThread { + kotlin.runCatching { + AccountStatusDialog().apply { + Bundle().apply { + putSerializable(accountStatusDialogDataKey, accountStatusDialogData) + arguments = this + } + }.showNow(activity.supportFragmentManager, tag) + } + } + } + + fun hide(activity: AppCompatActivity) { + activity.runOnUiThread { + activity.supportFragmentManager.findFragmentByTag(tag)?.let { + (it as? AccountStatusDialog)?.dismiss() + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/BackgroundLocationPermissionDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/BackgroundLocationPermissionDialog.kt new file mode 100644 index 000000000..f6c10af34 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/BackgroundLocationPermissionDialog.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.setFragmentResult +import com.windscribe.mobile.databinding.BackgroundLocationPermissionDialogBinding +import com.windscribe.mobile.utils.PermissionManagerImpl + + +class BackgroundLocationPermissionDialog : FullScreenDialog() { + private var binding: BackgroundLocationPermissionDialogBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = BackgroundLocationPermissionDialogBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.tvOk?.setOnClickListener { + setFragmentResult(PermissionManagerImpl.resultKey, Bundle().apply { putBoolean(PermissionManagerImpl.okButtonKey, true) }) + dismiss() + } + binding?.tvCancel?.setOnClickListener { + setFragmentResult(PermissionManagerImpl.resultKey, Bundle()) + dismissAllowingStateLoss() + } + dialog?.setOnCancelListener { + setFragmentResult(PermissionManagerImpl.resultKey, Bundle()) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/EditConfigFileDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/EditConfigFileDialog.kt new file mode 100644 index 000000000..8689f2cf3 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/EditConfigFileDialog.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.R +import com.windscribe.mobile.databinding.EditConfigLayoutBinding +import com.windscribe.vpn.serverlist.entity.ConfigFile + +interface EditConfigFileDialogCallback { + fun onConfigFileUpdated(configFile: ConfigFile) + fun onSubmitUsernameAndPassword(configFile: ConfigFile) +} + +class EditConfigFileDialog : FullScreenDialog() { + private var requestDialogCallback: EditConfigFileDialogCallback? = null + private var binding: EditConfigLayoutBinding? = null + override fun onAttach(context: Context) { + super.onAttach(context) + requestDialogCallback = context as? EditConfigFileDialogCallback + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = EditConfigLayoutBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val configFile = arguments?.getSerializable(configFileKey) as? ConfigFile + configFile?.let { + configFile.username?.let { + binding?.username?.setText(it) + } + configFile.password?.let { + binding?.password?.setText(it) + } + binding?.name?.setText(configFile.name) + binding?.rememberCheck?.setImageResource(if (configFile.isRemember) R.drawable.ic_checkmark_on else R.drawable.ic_checkmark_off) + binding?.rememberCheck?.setOnClickListener { + configFile.isRemember = !configFile.isRemember + binding?.rememberCheck?.setImageResource( + if (configFile.isRemember) R.drawable.ic_checkmark_on else R.drawable.ic_checkmark_off + ) + } + binding?.requestAlertOk?.setOnClickListener { + configFile.name = binding?.name?.text.toString() + configFile.username = binding?.username?.text.toString() + configFile.password = binding?.password?.text.toString() + configFile.type = 1 + requestDialogCallback?.onConfigFileUpdated(configFile) + dismiss() + } + binding?.requestAlertCancel?.setOnClickListener { dismiss() } + } + } + + companion object { + const val tag: String = "EditConfigFileDialog" + private const val configFileKey = "configFile" + fun show(activity: AppCompatActivity, configFile: ConfigFile) { + activity.runOnUiThread { + kotlin.runCatching { + EditConfigFileDialog().apply { + Bundle().apply { + putSerializable(configFileKey, configFile) + arguments = this + } + }.showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/ErrorDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/ErrorDialog.kt new file mode 100644 index 000000000..d0709e7c0 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/ErrorDialog.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.FragmentErrorBinding + +class ErrorDialog : FullScreenDialog() { + + private var binding: FragmentErrorBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = FragmentErrorBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.getInt(backgroundColorKey)?.let { + view.setBackgroundColor(it) + } + binding?.error?.text = arguments?.getString(errorKey) + binding?.closeBtn?.requestFocus() + binding?.closeBtn?.setOnClickListener { + dismiss() + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onDismiss(dialog: DialogInterface) { + activity?.let { + if (arguments?.getBoolean(closeActivityKey) == true) { + it.finish() + } + } + super.onDismiss(dialog) + } + + companion object { + const val tag = "error_dialog" + private const val errorKey = "error" + private const val backgroundColorKey = "backgroundColor" + private const val closeActivityKey = "closeActivity" + + @JvmStatic + fun show( + activity: AppCompatActivity, + error: String?, + @ColorInt backgroundColor: Int? = null, + closeActivity: Boolean = false + ) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + ErrorDialog().apply { + Bundle().apply { + putString(errorKey, error) + backgroundColor?.let { putInt(backgroundColorKey, it) } + putBoolean(closeActivityKey, closeActivity) + arguments = this + } + }.showNow(activity.supportFragmentManager, tag) + } + } + } + + fun hide(activity: AppCompatActivity) { + activity.supportFragmentManager.findFragmentByTag(tag)?.let { + (it as? ErrorDialog)?.dismiss() + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/ExtraDataUseWarningDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/ExtraDataUseWarningDialog.kt new file mode 100644 index 000000000..07c06a64c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/ExtraDataUseWarningDialog.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.FragmentExtraDataUseWarningBinding + +interface ExtraDataUseWarningDialogCallBack { + fun turnOnDecoyTraffic() +} + +class ExtraDataUseWarningDialog : FullScreenDialog() { + private var extraDataUseWarningDialogCallBack: ExtraDataUseWarningDialogCallBack? = null + private var binding: FragmentExtraDataUseWarningBinding? = null + override fun onAttach(context: Context) { + super.onAttach(context) + extraDataUseWarningDialogCallBack = context as? ExtraDataUseWarningDialogCallBack + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = FragmentExtraDataUseWarningBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.tvOk?.setOnClickListener { + extraDataUseWarningDialogCallBack?.turnOnDecoyTraffic() + dismiss() + } + binding?.tvCancel?.setOnClickListener { dismiss() } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + companion object { + const val tag = "ExtraDataUseWarningDialog" + fun show(activity: AppCompatActivity) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + ExtraDataUseWarningDialog().showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/ForegroundLocationPermissionDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/ForegroundLocationPermissionDialog.kt new file mode 100644 index 000000000..101835c35 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/ForegroundLocationPermissionDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.setFragmentResult +import com.windscribe.mobile.databinding.ForegroundLocationPermissionDialogBinding +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.okButtonKey +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.resultKey + +class ForegroundLocationPermissionDialog : FullScreenDialog() { + private var binding: ForegroundLocationPermissionDialogBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = ForegroundLocationPermissionDialogBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.tvOk?.setOnClickListener { + setFragmentResult(resultKey, Bundle().apply { putBoolean(okButtonKey, true) }) + dismiss() + } + binding?.tvCancel?.setOnClickListener { + setFragmentResult(resultKey, Bundle()) + dismissAllowingStateLoss() + } + dialog?.setOnCancelListener { + setFragmentResult(resultKey, Bundle()) + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/FullScreenDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/FullScreenDialog.kt new file mode 100644 index 000000000..953781460 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/FullScreenDialog.kt @@ -0,0 +1,50 @@ +package com.windscribe.mobile.dialogs + +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.fragment.app.DialogFragment +import com.windscribe.mobile.R + +open class FullScreenDialog : DialogFragment() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.FullScreenWithNoStatusBar) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + fun setViewWithCutout(view: View) { + val window = activity?.window + window?.setFormat(PixelFormat.RGBA_8888) + var boundingRect: List = ArrayList() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val displayCutout = window?.decorView?.rootWindowInsets?.displayCutout + if (displayCutout != null) { + boundingRect = displayCutout.boundingRects + } + } + if (boundingRect.isNotEmpty()) { + val boundingRectHeight = boundingRect[0].height() + val backButton = view.findViewById(R.id.nav_button) + backButton?.setPaddingRelative( + backButton.paddingStart, + backButton.paddingTop + boundingRectHeight / 2, + backButton.paddingEnd, + backButton.paddingBottom + ) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/LocationPermissionMissingDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/LocationPermissionMissingDialog.kt new file mode 100644 index 000000000..ae16af6eb --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/LocationPermissionMissingDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.setFragmentResult +import com.windscribe.mobile.databinding.LocationPermissionMissingDialogBinding +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.okButtonKey +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.resultKey + +class LocationPermissionMissingDialog : FullScreenDialog() { + private var binding: LocationPermissionMissingDialogBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = LocationPermissionMissingDialogBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.tvOk?.setOnClickListener { + setFragmentResult(resultKey, Bundle().apply { putBoolean(okButtonKey, true) }) + dismiss() + } + binding?.tvCancel?.setOnClickListener { + setFragmentResult(resultKey, Bundle()) + dismissAllowingStateLoss() + } + dialog?.setOnCancelListener { + setFragmentResult(resultKey, Bundle()) + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/NodeStatusDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/NodeStatusDialog.kt new file mode 100644 index 000000000..cf3245fa0 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/NodeStatusDialog.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.NodeStatusLayoutBinding + +interface NodeStatusDialogCallback { + fun checkNodeStatus() +} + +class NodeStatusDialog : FullScreenDialog() { + private var callBack: NodeStatusDialogCallback? = null + private var binding: NodeStatusLayoutBinding? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + callBack = context as? NodeStatusDialogCallback + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = NodeStatusLayoutBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.nodeStatusPrimaryButton?.setOnClickListener { + callBack?.checkNodeStatus() + dismiss() + } + binding?.nodeStatusSecondaryButton?.setOnClickListener { + dismiss() + } + if (arguments?.getBoolean(staticLocation, false) == true) { + binding?.nodeStatusPrimaryButton?.visibility = View.GONE + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + companion object { + const val tag = "NodeStatusDialog" + const val staticLocation = "IsStaticLocation" + fun show(activity: AppCompatActivity, isStaticLocation: Boolean) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + val dialog = NodeStatusDialog() + dialog.arguments = Bundle().apply { putBoolean(staticLocation, isStaticLocation) } + dialog.showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/PowerWhitelistDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/PowerWhitelistDialog.kt new file mode 100644 index 000000000..c6dbdbfab --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/PowerWhitelistDialog.kt @@ -0,0 +1,83 @@ +package com.windscribe.mobile.dialogs + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import com.windscribe.mobile.databinding.PowerWhiteListDialogBinding + +interface PowerWhitelistDialogCallback { + fun neverAskPowerWhiteListPermissionAgain() + fun askPowerWhiteListPermissionLater() + fun askForPowerWhiteListPermission() +} + +class PowerWhitelistDialog : Fragment() { + private var callback: PowerWhitelistDialogCallback? = null + private var binding: PowerWhiteListDialogBinding? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + callback = activity as? PowerWhitelistDialogCallback + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = PowerWhiteListDialogBinding.inflate(inflater, container, false) + return binding?.root + } + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.root?.setOnTouchListener { _, _ -> true } + binding?.ok?.setOnClickListener { + callback?.askForPowerWhiteListPermission() + closeDialog() + } + binding?.later?.setOnClickListener { + callback?.askPowerWhiteListPermissionLater() + closeDialog() + } + binding?.neverAskAgain?.setOnClickListener { + callback?.neverAskPowerWhiteListPermissionAgain() + closeDialog() + } + } + + private fun closeDialog() { + activity?.supportFragmentManager?.beginTransaction()?.remove(this)?.commit() + } + + override fun onStart() { + super.onStart() + activity?.window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + } + + companion object { + private const val TAG = "PowerWhitelistDialog" + + fun show(activity: AppCompatActivity) { + activity.runOnUiThread { + val fragmentManager = activity.supportFragmentManager + if (fragmentManager.findFragmentByTag(TAG) == null) { + val transaction = fragmentManager.beginTransaction() + transaction.add(android.R.id.content, PowerWhitelistDialog(), TAG) + transaction.commitAllowingStateLoss() + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/ProgressDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/ProgressDialog.kt new file mode 100644 index 000000000..8ec262ede --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/ProgressDialog.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.FragmentProgressBinding + +class ProgressDialog : FullScreenDialog() { + + private var binding: FragmentProgressBinding? = null + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = FragmentProgressBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // Delay the progress bar visibility to avoid the flicker + Looper.getMainLooper().let { + Handler(it).postDelayed({ + binding?.progressBar?.visibility = View.VISIBLE + }, 100) + } + arguments?.getString(progressTextKey)?.let { + binding?.progressLabel?.text = it + } + arguments?.getInt(ProgressDialog.backgroundColorKey)?.let { + view.setBackgroundColor(it) + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + fun updateProgressStatus(call: String) { + activity?.runOnUiThread { + binding?.progressLabel?.text = call + } + } + + companion object { + private const val progressTextKey = "progressTextKey" + private const val backgroundColorKey = "backgroundColor" + const val tag = "ProgressDialog" + + @JvmStatic + fun show(activity: AppCompatActivity, progressText: String? = null, @ColorInt backgroundColor: Int? = null) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + ProgressDialog().apply { + Bundle().apply { + putString(progressTextKey, progressText) + backgroundColor?.let { putInt(ProgressDialog.backgroundColorKey, it) } + arguments = this + } + }.show(activity.supportFragmentManager, tag) + } + } + } + + @JvmStatic + fun hide(activity: AppCompatActivity) { + activity.runOnUiThread { + activity.supportFragmentManager.findFragmentByTag(tag)?.let { + (it as ProgressDialog).dismiss() + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/RateAppDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/RateAppDialog.kt new file mode 100644 index 000000000..d695394d8 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/RateAppDialog.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.RateMyDialogBinding + +interface RateAppDialogCallback { + fun neverAskAgainClicked() + fun rateLaterClicked() + fun rateNowClicked() +} + +class RateAppDialog : FullScreenDialog() { + private var rateAppDialogCallback: RateAppDialogCallback? = null + private var binding: RateMyDialogBinding? = null + override fun onAttach(context: Context) { + super.onAttach(context) + rateAppDialogCallback = activity as RateAppDialogCallback? + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = RateMyDialogBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding?.rateMeNow?.setOnClickListener { + rateAppDialogCallback?.rateNowClicked() + dismiss() + } + binding?.rateMeLater?.setOnClickListener { + rateAppDialogCallback?.rateLaterClicked() + dismiss() + } + binding?.neverAskAgain?.setOnClickListener { + rateAppDialogCallback?.neverAskAgainClicked() + dismiss() + } + } + + companion object { + const val tag = "RateAppDialog" + fun show(activity: AppCompatActivity) { + activity.runOnUiThread { + kotlin.runCatching { + RateAppDialog().showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/ShareAppLinkDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/ShareAppLinkDialog.kt new file mode 100644 index 000000000..1b94fcf7d --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/ShareAppLinkDialog.kt @@ -0,0 +1,80 @@ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ShareCompat +import com.windscribe.mobile.R +import com.windscribe.mobile.databinding.FragmentShareAppLinkBinding +import com.windscribe.mobile.di.DaggerDialogComponent +import com.windscribe.mobile.di.DialogModule +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.apppreference.PreferencesHelper +import com.windscribe.vpn.repository.UserRepository +import javax.inject.Inject + +class ShareAppLinkDialog : FullScreenDialog() { + + @Inject + lateinit var userRepository: UserRepository + + @Inject + lateinit var preferencesHelper: PreferencesHelper + + private var binding: FragmentShareAppLinkBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = FragmentShareAppLinkBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + DaggerDialogComponent.builder().applicationComponent(appContext.applicationComponent) + .dialogModule(DialogModule(this)).build().inject(this) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + preferencesHelper.alreadyShownShareAppLink = true + binding?.shareAppNavButton?.setOnClickListener { + dismiss() + } + binding?.shareAppLinkButton?.setOnClickListener { + userRepository.user.value?.let { + val launchActivity = activity as AppCompatActivity + val launchUrl = + "https://play.google.com/store/apps/details?id=${launchActivity.packageName}" + ShareCompat.IntentBuilder(launchActivity).setType("text/plain") + .setChooserTitle(getString(R.string.share_app)) + .setText(getString(R.string.share_app_description, it.userName, launchUrl)) + .startChooser() + } + dismiss() + } + } + + companion object { + const val tag = "ShareAppLinkDialog" + fun show(activity: AppCompatActivity) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + ShareAppLinkDialog().showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/SuccessDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/SuccessDialog.kt new file mode 100644 index 000000000..c284f1700 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/SuccessDialog.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.FragmentSuccessBinding + +class SuccessDialog : FullScreenDialog() { + + private var binding: FragmentSuccessBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = FragmentSuccessBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + arguments?.getInt(backgroundColorKey)?.let { + view.setBackgroundColor(it) + } + binding?.message?.text = arguments?.getString(messageKey) + binding?.closeBtn?.setOnClickListener { + dismiss() + arguments?.getBoolean(exitKey, false)?.let { exit -> + if (exit) { + activity?.finish() + } + } + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + companion object { + const val tag = "success_dialog" + private const val backgroundColorKey = "backgroundColor" + private const val messageKey = "message" + private const val exitKey = "exitKey" + fun show(activity: AppCompatActivity, message: String?, backgroundColor: Int? = null, exitOnClose: Boolean = false) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + SuccessDialog().apply { + Bundle().apply { + putString(messageKey, message) + putBoolean(exitKey, exitOnClose) + backgroundColor?.let { putInt(backgroundColorKey, it) } + arguments = this + } + }.showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/UnknownErrorDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/UnknownErrorDialog.kt new file mode 100644 index 000000000..ae05b0979 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/UnknownErrorDialog.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.databinding.UnknownErrorAlertBinding + + +interface UnknownErrorDialogCallback { + fun contactSupport() + fun exportLog() +} + +class UnknownErrorDialog : FullScreenDialog() { + private var callback: UnknownErrorDialogCallback? = null + private var binding: UnknownErrorAlertBinding? = null + override fun onAttach(context: Context) { + callback = context as? UnknownErrorDialogCallback + super.onAttach(context) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = UnknownErrorAlertBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val error = arguments?.getString(errorKey) + binding?.unknownErrorDescription?.text = error + binding?.unknownErrorContactSupportButton?.setOnClickListener { + callback?.contactSupport() + dismiss() + } + binding?.unknownErrorCancelButton?.setOnClickListener { + dismiss() + } + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + companion object { + const val tag = "UnknownErrorDialog" + private const val errorKey = "error" + fun show(activity: AppCompatActivity, error: String) { + if (activity.supportFragmentManager.findFragmentByTag(tag) != null) { + return + } + activity.runOnUiThread { + kotlin.runCatching { + UnknownErrorDialog().apply { + arguments = Bundle().apply { + putString(errorKey, error) + } + }.showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/dialogs/UsernameAndPasswordRequestDialog.kt b/mobile/src/main/java/com/windscribe/mobile/dialogs/UsernameAndPasswordRequestDialog.kt new file mode 100644 index 000000000..88b803d35 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/dialogs/UsernameAndPasswordRequestDialog.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.dialogs + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import com.windscribe.mobile.R +import com.windscribe.mobile.databinding.UsernameAndPasswordRequestLayoutBinding +import com.windscribe.vpn.serverlist.entity.ConfigFile + +class UsernameAndPasswordRequestDialog : FullScreenDialog() { + private var requestDialogCallback: EditConfigFileDialogCallback? = null + private var binding: UsernameAndPasswordRequestLayoutBinding? = null + override fun onAttach(context: Context) { + super.onAttach(context) + requestDialogCallback = context as? EditConfigFileDialogCallback + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + binding = UsernameAndPasswordRequestLayoutBinding.inflate(inflater, container, false) + return binding?.root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val configFile = arguments?.getSerializable(configFileKey) as? ConfigFile + configFile?.let { + configFile.username?.let { + binding?.username?.setText(it) + } + configFile.password?.let { + binding?.password?.setText(it) + } + binding?.rememberCheck?.setImageResource(if (configFile.isRemember) R.drawable.ic_checkmark_on else R.drawable.ic_checkmark_off) + binding?.rememberCheck?.setOnClickListener { + configFile.isRemember = !configFile.isRemember + binding?.rememberCheck?.setImageResource( + if (configFile.isRemember) R.drawable.ic_checkmark_on else R.drawable.ic_checkmark_off + ) + } + binding?.requestAlertOk?.setOnClickListener { + configFile.username = binding?.username?.text.toString() + configFile.password = binding?.password?.text.toString() + dismiss() + requestDialogCallback?.onSubmitUsernameAndPassword(configFile) + } + binding?.requestAlertCancel?.setOnClickListener { + dismiss() + } + } + } + + companion object { + const val tag = "UsernameAndPasswordRequestDialog" + private const val configFileKey = "configFile" + + fun show(activity: AppCompatActivity, configFile: ConfigFile) { + activity.runOnUiThread { + kotlin.runCatching { + UsernameAndPasswordRequestDialog().apply { + arguments = Bundle().apply { + putSerializable(configFileKey, configFile) + } + }.showNow(activity.supportFragmentManager, tag) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/email/AddEmailActivity.kt b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailActivity.kt new file mode 100644 index 000000000..55744dc4f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailActivity.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.email + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Patterns +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.work.Data +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.confirmemail.ConfirmActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.dialogs.ProgressDialog +import com.windscribe.mobile.welcome.SoftInputAssist +import com.windscribe.mobile.windscribe.WindscribeActivity.Companion.getStartIntent +import com.windscribe.vpn.Windscribe +import com.windscribe.vpn.commonutils.ThemeUtils.getColor +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class AddEmailActivity : BaseActivity(), AddEmailView { + + @BindView(R.id.cl_add_email) + lateinit var constraintLayoutMain: ConstraintLayout + + @BindView(R.id.email_description) + lateinit var emailDescription: TextView + + @BindView(R.id.email) + lateinit var emailEditView: EditText + + @BindView(R.id.email_error) + lateinit var emailErrorView: ImageView + + @BindView(R.id.next) + lateinit var nextButton: TextView + + @BindView(R.id.nav_title) + lateinit var titleView: TextView + + @Inject + lateinit var presenter: AddEmailPresenter + + private val logger = LoggerFactory.getLogger("basic") + private var softInputAssist: SoftInputAssist? = null + + private val generalTextWatcher: TextWatcher = object : TextWatcher { + override fun afterTextChanged(s: Editable) { + if (s.isNotEmpty()) { + if (s.hashCode() == emailEditView.text.hashCode()) { + emailDescription.text = getString(R.string.email_description) + emailDescription.setTextColor( + getColor( + this@AddEmailActivity, + R.attr.wdSecondaryColor, + R.color.colorWhite50 + ) + ) + emailErrorView.visibility = View.GONE + emailEditView.setTextColor( + getColor( + this@AddEmailActivity, + R.attr.wdPrimaryColor, + R.color.colorWhite50 + ) + ) + } + } + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (start == 0 && before == 1 && count == 0) { + nextButton.isPressed = false + } else { + nextButton.isEnabled = + s.hashCode() == emailEditView.text.hashCode() && Patterns.EMAIL_ADDRESS.matcher( + s + ) + .matches() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_add_email_address, true) + presenter.setUpLayout() + } + + override fun onResume() { + softInputAssist?.onResume() + super.onResume() + } + + override fun onPause() { + softInputAssist?.onPause() + super.onPause() + } + + override fun onDestroy() { + softInputAssist?.onDestroy() + presenter.onDestroy() + super.onDestroy() + } + + override fun gotoWindscribeActivity() { + Windscribe.appContext.workManager.updateSession(Data.EMPTY) + if (intent.getBooleanExtra(finishAfterAddEmail, false)) { + finish() + } else if (intent.getBooleanExtra(goToHomeAfterFinish, false)) { + val startIntent = getStartIntent(this) + startIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(startIntent) + } else { + startActivity(ConfirmActivity.getStartIntent(this)) + finish() + } + } + + override fun hideSoftKeyboard() { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow( + window.decorView.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + + @OnClick(R.id.next) + fun onAddEmailClick() { + if (emailEditView.text != null) { + presenter.onAddEmailClicked(emailEditView.text.toString()) + } + } + + @OnClick(R.id.nav_button) + fun onBackButtonPressed() { + onBackPressed() + } + + override fun prepareUiForApiCallFinished() { + logger.info("Preparing ui for api call finished...") + ProgressDialog.hide(this) + } + + override fun prepareUiForApiCallStart() { + logger.info("Preparing ui for api call start...") + ProgressDialog.show(this) + } + + override fun setUpLayout(title: String) { + emailEditView.addTextChangedListener(generalTextWatcher) + softInputAssist = SoftInputAssist(this, intArrayOf()) + titleView.text = title + } + + override fun showInputError(errorText: String) { + emailDescription.setTextColor(resources.getColor(R.color.colorRed)) + emailDescription.text = errorText + emailErrorView.visibility = View.VISIBLE + emailEditView.setTextColor(resources.getColor(R.color.colorRed)) + } + + override fun showToast(toastString: String) { + Toast.makeText(this, toastString, Toast.LENGTH_SHORT).show() + } + + companion object { + const val finishAfterAddEmail = "finishAfterAddEmail" + const val goToHomeAfterFinish = "goToHomeAfterFinish" + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/email/AddEmailPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailPresenter.kt new file mode 100644 index 000000000..d81a8e7e2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailPresenter.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.email + +interface AddEmailPresenter { + fun onAddEmailClicked(emailAddress: String) + fun onDestroy() + fun setUpLayout() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/email/AddEmailPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailPresenterImpl.kt new file mode 100644 index 000000000..a3e718b6a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailPresenterImpl.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.email + +import android.text.TextUtils +import android.util.Patterns +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.AddEmailResponse +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.constants.NetworkErrorCodes +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.repository.CallResult +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class AddEmailPresenterImpl @Inject constructor( + private val emailView: AddEmailView, + private val interactor: ActivityInteractor +) : AddEmailPresenter { + + private val logger = LoggerFactory.getLogger("basic") + + override fun onDestroy() { + if (interactor.getCompositeDisposable().isDisposed.not()) { + interactor.getCompositeDisposable().dispose() + } + } + + override fun onAddEmailClicked(emailAddress: String) { + logger.info("Validating input email address...") + if (TextUtils.isEmpty(emailAddress)) { + logger.info("Email input empty...") + emailView.showInputError(interactor.getResourceString(R.string.email_empty)) + return + } + if (Patterns.EMAIL_ADDRESS.matcher(emailAddress).matches()) { + //Post email address + emailView.hideSoftKeyboard() + emailView.prepareUiForApiCallStart() + logger.info("Posting users email address...") + interactor.getCompositeDisposable().add( + interactor.getApiCallManager() + .addUserEmailAddress(emailAddress) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + logger + .debug("Error adding email address..." + e.localizedMessage) + emailView + .showToast("Sorry! We were unable to add your email address...") + emailView.prepareUiForApiCallFinished() + } + + override fun onSuccess( + postEmailResponseClass: GenericResponseClass + ) { + emailView.prepareUiForApiCallFinished() + when (val result = + postEmailResponseClass.callResult()) { + is CallResult.Error -> { + if (result.code != NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + emailView.showToast(result.errorMessage) + logger.debug( + "Server returned error. " + postEmailResponseClass.errorClass.toString() + ) + emailView.showInputError(result.errorMessage) + } + } + is CallResult.Success -> { + emailView.showToast(interactor.getResourceString(R.string.added_email_successfully)) + logger.info("Email address added successfully...") + emailView.gotoWindscribeActivity() + } + } + } + }) + ) + } else { + emailView.showInputError(interactor.getResourceString(R.string.invalid_email_format)) + } + } + + override fun setUpLayout() { + emailView.setUpLayout(interactor.getResourceString(R.string.add_email)) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/email/AddEmailView.kt b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailView.kt new file mode 100644 index 000000000..255eb6c9b --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/email/AddEmailView.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.email + +interface AddEmailView { + fun gotoWindscribeActivity() + fun hideSoftKeyboard() + fun prepareUiForApiCallFinished() + fun prepareUiForApiCallStart() + fun setUpLayout(title: String) + fun showInputError(errorText: String) + fun showToast(toastString: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/fragments/FeatureFragments.java b/mobile/src/main/java/com/windscribe/mobile/fragments/FeatureFragments.java new file mode 100644 index 000000000..f4bbb31e7 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/fragments/FeatureFragments.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.windscribe.mobile.R; + +public class FeatureFragments extends Fragment { + + private Integer pageNumber; + + public static FeatureFragments newInstance(Integer featurePageNumber) { + FeatureFragments featureFragment = new FeatureFragments(); + Bundle bundle = new Bundle(); + bundle.putInt("feature_page_number", featurePageNumber); + featureFragment.setArguments(bundle); + return featureFragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + pageNumber = getArguments() != null ? getArguments().getInt("feature_page_number") : 0; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view; + if (pageNumber == 0) { + view = inflater.inflate(R.layout.feature_page_1, container, false); + } else if (pageNumber == 1) { + view = inflater.inflate(R.layout.feature_page_2, container, false); + } else if (pageNumber == 2) { + view = inflater.inflate(R.layout.feature_page_3, container, false); + } else { + view = inflater.inflate(R.layout.feature_page_4, container, false); + } + return view; + + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/fragments/FeaturePageTransformer.java b/mobile/src/main/java/com/windscribe/mobile/fragments/FeaturePageTransformer.java new file mode 100644 index 000000000..c68e38a2d --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/fragments/FeaturePageTransformer.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.fragments; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.viewpager.widget.ViewPager; + +public class FeaturePageTransformer implements ViewPager.PageTransformer { + + @Override + public void transformPage(@NonNull View view, float position) { + if (!(position >= -1) || !(position <= 1)) { + view.setAlpha(1); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/fragments/GhostMostAccountFragment.java b/mobile/src/main/java/com/windscribe/mobile/fragments/GhostMostAccountFragment.java new file mode 100644 index 000000000..a91a564e3 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/fragments/GhostMostAccountFragment.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentTransaction; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.listeners.AccountFragmentCallback; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; + + +public class GhostMostAccountFragment extends Fragment { + + @BindView(R.id.login) + Button loginButton; + + @BindView(R.id.nav_title) + TextView titleView; + + private AccountFragmentCallback callback; + + private boolean proUser = false; + + public static GhostMostAccountFragment getInstance() { + return new GhostMostAccountFragment(); + } + + public GhostMostAccountFragment() { + + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + try { + callback = (AccountFragmentCallback) context; + } catch (ClassCastException ignored) { + + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_ghost_account, container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + titleView.setText(getString(R.string.my_account)); + + if (getArguments() != null) { + proUser = getArguments().getBoolean("pro_user", false); + } + loginButton.setVisibility(proUser ? View.GONE : View.VISIBLE); + } + + public void add(AppCompatActivity activity, int container, boolean addToBackStack, boolean proUser) { + FragmentTransaction transaction = activity.getSupportFragmentManager() + .beginTransaction() + .replace(container, this); + Bundle bundle = new Bundle(); + bundle.putBoolean("pro_user", proUser); + setArguments(bundle); + if (addToBackStack) { + transaction.addToBackStack(this.getClass().getName()); + } + transaction.commit(); + } + + @OnClick(R.id.nav_button) + public void onBackPressed() { + if (getActivity() != null) { + getActivity().onBackPressed(); + } + } + + @OnClick(R.id.login) + public void onLoginClicked() { + callback.onLoginClicked(); + } + + @OnClick(R.id.sign_up) + public void onSignUpClicked() { + callback.onSignUpClicked(); + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/fragments/SearchFragment.java b/mobile/src/main/java/com/windscribe/mobile/fragments/SearchFragment.java new file mode 100644 index 000000000..93f8cbbb6 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/fragments/SearchFragment.java @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.fragments; + +import static android.content.Context.SEARCH_SERVICE; + +import android.annotation.SuppressLint; +import android.app.SearchManager; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.media.AudioAttributes; +import android.os.Build; +import android.os.Bundle; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.SearchView; +import androidx.core.content.res.ResourcesCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup; +import com.windscribe.mobile.R; +import com.windscribe.mobile.adapter.ExpandedAdapter; +import com.windscribe.mobile.adapter.SearchRegionsAdapter; +import com.windscribe.mobile.windscribe.WindscribeActivity; +import com.windscribe.vpn.Windscribe; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.serverlist.entity.City; +import com.windscribe.vpn.serverlist.entity.Group; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +import butterknife.BindView; +import butterknife.ButterKnife; + + +@SuppressWarnings("rawtypes") +public class SearchFragment extends Fragment { + + private static final String EXPAND_STATE_MAP = "expandable_recyclerview_adapter_expand_state_map"; + + @BindView(R.id.recycle_server_list) + RecyclerView mRecyclerView; + + @BindView(R.id.minimize_icon) + ImageView minimizeBtn; + + @BindView(R.id.clear_icon) + ImageView clearIcon; + + @BindView(R.id.progress) + ProgressBar progressBar; + + @BindView(R.id.searchView) + SearchView searchView; + + private ServerListData serverListData; + + private ExpandedAdapter expandedAdapter; + + private List groups; + + private int lastPositionSnapped = 0; + + private LinearLayoutManager linearLayoutManager; + + private ListViewClickListener listViewClickListener; + + private SearchRegionsAdapter regionsAdapter; + + public static SearchFragment newInstance(List groups, ServerListData dataDetails, + ListViewClickListener listViewClickListener) { + return new SearchFragment(groups, dataDetails, listViewClickListener); + } + + public SearchFragment(List groups, ServerListData serverListData, + ListViewClickListener listViewClickListener) { + super(); + this.groups = groups; + this.serverListData = serverListData; + this.listViewClickListener = listViewClickListener; + } + + public SearchFragment() { + super(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + if (getActivity() != null) { + View view = inflater.inflate(R.layout.search_layout, container, false); + ButterKnife.bind(this, view); + return view; + } else { + return super.getView(); + } + + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + setUpCustomSearchBox(view); + + // Recycle view + linearLayoutManager = new LinearLayoutManager(getContext()); + mRecyclerView.setLayoutManager(linearLayoutManager); + + if (groups != null) { + regionsAdapter = new SearchRegionsAdapter(groups, serverListData, listViewClickListener); + mRecyclerView.setAdapter(regionsAdapter); + expandedAdapter = new ExpandedAdapter(groups, serverListData, listViewClickListener); + } + + setSearchView(true); + setScrollHapticFeedback(); + + // Force show keyboard once search view has focus. + searchView.setOnQueryTextFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + v.postDelayed(() -> { + InputMethodManager imm = (InputMethodManager) requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(v.findFocus(), 0); + }, 200); + } + }); + } + + public void scrollTo(int scrollTo) { + if (mRecyclerView.getAdapter() != null) { + mRecyclerView.smoothScrollToPosition(scrollTo); + } + } + + public void setSearchView(boolean open) { + if (getActivity() != null) { + final SearchManager searchManager = (SearchManager) getActivity().getSystemService(SEARCH_SERVICE); + if (open) { + searchView.setSearchableInfo(searchManager.getSearchableInfo(getActivity().getComponentName())); + searchView.setIconifiedByDefault(false); + searchView.setFocusable(true); + searchView.setIconified(false); + searchView.requestFocus(); + } else { + if (getActivity() != null) { + searchView.setQuery("", false); + getActivity().onBackPressed(); + } + } + } + } + + @SuppressLint("NotifyDataSetChanged") + public void updateDataSet(ServerListData serverListData) { + regionsAdapter.setServerListData(serverListData); + expandedAdapter.setServerListData(serverListData); + regionsAdapter.notifyDataSetChanged(); + expandedAdapter.notifyDataSetChanged(); + } + + private Group filterIfContains(Group group, String keyword) { + List cities = new ArrayList<>(); + if (group.getItems() != null) { + for (Object item : group.getItems()) { + City city = (City) item; + if (city.getNickName().toLowerCase().contains(keyword.toLowerCase()) | city.getNodeName() + .toLowerCase().contains(keyword.toLowerCase())) { + cities.add(city); + } + } + } + + if (cities.size() > 0) { + return new Group(group.getTitle(), group.getRegion(), cities, group.getLatencyAverage()); + } + + boolean groupFound = group.getTitle().toLowerCase().contains(keyword.toLowerCase()); + if (cities.size() == 0 && groupFound) { + return group; + } + return null; + } + + private boolean filterIfStartWith(Group group, String keyword) { + if (group.getTitle().toLowerCase().startsWith(keyword.toLowerCase())) { + return true; + } + if (group.getItems() != null) { + for (Object item : group.getItems()) { + City city = (City) item; + if (city.getNickName().toLowerCase().startsWith(keyword.toLowerCase()) | city.getNodeName() + .toLowerCase().startsWith(keyword.toLowerCase())) { + return true; + } + } + } + return false; + } + + private Comparator getComparator(String part) { + return (o1, o2) -> { + boolean containsFirst = filterIfStartWith(o1, part); + boolean containsSecond = filterIfStartWith(o2, part); + + if (containsFirst && !containsSecond) { + return -1; + } + + if (!containsFirst && containsSecond) { + return 1; + } + + return 0; + }; + } + + private void setScrollHapticFeedback() { + mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + int centerView = linearLayoutManager.findFirstVisibleItemPosition(); + updateAdapterPosition(centerView); + } + }); + } + + private void setUpCustomSearchBox(View view) { + // Search view + searchView.setIconifiedByDefault(false); + searchView.setQueryHint(getString(R.string.search)); + searchView.setFocusable(false); + // Filter results on text change + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextChange(String s) { + if (s.isEmpty()) { + clearIcon.setVisibility(View.GONE); + } else { + clearIcon.setVisibility(View.VISIBLE); + } + if (regionsAdapter != null) { + // Group list + List groupList = new ArrayList<>(); + for (ExpandableGroup expandableGroup : groups) { + Group group = (Group) expandableGroup; + groupList.add(group); + } + // Group list sorted by keyword + Collections.sort(groupList, getComparator(s)); + + // Only keep item with keyword + List updatedList = new ArrayList<>(); + for (Group group : groupList) { + Group filteredGroup = filterIfContains(group, s); + if (filteredGroup != null) { + updatedList.add(filteredGroup); + } + } + if (updatedList.size() < groups.size()) { + expandedAdapter = new ExpandedAdapter(updatedList, + serverListData, listViewClickListener); + Bundle bundle = new Bundle(); + boolean[] states = new boolean[updatedList.size()]; + for (int i = 0; i < updatedList.size(); i++) { + states[i] = true; + } + bundle.putBooleanArray(EXPAND_STATE_MAP, states); + expandedAdapter.onRestoreInstanceState(bundle); + mRecyclerView.setAdapter(expandedAdapter); + } else { + regionsAdapter = new SearchRegionsAdapter(groups, + serverListData, listViewClickListener); + mRecyclerView.setAdapter(regionsAdapter); + } + } + return false; + } + + @Override + public boolean onQueryTextSubmit(String s) { + searchView.clearFocus(); + return true; + } + }); + + // Search text + TextView searchText = searchView.findViewById(androidx.appcompat.R.id.search_src_text); + int color = ThemeUtils.getColor(searchView.getContext(), R.attr.nodeListGroupTextColor, R.color.colorWhite40); + searchText.setTextColor(color); + searchText.setHintTextColor(color); + searchText.setTextSize(Dimension.SP, 14); + Typeface typeface = ResourcesCompat.getFont(view.getContext(), R.font.ibm_plex_sans_regular); + searchText.setTypeface(typeface); + searchText.setPadding(0, 0, 0, 0); + // Clear text + clearIcon.setOnClickListener(v -> { + searchView.clearFocus(); + searchView.setQuery("", false); + regionsAdapter = new SearchRegionsAdapter(groups, serverListData, listViewClickListener); + mRecyclerView.setAdapter(regionsAdapter); + }); + // Search icon + ImageView searchIcon = this.searchView.findViewById(androidx.appcompat.R.id.search_mag_icon); + searchIcon.setPadding(0, 0, 0, 0); + searchIcon.setScaleType(ImageView.ScaleType.FIT_START); + searchIcon.setImageTintList(ColorStateList.valueOf(color)); + // Minimize + minimizeBtn.setImageTintList(ColorStateList.valueOf(color)); + minimizeBtn.setOnClickListener(v -> { + if (getActivity() != null) { + setSearchView(false); + } + }); + + } + + private void updateAdapterPosition(int position) { + boolean hapticFeedbackEnabled = Windscribe.getAppContext().getPreference().isHapticFeedbackEnabled(); + if (position == lastPositionSnapped | !hapticFeedbackEnabled) { + return; + } + Vibrator vibrator = (Vibrator) requireActivity() + .getSystemService(Context.VIBRATOR_SERVICE); + if (getActivity() instanceof WindscribeActivity && vibrator != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + VibrationEffect vibrationEffect = VibrationEffect.createOneShot(8, 255); + if (vibrationEffect != null) { + try { + vibrator.vibrate(vibrationEffect); + } catch (Exception ignored) { + } + } + + } else { + AudioAttributes audioAttributes = new AudioAttributes.Builder().build(); + vibrator.vibrate(8, audioAttributes); + } + } + lastPositionSnapped = position; + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/fragments/ServerListFragment.kt b/mobile/src/main/java/com/windscribe/mobile/fragments/ServerListFragment.kt new file mode 100644 index 000000000..5be77b954 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/fragments/ServerListFragment.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.fragments + +import android.annotation.SuppressLint +import android.content.Context +import android.media.AudioAttributes +import android.os.Build +import android.os.Bundle +import android.os.VibrationEffect +import android.os.Vibrator +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.ConfigAdapter +import com.windscribe.mobile.custom_view.refresh.RecyclerRefreshLayout +import com.windscribe.mobile.custom_view.refresh.RefreshViewEg +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.di.DaggerActivityComponent +import com.windscribe.mobile.holder.ConfigViewHolder +import com.windscribe.mobile.holder.RemoveConfigHolder +import com.windscribe.mobile.windscribe.FragmentClickListener +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.Windscribe.Companion.appContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +class ServerListFragment : Fragment() { + + @JvmField + @BindView(R.id.cl_data_status) + var upgradeLayout: ConstraintLayout? = null + + @JvmField + @BindView(R.id.recycler_view_server_list) + var recyclerView: RecyclerView? = null + private var lastPositionSnapped = 0 + + @JvmField + @BindView(R.id.tv_add_config_button) + var addConfigButton: TextView? = null + + @JvmField + @BindView(R.id.tv_deviceName) + var deviceName: TextView? = null + + @JvmField + @BindView(R.id.img_nothing_to_show) + var imageViewBrokenHeart: ImageView? = null + + @JvmField + @BindView(R.id.tv_reload) + var reloadViewButton: TextView? = null + + @JvmField + @BindView(R.id.tv_adapter_load_error) + var textViewAdapterLoadError: TextView? = null + + @JvmField + @BindView(R.id.tv_add_button) + var textViewAddButton: TextView? = null + + @JvmField + @BindView(R.id.data_left) + var textViewDataRemaining: TextView? = null + + @JvmField + @BindView(R.id.data_upgrade_label) + var textViewDataUpgrade: TextView? = null + + @JvmField + @BindView(R.id.cl_server_list_fragment) + var serverListParentLayout: ConstraintLayout? = null + + @JvmField + @BindView(R.id.recycler_view_server_list_swipe) + var swipeRefreshLayout: RecyclerRefreshLayout? = null + + private var fragmentClickListener: FragmentClickListener? = null + private var linearLayoutManager: LinearLayoutManager? = null + private var mFragmentNumber = 0 + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DaggerActivityComponent.builder().activityModule(ActivityModule()) + .applicationComponent( + appContext + .applicationComponent + ).build().inject(this) + arguments?.let { + mFragmentNumber = it.getInt("fragment_number", 0) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val fragmentView = inflater.inflate(R.layout.server_list_fragment_layout, container, false) + ButterKnife.bind(this, fragmentView) + linearLayoutManager = LinearLayoutManager(context) + linearLayoutManager?.orientation = RecyclerView.VERTICAL + recyclerView?.layoutManager = linearLayoutManager + linearLayoutManager?.isItemPrefetchEnabled = false + swipeRefreshLayout?.isEnabled = false + activity?.lifecycleScope?.launch { + delay(1500) + setSwipeRefreshLayout(fragmentView) + setScrollHapticFeedback() + } + return fragmentView + } + + fun addSwipeListener() { + ItemTouchHelper(object : + ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + override fun getSwipeDirs( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + if (viewHolder is ConfigViewHolder) { + return ItemTouchHelper.LEFT + } + return if (viewHolder is RemoveConfigHolder) { + ItemTouchHelper.RIGHT + } else super.getSwipeDirs(recyclerView, viewHolder) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + @SuppressLint("NotifyDataSetChanged") + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + if (recyclerView?.isComputingLayout == true) { + return + } + val adapter = recyclerView?.adapter + if (adapter is ConfigAdapter) { + val configFiles = adapter.configFiles + if (direction == ItemTouchHelper.LEFT && configFiles.size > 0) { + for (configFile in configFiles) { + configFile.type = 1 + } + val type = configFiles[viewHolder.adapterPosition].type + if (type == 1) { + configFiles[viewHolder.adapterPosition].type = 2 + } + adapter.notifyDataSetChanged() + } + if (direction == ItemTouchHelper.RIGHT) { + for (configFile in configFiles) { + if (configFile.type == 2) { + configFile.type = 1 + } + } + adapter.notifyDataSetChanged() + } + } + } + }).attachToRecyclerView(recyclerView) + } + + fun clearErrors() { + imageViewBrokenHeart?.visibility = View.GONE + textViewAdapterLoadError?.visibility = View.GONE + textViewAddButton?.visibility = View.GONE + textViewAdapterLoadError?.text = "" + reloadViewButton?.visibility = View.GONE + addConfigButton?.visibility = View.GONE + } + + fun hideUpgradeLayout() { + upgradeLayout?.visibility = View.GONE + } + + // Add static ip + @OnClick(R.id.tv_add_button, R.id.tv_add_config_button) + fun onAddClick() { + if (fragmentClickListener != null) { + if (mFragmentNumber == 4) { + fragmentClickListener?.onAddConfigClick() + } else { + fragmentClickListener?.onStaticIpClick() + } + } + } + + @OnClick(R.id.tv_reload) + fun onReloadClick() { + if (fragmentClickListener != null) { + fragmentClickListener?.onReloadClick() + } + } + + @OnClick(R.id.data_upgrade_label) + fun onUpgradeViewClick() { + if (fragmentClickListener != null) { + fragmentClickListener?.onUpgradeClicked() + } + } + + fun scrollTo(scrollTo: Int) { + if (recyclerView?.adapter != null) { + recyclerView?.smoothScrollToPosition(scrollTo) + } + } + + fun setAddMoreConfigLayout(error: String?, configCount: Int) { + if (activity == null) { + return + } + clearErrors() + setSwipeRefreshLayoutEnabled(false) + if (configCount == 0) { + + imageViewBrokenHeart + ?.setImageDrawable( + ContextCompat.getDrawable( + requireActivity(), + R.drawable.ic_custom_config_icon + ) + ) + imageViewBrokenHeart?.visibility = View.VISIBLE + textViewAdapterLoadError?.text = error + textViewAdapterLoadError?.visibility = View.VISIBLE + addConfigButton?.visibility = View.VISIBLE + } else { + textViewAddButton?.visibility = View.VISIBLE + textViewAddButton?.setText(R.string.add_vpn_config) + } + } + + fun setErrorNoItems(errorNoItems: String?) { + clearErrors() + setSwipeRefreshLayoutEnabled(false) + imageViewBrokenHeart?.visibility = View.VISIBLE + textViewAdapterLoadError?.visibility = View.VISIBLE + textViewAdapterLoadError?.text = errorNoItems + } + + fun setErrorNoStaticIp(btnText: String?, error: String?, deviceName: String?) { + clearErrors() + textViewAdapterLoadError?.visibility = View.VISIBLE + textViewAddButton?.visibility = View.VISIBLE + textViewAdapterLoadError?.text = error + textViewAddButton?.text = btnText + if (deviceName != null && deviceName.isNotEmpty()) { + this.deviceName?.visibility = View.VISIBLE + this.deviceName?.text = deviceName + } else { + this.deviceName?.visibility = View.INVISIBLE + this.deviceName?.text = "" + } + } + + fun setFragmentClickListener(fragmentClickListener: FragmentClickListener?) { + this.fragmentClickListener = fragmentClickListener + } + + fun setLoadRetry(message: String?) { + clearErrors() + textViewAdapterLoadError?.visibility = View.VISIBLE + textViewAdapterLoadError?.text = message + reloadViewButton?.text = resources.getString(R.string.retry) + reloadViewButton?.visibility = View.VISIBLE + } + + fun setRefreshingLayout(refreshing: Boolean) { + swipeRefreshLayout?.setRefreshing(refreshing) + swipeRefreshLayout?.isEnabled = refreshing + } + + fun setSwipeRefreshLayoutEnabled(enabled: Boolean) { + if (swipeRefreshLayout != null) { + swipeRefreshLayout?.isEnabled = enabled && canPullToRefresh() + } + } + + fun showUpgradeLayout(color: Int, upgradeLabel: String?, dataLeft: String?) { + upgradeLayout?.visibility = View.VISIBLE + textViewDataUpgrade?.text = upgradeLabel + textViewDataRemaining?.text = dataLeft + textViewDataRemaining?.setTextColor(color) + } + + private fun canPullToRefresh(): Boolean { + val firstViewPosition = linearLayoutManager?.findFirstVisibleItemPosition() + val childCount = linearLayoutManager?.childCount ?: 0 + return firstViewPosition == 0 && childCount > 0 + } + + private fun onRefreshForPing() { + var fragmentIndex = 0 + arguments?.let { + fragmentIndex = it.getInt("fragment_number", 0) + } + when (fragmentIndex) { + 0 -> fragmentClickListener?.onRefreshPingsForAllServers() + 1 -> fragmentClickListener?.onRefreshPingsForFavouritesServers() + 2 -> fragmentClickListener?.onRefreshPingsForStreamingServers() + 3 -> fragmentClickListener?.onRefreshPingsForStaticServers() + 4 -> fragmentClickListener?.onRefreshPingsForConfigServers() + } + } + + private fun setScrollHapticFeedback() { + recyclerView?.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + val centerView = linearLayoutManager?.findFirstVisibleItemPosition() + if (centerView != null) { + updateAdapterPosition(centerView) + } + swipeRefreshLayout?.isEnabled = canPullToRefresh() + } + }) + } + + private fun setSwipeRefreshLayout(view: View) { + // Refresh layout + val refreshViewEg = RefreshViewEg(activity) + swipeRefreshLayout?.setRefreshView(refreshViewEg, view.layoutParams) + refreshViewEg.layoutParams.height = resources.getDimension(R.dimen.reg_80dp).roundToInt() + refreshViewEg.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT + swipeRefreshLayout?.isEnabled = canPullToRefresh() + swipeRefreshLayout?.setRefreshInitialOffset( + -resources.getDimension(R.dimen.reg_68dp).roundToInt().toFloat() + ) + swipeRefreshLayout?.isNestedScrollingEnabled = false + swipeRefreshLayout?.setOnRefreshListener { + if (fragmentClickListener != null) { + if (activity is WindscribeActivity) { + (activity as WindscribeActivity?)?.performButtonClickHapticFeedback() + } + if ((linearLayoutManager?.childCount ?: 0) > 0) { + onRefreshForPing() + } + } + } + } + + private fun updateAdapterPosition(position: Int) { + val hapticFeedbackEnabled = appContext.preference.isHapticFeedbackEnabled + if (position == lastPositionSnapped || !hapticFeedbackEnabled) { + return + } + val vibrator = requireActivity().getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + if (activity is WindscribeActivity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val vibrationEffect = VibrationEffect.createOneShot(8, 255) + if (vibrationEffect != null) { + try { + vibrator.vibrate(vibrationEffect) + } catch (ignored: Exception) { + } + } + } else { + val audioAttributes = AudioAttributes.Builder().build() + vibrator.vibrate(8, audioAttributes) + } + } + lastPositionSnapped = position + } + + companion object { + fun newInstance(number: Int): ServerListFragment { + val serverListFragment = ServerListFragment() + val bundle = Bundle() + bundle.putInt("fragment_number", number) + serverListFragment.arguments = bundle + return serverListFragment + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsActivity.kt b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsActivity.kt new file mode 100644 index 000000000..bb116ebee --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsActivity.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.generalsettings + +import android.app.Activity +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.documentfile.provider.DocumentFile +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.CustomDialog +import com.windscribe.mobile.custom_view.preferences.AppBackgroundView +import com.windscribe.mobile.custom_view.preferences.DropDownView +import com.windscribe.mobile.custom_view.preferences.ToggleView +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.mainmenu.MainMenuActivity +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.state.PreferenceChangeObserver +import org.apache.commons.io.IOUtils +import org.slf4j.LoggerFactory +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import javax.inject.Inject + +class GeneralSettingsActivity : BaseActivity(), GeneralSettingsView { + + private val logger = LoggerFactory.getLogger("basic") + + @BindView(R.id.cl_app_background_settings) + lateinit var appBackgroundDropDown: AppBackgroundView + + @BindView(R.id.cl_selection_settings) + lateinit var locationSelectionDropDown: DropDownView + + @BindView(R.id.cl_language_settings) + lateinit var languageDropDown: DropDownView + + @BindView(R.id.cl_latency_settings) + lateinit var latencyDropDown: DropDownView + + @BindView(R.id.cl_theme_settings) + lateinit var themeDropDown: DropDownView + + @BindView(R.id.cl_notification_settings) + lateinit var notificationToggle: ToggleView + + @BindView(R.id.cl_show_health) + lateinit var locationLoadToggle: ToggleView + + @BindView(R.id.cl_haptic_settings) + lateinit var hapticToggle: ToggleView + + @BindView(R.id.nav_button) + lateinit var imgGeneralBackButton: ImageView + + @BindView(R.id.nav_title) + lateinit var tvActivityTitle: TextView + + @BindView(R.id.tv_version_label) + lateinit var tvVersionLabel: TextView + + @BindView(R.id.tv_version_selection) + lateinit var versionSelection: TextView + + @Inject + lateinit var presenter: GeneralSettingsPresenter + + @Inject + lateinit var preferenceChangeObserver: PreferenceChangeObserver + + @Inject + lateinit var sendDebugDialog: CustomDialog + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_general_settings, true) + setUpCustomViewDelegates() + logger.info("Setting up layout based on saved mode settings...") + presenter.setupInitialLayout() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK && data != null) { + val fileUri = data.data + logger.info(String.format("Received file uri %s", fileUri.toString())) + val file = fileUri?.let { uriToFile(it) } + if (file != null) { + logger.info("Converted uri to file") + try { + contentResolver.openInputStream(fileUri).use { inputStream -> + FileOutputStream(file).use { outputStream -> + if (isTypeImage(fileUri)) { + presenter.resizeAndSaveBitmap(inputStream!!, outputStream) + } else { + IOUtils.copy(inputStream, outputStream) + } + val path = file.absolutePath + logger.info(String.format("Saved file to %s", path)) + if (requestCode == DISCONNECTED_FLAG_PATH_PICK_REQUEST) { + presenter.onDisConnectedFlagPathPicked(path) + } else if (requestCode == CONNECTED_FLAG_PATH_PICK_REQUEST) { + presenter.onConnectedFlagPathPicked(path) + } + } + } + } catch (e: IOException) { + logger.info("Error copying file from input stream") + showToast("Error copying image to app's internal storage") + } + } else { + logger.info("Invalid file type") + showToast("Invalid file.") + } + } + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override val orderList: Array + get() = resources.getStringArray(R.array.order_list) + override val themeList: Array + get() = resources.getStringArray(R.array.theme_list) + + @OnClick(R.id.nav_button) + fun onBackButtonClicked() { + logger.info("User clicked on back arrow ...") + onBackPressed() + } + + private fun setUpCustomViewDelegates() { + locationSelectionDropDown.delegate = object : DropDownView.Delegate { + override fun onItemSelect(value: String) { + presenter.onSelectionSelected(value) + } + + override fun onExplainClick() {} + } + latencyDropDown.delegate = object : DropDownView.Delegate { + override fun onItemSelect(value: String) { + presenter.onLatencyTypeSelected(value) + } + + override fun onExplainClick() {} + } + languageDropDown.delegate = object : DropDownView.Delegate { + override fun onItemSelect(value: String) { + presenter.onLanguageSelected(value) + } + + override fun onExplainClick() {} + } + themeDropDown.delegate = object : DropDownView.Delegate { + override fun onItemSelect(value: String) { + presenter.onThemeSelected(value) + } + + override fun onExplainClick() {} + } + notificationToggle.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.onNotificationToggleButtonClicked() + } + + override fun onExplainClick() {} + } + locationLoadToggle.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.onShowHealthToggleClicked() + } + + override fun onExplainClick() {} + } + hapticToggle.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.onHapticToggleButtonClicked() + } + + override fun onExplainClick() {} + } + appBackgroundDropDown.delegate = object : AppBackgroundView.Delegate { + override fun onItemSelect(value: String) { + presenter.onAppBackgroundValueChanged(value) + } + + override fun onFirstRightIconClick() { + logger.info("User clicked on disconnected flag edit button...") + presenter.onDisconnectedFlagEditClicked( + DISCONNECTED_FLAG_PATH_PICK_REQUEST + ) + } + + override fun onSecondRightIconClick() { + logger.info("User clicked on connected flag edit button...") + presenter.onConnectedFlagEditClicked(CONNECTED_FLAG_PATH_PICK_REQUEST) + } + } + } + + override fun openFileChooser(requestCode: Int) { + val pickIntent = Intent(Intent.ACTION_OPEN_DOCUMENT) + pickIntent.type = "*/*" + logger.info(String.format("Creating pick intent for %s", requestCode)) + if (pickIntent.resolveActivity(packageManager) != null) { + startActivityForResult(pickIntent, requestCode) + } else { + logger.info("Pick intent did not resolve to any activity.") + showToast("No File manager found.") + } + } + + override fun registerLocaleChangeListener() { + preferenceChangeObserver.addLanguageChangeObserver(this) { + setLanguage() + presenter.onLanguageChanged() + } + } + + override fun resetTextResources( + title: String, + sortBy: String, + latencyDisplay: String, + language: String, + appearance: String, + notificationState: String, + hapticFeedback: String, + version: String, + connected: String, + disconnected: String, + appBackground: String + ) { + tvActivityTitle.text = title + locationSelectionDropDown.setTitle(sortBy) + latencyDropDown.setTitle(latencyDisplay) + languageDropDown.setTitle(language) + themeDropDown.setTitle(appearance) + notificationToggle.setTitle(notificationState) + hapticToggle.setTitle(hapticFeedback) + tvVersionLabel.text = version + appBackgroundDropDown.setTitle(appBackground) + appBackgroundDropDown.setFirstItemDescription(disconnected) + appBackgroundDropDown.setSecondItemDescription(connected) + } + + override fun setActivityTitle(activityTitle: String) { + tvActivityTitle.text = activityTitle + } + + override fun setAppVersionText(versionText: String) { + versionSelection.text = versionText + } + + override fun setConnectedFlagPath(path: String) { + appBackgroundDropDown.setSecondItemDescription(path) + } + + override fun setDisconnectedFlagPath(path: String) { + appBackgroundDropDown.setFirstItemDescription(path) + } + + override fun setFlagSizeLabel(label: String) { + appBackgroundDropDown.setFirstItemTitle(label) + appBackgroundDropDown.setSecondItemTitle(label) + } + + override fun setLanguageTextView(language: String) { + languageDropDown.setCurrentValue(language) + reloadApp() + } + + override fun setLatencyType(latencyType: String) { + latencyDropDown.setCurrentValue(latencyType) + } + + override fun setSelectionTextView(selection: String) { + locationSelectionDropDown.setCurrentValue(selection) + } + + override fun setupCustomFlagAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) { + appBackgroundDropDown.setAdapter(localiseValues, selectedKey, keys) + } + + override fun setupHapticToggleImage(ic_toggle_button_off: Int) { + hapticToggle.setToggleImage(ic_toggle_button_off) + } + + override fun setupLanguageAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) { + languageDropDown.setAdapter(localiseValues, selectedKey, keys) + } + + override fun setupLatencyAdapter( + localiseValues: Array, + selelctedKey: String, + keys: Array + ) { + latencyDropDown.setAdapter(localiseValues, selelctedKey, keys) + } + + override fun setupLocationHealthToggleImage(image: Int) { + locationLoadToggle.setToggleImage(image) + } + + override fun setupNotificationToggleImage(ic_toggle_button_off: Int) { + notificationToggle.setToggleImage(ic_toggle_button_off) + } + + override fun setupSelectionAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) { + locationSelectionDropDown.setAdapter(localiseValues, selectedKey, keys) + } + + override fun setupThemeAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) { + themeDropDown.setAdapter(localiseValues, selectedKey, keys) + } + + fun showToast(toastString: String) { + Toast.makeText(this, toastString, Toast.LENGTH_SHORT).show() + } + + private fun getFileName(fileUri: Uri): String? { + val fileDocument = DocumentFile.fromSingleUri(this, fileUri) + return fileDocument?.name + } + + private fun isTypeImage(fileUri: Uri): Boolean { + val fileName = getFileName(fileUri) + return if (fileName != null) { + fileName.endsWith(".jpeg") or fileName.endsWith(".jpg") or fileName.endsWith(".png") + } else false + } + + override fun reloadApp() { + TaskStackBuilder.create(this).addNextIntent(WindscribeActivity.getStartIntent(this)) + .addNextIntent(MainMenuActivity.getStartIntent(this)) + .addNextIntentWithParentStack(intent).startActivities() + } + + private fun uriToFile(fileUri: Uri): File? { + val fileName = getFileName(fileUri) + if (fileName != null) { + if (fileName.endsWith(".jpeg") or fileName.endsWith(".jpg") or fileName.endsWith(".png") or fileName + .endsWith(".gif") + ) { + return File(filesDir, fileName) + } + } + return null + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, GeneralSettingsActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsPresenter.kt new file mode 100644 index 000000000..f920cb874 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsPresenter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.generalsettings + +import android.content.Context +import java.io.InputStream +import java.io.OutputStream + +interface GeneralSettingsPresenter { + val savedLocale: String + fun onConnectedFlagEditClicked(requestCode: Int) + fun onConnectedFlagPathPicked(path: String) + fun onDestroy() + fun onDisConnectedFlagPathPicked(path: String) + fun onDisconnectedFlagEditClicked(requestCode: Int) + fun onHapticToggleButtonClicked() + fun onLanguageChanged() + fun onLanguageSelected(selectedLanguage: String) + fun onLatencyTypeSelected(latencyType: String) + fun onNotificationToggleButtonClicked() + fun onSelectionSelected(selection: String) + fun onShowHealthToggleClicked() + fun onThemeSelected(theme: String) + fun resizeAndSaveBitmap(inputStream: InputStream, outputStream: OutputStream) + fun setTheme(context: Context) + fun setupInitialLayout() + fun onAppBackgroundValueChanged(value: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsPresenterImpl.kt new file mode 100644 index 000000000..5e83883f2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsPresenterImpl.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.generalsettings + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.commonutils.WindUtilities +import com.windscribe.vpn.constants.PreferencesKeyConstants +import org.slf4j.LoggerFactory +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class GeneralSettingsPresenterImpl @Inject constructor( + private var settingsView: GeneralSettingsView, private var interactor: ActivityInteractor +) : GeneralSettingsPresenter { + + private val logger = LoggerFactory.getLogger("basic") + + override fun onDestroy() { + if (interactor.getCompositeDisposable().isDisposed.not()) { + interactor.getPreferenceChangeObserver().postLocationSettingsChange() + logger.info("Disposing observer...") + interactor.getCompositeDisposable().dispose() + } + } + + override val savedLocale: String + get() { + val selectedLanguage = interactor.getAppPreferenceInterface().savedLanguage + return selectedLanguage.substring( + selectedLanguage.indexOf("(") + 1, selectedLanguage.indexOf(")") + ) + } + + override fun onConnectedFlagEditClicked(requestCode: Int) { + settingsView.openFileChooser(requestCode) + } + + override fun onConnectedFlagPathPicked(path: String) { + val lastPath = interactor.getAppPreferenceInterface().connectedFlagPath + if (lastPath != null) { + val file = File(appContext.filesDir, lastPath) + if (file.exists()) { + val success = file.delete() + if (success) { + interactor.getAppPreferenceInterface().connectedFlagPath = null + } + } + } + interactor.getAppPreferenceInterface().connectedFlagPath = path + settingsView.setConnectedFlagPath(path) + } + + override fun onDisConnectedFlagPathPicked(path: String) { + val lastPath = interactor.getAppPreferenceInterface().disConnectedFlagPath + if (lastPath != null) { + val file = File(appContext.filesDir, lastPath) + if (file.exists()) { + val success = file.delete() + if (success) { + interactor.getAppPreferenceInterface().setDisconnectedFlagPath(null) + } + } + } + interactor.getAppPreferenceInterface().setDisconnectedFlagPath(path) + settingsView.setDisconnectedFlagPath(path) + } + + override fun onDisconnectedFlagEditClicked(requestCode: Int) { + settingsView.openFileChooser(requestCode) + } + + override fun onHapticToggleButtonClicked() { + if (interactor.getAppPreferenceInterface().isHapticFeedbackEnabled) { + logger.info("Previous haptic Toggle Settings: True") + interactor.getAppPreferenceInterface().setHapticFeedbackEnabled(false) + settingsView.setupHapticToggleImage(R.drawable.ic_toggle_button_off) + } else { + logger.info("Previous haptic Toggle Settings: False") + interactor.getAppPreferenceInterface().setHapticFeedbackEnabled(true) + settingsView.setupHapticToggleImage(R.drawable.ic_toggle_button_on) + } + } + + override fun onLanguageChanged() { + settingsView.resetTextResources( + interactor.getResourceString(R.string.general), + interactor.getResourceString(R.string.sort_by), + interactor.getResourceString(R.string.display_latency), + interactor.getResourceString(R.string.preferred_language), + interactor.getResourceString(R.string.theme), + interactor.getResourceString(R.string.show_timer_in_notifications), + interactor.getResourceString(R.string.haptic_setting_label), + interactor.getResourceString(R.string.version), + interactor.getResourceString(R.string.connected_lower_case), + interactor.getResourceString(R.string.disconnected_lower_case), + interactor.getResourceString(R.string.app_background) + ) + } + + override fun onLanguageSelected(selectedKey: String) { + //Save the selected language + val savedLanguage = interactor.getSavedLanguage() + val selectedIndex = interactor.getStringArray(R.array.language_codes).indexOf(selectedKey) + val selectedLanguage = interactor.getStringArray(R.array.language)[selectedIndex] + if (savedLanguage == selectedLanguage) { + logger.info("Language selected is same as saved. No action taken...") + } else { + interactor.saveSelectedLanguage(selectedLanguage) + settingsView.setLanguageTextView(selectedLanguage) + interactor.getPreferenceChangeObserver().postLanguageChange(selectedLanguage) + } + } + + override fun onLatencyTypeSelected(latencyType: String) { + val savedLatencyType = interactor.getAppPreferenceInterface().latencyType + if (savedLatencyType == latencyType) { + logger.info("Same latency selected as saved.") + } else { + logger.info("Saving selected latency type") + interactor.getAppPreferenceInterface().latencyType = latencyType + interactor.getServerListUpdater().invalidateServerListUI() + } + } + + override fun onNotificationToggleButtonClicked() { + if (interactor.getAppPreferenceInterface().notificationStat) { + logger.info("Previous notification Toggle Settings: True") + interactor.getAppPreferenceInterface().notificationStat = false + settingsView.setupNotificationToggleImage(R.drawable.ic_toggle_button_off) + interactor.getTrafficCounter().reset(false) + } else { + logger.info("Previous Notification Toggle Settings: False") + interactor.getAppPreferenceInterface().notificationStat = true + settingsView.setupNotificationToggleImage(R.drawable.ic_toggle_button_on) + interactor.getTrafficCounter().reset(true) + } + } + + override fun onSelectionSelected(selection: String) { + val savedSelection = interactor.getSavedSelection() + if (savedSelection == selection) { + logger.info("List selection selected is same as saved. No action taken...") + } else { + interactor.saveSelection(selection) + interactor.getServerListUpdater().invalidateServerListUI() + } + } + + override fun onShowHealthToggleClicked() { + if (interactor.getAppPreferenceInterface().isShowLocationHealthEnabled) { + logger.info("Previous show location health Toggle Settings: True") + interactor.getAppPreferenceInterface().isShowLocationHealthEnabled = false + settingsView.setupLocationHealthToggleImage(R.drawable.ic_toggle_button_off) + } else { + logger.info("Previous show location health Toggle Settings: False") + interactor.getAppPreferenceInterface().isShowLocationHealthEnabled = true + settingsView.setupLocationHealthToggleImage(R.drawable.ic_toggle_button_on) + } + interactor.getServerListUpdater().invalidateServerListUI() + } + + override fun onThemeSelected(theme: String) { + val savedTheme = interactor.getAppPreferenceInterface().selectedTheme + if (savedTheme == theme) { + logger.info("Same theme selected as saved.") + } else { + logger.info("Saving selected theme") + interactor.getAppPreferenceInterface().selectedTheme = theme + appContext.applicationInterface.setTheme() + settingsView.reloadApp() + } + } + + override fun resizeAndSaveBitmap(inputStream: InputStream, outputStream: OutputStream) { + val bitmap = BitmapFactory.decodeStream(inputStream) + val requiredHeight = interactor.getAppPreferenceInterface().flagViewHeight + if (bitmap.height > requiredHeight) { + val customBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, requiredHeight) + customBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + } else { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + } + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + logger.debug("Setting theme to $savedThem") + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + override fun setupInitialLayout() { + val savedLanguage = interactor.getSavedLanguage() + + //Setup language settings + settingsView.setupLanguageAdapter( + interactor.getStringArray(R.array.language), appContext.getLanguageCode(savedLanguage), interactor.getStringArray(R.array.language_codes) + ) + + // Setup notification stats toggle + settingsView.setupNotificationToggleImage( + if (interactor.getAppPreferenceInterface().notificationStat) R.drawable.ic_toggle_button_on else R.drawable.ic_toggle_button_off + ) + + // Setup Haptic toggle + settingsView.setupHapticToggleImage( + if (interactor.getAppPreferenceInterface().isHapticFeedbackEnabled) R.drawable.ic_toggle_button_on else R.drawable.ic_toggle_button_off + ) + + // Setup Show Location health + settingsView.setupLocationHealthToggleImage( + if (interactor.getAppPreferenceInterface().isShowLocationHealthEnabled) R.drawable.ic_toggle_button_on else R.drawable.ic_toggle_button_off + ) + + // Setup selection settings + val savedSelection = interactor.getAppPreferenceInterface().selection + settingsView.setupSelectionAdapter( + interactor.getStringArray(R.array.order_list), + savedSelection, + interactor.getStringArray(R.array.order_list_keys) + ) + + // Setup theme + val savedTheme = interactor.getAppPreferenceInterface().selectedTheme + settingsView.setupThemeAdapter( + interactor.getStringArray(R.array.theme_list), + savedTheme, + interactor.getStringArray(R.array.theme_list_keys) + ) + + // Setup latency settings + val savedLatencyType = interactor.getAppPreferenceInterface().latencyType + settingsView.setupLatencyAdapter( + interactor.getStringArray(R.array.latency_selection), + savedLatencyType, + interactor.getStringArray(R.array.latency_selection_keys) + ) + settingsView.setAppVersionText(WindUtilities.getVersionName()) + settingsView.setActivityTitle(interactor.getResourceString(R.string.general)) + settingsView.registerLocaleChangeListener() + setupAppBackgroundAdapter(interactor.getAppPreferenceInterface().isCustomBackground) + val expandedFlagWidth = interactor.getAppPreferenceInterface().flagViewWidth + val expandedFlagHeight = interactor.getAppPreferenceInterface().flagViewHeight + val flagDimensionsText = String.format("%sx%s", expandedFlagWidth, expandedFlagHeight) + settingsView.setFlagSizeLabel(flagDimensionsText) + } + + private fun setupAppBackgroundAdapter(custom: Boolean) { + if (custom) { + interactor.getAppPreferenceInterface().isCustomBackground = true + settingsView.setupCustomFlagAdapter( + interactor.getStringArray(R.array.background_list), + interactor.getStringArray(R.array.background_list_keys)[1], + interactor.getStringArray(R.array.background_list_keys) + ) + } else { + interactor.getAppPreferenceInterface().isCustomBackground = false + settingsView.setupCustomFlagAdapter( + interactor.getStringArray(R.array.background_list), + interactor.getStringArray(R.array.background_list_keys)[0], + interactor.getStringArray(R.array.background_list_keys) + ) + } + setAppBackgroundPaths() + } + + override fun onAppBackgroundValueChanged(value: String) { + val newValue = value == interactor.getStringArray(R.array.background_list_keys)[1] + if (newValue != interactor.getAppPreferenceInterface().isCustomBackground) { + interactor.getAppPreferenceInterface().isCustomBackground = newValue + setAppBackgroundPaths() + } + } + + private fun setAppBackgroundPaths() { + val disconnectedFlagPath = interactor.getAppPreferenceInterface().disConnectedFlagPath + val connectedFlagPath = interactor.getAppPreferenceInterface().connectedFlagPath + settingsView.setDisconnectedFlagPath( + (if (disconnectedFlagPath != null) Uri.parse( + disconnectedFlagPath + ).path else "")!! + ) + settingsView.setConnectedFlagPath( + (if (connectedFlagPath != null) Uri.parse( + connectedFlagPath + ).path else "")!! + ) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsView.kt b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsView.kt new file mode 100644 index 000000000..81a02184a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/generalsettings/GeneralSettingsView.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.generalsettings + +interface GeneralSettingsView { + val orderList: Array + val themeList: Array + fun openFileChooser(requestCode: Int) + fun registerLocaleChangeListener() + fun resetTextResources( + title: String, + sortBy: String, + latencyDisplay: String, + language: String, + appearance: String, + notificationState: String, + hapticFeedback: String, + version: String, + connected: String, + disconnected: String, + appBackground: String + ) + + fun setActivityTitle(activityTitle: String) + fun setAppVersionText(versionText: String) + fun setConnectedFlagPath(path: String) + fun setDisconnectedFlagPath(path: String) + fun setFlagSizeLabel(label: String) + fun setLanguageTextView(language: String) + fun setLatencyType(latencyType: String) + fun setSelectionTextView(selection: String) + fun reloadApp() + fun setupCustomFlagAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) + + fun setupHapticToggleImage(ic_toggle_button_off: Int) + fun setupLanguageAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) + + fun setupLatencyAdapter( + localiseValues: Array, + selelctedKey: String, + keys: Array + ) + + fun setupLocationHealthToggleImage(image: Int) + fun setupNotificationToggleImage(ic_toggle_button_off: Int) + fun setupSelectionAdapter( + localiseValues: Array, + selectedKey: String, + keys: Array + ) + + fun setupThemeAdapter(localiseValues: Array, selectedKey: String, keys: Array) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingFragmentListener.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingFragmentListener.java new file mode 100644 index 000000000..3e7e3efbc --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingFragmentListener.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing; + +public interface GpsSpoofingFragmentListener { + + void checkSuccess(); + + void exit(); + + void openDeveloperSettings(); + + void openSettings(); + + void setFragment(int index); + +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingPresenter.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingPresenter.java new file mode 100644 index 000000000..e8df93660 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingPresenter.java @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing; + +public interface GpsSpoofingPresenter { + + void onError(); + + void onSuccess(); +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingPresenterImp.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingPresenterImp.java new file mode 100644 index 000000000..b0440e5b0 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingPresenterImp.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing; + +import com.windscribe.vpn.ActivityInteractor; + +import javax.inject.Inject; + +public class GpsSpoofingPresenterImp implements GpsSpoofingPresenter { + + private final ActivityInteractor gpsSpoofingInteractor; + + private final GpsSpoofingSettingView gpsSpoofingSettingView; + + @Inject + public GpsSpoofingPresenterImp(GpsSpoofingSettingView gpsSpoofingSettingView, + ActivityInteractor activityInteractor) { + this.gpsSpoofingInteractor = activityInteractor; + this.gpsSpoofingSettingView = gpsSpoofingSettingView; + } + + @Override + public void onError() { + gpsSpoofingInteractor.getAppPreferenceInterface().setGpsSpoofing(true); + gpsSpoofingSettingView.onError(); + } + + @Override + public void onSuccess() { + gpsSpoofingInteractor.getAppPreferenceInterface().setGpsSpoofing(true); + gpsSpoofingSettingView.onSuccess(); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingSettingView.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingSettingView.java new file mode 100644 index 000000000..3293a5719 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingSettingView.java @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing; + +public interface GpsSpoofingSettingView { + + void onError(); + + void onSuccess(); +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingSettingsActivity.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingSettingsActivity.java new file mode 100644 index 000000000..b6b6f940f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/GpsSpoofingSettingsActivity.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.provider.Settings; +import android.widget.Toast; + +import androidx.viewpager2.widget.ViewPager2; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.adapter.GpsSpoofingPagerAdapter; +import com.windscribe.mobile.base.BaseActivity; +import com.windscribe.mobile.di.ActivityModule; +import com.windscribe.vpn.mocklocation.MockLocationManager; + +import javax.inject.Inject; + +import butterknife.BindView; +import butterknife.ButterKnife; + +public class GpsSpoofingSettingsActivity extends BaseActivity + implements GpsSpoofingSettingView, GpsSpoofingFragmentListener { + + @Inject + GpsSpoofingPresenter mPresenter; + + @BindView(R.id.view_pager) + ViewPager2 viewPager; + + public static Intent getStartIntent(Context context) { + return new Intent(context, GpsSpoofingSettingsActivity.class); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setActivityModule(new ActivityModule(this, this)).inject(this); + setContentLayout(R.layout.activity_gps_spoofing_settings, true); + ButterKnife.bind(this); + setViewSetViewPager(); + } + + @Override + public void checkSuccess() { + if (MockLocationManager.isAppSelectedInMockLocationList(getApplicationContext()) && MockLocationManager + .isDevModeOn(getApplicationContext())) { + mPresenter.onSuccess(); + } else { + mPresenter.onError(); + } + } + + @Override + public void exit() { + finish(); + } + + @Override + public void onError() { + setFragment(4); + } + + @Override + public void onSuccess() { + setFragment(3); + } + + @Override + public void openDeveloperSettings() { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (intent.resolveActivity(getPackageManager()) != null) { + startActivity(intent); + } else { + Toast.makeText(this, "Developer settings not found.", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void openSettings() { + Intent intent = new Intent(Settings.ACTION_SETTINGS); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (intent.resolveActivity(getPackageManager()) != null) { + startActivity(intent); + } else { + Toast.makeText(this, "Settings App not found.", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void setFragment(int index) { + viewPager.setCurrentItem(index); + } + + private void setViewSetViewPager() { + GpsSpoofingPagerAdapter adapter = new GpsSpoofingPagerAdapter(getSupportFragmentManager(), getLifecycle()); + viewPager.setAdapter(adapter); + viewPager.setCurrentItem(0); + viewPager.setUserInputEnabled(false); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingDeveloperSettings.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingDeveloperSettings.java new file mode 100644 index 000000000..b1dd498a5 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingDeveloperSettings.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.fragment.app.Fragment; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.gpsspoofing.GpsSpoofingFragmentListener; + +import org.jetbrains.annotations.NotNull; + +import butterknife.ButterKnife; +import butterknife.OnClick; + + +public class GpsSpoofingDeveloperSettings extends Fragment { + + private GpsSpoofingFragmentListener mListener; + + public GpsSpoofingDeveloperSettings() { + + } + + @Override + public void onAttach(@NotNull Context context) { + super.onAttach(context); + if (context instanceof GpsSpoofingFragmentListener) { + mListener = (GpsSpoofingFragmentListener) context; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + + View view = inflater.inflate(R.layout.gps_spoofing_developer, container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } + + @OnClick(R.id.close) + public void onBackPressed() { + if (mListener != null) { + mListener.exit(); + } + } + + @OnClick(R.id.next) + public void onNextPressed() { + if (mListener != null) { + mListener.setFragment(2); + } + } + + @OnClick(R.id.open_setting) + public void onOpenSettingsClick() { + if (mListener != null) { + mListener.openSettings(); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingError.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingError.java new file mode 100644 index 000000000..ba8c00578 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingError.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.fragment.app.Fragment; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.gpsspoofing.GpsSpoofingFragmentListener; + +import org.jetbrains.annotations.NotNull; + +import butterknife.ButterKnife; +import butterknife.OnClick; + + +public class GpsSpoofingError extends Fragment { + + private GpsSpoofingFragmentListener mListener; + + public GpsSpoofingError() { + + } + + @Override + public void onAttach(@NotNull Context context) { + super.onAttach(context); + if (context instanceof GpsSpoofingFragmentListener) { + mListener = (GpsSpoofingFragmentListener) context; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.gps_spoofing_error, container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } + + @OnClick(R.id.close) + public void onBackClick() { + if (mListener != null) { + mListener.exit(); + } + } + + @OnClick(R.id.start) + public void onStartClick() { + if (mListener != null) { + mListener.setFragment(1); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingMockSettings.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingMockSettings.java new file mode 100644 index 000000000..707693c24 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingMockSettings.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing.fragments; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.gpsspoofing.GpsSpoofingFragmentListener; + +import org.jetbrains.annotations.NotNull; + +import butterknife.BindView; +import butterknife.ButterKnife; +import butterknife.OnClick; + + +public class GpsSpoofingMockSettings extends Fragment { + + private GpsSpoofingFragmentListener mListener; + + @BindView(R.id.feature_explain) + TextView explainerText; + + public GpsSpoofingMockSettings() { + + } + + @Override + public void onAttach(@NotNull Context context) { + super.onAttach(context); + if (context instanceof GpsSpoofingFragmentListener) { + mListener = (GpsSpoofingFragmentListener) context; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + + View view = inflater.inflate(R.layout.gps_spoofing_mock_settings, container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + explainerText.setText(getString(R.string.add_to_mock_location_explain_android_12)); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } + + @OnClick(R.id.close) + public void onBackPressed() { + if (mListener != null) { + mListener.exit(); + } + } + + @OnClick(R.id.next) + public void onNextPressed() { + if (mListener != null) { + mListener.checkSuccess(); + } + } + + @OnClick(R.id.open_setting) + public void onOpenSettingsClick() { + if (mListener != null) { + mListener.openDeveloperSettings(); + } + } + + @OnClick(R.id.previous) + public void onPreviousPressed() { + if (mListener != null) { + mListener.setFragment(1); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingStart.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingStart.java new file mode 100644 index 000000000..f890f8a8e --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingStart.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.fragment.app.Fragment; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.gpsspoofing.GpsSpoofingFragmentListener; + +import org.jetbrains.annotations.NotNull; + +import butterknife.ButterKnife; +import butterknife.OnClick; + + +public class GpsSpoofingStart extends Fragment { + + private GpsSpoofingFragmentListener mListener; + + public GpsSpoofingStart() { + + } + + @Override + public void onAttach(@NotNull Context context) { + super.onAttach(context); + if (context instanceof GpsSpoofingFragmentListener) { + mListener = (GpsSpoofingFragmentListener) context; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.gps_spoofing_start, container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } + + @OnClick(R.id.back) + public void onBackClick() { + if (mListener != null) { + mListener.exit(); + } + } + + @OnClick(R.id.start) + public void onStartClick() { + if (mListener != null) { + mListener.setFragment(1); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingSuccess.java b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingSuccess.java new file mode 100644 index 000000000..d70b5e5d7 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/gpsspoofing/fragments/GpsSpoofingSuccess.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.gpsspoofing.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.fragment.app.Fragment; + +import com.windscribe.mobile.R; +import com.windscribe.mobile.gpsspoofing.GpsSpoofingFragmentListener; + +import org.jetbrains.annotations.NotNull; + +import butterknife.ButterKnife; +import butterknife.OnClick; + + +public class GpsSpoofingSuccess extends Fragment { + + private GpsSpoofingFragmentListener mListener; + + public GpsSpoofingSuccess() { + + } + + @Override + public void onAttach(@NotNull Context context) { + super.onAttach(context); + if (context instanceof GpsSpoofingFragmentListener) { + mListener = (GpsSpoofingFragmentListener) context; + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.gps_spoofing_success, container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onDetach() { + super.onDetach(); + mListener = null; + } + + @OnClick(R.id.close) + public void onCloseClick() { + if (mListener != null) { + mListener.exit(); + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/help/HelpActivity.kt b/mobile/src/main/java/com/windscribe/mobile/help/HelpActivity.kt new file mode 100644 index 000000000..828fb23dd --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/help/HelpActivity.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.help + +import android.annotation.SuppressLint +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.ConstraintLayout +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.advance.AdvanceParamsActivity +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.preferences.MultipleLinkExplainView +import com.windscribe.mobile.custom_view.preferences.SingleLinkExplainView +import com.windscribe.mobile.debug.DebugViewActivity.Companion.getStartIntent +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.ticket.SendTicketActivity +import com.windscribe.mobile.utils.UiUtil +import javax.inject.Inject + +class HelpActivity : BaseActivity(), HelpView { + @Inject + lateinit var presenter: HelpPresenter + + @BindView(R.id.debug_progress) + lateinit var imgProgress: ProgressBar + + @BindView(R.id.tv_send_label) + lateinit var sendDebugLogLabel: TextView + + @BindView(R.id.cl_debug_send) + lateinit var sendDebugView: ConstraintLayout + + @BindView(R.id.tv_debug_progress_label) + lateinit var labelProgress: TextView + + @BindView(R.id.nav_title) + lateinit var tvActivityTitle: TextView + + @BindView(R.id.tv_view_label) + lateinit var debugViewLabel: TextView + + @BindView(R.id.cl_debug_view) + lateinit var debugView: ConstraintLayout + + @BindView(R.id.sendTicket) + lateinit var sendTicketView: SingleLinkExplainView + + @BindView(R.id.cl_advance) + lateinit var advanceView: SingleLinkExplainView + + private var logSent = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_help, true) + presenter.init() + addClickListeners() + activityScope { + presenter.observeUserStatus() + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun addClickListeners() { + val knowledgeBtn = findViewById(R.id.knowledge) + knowledgeBtn.onClick { presenter.onKnowledgeBaseClick() } + val garryBtn = findViewById(R.id.garry) + garryBtn.onClick { presenter.onGarryClick() } + val ticketBtn = findViewById(R.id.sendTicket) + ticketBtn.onClick { presenter.onSendTicketClick() } + val communityBtn = findViewById(R.id.community) + communityBtn.onFirstItemClick { presenter.onRedditClick() } + communityBtn.onSecondItemClick { presenter.onDiscordClick() } + advanceView.onClick { presenter.advanceViewClick() } + UiUtil.setupOnTouchListener(container = debugView, textView = debugViewLabel) + UiUtil.setupOnTouchListener(container = sendDebugView, textView = sendDebugLogLabel) + } + + override fun goToSendTicket() { + startActivity(SendTicketActivity.getStartIntent(this)) + } + + @OnClick(R.id.nav_button) + fun onBackButtonPressed() { + super.onBackPressed() + } + + @OnClick(R.id.cl_debug_send, R.id.tv_send_label) + fun onSendDebugClicked() { + if (logSent.not()) { + presenter.onSendDebugClicked() + } + } + + @OnClick(R.id.cl_debug_view) + fun onViewLogClicked() { + val intent = getStartIntent(this, false) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun openInBrowser(url: String) { + openURLInBrowser(url) + } + + override fun setActivityTitle(title: String) { + tvActivityTitle.text = title + } + + override fun showProgress(inProgress: Boolean, success: Boolean) { + if (inProgress) { + imgProgress.visibility = View.VISIBLE + labelProgress.visibility = View.INVISIBLE + sendDebugLogLabel.text = getString(R.string.sending_log) + } else { + labelProgress.visibility = View.VISIBLE + val msg = + if (success) resources.getString(R.string.sent_thanks) else getString(R.string.error_try_again) + labelProgress.text = msg + imgProgress.visibility = View.INVISIBLE + sendDebugLogLabel.text = getString(R.string.send_log) + logSent = true + } + } + + override fun setSendTicketVisibility(visible: Boolean) { + if (visible) { + sendTicketView.visibility = View.VISIBLE + } else { + sendTicketView.visibility = View.GONE + } + } + + override fun showToast(message: String) { + runOnUiThread { Toast.makeText(this@HelpActivity, message, Toast.LENGTH_SHORT).show() } + } + + override fun showAdvanceParamsActivity() { + val startIntent = Intent(this, AdvanceParamsActivity::class.java) + startActivity(startIntent) + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, HelpActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/help/HelpPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/help/HelpPresenter.kt new file mode 100644 index 000000000..7a89002a9 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/help/HelpPresenter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.help + +import android.content.Context + +interface HelpPresenter { + fun init() + fun onDiscordClick() + fun onGarryClick() + fun onKnowledgeBaseClick() + fun onRedditClick() + fun onSendDebugClicked() + fun onSendTicketClick() + fun setTheme(context: Context) + suspend fun observeUserStatus() + fun advanceViewClick() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/help/HelpPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/help/HelpPresenterImpl.kt new file mode 100644 index 000000000..34628b704 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/help/HelpPresenterImpl.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.help + +import android.content.Context +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.api.response.GenericSuccess +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.constants.NetworkKeyConstants.getWebsiteLink +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.constants.UserStatusConstants +import com.windscribe.vpn.errormodel.WindError.Companion.instance +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class HelpPresenterImpl @Inject constructor( + private val helpView: HelpView, + private val interactor: ActivityInteractor +) : HelpPresenter { + private val logger = LoggerFactory.getLogger("basic") + override fun init() { + helpView.setActivityTitle(interactor.getResourceString(R.string.help_me)) + } + + override suspend fun observeUserStatus() { + interactor.getUserRepository().userInfo.collectLatest { + helpView.setSendTicketVisibility(it.isPro) + } + } + + override fun onDiscordClick() { + helpView.openInBrowser(NetworkKeyConstants.URL_DISCORD) + } + + override fun onGarryClick() { + helpView.openInBrowser(getWebsiteLink(NetworkKeyConstants.URL_GARRY)) + } + + override fun onKnowledgeBaseClick() { + helpView.openInBrowser(getWebsiteLink(NetworkKeyConstants.URL_KNOWLEDGE)) + } + + override fun onRedditClick() { + helpView.openInBrowser(NetworkKeyConstants.URL_REDDIT) + } + + override fun onSendDebugClicked() { + val userInGhostMode = interactor.getAppPreferenceInterface().userName == "na" + if (userInGhostMode) { + helpView.showToast("Log in send logs.") + return + } + helpView.showProgress(inProgress = true, success = false) + logger.info("Preparing debug file...") + interactor.getCompositeDisposable().add( + Single.fromCallable { interactor.getEncodedLog() } + .flatMap { encodedLog: String -> + logger.info("Reading log file successful, submitting app log...") + interactor.getApiCallManager() + .postDebugLog(interactor.getAppPreferenceInterface().userName, encodedLog) + }.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + helpView.showProgress(inProgress = false, success = false) + if (e is Exception) { + logger.debug( + "Error Submitting Log: " + + instance.rxErrorToString(e) + ) + } + } + + override fun onSuccess( + appLogSubmissionResponse: GenericResponseClass + ) { + helpView.showProgress( + false, appLogSubmissionResponse.dataClass != null && appLogSubmissionResponse.dataClass?.isSuccessful == true + ) + } + }) + ) + } + + override fun onSendTicketClick() { + helpView.goToSendTicket() + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + override fun advanceViewClick() { + helpView.showAdvanceParamsActivity() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/help/HelpView.kt b/mobile/src/main/java/com/windscribe/mobile/help/HelpView.kt new file mode 100644 index 000000000..421600da2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/help/HelpView.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.help + +interface HelpView { + fun goToSendTicket() + fun openInBrowser(url: String) + fun setActivityTitle(title: String) + fun showProgress(inProgress: Boolean, success: Boolean) + fun showToast(message: String) + fun setSendTicketVisibility(visible: Boolean) + fun showAdvanceParamsActivity() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/CityViewHolder.java b/mobile/src/main/java/com/windscribe/mobile/holder/CityViewHolder.java new file mode 100644 index 000000000..58206284b --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/CityViewHolder.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.holder; + +import android.util.Log; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.thoughtbot.expandablerecyclerview.viewholders.ChildViewHolder; +import com.windscribe.mobile.R; + +public class CityViewHolder extends ChildViewHolder implements View.OnClickListener { + + public final ImageView imgFavorite; + + public final ImageView imgLinkSpeed; + + public final ImageView imgSignalStrengthBar; + + public final TextView nodeGroupName; + + public final LinearProgressIndicator serverHealth; + + public final TextView tvSignalStrength; + + + public CityViewHolder(View itemView) { + super(itemView); + nodeGroupName = itemView.findViewById(R.id.node_name); + imgFavorite = itemView.findViewById(R.id.img_favorite); + imgSignalStrengthBar = itemView.findViewById(R.id.signal_strength_bar); + tvSignalStrength = itemView.findViewById(R.id.tv_signal_strength); + imgLinkSpeed = itemView.findViewById(R.id.link_speed); + serverHealth = itemView.findViewById(R.id.server_health); + } + + + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.cl_node_locations: + Log.d(this.getClass().getSimpleName(), "CONNECT TO " + nodeGroupName.getText().toString()); + break; + case R.id.node_name: + break; + default: + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/ConfigViewHolder.java b/mobile/src/main/java/com/windscribe/mobile/holder/ConfigViewHolder.java new file mode 100644 index 000000000..fa7f6e47f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/ConfigViewHolder.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.holder; + +import android.annotation.SuppressLint; +import android.content.res.ColorStateList; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.constants.NetworkKeyConstants; +import com.windscribe.vpn.serverlist.entity.ConfigFile; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +public class ConfigViewHolder extends RecyclerView.ViewHolder { + + private final TextView configNameView; + + private final ImageView imgFavoriteItemStrengthBar; + + private final TextView tvFavouriteItemStrength; + + public ConfigViewHolder(@NonNull View itemView) { + super(itemView); + configNameView = itemView.findViewById(R.id.config_name); + tvFavouriteItemStrength = itemView.findViewById(R.id.tv_config_item_strength); + imgFavoriteItemStrengthBar = itemView.findViewById(R.id.config_item_strength_bar); + } + + public void onBind(ConfigFile configFile, ListViewClickListener listViewClickListener, ServerListData serverListData, + int pingResult) { + configNameView.setText(configFile.getName()); + itemView.setOnClickListener(v -> listViewClickListener.onConfigFileClicked(configFile)); + if (serverListData.isShowLatencyInBar()) { + tvFavouriteItemStrength.setVisibility(View.GONE); + imgFavoriteItemStrengthBar.setVisibility(View.VISIBLE); + if (pingResult != -1) { + if (pingResult > -1 && pingResult < NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT) { + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_3_bar); + } else if (pingResult >= NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT) { + + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_2_bar); + + } else if (pingResult >= NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_1_BAR_UPPER_LIMIT) { + + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_1_bar); + + } else { + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_no_bar); + } + } + } else { + tvFavouriteItemStrength.setVisibility(View.VISIBLE); + imgFavoriteItemStrengthBar.setVisibility(View.GONE); + tvFavouriteItemStrength.setText(pingResult != -1 ? String.valueOf(pingResult) : "--"); + } + setTouchListener(this); + } + + private void setTextAndIconColors(ConfigViewHolder holder, int selectedColor) { + holder.imgFavoriteItemStrengthBar.setImageTintList(ColorStateList.valueOf(selectedColor)); + holder.configNameView.setTextColor(ColorStateList.valueOf(selectedColor)); + } + + @SuppressLint("ClickableViewAccessibility") + private void setTouchListener(ConfigViewHolder holder) { + holder.itemView.setOnTouchListener((View v, MotionEvent event) -> { + int defaultColor = ThemeUtils.getColor(v.getContext(), R.attr.wdSecondaryColor, R.color.colorWhite50); + int selectedColor = ThemeUtils.getColor(v.getContext(), R.attr.wdPrimaryColor, R.color.colorWhite50); + if (event.getAction() == MotionEvent.ACTION_DOWN) { + setTextAndIconColors(holder, selectedColor); + } else { + setTextAndIconColors(holder, defaultColor); + } + return false; + }); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/FavoriteViewHolder.java b/mobile/src/main/java/com/windscribe/mobile/holder/FavoriteViewHolder.java new file mode 100644 index 000000000..9a719e47a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/FavoriteViewHolder.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.holder; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.windscribe.mobile.R; + +public class FavoriteViewHolder extends RecyclerView.ViewHolder { + + public final ImageView imgFavorite; + + public final ImageView imgFavoriteItemStrengthBar; + + public final ImageView imgLinkSpeed; + + public final LinearProgressIndicator serverHealth; + + public final TextView tvFavoriteCityName; + + public final TextView tvFavouriteItemStrength; + + public FavoriteViewHolder(View itemView) { + super(itemView); + tvFavoriteCityName = itemView.findViewById(R.id.favorite_city_name); + imgFavoriteItemStrengthBar = itemView.findViewById(R.id.favorite_item_strength_bar); + tvFavouriteItemStrength = itemView.findViewById(R.id.tv_favorite_item_strength); + imgFavorite = itemView.findViewById(R.id.img_favorite_in_favorites); + imgLinkSpeed = itemView.findViewById(R.id.link_speed); + serverHealth = itemView.findViewById(R.id.server_health); + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/LogViewHolder.kt b/mobile/src/main/java/com/windscribe/mobile/holder/LogViewHolder.kt new file mode 100644 index 000000000..476781029 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/LogViewHolder.kt @@ -0,0 +1,15 @@ +package com.windscribe.mobile.holder + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.windscribe.mobile.R + + +class LogViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + fun bind(log: String){ + val view = itemView.findViewById(R.id.debugViewLabel) + view.text = log + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/RegionViewHolder.java b/mobile/src/main/java/com/windscribe/mobile/holder/RegionViewHolder.java new file mode 100644 index 000000000..e20413d3c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/RegionViewHolder.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.holder; + +import android.animation.Animator; +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.RotateAnimation; +import android.view.animation.ScaleAnimation; +import android.widget.ImageView; +import android.widget.TextView; + +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.thoughtbot.expandablerecyclerview.viewholders.GroupViewHolder; +import com.windscribe.mobile.R; +import com.windscribe.vpn.commonutils.ThemeUtils; +import com.windscribe.vpn.constants.AnimConstants; + +public class RegionViewHolder extends GroupViewHolder { + + public interface ItemExpandListener { + + void onItemExpand(); + + void onItemCollapse(); + } + + public final ImageView imgAnimationLine; + + public final ImageView imgCountryFlag; + + public ImageView imgDropDown; + + public final ImageView imgProBadge; + + public final ImageView imgP2pBadge; + + public final LinearProgressIndicator serverLoadBar; + + public final TextView tvCountryName; + + private final ArgbEvaluator argbEvaluator = new ArgbEvaluator(); + + private ItemExpandListener itemExpandListener; + + public RegionViewHolder(View itemView) { + super(itemView); + imgDropDown = itemView.findViewById(R.id.img_drop_down); + tvCountryName = itemView.findViewById(R.id.country_name); + imgCountryFlag = itemView.findViewById(R.id.country_flag); + imgDropDown = itemView.findViewById(R.id.img_drop_down); + imgAnimationLine = itemView.findViewById(R.id.field_line_location); + imgProBadge = itemView.findViewById(R.id.img_pro_badge); + serverLoadBar = itemView.findViewById(R.id.server_health); + imgP2pBadge = itemView.findViewById(R.id.img_p2p); + } + + @Override + public void collapse() { + super.collapse(); + final ValueAnimator valueAnimator = ValueAnimator.ofFloat(0f, 1f); + valueAnimator.addUpdateListener(valueAnimator1 -> tvCountryName.setTextColor((Integer) argbEvaluator + .evaluate(valueAnimator1.getAnimatedFraction(), ThemeUtils + .getColor(itemView.getContext(), R.attr.nodeListGroupTextColorSelected, + R.color.colorWhite), + ThemeUtils.getColor(itemView.getContext(), R.attr.nodeListGroupTextColor, + R.color.colorWhite)))); + valueAnimator.setDuration(AnimConstants.RECYCLER_VIEW_UNDERLINE_ANIMATION_DURATION); + valueAnimator.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationCancel(Animator animation) { + valueAnimator.removeAllListeners(); + } + + @Override + public void onAnimationEnd(Animator animation) { + valueAnimator.removeAllListeners(); + if (itemExpandListener != null) { + itemExpandListener.onItemCollapse(); + } + } + + @Override + public void onAnimationRepeat(Animator animation) { + + } + + @Override + public void onAnimationStart(Animator animation) { + } + }); + valueAnimator.start(); + scaleLineAnimation(itemView.findViewById(R.id.field_line_location), 1, 0, View.GONE); + rotateImageAnimation(itemView.findViewById(R.id.img_drop_down), -45, true); + } + + @Override + public void expand() { + super.expand(); + final ValueAnimator valueAnimator = ValueAnimator.ofFloat(0f, 1f); + valueAnimator.addUpdateListener(valueAnimator1 -> tvCountryName.setTextColor((Integer) argbEvaluator + .evaluate(valueAnimator1.getAnimatedFraction(), ThemeUtils + .getColor(itemView.getContext(), R.attr.nodeListGroupTextColor, R.color.colorWhite), + ThemeUtils.getColor(itemView.getContext(), R.attr.nodeListGroupTextColorSelected, + R.color.colorWhite)))); + valueAnimator.setDuration(AnimConstants.RECYCLER_VIEW_UNDERLINE_ANIMATION_DURATION); + valueAnimator.addListener(new Animator.AnimatorListener() { + @Override + public void onAnimationCancel(Animator animation) { + valueAnimator.removeAllListeners(); + } + + @Override + public void onAnimationEnd(Animator animation) { + valueAnimator.removeAllListeners(); + if (itemExpandListener != null) { + itemExpandListener.onItemExpand(); + } + } + + @Override + public void onAnimationRepeat(Animator animation) { + + } + + @Override + public void onAnimationStart(Animator animation) { + + } + }); + valueAnimator.start(); + + scaleLineAnimation(itemView.findViewById(R.id.field_line_location), 0, 1, View.VISIBLE); + rotateImageAnimation(itemView.findViewById(R.id.img_drop_down), 45, false); + + } + + public void setGroupName(String name) { + tvCountryName.setText(name); + + } + + public void setItemExpandListener(ItemExpandListener itemExpandListener) { + this.itemExpandListener = itemExpandListener; + } + + private void rotateImageAnimation(final View viewToRotate, float toDegrees, final boolean collapse) { + final RotateAnimation rotateAnimation = new RotateAnimation(0, toDegrees, + Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f); + + rotateAnimation.setFillAfter(true); + rotateAnimation.setDuration(AnimConstants.RECYCLER_VIEW_UNDERLINE_ANIMATION_DURATION); + rotateAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + if (collapse) { + viewToRotate.clearAnimation(); + imgDropDown.setImageResource(ThemeUtils + .getResourceId(itemView.getContext(), R.attr.close_list_icon, + R.drawable.ic_location_dropdown_collapse)); + } else { + imgDropDown.setImageResource(ThemeUtils + .getResourceId(itemView.getContext(), R.attr.expand_list_icon, + R.drawable.ic_location_drop_down_expansion)); + viewToRotate.clearAnimation(); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + + } + + @Override + public void onAnimationStart(Animation animation) { + } + }); + viewToRotate.setAnimation(rotateAnimation); + } + + private void scaleLineAnimation(final View viewToScale, float fromScale, + float toScale, final int visible) { + final ScaleAnimation scaleAnimation = new ScaleAnimation(fromScale, toScale, 1, 1, + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 1); + scaleAnimation.setFillAfter(true); + scaleAnimation.setDuration(AnimConstants.RECYCLER_VIEW_UNDERLINE_ANIMATION_DURATION); + scaleAnimation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + if (visible == View.GONE) { + viewToScale.setVisibility(visible); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + + } + + @Override + public void onAnimationStart(Animation animation) { + if (visible == View.VISIBLE) { + viewToScale.setVisibility(visible); + } + } + }); + viewToScale.setAnimation(scaleAnimation); + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/RemoveConfigHolder.java b/mobile/src/main/java/com/windscribe/mobile/holder/RemoveConfigHolder.java new file mode 100644 index 000000000..a5cbb8166 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/RemoveConfigHolder.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.holder; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; +import com.windscribe.vpn.constants.NetworkKeyConstants; +import com.windscribe.vpn.serverlist.entity.ConfigFile; +import com.windscribe.vpn.serverlist.entity.ServerListData; +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener; + +public class RemoveConfigHolder extends RecyclerView.ViewHolder { + + private final TextView configNameView; + + private final ImageView imgFavoriteItemStrengthBar; + + private final ImageView mDelete; + + private final ImageView mEdit; + + private final TextView tvFavouriteItemStrength; + + public RemoveConfigHolder(@NonNull View itemView) { + super(itemView); + configNameView = itemView.findViewById(R.id.config_name); + mDelete = itemView.findViewById(R.id.config_item_delete); + mEdit = itemView.findViewById(R.id.config_item_edit); + tvFavouriteItemStrength = itemView.findViewById(R.id.tv_config_item_strength); + imgFavoriteItemStrengthBar = itemView.findViewById(R.id.config_item_strength_bar); + } + + public void onBind(ConfigFile configFile, ListViewClickListener listViewClickListener, ServerListData dataDetails, + int pingResult) { + int length = configFile.getName().length(); + configNameView.setText(configFile.getName().substring(length / 2)); + mDelete.setOnClickListener(v -> listViewClickListener.deleteConfigFile(configFile)); + mEdit.setOnClickListener(v -> listViewClickListener.editConfigFile(configFile)); + + if (dataDetails.isShowLatencyInBar()) { + tvFavouriteItemStrength.setVisibility(View.GONE); + imgFavoriteItemStrengthBar.setVisibility(View.VISIBLE); + if (pingResult != -1) { + if (pingResult > -1 && pingResult < NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT) { + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_3_bar); + } else if (pingResult >= NetworkKeyConstants.PING_TEST_3_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT) { + + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_2_bar); + + } else if (pingResult >= NetworkKeyConstants.PING_TEST_2_BAR_UPPER_LIMIT + && pingResult < NetworkKeyConstants.PING_TEST_1_BAR_UPPER_LIMIT) { + + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_1_bar); + + } else { + imgFavoriteItemStrengthBar.setImageResource(R.drawable.ic_network_ping_black_no_bar); + } + } + } else { + tvFavouriteItemStrength.setVisibility(View.VISIBLE); + imgFavoriteItemStrengthBar.setVisibility(View.GONE); + if (pingResult != -1) { + tvFavouriteItemStrength.setText(String.valueOf(pingResult)); + } else { + tvFavouriteItemStrength.setText("--"); + } + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/holder/StaticRegionHolder.java b/mobile/src/main/java/com/windscribe/mobile/holder/StaticRegionHolder.java new file mode 100644 index 000000000..b8a7235c6 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/holder/StaticRegionHolder.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.holder; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; + + +public class StaticRegionHolder extends RecyclerView.ViewHolder { + + public final ImageView mImageIpType; + + public final ImageView mImgSignalStrengthBar; + + public final TextView mIpCityName; + + public final TextView mStaticIp; + + public final TextView mTextViewPing; + + + public StaticRegionHolder(@NonNull View itemView) { + super(itemView); + mImageIpType = itemView.findViewById(R.id.img_ip_type); + mIpCityName = itemView.findViewById(R.id.ip_city_name); + mStaticIp = itemView.findViewById(R.id.static_ip); + mImgSignalStrengthBar = itemView.findViewById(R.id.static_ip_signal_strength_bar); + mTextViewPing = itemView.findViewById(R.id.tv_signal_strength_static_ip); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/listeners/AccountFragmentCallback.kt b/mobile/src/main/java/com/windscribe/mobile/listeners/AccountFragmentCallback.kt new file mode 100644 index 000000000..7b5a7bd29 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/listeners/AccountFragmentCallback.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.listeners + +interface AccountFragmentCallback { + fun onLoginClicked() + fun onSignUpClicked() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/listeners/ProtocolClickListener.kt b/mobile/src/main/java/com/windscribe/mobile/listeners/ProtocolClickListener.kt new file mode 100644 index 000000000..5f849dd1c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/listeners/ProtocolClickListener.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.listeners + +import com.windscribe.vpn.backend.utils.ProtocolConfig + +interface ProtocolClickListener { + + fun onProtocolSelected(protocolConfig: ProtocolConfig?) +} diff --git a/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuActivity.kt b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuActivity.kt new file mode 100644 index 000000000..f6fa6c8b8 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuActivity.kt @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.mainmenu + +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.HapticFeedbackConstants +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.BuildConfig +import com.windscribe.mobile.R +import com.windscribe.mobile.about.AboutActivity +import com.windscribe.mobile.account.AccountActivity +import com.windscribe.mobile.advance.AdvanceParamsActivity +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.confirmemail.ConfirmActivity +import com.windscribe.mobile.connectionsettings.ConnectionSettingsActivity +import com.windscribe.mobile.custom_view.preferences.IconLinkView +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.dialogs.ShareAppLinkDialog +import com.windscribe.mobile.email.AddEmailActivity +import com.windscribe.mobile.generalsettings.GeneralSettingsActivity +import com.windscribe.mobile.help.HelpActivity +import com.windscribe.mobile.robert.RobertSettingsActivity +import com.windscribe.mobile.upgradeactivity.UpgradeActivity +import com.windscribe.mobile.utils.UiUtil +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.vpn.BuildConfig.BUILD_TYPE +import com.windscribe.vpn.BuildConfig.DEV +import com.windscribe.vpn.alert.showAlertDialog +import com.windscribe.vpn.backend.utils.WindVpnController +import com.windscribe.vpn.state.PreferenceChangeObserver +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class MainMenuActivity : BaseActivity(), MainMenuView { + @JvmField + @BindView(R.id.addEmail) + var addEmail: Button? = null + + @JvmField + @BindView(R.id.nav_button) + var backButton: ImageView? = null + + @BindView(R.id.cl_data_status) + lateinit var clDataStatus: ConstraintLayout + + @JvmField + @BindView(R.id.confirmEmail) + var confirmEmail: Button? = null + + @JvmField + @BindView(R.id.login) + var loginButton: Button? = null + + @Inject + lateinit var presenter: MainMenuPresenter + + @Inject + lateinit var preferenceChangeObserver: PreferenceChangeObserver + + @Inject + lateinit var vpnController: WindVpnController + + @JvmField + @BindView(R.id.setupAccount) + var setupAccountButton: Button? = null + + @JvmField + @BindView(R.id.nav_title) + var tvActivityTitle: TextView? = null + + @BindView(R.id.data_left) + lateinit var tvDataLeft: TextView + + @BindView(R.id.data_upgrade_label) + lateinit var tvDataUpgrade: TextView + + @BindView(R.id.cl_general) + lateinit var generalView: IconLinkView + + @BindView(R.id.cl_account) + lateinit var accountView: IconLinkView + + @BindView(R.id.cl_connection) + lateinit var connectionView: IconLinkView + + @BindView(R.id.cl_robert) + lateinit var robertView: IconLinkView + + @BindView(R.id.cl_help) + lateinit var helpView: IconLinkView + + @BindView(R.id.cl_about) + lateinit var aboutView: IconLinkView + + @BindView(R.id.cl_sign) + lateinit var logoutView: IconLinkView + + @BindView(R.id.cl_refer_for_data) + lateinit var referForDataView: IconLinkView + + @BindView(R.id.divider_refer_for_data) + lateinit var referForDataDivider: ImageView + + private val logger = LoggerFactory.getLogger(TAG) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_main_menu) + presenter.observeUserChange(this) + preferenceChangeObserver.addLanguageChangeObserver(this) { presenter.onLanguageChanged() } + setupCustomLayoutDelegates() + } + + private fun setupCustomLayoutDelegates() { + generalView.onClick { + performHapticFeedback(it) + presenter.onGeneralSettingsClicked() + } + accountView.onClick { + performHapticFeedback(it) + presenter.onMyAccountClicked() + } + connectionView.onClick { + performHapticFeedback(it) + presenter.onConnectionSettingsClicked() + } + robertView.onClick { + performHapticFeedback(it) + presenter.onRobertSettingsClicked() + } + helpView.onClick { + performHapticFeedback(it) + presenter.onHelpMeClicked() + } + aboutView.onClick { + performHapticFeedback(it) + presenter.onAboutClicked() + } + logoutView.onClick { + performHapticFeedback(it) + presenter.onSignOutClicked() + } + referForDataView.onClick { + performHapticFeedback(it) + presenter.onReferForDataClick() + } + UiUtil.setupOnTouchListener(textViewContainer = tvDataUpgrade, textView = tvDataUpgrade) + } + + override fun onResume() { + super.onResume() + presenter.setLayoutFromApiSession() + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun goRobertSettingsActivity() { + val intent = RobertSettingsActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun gotToHelp() { + val intent = HelpActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun gotoAboutActivity() { + val intent = AboutActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun gotoAccountActivity() { + val intent = AccountActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun gotoConnectionSettingsActivity() { + val intent = ConnectionSettingsActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun gotoGeneralSettingsActivity() { + val intent = GeneralSettingsActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun gotoLoginRegistrationActivity() { + val intent = WelcomeActivity.getStartIntent(this) + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + startActivity(intent) + finish() + } + + @OnClick(R.id.setupAccount) + fun onAccountSetUpClicked() { + presenter.onAccountSetUpClicked() + } + + @OnClick(R.id.addEmail) + fun onAddEmailClicked() { + presenter.onAddEmailClicked() + } + + @OnClick(R.id.nav_button) + fun onBackButtonClicked() { + performHapticFeedback(backButton) + logger.info("User clicked on back arrow...") + onBackPressed() + } + + @OnClick(R.id.confirmEmail) + fun onConfirmEmailClicked() { + presenter.onConfirmEmailClicked() + } + + @OnClick(R.id.cl_connection) + fun onConnectionSettingsClick() { + + logger.info("User clicked on connection settings...") + presenter.onConnectionSettingsClicked() + } + + @OnClick(R.id.login) + fun onLoginClicked() { + presenter.onLoginClicked() + } + + @OnClick(R.id.data_upgrade_label) + fun onUpgradeClicked() { + presenter.onUpgradeClicked() + } + + override fun openConfirmEmailActivity() { + startActivity(ConfirmActivity.getStartIntent(this)) + } + + override fun openUpgradeActivity() { + startActivity(UpgradeActivity.getStartIntent(this)) + } + + override fun resetAllTextResources( + activityTitle: String, + general: String, + account: String, + connection: String, + helpMe: String, + signOut: String, + about: String, + robert: String + ) { + tvActivityTitle?.text = activityTitle + generalView.text = general + accountView.text = account + connectionView.text = connection + helpView.text = helpMe + logoutView.text = signOut + aboutView.text = about + robertView.text = robert + } + + override fun setActionButtonVisibility( + loginButtonVisibility: Int, + addEmailButtonVisibility: Int, + setUpAccountButtonVisibility: Int, + confirmEmailButtonVisibility: Int + ) { + loginButton?.visibility = loginButtonVisibility + addEmail?.visibility = addEmailButtonVisibility + setupAccountButton?.visibility = setUpAccountButtonVisibility + confirmEmail?.visibility = confirmEmailButtonVisibility + } + + override fun setActivityTitle(title: String) { + tvActivityTitle?.text = title + } + + override fun setLoginButtonVisibility(visibility: Int) { + logoutView.visibility = visibility + } + + override fun setupLayoutForFreeUser(dataLeft: String, upgradeLabel: String, color: Int) { + clDataStatus.visibility = View.VISIBLE + tvDataLeft.text = dataLeft + tvDataLeft.setTextColor(color) + tvDataUpgrade.text = upgradeLabel + } + + override fun setupLayoutForPremiumUser() { + clDataStatus.visibility = View.GONE + } + + override fun showLogoutAlert() { + showAlertDialog( + getString(R.string.logout), + getString(R.string.logout_alert_description), + getString(R.string.logout), + getString(R.string.cancel) + ) { + presenter.continueWithLogoutClicked() + } + } + + override fun startAccountSetUpActivity() { + val startIntent = WelcomeActivity.getStartIntent(this) + startIntent.putExtra("startFragmentName", "AccountSetUp") + startActivity(startIntent) + } + + override fun startAddEmailActivity() { + val startIntent = Intent(this, AddEmailActivity::class.java) + startActivity(startIntent) + } + + override fun startLoginActivity() { + val startIntent = WelcomeActivity.getStartIntent(this) + startIntent.putExtra("startFragmentName", "Login") + startActivity(startIntent) + } + + private fun performHapticFeedback(view: View?) { + if (presenter.isHapticFeedbackEnabled()) { + view?.isHapticFeedbackEnabled = true + view?.performHapticFeedback( + HapticFeedbackConstants.VIRTUAL_KEY, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + } + + override fun showShareLinkDialog() { + ShareAppLinkDialog.show(this) + } + + override fun showShareLinkOption() { + referForDataView.visibility = View.VISIBLE + referForDataDivider.visibility = View.VISIBLE + } + + companion object { + private const val TAG = "basic" + + @JvmStatic + fun getStartIntent(context: Context?): Intent { + return Intent(context, MainMenuActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuPresenter.kt new file mode 100644 index 000000000..d55ade1d0 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuPresenter.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.mainmenu + +import android.content.Context + +interface MainMenuPresenter { + fun continueWithLogoutClicked() + fun savedLocale(): String + fun isHapticFeedbackEnabled(): Boolean + fun onAboutClicked() + fun onAccountSetUpClicked() + fun onAddEmailClicked() + fun onConfirmEmailClicked() + fun onConnectionSettingsClicked() + fun onDestroy() + fun onGeneralSettingsClicked() + fun onHelpMeClicked() + fun onLanguageChanged() + fun onLoginClicked() + fun onMyAccountClicked() + fun onRobertSettingsClicked() + fun onSignOutClicked() + fun onUpgradeClicked() + fun setLayoutFromApiSession() + fun observeUserChange(mainMenuActivity: MainMenuActivity) + fun setTheme(context: Context) + fun onReferForDataClick() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuPresenterImpl.kt new file mode 100644 index 000000000..27dfa527b --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuPresenterImpl.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.mainmenu + +import android.content.Context +import android.view.View +import com.windscribe.mobile.R +import com.windscribe.mobile.utils.UiUtil.getDataRemainingColor +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.api.response.UserSessionResponse +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.errormodel.WindError.Companion.instance +import com.windscribe.vpn.model.User +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class MainMenuPresenterImpl @Inject constructor( + private var mainMenuView: MainMenuView, + private var interactor: ActivityInteractor +) : MainMenuPresenter { + private val mPresenterLog = LoggerFactory.getLogger(TAG) + override fun onDestroy() { + // Dispose any composite disposable + interactor.getCompositeDisposable() + if (!interactor.getCompositeDisposable().isDisposed) { + mPresenterLog.info("Disposing observer...") + interactor.getCompositeDisposable().dispose() + } + } + + override fun continueWithLogoutClicked() { + interactor.getMainScope().launch { + interactor.getUserRepository().logout() + } + } + + override fun savedLocale(): String { + val selectedLanguage = interactor.getAppPreferenceInterface().savedLanguage + return selectedLanguage + .substring(selectedLanguage.indexOf("(") + 1, selectedLanguage.indexOf(")")) + } + + override fun onAboutClicked() { + mainMenuView.gotoAboutActivity() + } + + override fun onAccountSetUpClicked() { + mainMenuView.startAccountSetUpActivity() + } + + override fun onAddEmailClicked() { + mainMenuView.startAddEmailActivity() + } + + override fun onConfirmEmailClicked() { + mainMenuView.openConfirmEmailActivity() + } + + override fun onConnectionSettingsClicked() { + mPresenterLog.info("Starting settings activity...") + mainMenuView.gotoConnectionSettingsActivity() + } + + override fun onGeneralSettingsClicked() { + mPresenterLog.info("Going to general settings activity...") + mainMenuView.gotoGeneralSettingsActivity() + } + + override fun onHelpMeClicked() { + mPresenterLog.info("User clicked on Help..") + mainMenuView.gotToHelp() + } + + override fun onLanguageChanged() { + mainMenuView.resetAllTextResources( + interactor.getResourceString(R.string.preference), + interactor.getResourceString(R.string.general), + interactor.getResourceString(R.string.my_account), + interactor.getResourceString(R.string.connection), + interactor.getResourceString(R.string.help_me), + interactor.getResourceString(R.string.logout), + interactor.getResourceString(R.string.about), + interactor.getResourceString(R.string.robert) + ) + } + + override fun isHapticFeedbackEnabled(): Boolean { + return interactor.getAppPreferenceInterface().isHapticFeedbackEnabled + } + + override fun onLoginClicked() { + mainMenuView.startLoginActivity() + } + + override fun onMyAccountClicked() { + mPresenterLog.info("Going to account activity...") + mainMenuView.gotoAccountActivity() + } + + override fun onRobertSettingsClicked() { + mainMenuView.goRobertSettingsActivity() + } + + override fun onSignOutClicked() { + mainMenuView.showLogoutAlert() + } + + override fun onReferForDataClick() { + mainMenuView.showShareLinkDialog() + } + + override fun onUpgradeClicked() { + mPresenterLog.info("Showing upgrade dialog to the user...") + mainMenuView.openUpgradeActivity() + } + + override fun setLayoutFromApiSession() { + interactor.getCompositeDisposable().add( + interactor.getApiCallManager() + .getSessionGeneric(null) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver?>() { + override fun onError(e: Throwable) { + mPresenterLog.debug("Error while making get session call:" + e.message) + } + + override fun onSuccess(response: GenericResponseClass) { + if (response.dataClass != null) { + interactor.getUserRepository().reload(response.dataClass) + } else if (response.errorClass != null) { + mPresenterLog + .debug( + "Server returned error during get session call." + + response.errorClass.toString() + ) + } + } + }) + ) + } + + override fun observeUserChange(mainMenuActivity: MainMenuActivity) { + mainMenuView.setActivityTitle(interactor.getResourceString(R.string.preferences)) + interactor.getUserRepository().user.observe(mainMenuActivity) { user -> + setLayoutFromUserSession(user) + } + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + mPresenterLog.debug("Setting theme to $savedThem") + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + private fun setupActionButton(user: User) { + if (user.isPro && user.isGhost) { + mainMenuView.setLoginButtonVisibility(View.GONE) + mainMenuView.setActionButtonVisibility(View.GONE, View.GONE, View.VISIBLE, View.GONE) + } else if (user.isGhost) { + mainMenuView.setLoginButtonVisibility(View.GONE) + mainMenuView.setActionButtonVisibility(View.VISIBLE, View.GONE, View.VISIBLE, View.GONE) + } else if (user.emailStatus === User.EmailStatus.NoEmail) { + mainMenuView.setLoginButtonVisibility(View.VISIBLE) + mainMenuView.setActionButtonVisibility(View.GONE, View.VISIBLE, View.GONE, View.GONE) + } else if (user.emailStatus === User.EmailStatus.EmailProvided) { + mainMenuView.setLoginButtonVisibility(View.VISIBLE) + mainMenuView.setActionButtonVisibility(View.GONE, View.GONE, View.GONE, View.VISIBLE) + } else { + mainMenuView.setLoginButtonVisibility(View.VISIBLE) + mainMenuView.setActionButtonVisibility(View.GONE, View.GONE, View.GONE, View.GONE) + } + } + + private fun setLayoutFromUserSession(user: User) { + if (user.maxData != -1L) { + user.dataLeft?.let { + val dataRemaining = interactor.getDataLeftString(R.string.data_left, it) + mainMenuView.setupLayoutForFreeUser( + dataRemaining, + interactor.getResourceString(R.string.get_more_data), + getDataRemainingColor(it, user.maxData) + ) + } + } else { + mainMenuView.setupLayoutForPremiumUser() + } + if (user.isGhost.not() && user.isPro.not()) { + mainMenuView.showShareLinkOption() + } + setupActionButton(user) + } + + companion object { + private const val TAG = "main_menu_p" + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuView.kt b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuView.kt new file mode 100644 index 000000000..41abfd6e5 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/mainmenu/MainMenuView.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.mainmenu + +interface MainMenuView { + fun goRobertSettingsActivity() + fun gotToHelp() + fun gotoAboutActivity() + fun gotoAccountActivity() + fun gotoConnectionSettingsActivity() + fun gotoGeneralSettingsActivity() + fun gotoLoginRegistrationActivity() + fun openConfirmEmailActivity() + fun openUpgradeActivity() + fun resetAllTextResources( + activityTitle: String, + general: String, + account: String, + connection: String, + helpMe: String, + signOut: String, + about: String, + robert: String + ) + + fun setActionButtonVisibility( + loginButtonVisibility: Int, + addEmailButtonVisibility: Int, + setUpAccountButtonVisibility: Int, + confirmEmailButtonVisibility: Int + ) + + fun setActivityTitle(title: String) + fun setLoginButtonVisibility(visibility: Int) + fun setupLayoutForFreeUser(dataLeft: String, upgradeLabel: String, color: Int) + fun setupLayoutForPremiumUser() + fun showLogoutAlert() + fun startAccountSetUpActivity() + fun startAddEmailActivity() + fun startLoginActivity() + fun showShareLinkDialog() + fun showShareLinkOption() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityActivity.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityActivity.kt new file mode 100644 index 000000000..356e09549 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityActivity.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity + +import android.app.ActivityOptions +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.NetworkListAdapter +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.CustomDialog +import com.windscribe.mobile.custom_view.preferences.ToggleView +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.networksecurity.networkdetails.NetworkDetailsActivity.Companion.getStartIntent +import com.windscribe.mobile.networksecurity.viewholder.NetworkAdapterActionListener +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.backend.utils.WindVpnController +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class NetworkSecurityActivity : BaseActivity(), NetworkSecurityView, NetworkAdapterActionListener { + + @BindView(R.id.nav_title) + lateinit var activityTitleView: TextView + + @BindView(R.id.recycler_view_network_list) + lateinit var networkListRecyclerView: RecyclerView + + @BindView(R.id.auto_secure_new_networks) + lateinit var autoSecureNewNetworksToggleView: ToggleView + + @BindView(R.id.network_name) + lateinit var currentNetworkName: TextView + + @BindView(R.id.tv_current_protection) + lateinit var currentNetworkProtection: TextView + + @BindView(R.id.cl_current_network) + lateinit var clCurrentNetwork: ConstraintLayout + + @BindView(R.id.tv_no_network_list) + lateinit var tvNoNetworkFound: TextView + + @Inject + lateinit var customProgress: CustomDialog + + @Inject + lateinit var vpnController: WindVpnController + + @Inject + lateinit var presenter: NetworkSecurityPresenter + + private val logger = LoggerFactory.getLogger("basic") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_network_security, true) + networkListRecyclerView.layoutManager = + GridLayoutManager(this, 1) + activityTitleView.text = getString(R.string.network_options) + presenter.init() + setCustomLayoutDelegates() + } + + private fun setCustomLayoutDelegates() { + autoSecureNewNetworksToggleView.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.onAutoSecureToggleClick() + } + + override fun onExplainClick() {} + } + } + + override fun setAutoSecureToggle(resourceId: Int) { + autoSecureNewNetworksToggleView.setToggleImage(resourceId) + } + + override fun onResume() { + super.onResume() + presenter.setupNetworkListAdapter() + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun hideProgress() { + customProgress.dismiss() + } + + @OnClick(R.id.cl_current_network) + fun onCurrentNetworkClick() { + presenter.onCurrentNetworkClick() + } + + override fun setupCurrentNetwork(networkInfo: NetworkInfo) { + clCurrentNetwork.visibility = View.VISIBLE + currentNetworkName.text = networkInfo.networkName + val protectionStatus = if (networkInfo.isAutoSecureOn) appContext + .getText(R.string.network_secured) + .toString() else appContext.getText(R.string.network_unsecured).toString() + currentNetworkProtection.visibility = View.VISIBLE + currentNetworkProtection.text = protectionStatus + } + + override fun hideCurrentNetwork(){ + clCurrentNetwork.visibility = View.GONE + currentNetworkProtection.visibility = View.GONE + } + + override fun onAdapterLoadFailed(showUpdate: String) { + tvNoNetworkFound.visibility = View.VISIBLE + tvNoNetworkFound.text = showUpdate + } + + @OnClick(R.id.nav_button) + fun onBackArrowClicked() { + logger.info("User clicked back arrow...") + onBackPressed() + } + + override fun onItemSelected(networkInfo: NetworkInfo) { + logger.info("User selected " + networkInfo.networkName) + presenter.onNetworkSecuritySelected(networkInfo) + } + + override fun openNetworkSecurityDetails(networkName: String) { + val intent = getStartIntent(this, networkName) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun setAdapter(mNetworkList: List?) { + val mAdapter = NetworkListAdapter(mNetworkList) + networkListRecyclerView.adapter = mAdapter + networkListRecyclerView.itemAnimator = DefaultItemAnimator() + mAdapter.setAdapterActionListener(this) + presenter.onAdapterSet() + } + + override fun showProgress(progressTitle: String) { + customProgress.show() + (customProgress.findViewById(R.id.tv_dialog_header) as TextView).text = + progressTitle + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, NetworkSecurityActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityPresenter.kt new file mode 100644 index 000000000..da355ed6a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityPresenter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity + +import android.content.Context +import com.windscribe.vpn.localdatabase.tables.NetworkInfo + +interface NetworkSecurityPresenter { + val savedLocale: String + fun onAdapterSet() + fun onDestroy() + fun onNetworkSecuritySelected(networkInfo: NetworkInfo) + fun onCurrentNetworkClick() + fun setTheme(context: Context) + fun setupNetworkListAdapter() + fun init() + fun onAutoSecureToggleClick() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityPresenterImpl.kt new file mode 100644 index 000000000..dbd289397 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityPresenterImpl.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity + +import android.content.Context +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.errormodel.WindError.Companion.instance +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import com.windscribe.vpn.state.NetworkInfoListener +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import io.reactivex.subscribers.DisposableSubscriber +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class NetworkSecurityPresenterImpl @Inject constructor( + private val networkSecurityView: NetworkSecurityView, + private val interactor: ActivityInteractor +) : NetworkSecurityPresenter, NetworkInfoListener { + private val logger = LoggerFactory.getLogger("basic") + override fun onDestroy() { + interactor.getNetworkInfoManager().removeNetworkInfoListener(this) + //Dispose any observer + if (interactor.getCompositeDisposable().isDisposed.not()) { + logger.info("Disposing observer...") + interactor.getCompositeDisposable().dispose() + } + } + + override fun init() { + interactor.getNetworkInfoManager().networkInfo?.let { + networkSecurityView.setupCurrentNetwork(it) + } + interactor.getNetworkInfoManager().addNetworkInfoListener(this) + networkSecurityView.setAutoSecureToggle(if (interactor.getAppPreferenceInterface().isAutoSecureOn) R.drawable.ic_toggle_button_on else R.drawable.ic_toggle_button_off) + } + + override val savedLocale: String + get() { + val selectedLanguage = interactor.getAppPreferenceInterface().savedLanguage + return selectedLanguage.substring( + selectedLanguage.indexOf("(") + 1, + selectedLanguage.indexOf(")") + ) + } + + override fun onAdapterSet() { + networkSecurityView.hideProgress() + } + + override fun onNetworkSecuritySelected(networkInfo: NetworkInfo) { + networkSecurityView.openNetworkSecurityDetails(networkInfo.networkName) + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + override fun setupNetworkListAdapter() { + logger.info("Setting up network list adapter...") + networkSecurityView.showProgress(interactor.getResourceString(R.string.loading_network_list)) + interactor.getCompositeDisposable().add( + interactor.getNetworkInfoUpdated() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSubscriber>() { + override fun onComplete() { + networkSecurityView.setAdapter(null) + } + + override fun onError(e: Throwable) { + networkSecurityView.setAdapter(null) + logger.debug( + "Error reading network list data..." + + instance.convertThrowableToString(e) + ) + networkSecurityView.onAdapterLoadFailed( + interactor.getResourceString(R.string.no_saved_network_list) + ) + networkSecurityView.hideProgress() + } + + override fun onNext(networks: List?) { + logger.info("Reading network list data successful...") + val networkList = networks?.toMutableList() ?: mutableListOf() + val activeNetworkInfo = interactor.getNetworkInfoManager().networkInfo + if (activeNetworkInfo != null) { + networkList.removeIf { networkInfo: NetworkInfo -> networkInfo.networkName == activeNetworkInfo.networkName } + } + networkSecurityView.setAdapter(networkList) + } + }) + ) + } + + override fun onNetworkInfoUpdate(networkInfo: NetworkInfo?, userReload: Boolean) { + if(networkInfo != null){ + networkSecurityView.setupCurrentNetwork(networkInfo) + } else { + networkSecurityView.hideCurrentNetwork() + } + setupNetworkListAdapter() + } + + override fun onCurrentNetworkClick() { + val networkInfo = interactor.getNetworkInfoManager().networkInfo + if (networkInfo != null) { + networkSecurityView.openNetworkSecurityDetails(networkInfo.networkName) + } + } + + override fun onAutoSecureToggleClick() { + if (interactor.getAppPreferenceInterface().isAutoSecureOn) { + interactor.getAppPreferenceInterface().isAutoSecureOn = false + networkSecurityView.setAutoSecureToggle(R.drawable.ic_toggle_button_off) + } else { + interactor.getAppPreferenceInterface().isAutoSecureOn = true + networkSecurityView.setAutoSecureToggle(R.drawable.ic_toggle_button_on) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityView.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityView.kt new file mode 100644 index 000000000..3f7a72dd6 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/NetworkSecurityView.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity + +import com.windscribe.vpn.localdatabase.tables.NetworkInfo + +interface NetworkSecurityView { + fun hideProgress() + fun setupCurrentNetwork(networkInfo: NetworkInfo) + fun onAdapterLoadFailed(showUpdate: String) + fun openNetworkSecurityDetails(networkName: String) + fun setAdapter(mNetworkList: List?) + fun showProgress(progressTitle: String) + fun setAutoSecureToggle(resourceId: Int) + fun hideCurrentNetwork() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailPresenter.kt new file mode 100644 index 000000000..39d31dff9 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailPresenter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity.networkdetails + +import android.content.Context + +interface NetworkDetailPresenter { + fun onDestroy() + fun onPortSelected(port: String) + fun onProtocolSelected(protocol: String) + fun removeNetwork(name: String) + fun setNetworkDetails(name: String) + fun setProtocols() + fun setTheme(context: Context) + fun toggleAutoSecure() + fun togglePreferredProtocol() + fun init() + fun reloadNetworkOptions() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailPresenterImp.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailPresenterImp.kt new file mode 100644 index 000000000..3c3794336 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailPresenterImp.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity.networkdetails + +import android.content.Context +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.ActivityInteractorImpl.PortMapLoadCallback +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.api.response.PortMapResponse +import com.windscribe.vpn.api.response.PortMapResponse.PortMap +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_IKev2 +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import com.windscribe.vpn.services.DeviceStateService +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class NetworkDetailPresenterImp @Inject constructor( + private val networkView: NetworkDetailView, + private val interactor: ActivityInteractor +) : NetworkDetailPresenter { + + private val logger = LoggerFactory.getLogger("basic") + + override fun onDestroy() { + if (!interactor.getCompositeDisposable().isDisposed) { + interactor.getCompositeDisposable().dispose() + } + } + + override fun init(){ + networkView.setActivityTitle(interactor.getResourceString(R.string.network_options)) + } + + override fun onPortSelected(port: String) { + val networkInfo = networkView.networkInfo + networkInfo?.let { + networkInfo.port = port + interactor.getCompositeDisposable().add( + interactor.saveNetwork(networkInfo) + .subscribeOn(Schedulers.io()) + .flatMap { interactor.getNetwork(networkInfo.networkName) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(ignored: Throwable) { + networkView.showToast("Error Loading network.") + } + + override fun onSuccess(updatedNetwork: NetworkInfo) { + networkView.networkInfo = updatedNetwork + } + }) + ) + } + } + + override fun onProtocolSelected(protocol: String) { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + val networkInfo = networkView.networkInfo + networkInfo?.let { + networkInfo.protocol = + getProtocolFromHeading(portMapResponse, protocol) + interactor.getCompositeDisposable() + .add( + interactor.saveNetwork(networkInfo) + .subscribeOn(Schedulers.io()) + .flatMap { interactor.getNetwork(networkInfo.networkName) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(ignored: Throwable) { + networkView.showToast("Error saving network information.") + } + + override fun onSuccess(updatedNetwork: NetworkInfo) { + networkView.networkInfo = updatedNetwork + setPorts() + } + }) + ) + } + } + }) + } + + override fun removeNetwork(name: String) { + interactor.getCompositeDisposable() + .add(interactor.removeNetwork(name) + .flatMap { + interactor.getNetworkInfoManager().reload(true) + return@flatMap Single.just(it) + }.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(ignored: Throwable) { + networkView.showToast("Error deleting network") + } + + override fun onSuccess(integer: Int) { + networkView.onNetworkDeleted() + } + }) + ) + } + + override fun setNetworkDetails(name: String) { + networkView.setNetworkDetailError(false, null) + interactor.getCompositeDisposable().add( + interactor.getNetwork(name) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(ignored: Throwable) { + networkView.setNetworkDetailError(true, "Network name not found.") + } + + override fun onSuccess(networkInfo: NetworkInfo) { + val currentNetworkName = + interactor.getNetworkInfoManager().networkInfo?.networkName ?: "" + networkView.onNetworkDetailAvailable( + networkInfo, + currentNetworkName == networkInfo.networkName + ) + } + }) + ) + } + + fun setPorts() { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + networkView.networkInfo?.let { + val protocol = it.protocol + val savedPort = it.port + portMapResponse.let { + for (portMap in portMapResponse.portmap) { + if (portMap.protocol == protocol) { + networkView.setupPortMapAdapter(savedPort, portMap.ports) + } + } + } + } + } + }) + } + + override fun setProtocols() { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + networkView.networkInfo?.let { + val savedProtocol = it.protocol + var selectedPortMap: PortMap? = null + val protocols: MutableList = ArrayList() + portMapResponse.let { + for (portMap in portMapResponse.portmap) { + if (portMap.protocol == savedProtocol) { + selectedPortMap = portMap + } + protocols.add(portMap.heading) + } + selectedPortMap?.let { portMap -> + networkView.setupProtocolAdapter( + portMap.heading, + protocols.toTypedArray() + ) + setPorts() + } + } + } + } + }) + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + override fun toggleAutoSecure() { + interactor.saveWhiteListedNetwork(true) + val networkInfo = networkView.networkInfo + if (networkInfo == null) { + networkView.showToast("Make sure location permission is set Allow all the time") + return + } + networkInfo.isAutoSecureOn = !networkInfo.isAutoSecureOn + logger.debug("Auto secure toggle: ${!networkInfo.isAutoSecureOn}") + interactor.getCompositeDisposable().add( + interactor.saveNetwork(networkInfo) + .subscribeOn(Schedulers.io()) + .flatMap { interactor.getNetwork(networkInfo.networkName) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(ignored: Throwable) { + logger.debug("Auto secure toggle: ${!networkInfo.isAutoSecureOn}") + networkView.showToast("Failed to save network details.") + } + + override fun onSuccess(updatedNetwork: NetworkInfo) { + logger.debug("SSID: ${networkInfo.networkName} AutoSecure: ${networkInfo.isAutoSecureOn} Preferred Protocols: ${networkInfo.isPreferredOn} ${networkInfo.protocol} ${networkInfo.port}") + networkView.networkInfo = updatedNetwork + networkView.setAutoSecureToggle(updatedNetwork.isAutoSecureOn) + logger.debug("Reloading network info.") + interactor.getNetworkInfoManager().reload(true) + DeviceStateService.enqueueWork(appContext) + } + }) + ) + } + + override fun togglePreferredProtocol() { + val networkInfo = networkView.networkInfo + if (networkInfo == null) { + networkView.showToast("Check network permissions.") + return + } + networkInfo.isPreferredOn = !networkInfo.isPreferredOn + interactor.getCompositeDisposable().add( + interactor.saveNetwork(networkInfo) + .subscribeOn(Schedulers.io()) + .flatMap { interactor.getNetwork(networkInfo.networkName) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) { + networkView.showToast("Error...") + } + + override fun onSuccess(updatedNetwork: NetworkInfo) { + networkView.networkInfo = updatedNetwork + networkView.setPreferredProtocolToggle(updatedNetwork.isPreferredOn) + } + }) + ) + } + + private fun getProtocolFromHeading(portMapResponse: PortMapResponse, heading: String): String { + for (map in portMapResponse.portmap) { + if (map.heading == heading) { + return map.protocol + } + } + return PROTO_IKev2 + } + + override fun reloadNetworkOptions() { + interactor.getNetworkInfoManager().reload(false) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailView.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailView.kt new file mode 100644 index 000000000..64bfc8b75 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailView.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity.networkdetails + +import com.windscribe.vpn.localdatabase.tables.NetworkInfo + +interface NetworkDetailView { + var networkInfo: NetworkInfo? + fun onNetworkDeleted() + fun onNetworkDetailAvailable(networkInfo: NetworkInfo, inUse: Boolean) + fun setAutoSecureToggle(autoSecure: Boolean) + fun setNetworkDetailError(show: Boolean, error: String?) + fun setPreferredProtocolToggle(preferredProtocol: Boolean) + fun setupPortMapAdapter(port: String, portMap: List) + fun setupProtocolAdapter(protocol: String, protocols: Array) + fun showToast(message: String) + fun setActivityTitle(title: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailsActivity.kt b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailsActivity.kt new file mode 100644 index 000000000..13907bb9c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/networkdetails/NetworkDetailsActivity.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.networksecurity.networkdetails + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.ConstraintLayout +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.preferences.ConnectionModeView +import com.windscribe.mobile.custom_view.preferences.ExpandableToggleView +import com.windscribe.mobile.custom_view.preferences.ToggleView +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import javax.inject.Inject + +class NetworkDetailsActivity : BaseActivity(), NetworkDetailView { + + @JvmField + @BindView(R.id.cl_error) + var clError: ConstraintLayout? = null + + @JvmField + @BindView(R.id.error) + var networkErrorView: TextView? = null + + private var networkName: String? = null + + @JvmField + @BindView(R.id.cl_forget_network) + var forgetNetworkView: TextView? = null + + @JvmField + @BindView(R.id.cl_preferred_protocol) + var preferredProtocolToggleView: ExpandableToggleView? = null + + @JvmField + @BindView(R.id.cl_auto_secure) + var autoSecureToggleView: ToggleView? = null + + @JvmField + @BindView(R.id.nav_title) + var activityTitle: TextView? = null + + override var networkInfo: NetworkInfo? = null + + @Inject + lateinit var presenter: NetworkDetailPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_network_details, true) + presenter.init() + networkName = intent.getStringExtra("network_name") + networkName?.let { + presenter.setNetworkDetails(it) + } + setupCustomLayoutDelegates() + } + + override fun setActivityTitle(title: String) { + activityTitle?.text = title + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + @OnClick(R.id.nav_button) + fun onBackButtonClick() { + onBackPressed() + } + + override fun onBackPressed() { + presenter.reloadNetworkOptions() + super.onBackPressed() + } + + override fun onNetworkDeleted() { + finish() + } + + private fun setupCustomLayoutDelegates() { + forgetNetworkView?.setOnClickListener { + networkName?.let { + presenter.removeNetwork(it) + } + } + autoSecureToggleView?.delegate = object : ToggleView.Delegate { + override fun onToggleClick() { + presenter.toggleAutoSecure() + } + + override fun onExplainClick() { + + } + } + preferredProtocolToggleView?.delegate = object : ExpandableToggleView.Delegate { + override fun onToggleClick() { + presenter.togglePreferredProtocol() + } + + override fun onExplainClick() { + + } + } + val connectionModeView = preferredProtocolToggleView?.childView as? ConnectionModeView + connectionModeView?.delegate = object : ConnectionModeView.Delegate { + override fun onProtocolSelected(protocol: String) { + presenter.onProtocolSelected(protocol) + } + + override fun onPortSelected(protocol: String, port: String) { + presenter.onPortSelected(port) + } + } + } + + override fun onNetworkDetailAvailable(networkInfo: NetworkInfo, inUse: Boolean) { + this.networkInfo = networkInfo + setAutoSecureToggle(networkInfo.isAutoSecureOn) + setPreferredProtocolToggle(networkInfo.isPreferredOn) + presenter.setProtocols() + if (inUse) { + forgetNetworkView?.visibility = View.GONE + } else { + forgetNetworkView?.visibility = View.VISIBLE + } + } + + override fun setAutoSecureToggle(autoSecure: Boolean) { + if (autoSecure) { + autoSecureToggleView?.setToggleImage(R.drawable.ic_toggle_button_on) + } else { + autoSecureToggleView?.setToggleImage(R.drawable.ic_toggle_button_off) + } + } + + override fun setNetworkDetailError(show: Boolean, error: String?) { + if (show) { + preferredProtocolToggleView?.visibility = View.GONE + autoSecureToggleView?.visibility = View.GONE + forgetNetworkView?.visibility = View.GONE + clError?.visibility = View.VISIBLE + networkErrorView?.text = error + } else { + preferredProtocolToggleView?.visibility = View.VISIBLE + autoSecureToggleView?.visibility = View.VISIBLE + forgetNetworkView?.visibility = View.VISIBLE + clError?.visibility = View.GONE + networkErrorView?.text = "" + } + } + + override fun setPreferredProtocolToggle(preferredProtocol: Boolean) { + networkInfo?.let { + if (preferredProtocol && it.isAutoSecureOn) { + preferredProtocolToggleView?.setToggleImage(R.drawable.ic_toggle_button_on) + } else { + preferredProtocolToggleView?.setToggleImage(R.drawable.ic_toggle_button_off) + } + } + } + + override fun setupPortMapAdapter(port: String, portMap: List) { + val connectionModeView = preferredProtocolToggleView?.childView as? ConnectionModeView + connectionModeView?.sePortAdapter(port, portMap) + } + + override fun setupProtocolAdapter(protocol: String, protocols: Array) { + val connectionModeView = preferredProtocolToggleView?.childView as? ConnectionModeView + connectionModeView?.seProtocolAdapter(protocol, protocols) + } + + override fun showToast(message: String) { + runOnUiThread { + Toast.makeText(this@NetworkDetailsActivity, message, Toast.LENGTH_SHORT).show() + } + } + + companion object { + @JvmStatic + fun getStartIntent(context: Context, networkName: String): Intent { + val intent = Intent(context, NetworkDetailsActivity::class.java) + intent.putExtra("network_name", networkName) + return intent + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/viewholder/NetworkAdapterActionListener.java b/mobile/src/main/java/com/windscribe/mobile/networksecurity/viewholder/NetworkAdapterActionListener.java new file mode 100644 index 000000000..34df738d8 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/viewholder/NetworkAdapterActionListener.java @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.networksecurity.viewholder; + +import com.windscribe.vpn.localdatabase.tables.NetworkInfo; + +public interface NetworkAdapterActionListener { + + void onItemSelected(NetworkInfo networkInfo); +} diff --git a/mobile/src/main/java/com/windscribe/mobile/networksecurity/viewholder/NetworkListViewHolder.java b/mobile/src/main/java/com/windscribe/mobile/networksecurity/viewholder/NetworkListViewHolder.java new file mode 100644 index 000000000..8da16a4c7 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/networksecurity/viewholder/NetworkListViewHolder.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.networksecurity.viewholder; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.windscribe.mobile.R; + + +public class NetworkListViewHolder extends RecyclerView.ViewHolder { + + public final TextView tvNetworkName; + + public final TextView tvProtection; + + public final ImageView dividerView; + + + public NetworkListViewHolder(View itemView) { + super(itemView); + tvNetworkName = itemView.findViewById(R.id.network_name); + tvProtection = itemView.findViewById(R.id.tv_current_protection); + dividerView = itemView.findViewById(R.id.divider); + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedActivity.kt b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedActivity.kt new file mode 100644 index 000000000..585b96c55 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedActivity.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.newsfeedactivity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.NewsFeedAdapter +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.CustomDialog +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.upgradeactivity.UpgradeActivity +import com.windscribe.vpn.api.response.PushNotificationAction +import com.windscribe.vpn.constants.ExtraConstants.PROMO_EXTRA +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class NewsFeedActivity : BaseActivity(), NewsFeedView { + @Inject + lateinit var customProgressDialog: CustomDialog + + @Inject + lateinit var presenter: NewsFeedPresenter + + @BindView(R.id.recycler_view_news_feed) + lateinit var newsFeedRecyclerView: RecyclerView + + @BindView(R.id.tv_error) + lateinit var tvError: TextView + + val logger: Logger = LoggerFactory.getLogger("basic") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_news_feed, false) + newsFeedRecyclerView.itemAnimator = DefaultItemAnimator() + newsFeedRecyclerView.layoutManager = LinearLayoutManager(this) + presenter.init( + intent.getBooleanExtra("showPopUp", false), + intent.getIntExtra("popUp", -1) + ) + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun hideProgress() { + logger.info("Hiding progress dialog.") + customProgressDialog.dismiss() + } + + @OnClick(R.id.img_news_feed_close_btn) + fun onCloseButtonClicked() { + onBackPressed() + } + + @OnClick(R.id.tv_error) + fun onErrorButtonClicked() { + logger.info("User clicked on error button.") + presenter.init(false, -1) + } + + override fun setNewsFeedAdapter(mAdapter: NewsFeedAdapter) { + logger.info("Setting news feed adapter.") + newsFeedRecyclerView.adapter = mAdapter + } + + override fun showLoadingError(errorMessage: String) { + logger.info("Showing loading error. Error message: $errorMessage") + tvError.visibility = View.VISIBLE + tvError.text = errorMessage + } + + override fun showProgress(progressTitle: String) { + logger.info("User clicked on error button.") + customProgressDialog.show() + (customProgressDialog.findViewById(R.id.tv_dialog_header) as TextView).text = + progressTitle + } + + override fun startUpgradeActivity(pushNotificationAction: PushNotificationAction) { + logger.info("Promo action notification , Launching upgrade Activity.") + val launchIntent = UpgradeActivity.getStartIntent(this) + launchIntent.putExtra(PROMO_EXTRA, pushNotificationAction) + startActivity(launchIntent) + } + + companion object { + fun getStartIntent(context: Context?, showPopUp: Boolean, popUp: Int): Intent { + val startIntent = Intent(context, NewsFeedActivity::class.java) + startIntent.putExtra("showPopUp", showPopUp) + startIntent.putExtra("popUp", popUp) + return startIntent + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedListener.kt b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedListener.kt new file mode 100644 index 000000000..256dff7f8 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedListener.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.newsfeedactivity + +import com.windscribe.vpn.localdatabase.tables.WindNotification + +interface NewsFeedListener { + fun onNotificationActionClick(windNotification: WindNotification) + fun onNotificationExpand(windNotification: WindNotification) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedPresenter.kt new file mode 100644 index 000000000..a6d83fa20 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedPresenter.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.newsfeedactivity + +interface NewsFeedPresenter { + fun init(showPopUp: Boolean, popUpId: Int) + fun onDestroy() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedPresenterImpl.kt new file mode 100644 index 000000000..ff6dfba9c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedPresenterImpl.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.newsfeedactivity + +import com.windscribe.mobile.adapter.NewsFeedAdapter +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.PushNotificationAction +import com.windscribe.vpn.errormodel.WindError.Companion.instance +import com.windscribe.vpn.localdatabase.tables.WindNotification +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import javax.inject.Inject + +class NewsFeedPresenterImpl @Inject constructor( + private val newsFeedView: NewsFeedView, + private val interactor: ActivityInteractor +) : NewsFeedPresenter, NewsFeedListener { + private var adapter: NewsFeedAdapter? = null + private val logger = LoggerFactory.getLogger("basic") + override fun onDestroy() { + interactor.getCompositeDisposable() + if (!interactor.getCompositeDisposable().isDisposed) { + interactor.getCompositeDisposable().dispose() + } + } + + override fun init(showPopUp: Boolean, popUpId: Int) { + //Set news feed alert to false + newsFeedView.showProgress("Loading") + interactor.getAppPreferenceInterface().setShowNewsFeedAlert(false) + interactor.getCompositeDisposable().add( + interactor.getNotifications() + .onErrorResumeNext( + interactor.getNotificationUpdater().update() + .andThen(interactor.getNotifications()) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ notifications: List -> + onNotificationResponse( + showPopUp, + popUpId, + notifications + ) + }) { throwable: Throwable -> onNotificationResponseError(throwable) }) + } + + override fun onNotificationActionClick(windNotification: WindNotification) { + val newsfeedAction = windNotification.action + if (newsfeedAction != null) { + val pushNotificationAction = PushNotificationAction( + newsfeedAction.pcpID, + newsfeedAction.promoCode, newsfeedAction.type + ) + if (pushNotificationAction.type == "promo") { + newsFeedView.startUpgradeActivity(pushNotificationAction) + } + } + } + + override fun onNotificationExpand(windNotification: WindNotification) { + interactor.getAppPreferenceInterface() + .saveNotificationId(windNotification.notificationId.toString()) + } + + private fun onNotificationResponse( + showPopUp: Boolean, + popUpId: Int, + mNotificationList: List + ) { + logger.info("Loaded notification data successfully...") + var firstItemToOpen = -1 + for (wn in mNotificationList) { + val read = interactor.getAppPreferenceInterface() + .isNotificationAlreadyShown(wn.notificationId.toString()) + if (!read && firstItemToOpen == -1) { + firstItemToOpen = wn.notificationId + } + wn.isRead = read + } + if (showPopUp) { + firstItemToOpen = popUpId + } else if (firstItemToOpen != -1) { + logger.debug("Showing unread message with Id: $firstItemToOpen") + } else { + logger.debug("No pop up or unread message to show") + } + adapter = NewsFeedAdapter( + mNotificationList, firstItemToOpen, + this@NewsFeedPresenterImpl + ) + newsFeedView.setNewsFeedAdapter(adapter!!) + newsFeedView.hideProgress() + } + + private fun onNotificationResponseError(throwable: Throwable) { + logger.debug( + "Error getting notification data. Error: " + + instance.convertThrowableToString(throwable) + ) + newsFeedView.showLoadingError("Error loading news feed data...") + newsFeedView.hideProgress() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedView.kt b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedView.kt new file mode 100644 index 000000000..5ccd0ff23 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/newsfeedactivity/NewsFeedView.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.newsfeedactivity + +import com.windscribe.mobile.adapter.NewsFeedAdapter +import com.windscribe.vpn.api.response.PushNotificationAction + +interface NewsFeedView { + fun hideProgress() + fun setNewsFeedAdapter(mAdapter: NewsFeedAdapter) + fun showLoadingError(errorMessage: String) + fun showProgress(progressTitle: String) + fun startUpgradeActivity(pushNotificationAction: PushNotificationAction) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsActivity.kt b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsActivity.kt new file mode 100644 index 000000000..58b472333 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsActivity.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.robert + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.RobertSettingsAdapter +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.dialogs.ErrorDialog +import com.windscribe.mobile.dialogs.ProgressDialog +import com.windscribe.mobile.utils.UiUtil +import javax.inject.Inject + +class RobertSettingsActivity : BaseActivity(), RobertSettingsView { + @BindView(R.id.cl_custom_rules) + lateinit var clCustomRules: ConstraintLayout + + @BindView(R.id.tv_custom_rules) + lateinit var customRulesLabel: TextView + + @BindView(R.id.nav_title) + lateinit var activityTitleView: TextView + + @BindView(R.id.custom_rules_arrow) + lateinit var customRulesArrow: ImageView + + @BindView(R.id.custom_rules_progress) + lateinit var customRulesProgressView: ProgressBar + + @Inject + lateinit var presenter: RobertSettingsPresenter + + @BindView(R.id.recycle_settings_view) + lateinit var recyclerSettingsView: RecyclerView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_robert_settings, true) + presenter.init() + customRulesArrow.tag = R.drawable.link_arrow_icon + UiUtil.setupOnTouchListener( + container = clCustomRules, + iconView = customRulesArrow, + textView = customRulesLabel + ) + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun hideProgress() { + ProgressDialog.hide(this) + } + + override fun openUrl(url: String) { + openURLInBrowser(url) + } + + override fun setAdapter(robertSettingsAdapter: RobertSettingsAdapter) { + recyclerSettingsView.layoutManager = LinearLayoutManager(this) + recyclerSettingsView.adapter = robertSettingsAdapter + } + + override fun setTitle(title: String) { + activityTitleView.text = title + } + + override fun setWebSessionLoading(loading: Boolean) { + customRulesArrow.visibility = if (loading) View.GONE else View.VISIBLE + customRulesProgressView.visibility = + if (loading) View.VISIBLE else View.GONE + clCustomRules.isEnabled = !loading + } + + override fun showError(error: String) { + ErrorDialog.show(this, error) + } + + override fun showErrorDialog(error: String) { + ErrorDialog.show(this, error) + } + + override fun showProgress() { + ProgressDialog.show(this) + } + + override fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + @OnClick(R.id.nav_button) + fun onBackButtonClick() { + onBackPressed() + } + + @OnClick(R.id.cl_custom_rules) + fun onCustomRulesClick() { + presenter.onCustomRulesClick() + } + + @OnClick(R.id.learn_more) + fun onLearnMoreClick() { + presenter.onLearnMoreClick() + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, RobertSettingsActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsPresenter.kt new file mode 100644 index 000000000..5182aad9b --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsPresenter.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.robert + +import android.content.Context + +interface RobertSettingsPresenter { + val savedLocale: String? + fun init() + fun onCustomRulesClick() + fun onDestroy() + fun onLearnMoreClick() + fun setTheme(context: Context) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsPresenterImpl.kt new file mode 100644 index 000000000..3f74c6437 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsPresenterImpl.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.robert + +import android.content.Context +import android.net.Uri +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.RobertAdapterListener +import com.windscribe.mobile.adapter.RobertSettingsAdapter +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.api.response.* +import com.windscribe.vpn.constants.FeatureExplainer +import com.windscribe.vpn.constants.NetworkErrorCodes +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.exceptions.WindScribeException +import com.windscribe.vpn.repository.CallResult +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import java.util.concurrent.TimeUnit + +class RobertSettingsPresenterImpl( + private val robertSettingsView: RobertSettingsView, + private val interactor: ActivityInteractor +) : RobertSettingsPresenter, RobertAdapterListener { + private val mPresenterLog = LoggerFactory.getLogger("basic") + private var robertSettingsAdapter: RobertSettingsAdapter? = null + override fun onDestroy() { + interactor.getCompositeDisposable().clear() + } + + override val savedLocale: String + get() { + val selectedLanguage = + interactor.getAppPreferenceInterface().savedLanguage + return selectedLanguage.substring( + selectedLanguage.indexOf("(") + 1, + selectedLanguage.indexOf(")") + ) + } + + override fun init() { + robertSettingsView.setTitle(interactor.getResourceString(R.string.robert)) + loadSettings() + } + + override fun onCustomRulesClick() { + robertSettingsView.setWebSessionLoading(true) + mPresenterLog.info("Opening robert rules page in browser...") + interactor.getCompositeDisposable() + .add(interactor.getApiCallManager().getWebSession() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ response: GenericResponseClass -> + handleWebSessionResponse( + response + ) + }) { throwable: Throwable -> handleWebSessionError(throwable) }) + } + + override fun onLearnMoreClick() { + robertSettingsView.openUrl(FeatureExplainer.ROBERT) + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + override fun settingChanged( + originalList: List, + filter: RobertFilter, + position: Int + ) { + robertSettingsAdapter?.settingUpdateInProgress = true + robertSettingsView.showProgress() + interactor.getCompositeDisposable() + .add(interactor.getApiCallManager().updateRobertSettings(filter.id, filter.status) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response: GenericResponseClass -> + handleRobertSettingUpdateResponse( + response, + originalList, + position + ) + } + ) { + handleRobertSettingsUpdateError( + interactor.getResourceString(R.string.failed_to_update_robert_rules), + originalList, + position + ) + }) + } + + private fun handleRobertLoadSettingResponse(robertFilters: List) { + robertSettingsAdapter = RobertSettingsAdapter(this) + robertSettingsAdapter?.let { + it.data = robertFilters + robertSettingsView.setAdapter(it) + } + robertSettingsView.hideProgress() + } + + private fun handleRobertSettingUpdateResponse( + response: GenericResponseClass, + originalList: List, position: Int + ) { + robertSettingsAdapter?.settingUpdateInProgress = false + when (val result = response.callResult()) { + is CallResult.Error -> { + if (result.code != NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + handleRobertSettingsUpdateError(result.errorMessage, originalList, position) + } else { + handleRobertSettingsUpdateError( + interactor.getResourceString(R.string.failed_to_update_robert_rules), + originalList, + position + ) + } + } + is CallResult.Success -> { + robertSettingsView.hideProgress() + robertSettingsView.showToast(interactor.getResourceString(R.string.successfully_updated_robert_rules)) + appContext.workManager.updateRobertRules() + } + } + } + + private fun handleRobertSettingsUpdateError( + error: String, + originalList: List, + position: Int + ) { + robertSettingsAdapter?.settingUpdateInProgress = false + robertSettingsView.hideProgress() + robertSettingsView.showToast(error) + robertSettingsAdapter?.data = originalList + robertSettingsAdapter?.notifyItemChanged(position) + } + + private fun handleWebSessionError(throwable: Throwable) { + mPresenterLog.debug( + String.format( + "Failed to generate web session: %s", + throwable.localizedMessage + ) + ) + robertSettingsView.setWebSessionLoading(false) + robertSettingsView.showErrorDialog("Failed to generate web session. Check your network connection.") + } + + private fun handleWebSessionResponse(response: GenericResponseClass) { + robertSettingsView.setWebSessionLoading(false) + when (val result = response.callResult()) { + is CallResult.Error -> { + if (result.code != NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + mPresenterLog.debug( + String.format( + "Failed to generate web session: %s", + result.errorMessage + ) + ) + robertSettingsView.showErrorDialog(result.errorMessage) + } else { + robertSettingsView.showErrorDialog("Failed to generate Web-Session. Check your network connection.") + } + } + is CallResult.Success -> { + robertSettingsView.openUrl(responseToUrl(result.data)) + } + } + } + + @Throws(WindScribeException::class) + private fun loadFromDatabase(throwable: Throwable): Single> { + val json = interactor.getAppPreferenceInterface() + .getResponseString(PreferencesKeyConstants.ROBERT_FILTERS) + ?: throw WindScribeException(throwable.localizedMessage) + return Single.just( + Gson().fromJson( + json, + object : TypeToken>() {}.type + ) + ) + } + + private fun loadSettings() { + robertSettingsView.showProgress() + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().getRobertFilters() + .flatMap { response: GenericResponseClass -> + saveToDatabase( + response + ) + } + .onErrorResumeNext { throwable: Throwable -> loadFromDatabase(throwable) } + .subscribeOn(Schedulers.io()) + .delaySubscription(1, TimeUnit.SECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ robertFilters: List -> + handleRobertLoadSettingResponse( + robertFilters + ) + } + ) { + robertSettingsView.hideProgress() + robertSettingsView + .showError("Failed to load to Robert settings. Check your network connection.") + }) + } + + private fun responseToUrl(webSession: WebSession): String { + val uri = Uri.Builder() + .scheme("https") + .authority(NetworkKeyConstants.WEB_URL?.replace("https://", "")) + .path("myaccount") + .fragment("robertrules") + .appendQueryParameter("temp_session", webSession.tempSession) + .build() + return uri.toString() + } + + @Throws(WindScribeException::class) + private fun saveToDatabase( + response: GenericResponseClass + ): Single> { + when (val result = response.callResult()) { + is CallResult.Error -> { + if (result.code != NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + throw WindScribeException(result.errorMessage) + } else { + throw WindScribeException("Unexpected Api response.") + } + } + is CallResult.Success -> { + val robertSettings = result.data.filters + val json = Gson().toJson(robertSettings) + interactor.getAppPreferenceInterface() + .saveResponseStringData(PreferencesKeyConstants.ROBERT_FILTERS, json) + return Single.just(robertSettings) + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsView.kt b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsView.kt new file mode 100644 index 000000000..33d821d3a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/robert/RobertSettingsView.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.robert + +import com.windscribe.mobile.adapter.RobertSettingsAdapter + +interface RobertSettingsView { + fun hideProgress() + fun openUrl(url: String) + fun setAdapter(robertSettingsAdapter: RobertSettingsAdapter) + fun setTitle(title: String) + fun setWebSessionLoading(loading: Boolean) + fun showError(error: String) + fun showErrorDialog(error: String) + fun showProgress() + fun showToast(message: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splash/SplashActivity.kt b/mobile/src/main/java/com/windscribe/mobile/splash/SplashActivity.kt new file mode 100644 index 000000000..b7b43ae25 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splash/SplashActivity.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splash + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.di.DaggerActivityComponent +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.Windscribe.Companion.appContext +import org.slf4j.LoggerFactory +import javax.inject.Inject + +@SuppressLint("CustomSplashScreen") +class SplashActivity : BaseActivity(), SplashView { + + @Inject + lateinit var presenter: SplashPresenter + + private val logger = LoggerFactory.getLogger("basic") + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) + DaggerActivityComponent.builder().activityModule(ActivityModule(this, this)) + .applicationComponent( + appContext + .applicationComponent + ).build().inject(this) + if (Build.VERSION.SDK_INT >= 23) { + splashScreen.setKeepOnScreenCondition { true } + } else { + setContentView(R.layout.activity_splash) + } + logger.info("OnCreate: Splash Activity") + presenter.checkNewMigration() + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override fun navigateToAccountSetUp() { + logger.info("Navigating to account set up activity...") + val intent = Intent(this, WelcomeActivity::class.java) + intent.putExtra("startFragmentName", "AccountSetUp") + intent.putExtra("skipToHome", true) + startActivity(intent) + finish() + } + + override fun navigateToHome() { + logger.info("Navigating to home activity...") + val homeIntent = Intent(this, WindscribeActivity::class.java) + if (intent.extras != null) { + logger.debug("Forwarding intent extras home activity.") + homeIntent.putExtras(intent.extras!!) + } + startActivity(homeIntent) + finish() + } + + override fun navigateToLogin() { + logger.info("Navigating to login activity...") + val loginIntent = Intent(this, WelcomeActivity::class.java) + startActivity(loginIntent) + finish() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splash/SplashPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/splash/SplashPresenter.kt new file mode 100644 index 000000000..b9d0f194c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splash/SplashPresenter.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splash + +interface SplashPresenter { + fun checkNewMigration() + fun onDestroy() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splash/SplashPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/splash/SplashPresenterImpl.kt new file mode 100644 index 000000000..54ead87df --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splash/SplashPresenterImpl.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splash + +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.constants.UserStatusConstants +import com.windscribe.vpn.errormodel.WindError +import io.reactivex.Completable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableCompletableObserver +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import java.util.Date +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class SplashPresenterImpl @Inject constructor( + private var view: SplashView, + private var interactor: ActivityInteractor +) : SplashPresenter { + + private val logger = LoggerFactory.getLogger("basic") + + /* Stop Session service if running + * Check purchase token if available + * Get User session. + * Update User status + * Check if server List need update + * Update server config, server credentials op and ikEv2 + * Request , Parse and save list + * Check if ping test can be done + * Ping every node and Save to database + * Save best location id based on lowest ping. + * + * */ + override fun onDestroy() { + if (!interactor.getCompositeDisposable().isDisposed) { + interactor.getCompositeDisposable().dispose() + } + } + + fun checkApplicationInstanceAndDecideActivity() { + if (interactor.getAppPreferenceInterface().isNewApplicationInstance) { + interactor.getAppPreferenceInterface().isNewApplicationInstance = false + val installation = interactor.getAppPreferenceInterface() + .getResponseString(PreferencesKeyConstants.NEW_INSTALLATION) + if (PreferencesKeyConstants.I_NEW == installation) { + //Record new install + interactor.getAppPreferenceInterface() + .saveResponseStringData( + PreferencesKeyConstants.NEW_INSTALLATION, + PreferencesKeyConstants.I_OLD + ) + interactor.getCompositeDisposable().add( + interactor.getApiCallManager() + .recordAppInstall() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + logger.debug(WindError.instance.rxErrorToString(e as Exception)) + decideActivity() + } + + override fun onSuccess( + recordInstallResponse: GenericResponseClass + ) { + if (recordInstallResponse.dataClass != null) { + logger.info( + "Recording app install success. " + + recordInstallResponse.dataClass + ) + } else if (recordInstallResponse.errorClass != null) { + logger.debug( + "Recording app install failed. " + + recordInstallResponse.errorClass.toString() + ) + } + decideActivity() + } + }) + ) + } else { + //Not a new install, decide activity + decideActivity() + } + } else { + //Decide which activity to goto + decideActivity() + } + } + + override fun checkNewMigration() { + interactor.getAutoConnectionManager().reset() + migrateSessionAuthIfRequired() + val userLoggedIn = interactor.getAppPreferenceInterface().sessionHash != null + if (userLoggedIn) { + interactor.getCompositeDisposable().add( + interactor.serverDataAvailable() + .flatMap { serverListAvailable -> + return@flatMap Single.create { sub -> + interactor.getFireBaseManager().getFirebaseToken { token -> + sub.onSuccess(token ?: "") + } + }.flatMap { + interactor.getApiCallManager().getSessionGeneric(it) + }.flatMap { + return@flatMap Single.just(serverListAvailable) + } + }.timeout(500, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(ignored: Throwable) { + checkApplicationInstanceAndDecideActivity() + } + + override fun onSuccess(serverListAvailable: Boolean) { + if (serverListAvailable) { + logger.info("Migration not required.") + checkApplicationInstanceAndDecideActivity() + } else { + logger.info("Migration required. updating server list.") + updateDataFromApiAndOldStorage() + } + } + }) + ) + } else { + checkApplicationInstanceAndDecideActivity() + } + } + + fun decideActivity() { + val sessionHash = interactor.getAppPreferenceInterface().sessionHash + if (sessionHash != null) { + if (interactor.getAppPreferenceInterface().loginTime == null){ + interactor.getAppPreferenceInterface().loginTime = Date() + } + logger.info("Session auth hash present. User is already logged in...") + if (view.isConnectedToNetwork.not()) { + logger.info("NO ACTIVE NETWORK FOUND! Starting home activity with stale data.") + } + if (shouldShowAccountSetUp()) { + view.navigateToAccountSetUp() + } else { + view.navigateToHome() + } + } else { + view.navigateToLogin() + } + } + + // Move SessionAuth to secure preferences + private fun migrateSessionAuthIfRequired() { + val oldSessionAuth = interactor.getAppPreferenceInterface().oldSessionAuth + val newSessionAuth = interactor.getAppPreferenceInterface().sessionHash + if (oldSessionAuth != null && newSessionAuth == null) { + logger.debug("Migrating session auth to secure preferences") + interactor.getAppPreferenceInterface().sessionHash = oldSessionAuth + interactor.getAppPreferenceInterface().clearOldSessionAuth() + } + } + + private fun shouldShowAccountSetUp(): Boolean { + val ghostAccount = interactor.getAppPreferenceInterface().userIsInGhostMode() + val proUser = (interactor.getAppPreferenceInterface().userStatus + == UserStatusConstants.USER_STATUS_PREMIUM) + return ghostAccount && proUser + } + + private fun updateDataFromApiAndOldStorage() { + interactor.getCompositeDisposable().add( + interactor.getServerListUpdater().update() + .doOnError { logger.info("Failed to download server list.") } + .andThen(interactor.getStaticListUpdater().update()) + .doOnError { logger.info("Failed to download static server list.") } + .andThen(interactor.updateUserData()) + .andThen(Completable.fromAction { + interactor.getPreferenceChangeObserver().postCityServerChange() + }) + .onErrorResumeNext { throwable: Throwable -> + logger.info( + "*********Preparing dashboard failed: " + throwable.toString() + + " Use reload button in server list in home activity.*******" + ) + interactor.updateUserData().andThen( + Completable.fromAction { + interactor.getPreferenceChangeObserver().postCityServerChange() + }) + } + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + checkApplicationInstanceAndDecideActivity() + } + + override fun onError(ignored: Throwable) { + checkApplicationInstanceAndDecideActivity() + } + }) + ) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splash/SplashView.kt b/mobile/src/main/java/com/windscribe/mobile/splash/SplashView.kt new file mode 100644 index 000000000..9d0b8cf9c --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splash/SplashView.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splash + +interface SplashView { + val isConnectedToNetwork: Boolean + fun navigateToAccountSetUp() + fun navigateToHome() + fun navigateToLogin() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingActivity.kt b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingActivity.kt new file mode 100644 index 000000000..7ce42af4b --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingActivity.kt @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splittunneling + +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.annotation.Dimension +import androidx.appcompat.widget.SearchView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.transition.AutoTransition +import androidx.transition.Transition +import androidx.transition.TransitionManager +import butterknife.BindView +import butterknife.OnClick +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.InstalledAppsAdapter +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.custom_view.preferences.ExpandableToggleView +import com.windscribe.mobile.custom_view.preferences.SplitRoutingModeView +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.vpn.backend.utils.WindVpnController +import com.windscribe.vpn.commonutils.ThemeUtils.getColor +import com.windscribe.vpn.constants.AnimConstants +import com.windscribe.vpn.constants.FeatureExplainer +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +class SplitTunnelingActivity : BaseActivity(), SplitTunnelingView { + + @BindView(R.id.nav_title) + lateinit var activityTitle: TextView + + @BindView(R.id.recycler_view_app_list) + lateinit var appListRecyclerView: RecyclerView + + @BindView(R.id.cl_split_tunnel_settings) + lateinit var mainContainer: ConstraintLayout + + @BindView(R.id.minimize_icon) + lateinit var minimizeIcon: ImageView + + @BindView(R.id.clear_icon) + lateinit var clearIcon: ImageView + + @BindView(R.id.progress) + lateinit var progressBar: ProgressBar + + @BindView(R.id.searchView) + lateinit var searchView: SearchView + + @BindView(R.id.cl_switch) + lateinit var modeToggleView: ExpandableToggleView + + @Inject + lateinit var windVpnController: WindVpnController + + @Inject + lateinit var presenter: SplitTunnelingPresenter + + private var mTransition: AutoTransition? = null + private val constraintSetTunnel = ConstraintSet() + + private val mSplitViewLog = LoggerFactory.getLogger("basic") + private val setView = AtomicBoolean() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_split_tunneling, true) + constraintSetTunnel.clone(mainContainer) + setView.set(true) + mainContainer.viewTreeObserver.addOnGlobalLayoutListener { + if (setView.getAndSet(false)) { + presenter.setupLayoutBasedOnPreviousSettings() + } + } + setUpCustomSearchBox() + activityTitle.text = getString(R.string.split_tunneling) + setupCustomLayoutDelegates() + } + + private fun setupCustomLayoutDelegates() { + modeToggleView.delegate = object : ExpandableToggleView.Delegate { + override fun onToggleClick() { + presenter.onToggleButtonClicked() + } + + override fun onExplainClick() {} + } + val splitRoutingModeView = modeToggleView.childView as SplitRoutingModeView + splitRoutingModeView.delegate = object : SplitRoutingModeView.Delegate { + override fun onModeSelect(mode: String) { + presenter.onNewRoutingModeSelected(mode) + } + } + } + + override fun onDestroy() { + presenter.onDestroy() + super.onDestroy() + } + + override val splitRoutingModes: Array + get() = resources.getStringArray(R.array.split_mode_list) + + override fun hideTunnelSettingsLayout() { + mSplitViewLog.info("Setting up layout for split tunnel settings on..") + constraintSetTunnel.setVisibility(R.id.cl_app_list, ConstraintSet.GONE) + minimizeIcon.visibility = View.GONE + constraintSetTunnel.setVisibility(R.id.minimize_icon, ConstraintSet.GONE) + clearIcon.visibility = View.GONE + constraintSetTunnel.setVisibility(R.id.clear_icon, ConstraintSet.GONE) + //Start transition + mTransition = AutoTransition() + mTransition?.duration = AnimConstants.CONNECTION_MODE_ANIM_DURATION + mTransition?.addListener(object : Transition.TransitionListener { + override fun onTransitionCancel(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionEnd(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionPause(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionResume(transition: Transition) {} + override fun onTransitionStart(transition: Transition) {} + }) + TransitionManager.beginDelayedTransition(mainContainer, mTransition) + constraintSetTunnel.applyTo(mainContainer) + } + + @OnClick(R.id.nav_button) + fun onBackButtonPressed() { + onBackPressed() + } + + @OnClick(R.id.learn_more) + fun onLearMoreClick() { + openURLInBrowser(FeatureExplainer.SPLIT_TUNNELING) + } + + override fun onBackPressed() { + presenter.onBackPressed() + super.onBackPressed() + } + + override fun onSearchRequested(): Boolean { + return false + } + + override fun restartConnection() { + windVpnController.connectAsync() + } + + override fun setRecyclerViewAdapter(mAdapter: InstalledAppsAdapter) { + val layoutManager = LinearLayoutManager(this) + layoutManager.isItemPrefetchEnabled = false + appListRecyclerView.layoutManager = layoutManager + appListRecyclerView.adapter = mAdapter + } + + override fun setSplitModeTextView(mode: String, textDescription: Int) { + modeToggleView.setDescription(textDescription) + } + + override fun setSplitRoutingModeAdapter( + localizeValues: Array, selectedValue: String, values: Array + ) { + val splitRoutingModeView = modeToggleView.childView as SplitRoutingModeView + splitRoutingModeView.setAdapter(selectedValue, values, localizeValues) + } + + override fun setupToggleImage(resourceId: Int) { + modeToggleView.setToggleImage(resourceId) + } + + override fun showProgress(progress: Boolean) { + if (progress) { + minimizeIcon.visibility = View.GONE + clearIcon.visibility = View.GONE + searchView.visibility = View.INVISIBLE + progressBar.visibility = View.VISIBLE + } else { + progressBar.visibility = View.GONE + searchView.visibility = View.VISIBLE + minimizeIcon.visibility = View.GONE + clearIcon.visibility = View.GONE + } + } + + override fun showTunnelSettingsLayout() { + mSplitViewLog.info("Setting up layout for split tunnel settings on..") + constraintSetTunnel.setVisibility(R.id.cl_app_list, ConstraintSet.VISIBLE) + + //Start transition + mTransition = AutoTransition() + mTransition?.excludeTarget(R.id.minimize_icon, true) + mTransition?.duration = AnimConstants.CONNECTION_MODE_ANIM_DURATION + mTransition?.addListener(object : Transition.TransitionListener { + override fun onTransitionCancel(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionEnd(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionPause(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionResume(transition: Transition) {} + override fun onTransitionStart(transition: Transition) {} + }) + TransitionManager.beginDelayedTransition(mainContainer, mTransition) + constraintSetTunnel.applyTo(mainContainer) + } + + @OnClick(R.id.minimize_icon) + fun onMinimizeIconClick() { + searchView.setQuery("", false) + searchView.clearFocus() + minimizeTopView(false) + } + + private fun minimizeTopView(minimize: Boolean) { + mSplitViewLog.info("Setting up layout to max..$minimize") + if (minimize) { + constraintSetTunnel.setMargin( + R.id.cl_app_list, ConstraintSet.TOP, resources.getDimension( + R.dimen.reg_16dp + ).toInt() + ) + constraintSetTunnel.setVisibility(R.id.cl_top_bar, ConstraintSet.GONE) + constraintSetTunnel.setVisibility(R.id.cl_switch, ConstraintSet.GONE) + constraintSetTunnel.setVisibility(R.id.cl_app_list, ConstraintSet.VISIBLE) + } else { + constraintSetTunnel.setMargin(R.id.cl_app_list, ConstraintSet.TOP, 0) + constraintSetTunnel.setVisibility(R.id.minimize_icon, ConstraintSet.GONE) + constraintSetTunnel.setVisibility(R.id.cl_top_bar, ConstraintSet.VISIBLE) + constraintSetTunnel.setVisibility(R.id.cl_switch, ConstraintSet.VISIBLE) + constraintSetTunnel.setVisibility(R.id.cl_app_list, ConstraintSet.VISIBLE) + } + //Start transition + mTransition = AutoTransition() + mTransition?.duration = 300 + mTransition?.addListener(object : Transition.TransitionListener { + override fun onTransitionCancel(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionEnd(transition: Transition) { + minimizeIcon.visibility = if (minimize) View.VISIBLE else View.GONE + transition.removeListener(this) + } + + override fun onTransitionPause(transition: Transition) { + transition.removeListener(this) + } + + override fun onTransitionResume(transition: Transition) {} + override fun onTransitionStart(transition: Transition) {} + }) + mTransition?.excludeChildren(R.id.recycler_view_app_list, true) + TransitionManager.beginDelayedTransition(mainContainer, mTransition) + constraintSetTunnel.applyTo(mainContainer) + } + + private fun setUpCustomSearchBox() { + // Search view + searchView.setIconifiedByDefault(false) + searchView.queryHint = getString(R.string.search) + searchView.isFocusable = false + // Filter results on text change + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextChange(s: String): Boolean { + if (s.isEmpty()) { + clearIcon.visibility = View.GONE + } else { + clearIcon.visibility = View.VISIBLE + } + presenter.onFilter(s) + return false + } + + override fun onQueryTextSubmit(s: String): Boolean { + searchView.clearFocus() + return true + } + }) + // Hide top layout items to make more room for search view and apps + searchView.setOnQueryTextFocusChangeListener { _: View?, hasFocus: Boolean -> + if (hasFocus) { + minimizeTopView(true) + } + } + + // Search text + val searchText = + searchView.findViewById(androidx.appcompat.R.id.search_src_text) + searchText.setTextColor(getColor(this, R.attr.wdSecondaryColor, R.color.colorWhite)) + searchText.setHintTextColor(getColor(this, R.attr.wdSecondaryColor, R.color.colorWhite)) + searchText.setTextSize(Dimension.SP, 14f) + val typeface = ResourcesCompat.getFont(this, R.font.ibm_plex_sans_regular) + searchText.typeface = typeface + searchText.setPadding(0, 0, 0, 0) + // Clear text + clearIcon.setOnClickListener { v: View? -> + searchView.clearFocus() + searchView.setQuery("", false) + presenter.onFilter("") + } + // Search icon + val searchIcon = + searchView.findViewById(androidx.appcompat.R.id.search_mag_icon) + searchIcon.setPadding(0, 0, 0, 0) + searchIcon.scaleType = ImageView.ScaleType.FIT_START + searchIcon.imageTintList = + ColorStateList.valueOf(getColor(this, R.attr.searchTextColor, R.color.colorWhite)) + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, SplitTunnelingActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingPresenter.kt new file mode 100644 index 000000000..22dbcb165 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingPresenter.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splittunneling + +import android.content.Context + +interface SplitTunnelingPresenter { + fun onBackPressed() + fun onDestroy() + fun onFilter(query: String) + fun onNewRoutingModeSelected(mode: String) + fun onToggleButtonClicked() + fun setTheme(context: Context) + fun setupLayoutBasedOnPreviousSettings() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingPresenterImpl.kt new file mode 100644 index 000000000..b52d61451 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingPresenterImpl.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splittunneling + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.InstalledAppsAdapter +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.api.response.InstalledAppsData +import com.windscribe.vpn.commonutils.SortByName +import com.windscribe.vpn.commonutils.SortBySelected +import com.windscribe.vpn.constants.PreferencesKeyConstants +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import org.slf4j.LoggerFactory +import java.util.* +import javax.inject.Inject + +class SplitTunnelingPresenterImpl @Inject constructor( + private val splitTunnelView: SplitTunnelingView, + private val interactor: ActivityInteractor +) : SplitTunnelingPresenter, InstalledAppsAdapter.InstalledAppListener { + var installedAppsAdapter: InstalledAppsAdapter? = null + private val mInstalledAppList: MutableList = ArrayList() + private val logger = LoggerFactory.getLogger("basic") + override fun onDestroy() { + //Dispose any composite disposable + if (!interactor.getCompositeDisposable().isDisposed) { + logger.info("Disposing observer...") + interactor.getCompositeDisposable().dispose() + } + } + + override fun onBackPressed() { + val isReconnectRequired = + interactor.getAppPreferenceInterface().requiredReconnect() + if (isReconnectRequired && interactor.getVpnConnectionStateManager() + .isVPNActive() + ) { + logger + .info("Split routing settings were changes and connection state is connected. Reconnecting to apply settings..") + interactor.getAppPreferenceInterface().setReconnectRequired(false) + splitTunnelView.restartConnection() + } + } + + override fun onFilter(query: String) { + installedAppsAdapter?.filter(query) + } + + override fun onInstalledAppClick(updatedApp: InstalledAppsData, reloadAdapter: Boolean) { + interactor.getCompositeDisposable() + .add( + interactor.getAppPreferenceInterface().installedApps + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableSingleObserver>() { + override fun onError(ignore: Throwable) { + val list: MutableList = ArrayList() + saveApps(list, updatedApp, reloadAdapter) + } + + override fun onSuccess(installedAppsData: List) { + saveApps(installedAppsData.toMutableList(), updatedApp, reloadAdapter) + } + }) + ) + } + + override fun onNewRoutingModeSelected(mode: String) { + interactor.getAppPreferenceInterface().setReconnectRequired(true) + val savedMode = interactor.getAppPreferenceInterface().splitRoutingMode + if (savedMode != mode) { + interactor.getAppPreferenceInterface().saveSplitRoutingMode(mode) + if (mode == PreferencesKeyConstants.EXCLUSIVE_MODE) { + splitTunnelView.setSplitModeTextView( + mode, + R.string.feature_tunnel_mode_exclusive + ) + } else { + val pm = appContext.packageManager + val packageName = appContext.packageName + try { + val applicationInfo = pm + .getApplicationInfo(packageName, PackageManager.GET_META_DATA) + val mData = InstalledAppsData( + pm.getApplicationLabel(applicationInfo).toString(), + applicationInfo.packageName, pm.getApplicationIcon(applicationInfo) + ) + mData.isChecked = true + onInstalledAppClick(mData, true) + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + } + splitTunnelView.setSplitModeTextView( + mode, + R.string.feature_tunnel_mode_inclusive + ) + } + } + } + + override fun onToggleButtonClicked() { + interactor.getAppPreferenceInterface().setReconnectRequired(true) + if (interactor.getAppPreferenceInterface().splitTunnelToggle) { + interactor.getAppPreferenceInterface().splitTunnelToggle = false + splitTunnelView.setupToggleImage(R.drawable.ic_toggle_button_off) + splitTunnelView.hideTunnelSettingsLayout() + } else { + interactor.getAppPreferenceInterface().splitTunnelToggle = true + splitTunnelView.setupToggleImage(R.drawable.ic_toggle_button_on) + splitTunnelView.showTunnelSettingsLayout() + } + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + logger.debug("Setting theme to $savedThem") + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + override fun setupLayoutBasedOnPreviousSettings() { + if (interactor.getAppPreferenceInterface().splitTunnelToggle) { + //Toggle is on so show the mode and selected app settings + splitTunnelView.showTunnelSettingsLayout() + //Toggle Button ON + splitTunnelView.setupToggleImage(R.drawable.ic_toggle_button_on) + } else { + //Hide the settings and List + splitTunnelView.hideTunnelSettingsLayout() + //Toggle Button OFF + splitTunnelView.setupToggleImage(R.drawable.ic_toggle_button_off) + } + + //Setup application list adapter + setupAppListAdapter() + setupSplitRoutingMode() + } + + private fun modifyList(savedApps: List) { + val pm = appContext.packageManager + interactor.getCompositeDisposable() + .add(Single.fromCallable { pm.getInstalledApplications(PackageManager.GET_META_DATA) } + .flatMap { packages: List -> + for (applicationInfo in packages) { + val mData = InstalledAppsData( + pm.getApplicationLabel(applicationInfo).toString(), + applicationInfo.packageName, + pm.getApplicationIcon(applicationInfo) + ) + for (installedAppsData in savedApps) { + if (mData.packageName == installedAppsData) { + mData.isChecked = true + } + } + mInstalledAppList.add(mData) + } + Collections.sort(mInstalledAppList, SortByName()) + Collections.sort(mInstalledAppList, SortBySelected()) + Single.fromCallable { mInstalledAppList } + }.cache() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableSingleObserver?>() { + override fun onError(ignored: Throwable) { + splitTunnelView.showProgress(false) + } + + override fun onSuccess(packages: List) { + splitTunnelView.showProgress(false) + installedAppsAdapter = InstalledAppsAdapter( + mInstalledAppList, + this@SplitTunnelingPresenterImpl + ) + splitTunnelView.setRecyclerViewAdapter(installedAppsAdapter!!) + } + }) + ) + } + + @SuppressLint("NotifyDataSetChanged") + private fun saveApps( + savedList: MutableList, + updatedApp: InstalledAppsData, + reloadAdapter: Boolean + ) { + interactor.getAppPreferenceInterface().setReconnectRequired(true) + if (updatedApp.isChecked) { + savedList.add(updatedApp.packageName) + } else { + savedList.remove(updatedApp.packageName) + interactor.getAppPreferenceInterface().saveInstalledApps(savedList) + } + interactor.getAppPreferenceInterface().saveInstalledApps(savedList) + if (reloadAdapter) { + installedAppsAdapter?.notifyDataSetChanged() + } + } + + private fun setupAppListAdapter() { + splitTunnelView.showProgress(true) + interactor.getCompositeDisposable() + .add( + interactor.getAppPreferenceInterface().installedApps + .cache() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableSingleObserver>() { + override fun onError(ignored: Throwable) { + splitTunnelView.showProgress(false) + modifyList(emptyList()) + } + + override fun onSuccess(installedAppsData: List) { + modifyList(installedAppsData) + } + }) + ) + } + + private fun setupSplitRoutingMode() { + val mode = interactor.getAppPreferenceInterface().splitRoutingMode + splitTunnelView.setSplitRoutingModeAdapter( + splitTunnelView.splitRoutingModes, + mode, + arrayOf(PreferencesKeyConstants.EXCLUSIVE_MODE, PreferencesKeyConstants.INCLUSIVE_MODE) + ) + if (mode == PreferencesKeyConstants.EXCLUSIVE_MODE) { + splitTunnelView.setSplitModeTextView(mode, R.string.feature_tunnel_mode_exclusive) + } else { + splitTunnelView.setSplitModeTextView(mode, R.string.feature_tunnel_mode_inclusive) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingView.kt b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingView.kt new file mode 100644 index 000000000..4059af971 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/splittunneling/SplitTunnelingView.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.splittunneling + +import com.windscribe.mobile.adapter.InstalledAppsAdapter + +interface SplitTunnelingView { + val splitRoutingModes: Array + fun hideTunnelSettingsLayout() + fun restartConnection() + fun setRecyclerViewAdapter(mAdapter: InstalledAppsAdapter) + fun setSplitModeTextView(mode: String, textDescription: Int) + fun setSplitRoutingModeAdapter( + localizeValues: Array, + selectedValue: String, + values: Array + ) + + fun setupToggleImage(resourceId: Int) + fun showProgress(progress: Boolean) + fun showTunnelSettingsLayout() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketActivity.kt b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketActivity.kt new file mode 100644 index 000000000..487d14e1e --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketActivity.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.ticket + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.inputmethod.InputMethodManager +import android.widget.* +import androidx.appcompat.widget.AppCompatEditText +import butterknife.BindView +import butterknife.OnClick +import butterknife.OnItemSelected +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.dialogs.ErrorDialog +import com.windscribe.mobile.dialogs.ProgressDialog +import com.windscribe.mobile.dialogs.SuccessDialog +import com.windscribe.mobile.welcome.SoftInputAssist +import com.windscribe.vpn.api.response.QueryType +import com.windscribe.vpn.commonutils.ThemeUtils +import java.util.* +import javax.inject.Inject + +class SendTicketActivity : BaseActivity(), SendTicketView, TextWatcher { + @Inject + lateinit var presenter: SendTicketPresenter + + @BindView(R.id.btn_send_ticket) + lateinit var btnSendButton: Button + + @BindView(R.id.tv_current_category) + lateinit var currentQueryType: TextView + + @BindView(R.id.email) + lateinit var emailView: AppCompatEditText + + @BindView(R.id.scroll_view) + lateinit var scrollView: ScrollView + + @BindView(R.id.message) + lateinit var messageView: AppCompatEditText + + @BindView(R.id.spinner_query) + lateinit var queryTypeSpinner: Spinner + + @BindView(R.id.subject) + lateinit var subjectView: AppCompatEditText + + @BindView(R.id.nav_title) + lateinit var tvActivityTitle: TextView + + private var softInputAssist: SoftInputAssist? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_send_ticket, true) + softInputAssist = SoftInputAssist(this, intArrayOf()) + presenter.init() + } + + override fun onResume() { + super.onResume() + softInputAssist?.onResume() + } + + override fun onPause() { + super.onPause() + softInputAssist?.onPause() + } + + override fun onDestroy() { + softInputAssist?.onDestroy() + super.onDestroy() + } + + override fun addTextChangeListener() { + messageView.addTextChangedListener(this) + emailView.addTextChangedListener(this) + subjectView.addTextChangedListener(this) + } + + override fun afterTextChanged(s: Editable) { + val message = Objects.requireNonNull( + messageView.text + ).toString() + val email = Objects.requireNonNull( + emailView.text + ).toString() + val subject = Objects.requireNonNull( + subjectView.text + ).toString() + presenter.onInputChanged(email, subject, message) + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + + @SuppressLint("NonConstantResourceId") + @OnClick(R.id.nav_button) + fun onBackButtonPressed() { + super.onBackPressed() + } + + override fun onBackPressed() { + setInputState(true) + super.onBackPressed() + } + + @OnClick(R.id.tv_current_category, R.id.img_category_drop_down_btn) + fun onCurrentQueryTypeClick() { + queryTypeSpinner.performClick() + } + + @OnItemSelected(R.id.spinner_query) + fun onQueryTypeSelected() { + val queryType = queryTypeSpinner.selectedItem.toString() + currentQueryType.text = queryType + QueryType.values()[queryTypeSpinner.selectedItemPosition].let { + presenter.onQueryTypeSelected(it) + } + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + + @SuppressLint("NonConstantResourceId") + @OnClick(R.id.btn_send_ticket) + fun sendTicketClicked() { + hideKeyBoard() + messageView.clearFocus() + presenter.onSendTicketClicked( + Objects.requireNonNull( + emailView.text + ).toString(), Objects + .requireNonNull(subjectView.text).toString(), + Objects.requireNonNull(messageView.text).toString() + ) + } + + override fun setActivityTitle(title: String) { + tvActivityTitle.text = title + } + + override fun setEmail(email: String) { + emailView.setText(email) + } + + private fun setInputState(isEnabled: Boolean) { + queryTypeSpinner.isEnabled = isEnabled + emailView.isEnabled = isEnabled + subjectView.isEnabled = isEnabled + btnSendButton.isEnabled = isEnabled + messageView.isEnabled = isEnabled + } + + override fun setErrorLayout(message: String) { + setInputState(false) + ErrorDialog.show(this, message) + } + + override fun setProgressView(show: Boolean) { + if (show) { + ProgressDialog.show(this) + } else { + ProgressDialog.hide(this) + } + } + + override fun setQueryTypeSpinner() { + val queryAdapter = ArrayAdapter( + this, + R.layout.drop_down_layout, + R.id.tv_drop_down, + resources.getStringArray(R.array.query_types) + ) + queryTypeSpinner.adapter = queryAdapter + } + + override fun setSendButtonState(enabled: Boolean) { + btnSendButton.isEnabled = enabled + } + + override fun setSuccessLayout(message: String) { + emailView.setText("") + subjectView.setText("") + messageView.setText("") + queryTypeSpinner.setSelection(0) + SuccessDialog.show(this, message, ThemeUtils.getColor(this, R.attr.wdPrimaryInvertedColor, R.color.colorBackgroundDark), true) + } + + private fun hideKeyBoard() { + try { + currentFocus?.windowToken?.let { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(it, 0) + } + } catch (ignored: Exception) { + } + } + + companion object { + fun getStartIntent(context: Context?): Intent { + return Intent(context, SendTicketActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketPresenter.kt b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketPresenter.kt new file mode 100644 index 000000000..cea2c4b2d --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketPresenter.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.ticket + +import android.content.Context +import com.windscribe.vpn.api.response.QueryType + +interface SendTicketPresenter { + fun init() + fun onInputChanged(email: String, subject: String, message: String) + fun onQueryTypeSelected(queryType: QueryType) + fun onSendTicketClicked(email: String, subject: String, message: String) + fun setTheme(context: Context) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketPresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketPresenterImpl.kt new file mode 100644 index 000000000..a477b3da2 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketPresenterImpl.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.ticket + +import android.content.Context +import android.util.Patterns +import com.windscribe.mobile.R +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.ApiErrorResponse +import com.windscribe.vpn.api.response.GenericResponseClass +import com.windscribe.vpn.api.response.QueryType +import com.windscribe.vpn.api.response.TicketResponse +import com.windscribe.vpn.constants.NetworkErrorCodes +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.repository.CallResult +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class SendTicketPresenterImpl @Inject constructor( + private val sendTicketView: SendTicketView, + private val interactor: ActivityInteractor +) : SendTicketPresenter { + private var queryType = QueryType.Account + override fun init() { + sendTicketView.setActivityTitle(interactor.getResourceString(R.string.contact_humans)) + sendTicketView.setQueryTypeSpinner() + sendTicketView.addTextChangeListener() + interactor.getUserRepository().user.value?.email?.let { + sendTicketView.setEmail(it) + } + } + + override fun onInputChanged(email: String, subject: String, message: String) { + sendTicketView.setSendButtonState( + validEmail(email) && validMessage(message) && validSubject( + subject + ) + ) + } + + override fun onQueryTypeSelected(queryType: QueryType) { + this.queryType = queryType + } + + override fun onSendTicketClicked(email: String, subject: String, message: String) { + sendTicketView.setProgressView(true) + val username = interactor.getAppPreferenceInterface().userName + interactor.getCompositeDisposable() + .add(interactor.getApiCallManager().sendTicket(email, username, subject, message, queryType.value.toString(), queryType.name, "app_android") + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + sendTicketView.setProgressView(false) + sendTicketView.setErrorLayout("Failed to submit ticket. Try again.") + } + + override fun onSuccess( + response: GenericResponseClass + ) { + sendTicketView.setProgressView(false) + when (val result = response.callResult()) { + is CallResult.Error -> { + if (result.code == NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + sendTicketView.setErrorLayout("Failed to submit ticket. Try again.") + } else { + sendTicketView.setErrorLayout(result.errorMessage) + } + } + is CallResult.Success -> sendTicketView.setSuccessLayout("Sweet, we’ll get back to you as soon as one of our agents is back from lunch.") + } + } + }) + ) + } + + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + private fun validEmail(email: String): Boolean { + return email.isNotEmpty() && Patterns.EMAIL_ADDRESS.matcher(email).matches() + } + + private fun validMessage(message: String): Boolean { + return message.isNotEmpty() + } + + private fun validSubject(subject: String): Boolean { + return subject.isNotEmpty() + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketView.kt b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketView.kt new file mode 100644 index 000000000..5670ca7c6 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/ticket/SendTicketView.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.ticket + +interface SendTicketView { + fun addTextChangeListener() + fun setActivityTitle(title: String) + fun setEmail(email: String) + fun setErrorLayout(message: String) + fun setProgressView(show: Boolean) + fun setQueryTypeSpinner() + fun setSendButtonState(enabled: Boolean) + fun setSuccessLayout(message: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/utils/PermissionManager.kt b/mobile/src/main/java/com/windscribe/mobile/utils/PermissionManager.kt new file mode 100644 index 000000000..7d9bde88a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/utils/PermissionManager.kt @@ -0,0 +1,208 @@ +package com.windscribe.mobile.utils + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResultListener +import com.windscribe.mobile.dialogs.BackgroundLocationPermissionDialog +import com.windscribe.mobile.dialogs.ForegroundLocationPermissionDialog +import com.windscribe.mobile.dialogs.LocationPermissionMissingDialog +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.disabledFeatureTag +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.okButtonKey +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.rationaleTag +import com.windscribe.mobile.utils.PermissionManagerImpl.Companion.resultKey + +/** + * Build permission request for each permission + * and register in onCreate of activity. + */ +data class PermissionRequest(val activity: AppCompatActivity, val permission: String, val rationaleDialog: DialogFragment, val disabledFeatureDialog: DialogFragment) { + var callback: ((Boolean) -> Unit)? = null + var launcher: ActivityResultLauncher? = null + fun isGranted(context: Context): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + /** + * Requests given permission and shows rational dialog if required. + */ + fun request() { + if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) { + showRationale(activity) { userAccepted -> + if (userAccepted) { + launcher?.launch(permission) + } else { + callback?.let { it -> it(false) } + } + } + } else { + launcher?.launch(permission) + } + } + + /** + * called when Permission result is returned. + * shows disabled feature dialog when denied. + */ + fun permissionResultReceived(granted: Boolean) { + if (granted) { + callback?.let { it -> it(true) } + } else { + showDisabledFeatureDialog(activity) { goToSettings -> + if (goToSettings) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", activity.packageName, null) + intent.data = uri + if (intent.resolveActivity(activity.packageManager) != null) { + activity.startActivity(intent) + } + } else { + callback?.let { it(false) } + } + } + } + } + + private fun showRationale(activity: AppCompatActivity, callback: (Boolean) -> Unit) { + if (activity.supportFragmentManager.findFragmentByTag(rationaleTag) != null) { + callback(false) + return + } + activity.runOnUiThread { + kotlin.runCatching { + rationaleDialog.showNow(activity.supportFragmentManager, rationaleTag) + rationaleDialog.setFragmentResultListener(resultKey) { _, bundle -> + callback(bundle.containsKey(okButtonKey)) + } + } + } + } + + private fun showDisabledFeatureDialog(activity: AppCompatActivity, callback: (Boolean) -> Unit) { + if (activity.supportFragmentManager.findFragmentByTag(disabledFeatureTag) != null) { + callback(false) + return + } + activity.runOnUiThread { + kotlin.runCatching { + disabledFeatureDialog.showNow(activity.supportFragmentManager, rationaleTag) + disabledFeatureDialog.setFragmentResultListener(resultKey) { _, bundle -> + callback(bundle.containsKey(okButtonKey)) + } + } + } + } +} + +interface PermissionManager { + fun register(activity: AppCompatActivity) + fun withForegroundLocationPermission(callback: (error: String?) -> Unit) + fun withBackgroundLocationPermission(callback: (error: String?) -> Unit) + fun isBackgroundPermissionGranted(): Boolean +} + +class PermissionManagerImpl(private val activity: AppCompatActivity) : PermissionManager { + companion object { + const val resultKey = "resultKey" + const val okButtonKey = "okButtonKey" + const val rationaleTag = "RationalTag" + const val disabledFeatureTag = "DisabledFeatureTag" + } + + private lateinit var foregroundPermissionRequest: PermissionRequest + private lateinit var backgroundPermissionRequest: PermissionRequest + + override fun isBackgroundPermissionGranted(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return ::backgroundPermissionRequest.isInitialized && backgroundPermissionRequest.isGranted(activity) + } + return true + } + + /** + * Register activity for result callbacks for each permission. Must be called from onCreate of activity. + */ + override fun register(activity: AppCompatActivity) { + foregroundPermissionRequest = PermissionRequest(activity, Manifest.permission.ACCESS_FINE_LOCATION, ForegroundLocationPermissionDialog(), LocationPermissionMissingDialog()) + val foregroundLocationPermissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { permission -> + foregroundPermissionRequest.permissionResultReceived(permission) + } + foregroundPermissionRequest.launcher = foregroundLocationPermissionLauncher + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + backgroundPermissionRequest = PermissionRequest(activity, Manifest.permission.ACCESS_BACKGROUND_LOCATION, BackgroundLocationPermissionDialog(), LocationPermissionMissingDialog()) + val backgroundLocationPermissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { permission -> + backgroundPermissionRequest.permissionResultReceived(permission) + } + backgroundPermissionRequest.launcher = backgroundLocationPermissionLauncher + } + } + + /** + * Requests background location permission. + * if granted callback is called with no error. + */ + private fun askForBackgroundLocationPermission(callback: (error: String?) -> Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + backgroundPermissionRequest.callback = { granted -> + if (granted) { + callback(null) + } else { + callback("Background location permission denied") + } + } + backgroundPermissionRequest.request() + } else { + callback(null) + } + } + + /** + * Requests foreground location permission. + * if granted callback is called with no error. + */ + override fun withForegroundLocationPermission(callback: (error: String?) -> Unit) { + foregroundPermissionRequest.callback = { granted -> + if (granted) { + callback(null) + } else { + callback("Fine location permission denied.") + } + } + foregroundPermissionRequest.request() + } + + /** + * Requests foreground and background location permission. + * if granted callback is called with no error. + */ + override fun withBackgroundLocationPermission(callback: (error: String?) -> Unit) { + val backgroundPermissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + backgroundPermissionRequest.isGranted(activity) + } else { + true + } + if (foregroundPermissionRequest.isGranted(activity) && backgroundPermissionGranted) { + callback(null) + } else if (foregroundPermissionRequest.isGranted(activity)) { + askForBackgroundLocationPermission(callback) + } else { + withForegroundLocationPermission { + if (it == null) { + askForBackgroundLocationPermission(callback) + } else { + callback(it) + } + } + } + } + +} + diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/SoftInputAssist.java b/mobile/src/main/java/com/windscribe/mobile/welcome/SoftInputAssist.java new file mode 100644 index 000000000..d9e1f61c4 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/SoftInputAssist.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ + +package com.windscribe.mobile.welcome; + +import static android.view.View.VISIBLE; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; + +public class SoftInputAssist { + + private final Rect contentAreaOfWindowBounds = new Rect(); + + private ViewGroup contentContainer; + + private final int[] notImportantViews; + + private View rootView; + + private final FrameLayout.LayoutParams rootViewLayout; + + private int usableHeightPrevious = 0; + + private final ViewTreeObserver.OnGlobalLayoutListener listener = this::possiblyResizeChildOfContent; + + private ViewTreeObserver viewTreeObserver; + + public SoftInputAssist(Activity activity, int[] notImportantViews) { + contentContainer = activity.findViewById(android.R.id.content); + rootView = contentContainer.getChildAt(0); + rootViewLayout = (FrameLayout.LayoutParams) rootView.getLayoutParams(); + this.notImportantViews = notImportantViews; + } + + public boolean isLargeScreen(Context context) { + return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + public void onDestroy() { + rootView = null; + contentContainer = null; + viewTreeObserver = null; + } + + public void onPause() { + if (viewTreeObserver.isAlive()) { + viewTreeObserver.removeOnGlobalLayoutListener(listener); + } + } + + public void onResume() { + if (viewTreeObserver == null || !viewTreeObserver.isAlive()) { + viewTreeObserver = rootView.getViewTreeObserver(); + } + + viewTreeObserver.addOnGlobalLayoutListener(listener); + } + + private void possiblyResizeChildOfContent() { + if (contentContainer == null) return; + contentContainer.getWindowVisibleDisplayFrame(contentAreaOfWindowBounds); + int usableHeightNow = contentAreaOfWindowBounds.bottom; + if (usableHeightNow != usableHeightPrevious) { + boolean hideViews = usableHeightNow < usableHeightPrevious && usableHeightPrevious != 0 && !isLargeScreen( + contentContainer.getContext()); + for (int viewId : notImportantViews) { + View view = contentContainer.findViewById(viewId); + if (view != null) { + view.setVisibility(hideViews ? View.GONE : VISIBLE); + } + } + rootViewLayout.height = usableHeightNow; + rootView.requestLayout(); + usableHeightPrevious = usableHeightNow; + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomeActivity.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomeActivity.kt new file mode 100644 index 000000000..661befc3a --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomeActivity.kt @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.transition.Slide +import android.view.Gravity +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import androidx.core.view.GravityCompat +import androidx.fragment.app.Fragment +import butterknife.ButterKnife +import com.windscribe.mobile.R +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.di.DaggerActivityComponent +import com.windscribe.mobile.dialogs.ErrorDialog +import com.windscribe.mobile.dialogs.ProgressDialog +import com.windscribe.mobile.dialogs.UnknownErrorDialog +import com.windscribe.mobile.dialogs.UnknownErrorDialogCallback +import com.windscribe.mobile.welcome.fragment.EmergencyConnectFragment +import com.windscribe.mobile.welcome.fragment.FragmentCallback +import com.windscribe.mobile.welcome.fragment.LoginFragment +import com.windscribe.mobile.welcome.fragment.NoEmailAttentionFragment +import com.windscribe.mobile.welcome.fragment.SignUpFragment +import com.windscribe.mobile.welcome.fragment.WelcomeActivityCallback +import com.windscribe.mobile.welcome.fragment.WelcomeFragment +import com.windscribe.mobile.welcome.state.EmergencyConnectUIState +import com.windscribe.mobile.welcome.viewmodal.EmergencyConnectViewModal +import com.windscribe.mobile.windscribe.WindscribeActivity +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.constants.NetworkKeyConstants.getWebsiteLink +import java.io.File +import javax.inject.Inject + +class WelcomeActivity : BaseActivity(), FragmentCallback, WelcomeView, UnknownErrorDialogCallback { + + @Inject + lateinit var presenter: WelcomePresenter + + @Inject + lateinit var emergencyConnectViewModal: Lazy + + private val requestLocationPermissionCode = 201 + private var softInputAssist: SoftInputAssist? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_welcome) + DaggerActivityComponent.builder().activityModule(ActivityModule(this, this)) + .applicationComponent( + appContext.applicationComponent + ).build().inject(this) + ButterKnife.bind(this) + addStartFragment() + } + + override fun onResume() { + super.onResume() + softInputAssist?.onResume() + } + + override fun onPause() { + super.onPause() + softInputAssist?.onPause() + } + + override fun onDestroy() { + softInputAssist?.onDestroy() + presenter.onDestroy() + super.onDestroy() + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + if (requestCode == requestLocationPermissionCode) { + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + presenter.exportLog() + } else { + showToast("Please provide storage permission") + } + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + override fun clearInputErrors() { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is WelcomeActivityCallback) { + (fragment as WelcomeActivityCallback).clearInputErrors() + } + } + + override fun contactSupport() { + val intent = Intent(Intent.ACTION_SEND) + intent.setType("plain/text") + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("helpdesk@windscribe.com")) + intent.putExtra(Intent.EXTRA_SUBJECT, "Restrictive Network Detected") + intent.putExtra(Intent.EXTRA_TEXT, "") + presenter.getLogUri()?.let { + val fileUri = FileProvider.getUriForFile(this, "com.windscribe.vpn.provider", it) + intent.putExtra(Intent.EXTRA_STREAM, fileUri) + } + if(intent.resolveActivity(packageManager) != null){ + startActivity(Intent.createChooser(intent, "Select Email Provider")) + } + } + + override fun exportLog() { + presenter.exportLog() + } + + override fun goToSignUp() { + val signUpFragment = SignUpFragment.newInstance(false) + val direction = GravityCompat.getAbsoluteGravity( + GravityCompat.END, resources.configuration.layoutDirection + ) + signUpFragment.enterTransition = Slide(direction).addTarget(R.id.sign_up_container) + replaceFragment(signUpFragment, true) + } + + override fun gotoHomeActivity(clearTop: Boolean) { + if (emergencyConnectViewModal.value.uiState.value != EmergencyConnectUIState.Disconnected) { + emergencyConnectViewModal.value.disconnect() + } + val startIntent = Intent(this, WindscribeActivity::class.java) + if (clearTop) { + startIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + startActivity(startIntent) + finish() + } + + override fun hideSoftKeyboard() { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow( + window.decorView.windowToken, InputMethodManager.HIDE_NOT_ALWAYS + ) + } + + override fun launchShareIntent(file: File) { + val fileUri = FileProvider.getUriForFile( + this, "com.windscribe.vpn.provider", file + ) + ShareCompat.IntentBuilder.from(this).setType("*/*").setStream(fileUri).startChooser() + } + + override fun onAccountClaimButtonClick( + username: String, password: String, email: String, ignoreEmptyEmail: Boolean, voucherCode: String + ) { + presenter.startAccountClaim(username, password, email, ignoreEmptyEmail, voucherCode) + } + + override fun onBackButtonPressed() { + onBackPressed() + } + + override fun onBackPressed() { + presenter.onBackPressed() + super.onBackPressed() + } + + override fun onContinueWithOutAccountClick() { + presenter.startGhostAccountSetup() + } + + override fun onForgotPasswordClick() { + openURLInBrowser(getWebsiteLink(NetworkKeyConstants.URL_FORGOT_PASSWORD)) + } + + override fun onLoginButtonClick(username: String, password: String, twoFa: String) { + presenter.startLoginProcess(username, password, twoFa) + } + + override fun onEmergencyClick() { + EmergencyConnectFragment.show(supportFragmentManager, R.id.fragment_container) + } + + override fun onLoginClick() { + val loginFragment = LoginFragment() + val direction = GravityCompat.getAbsoluteGravity( + GravityCompat.END, resources.configuration.layoutDirection + ) + loginFragment.enterTransition = Slide(direction).addTarget(R.id.login_container) + replaceFragment(loginFragment, true) + } + + override fun onSignUpButtonClick( + username: String, + password: String, + email: String, + referralUsername: String, + ignoreEmptyEmail: Boolean, + voucherCode: String + ) { + if (ignoreEmptyEmail) { + supportFragmentManager.popBackStack() + } + presenter.startSignUpProcess( + username, password, email, referralUsername, ignoreEmptyEmail, voucherCode + ) + } + + override fun onSkipToHomeClick() { + val startIntent = Intent(this, WindscribeActivity::class.java) + startActivity(startIntent) + finish() + } + + override fun prepareUiForApiCallFinished() { + ProgressDialog.hide(this) + val progressFragment = supportFragmentManager.findFragmentById(R.id.progress_container) + if (progressFragment is NoEmailAttentionFragment) { + supportFragmentManager.popBackStack() + } + val mainFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (mainFragment is NoEmailAttentionFragment) { + supportFragmentManager.popBackStack() + } + } + + override fun prepareUiForApiCallStart() { + ProgressDialog.show(this) + } + + private fun replaceFragment(fragment: Fragment, addToBackStack: Boolean) { + val transaction = + supportFragmentManager.beginTransaction().replace(R.id.fragment_container, fragment) + if (addToBackStack) { + transaction.addToBackStack(fragment.javaClass.name) + } + transaction.commit() + } + + override fun setEmailError(errorMessage: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is SignUpFragment) { + fragment.setEmailError(errorMessage) + } + } + + override fun setFaFieldsVisibility(visible: Int) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is LoginFragment) { + fragment.setTwoFaVisibility(visible) + } + } + + override fun setLoginRegistrationError(error: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is WelcomeActivityCallback) { + (fragment as WelcomeActivityCallback).setLoginError(error) + } + } + + override fun setPasswordError(error: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is WelcomeActivityCallback) { + (fragment as WelcomeActivityCallback).setPasswordError(error) + } + } + + override fun setTwoFaError(errorMessage: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is LoginFragment) { + fragment.setTwoFaError(errorMessage) + } + } + + override fun setUsernameError(error: String) { + val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) + if (fragment is WelcomeActivityCallback) { + (fragment as WelcomeActivityCallback).setUsernameError(error) + } + } + + override fun setWindow() { + val statusBarColor = resources.getColor(android.R.color.transparent) + window.decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + window.statusBarColor = statusBarColor + } + + override fun showError(error: String) { + ErrorDialog.show(this, error) + } + + override fun showFailedAlert(error: String) { + UnknownErrorDialog.show(this, error) + } + + override fun showNoEmailAttentionFragment( + username: String, password: String, accountClaim: Boolean, pro: Boolean, voucherCode: String + ) { + val noEmailAttentionFragment = + NoEmailAttentionFragment(accountClaim, username, password, pro, voucherCode) + noEmailAttentionFragment.enterTransition = + Slide(Gravity.BOTTOM).addTarget(R.id.email_fragment_container) + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, noEmailAttentionFragment) + .addToBackStack(noEmailAttentionFragment.javaClass.name).commit() + } + + override fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + override fun updateCurrentProcess(mCurrentCall: String) { + val fragment = supportFragmentManager.findFragmentByTag(ProgressDialog.tag) + if (fragment is ProgressDialog) { + fragment.updateProgressStatus(mCurrentCall) + } + } + + private fun addStartFragment() { + val startFragmentName = intent.getStringExtra("startFragmentName") + val skipToHome = intent.getBooleanExtra("skipToHome", false) + val fragment: Fragment + if (startFragmentName != null && startFragmentName == "Login") { + fragment = LoginFragment() + softInputAssist = SoftInputAssist(this, intArrayOf(R.id.forgot_password)) + } else if (startFragmentName != null && startFragmentName == "SignUp") { + softInputAssist = SoftInputAssist(this, intArrayOf(R.id.forgot_password)) + fragment = SignUpFragment.newInstance(false) + } else if (startFragmentName != null && startFragmentName == "AccountSetUp") { + softInputAssist = + SoftInputAssist(this, intArrayOf(R.id.forgot_password, R.id.set_up_later_button)) + val proAccount = presenter.isUserPro + fragment = SignUpFragment.newInstance(proAccount) + } else { + softInputAssist = SoftInputAssist(this, intArrayOf(R.id.forgot_password)) + fragment = WelcomeFragment() + } + val bundle = Bundle() + bundle.putString("startFragmentName", startFragmentName) + bundle.putBoolean("skipToHome", skipToHome) + fragment.arguments = bundle + val direction = GravityCompat.getAbsoluteGravity( + GravityCompat.END, resources.configuration.layoutDirection + ) + fragment.enterTransition = Slide(direction).addTarget(R.id.welcome_container) + replaceFragment(fragment, false) + } + + companion object { + @JvmStatic + fun getStartIntent(context: Context?): Intent { + return Intent(context, WelcomeActivity::class.java) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomePresenter.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomePresenter.kt new file mode 100644 index 000000000..047c366cf --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomePresenter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome + +import java.io.File + +interface WelcomePresenter { + fun exportLog() + val isUserPro: Boolean + fun onBackPressed() + fun onDestroy() + fun startAccountClaim( + username: String, + password: String, + email: String, + ignoreEmptyEmail: Boolean, + voucherCode: String + ) + + fun startGhostAccountSetup() + fun startLoginProcess(username: String, password: String, twoFa: String) + fun startSignUpProcess( + username: String, + password: String, + email: String, + referralUsername: String, + ignoreEmptyEmail: Boolean, + voucherCode: String + ) + + fun getLogUri(): File? +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomePresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomePresenterImpl.kt new file mode 100644 index 000000000..1d9e183ec --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomePresenterImpl.kt @@ -0,0 +1,473 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome + +import android.text.TextUtils +import android.util.Patterns +import android.view.View +import com.windscribe.mobile.R +import com.windscribe.vpn.commonutils.CommonPasswordChecker +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.api.response.* +import com.windscribe.vpn.backend.Util +import com.windscribe.vpn.commonutils.RegionLocator +import com.windscribe.vpn.commonutils.WindUtilities +import com.windscribe.vpn.constants.NetworkErrorCodes +import com.windscribe.vpn.constants.UserStatusConstants.USER_STATUS_PREMIUM +import com.windscribe.vpn.errormodel.SessionErrorHandler +import com.windscribe.vpn.errormodel.WindError +import com.windscribe.vpn.repository.CallResult +import io.reactivex.Completable +import io.reactivex.Single +import io.reactivex.SingleSource +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.functions.Function +import io.reactivex.observers.DisposableCompletableObserver +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.util.* +import javax.inject.Inject + +class WelcomePresenterImpl @Inject constructor( + private val welcomeView: WelcomeView, private val interactor: ActivityInteractor +) : WelcomePresenter { + + private val logger = LoggerFactory.getLogger("basic") + + override fun onDestroy() { + interactor.getCompositeDisposable().clear() + } + + override fun exportLog() { + try { + val file = File(interactor.getDebugFilePath()) + welcomeView.launchShareIntent(file) + } catch (e: Exception) { + welcomeView.showToast(WindError.instance.rxErrorToString(e)) + } + } + + override fun getLogUri(): File? { + return try { + File(interactor.getDebugFilePath()) + } catch (e: Exception) { + null + } + } + + override val isUserPro: Boolean + get() = interactor.getAppPreferenceInterface().userStatus == USER_STATUS_PREMIUM + + override fun onBackPressed() { + interactor.getCompositeDisposable().clear() + welcomeView.hideSoftKeyboard() + } + + override fun startAccountClaim( + username: String, password: String, email: String, ignoreEmptyEmail: Boolean, voucherCode: String + ) { + welcomeView.hideSoftKeyboard() + if (validateLoginInputs(username, password, email, false)) { + if (ignoreEmptyEmail.not() && email.isEmpty()) { + val proUser = + (interactor.getAppPreferenceInterface().userStatus == USER_STATUS_PREMIUM) + welcomeView.showNoEmailAttentionFragment(username, password, true, proUser, voucherCode) + return + } + logger.info("Trying to claim account with provided credentials...") + welcomeView.prepareUiForApiCallFinished() + welcomeView.prepareUiForApiCallStart() + interactor.getCompositeDisposable().add(interactor.getApiCallManager() + .claimAccount(username, password, email, voucherCode) + .doOnSubscribe { welcomeView.updateCurrentProcess("Signing up") } + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + logger.error("Account claim: {}", e.message) + onSignUpFailedWithNoError() + } + + override fun onSuccess(genericLoginResponse: GenericResponseClass) { + when (val result = + genericLoginResponse.callResult()) { + is CallResult.Error -> { + logger.error("Account claim: {}", result) + if (result.code == NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + onSignUpFailedWithNoError() + } else { + onLoginResponseError(result.code, result.errorMessage) + } + } + is CallResult.Success -> { + logger.info("Account claimed successfully...") + welcomeView.updateCurrentProcess("SignUp successful...") + onAccountClaimSuccess(username) + } + } + } + }) + ) + } + } + + override fun startGhostAccountSetup() { + welcomeView.prepareUiForApiCallStart() + welcomeView.updateCurrentProcess("Signing In") + interactor.getCompositeDisposable().add(interactor.getApiCallManager().getReg() + .flatMap(Function, SingleSource>> label@{ regToken: GenericResponseClass -> + when (val result = regToken.callResult()) { + is CallResult.Error -> { + if (result.code == NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + throw Exception("Unknown Error") + } else { + throw Exception(result.errorMessage) + } + } + is CallResult.Success -> { + return@label interactor.getApiCallManager().signUpUsingToken(result.data.token) + } + } + }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + welcomeView.prepareUiForApiCallFinished() + logger.error("Ghost account setup: {}", e.message) + if (e is IOException) { + welcomeView.showError("Unable to reach server. Check your network connection.") + } else { + welcomeView.goToSignUp() + } + } + + override fun onSuccess( + regResponse: GenericResponseClass + ) { + when (val result = regResponse.callResult()) { + is CallResult.Error -> { + logger.error("Ghost account setup: {}", result) + welcomeView.prepareUiForApiCallFinished() + if (result.code != NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + welcomeView.goToSignUp() + } + } + is CallResult.Success -> { + interactor.getAppPreferenceInterface().sessionHash = + result.data.sessionAuthHash + interactor.getFireBaseManager().getFirebaseToken { token -> + prepareLoginRegistrationDashboard(token) + } + } + } + } + }) + ) + } + + override fun startLoginProcess(username: String, password: String, twoFa: String) { + welcomeView.hideSoftKeyboard() + if (validateLoginInputs(username, password, "", true)) { + logger.info("Trying to login with provided credentials...") + welcomeView.prepareUiForApiCallStart() + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().logUserIn(username, password, twoFa) + .doOnSubscribe { welcomeView.updateCurrentProcess(interactor.getResourceString(R.string.signing_in)) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith( + object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + logger.error("Login: {}", e.message) + onLoginFailedWithNoError() + } + + override fun onSuccess( + genericLoginResponse: GenericResponseClass + ) { + when (val result = + genericLoginResponse.callResult()) { + is CallResult.Error -> { + logger.error("Login: {}", result) + if (result.code == NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + onLoginFailedWithNoError() + } else { + onLoginResponseError(result.code, result.errorMessage) + } + } + is CallResult.Success -> { + logger.info("Logged user in successfully...") + welcomeView.updateCurrentProcess("Login successful...") + interactor.getAppPreferenceInterface().sessionHash = + result.data.sessionAuthHash + interactor.getFireBaseManager().getFirebaseToken { token -> + prepareLoginRegistrationDashboard(token) + } + } + } + } + }) + ) + } + } + + override fun startSignUpProcess( + username: String, + password: String, + email: String, + referralUsername: String, + ignoreEmptyEmail: Boolean, + voucherCode: String + ) { + welcomeView.hideSoftKeyboard() + if (validateLoginInputs(username, password, email, false)) { + if (!ignoreEmptyEmail && email.isEmpty()) { + welcomeView.showNoEmailAttentionFragment(username, password, accountClaim = false, pro = false, voucherCode) + return + } + logger.info("Trying to sign up with provided credentials...") + welcomeView.prepareUiForApiCallStart() + interactor.getCompositeDisposable().add(interactor.getApiCallManager() + .signUserIn(username, password, referralUsername, email, voucherCode) + .doOnSubscribe { welcomeView.updateCurrentProcess("Signing up") } + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : + DisposableSingleObserver>() { + override fun onError(e: Throwable) { + logger.error("Signup: {}", e.message) + onSignUpFailedWithNoError() + } + + override fun onSuccess( + genericLoginResponse: GenericResponseClass + ) { + when (val result = + genericLoginResponse.callResult()) { + is CallResult.Error -> { + logger.error("Signup: {}", result) + if (result.code == NetworkErrorCodes.ERROR_UNEXPECTED_API_DATA) { + onSignUpFailedWithNoError() + } else { + onLoginResponseError(result.code, result.errorMessage) + } + } + is CallResult.Success -> { + logger.info("Sign up user successfully...") + welcomeView.updateCurrentProcess("SignUp successful...") + interactor.getAppPreferenceInterface().sessionHash = + result.data.sessionAuthHash + interactor.getFireBaseManager().getFirebaseToken { session -> + prepareLoginRegistrationDashboard(session) + } + } + } + } + }) + ) + } + } + + private fun evaluatePassword(password: String): Boolean { + val pattern = Regex("(?=.*[a-z])(?=.*[A-Z])(?=\\S+$).{8,}") + return password.matches(pattern) + } + + private fun onAccountClaimSuccess(username: String) { + welcomeView.updateCurrentProcess(interactor.getResourceString(R.string.getting_session)) + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().getSessionGeneric(null) + .flatMapCompletable { sessionResponse: GenericResponseClass -> + Completable.fromSingle(Single.fromCallable { + interactor.getUserRepository().reload(sessionResponse.dataClass, null) + true + }) + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + welcomeView.gotoHomeActivity(true) + } + + override fun onError(e: Throwable) { + welcomeView.prepareUiForApiCallFinished() + welcomeView.showError("Unable to auto login. Log in using new credentials.") + logger.error("Account claim session: {}", e.message) + } + }) + ) + } + + private fun onLoginFailedWithNoError() { + welcomeView.prepareUiForApiCallFinished() + welcomeView.showFailedAlert(interactor.getResourceString(R.string.failed_network_alert)) + } + + private fun onLoginResponseError(errorCode: Int, error: String) { + logger.debug("Error code $errorCode error $error") + welcomeView.prepareUiForApiCallFinished() + val errorMessage = SessionErrorHandler.instance.getErrorMessage(errorCode, error) + when (errorCode) { + NetworkErrorCodes.ERROR_2FA_REQUIRED, NetworkErrorCodes.ERROR_INVALID_2FA -> { + welcomeView.setFaFieldsVisibility(View.VISIBLE) + welcomeView.setTwoFaError(errorMessage) + } + NetworkErrorCodes.ERROR_USER_NAME_ALREADY_TAKEN, NetworkErrorCodes.ERROR_USER_NAME_ALREADY_IN_USE -> { + welcomeView.setUsernameError(errorMessage) + } + NetworkErrorCodes.ERROR_EMAIL_ALREADY_EXISTS, NetworkErrorCodes.ERROR_DISPOSABLE_EMAIL -> { + welcomeView.setEmailError(errorMessage) + } + else -> { + welcomeView.setLoginRegistrationError(errorMessage) + } + } + } + + private fun onSignUpFailedWithNoError() { + welcomeView.prepareUiForApiCallFinished() + welcomeView.showFailedAlert(interactor.getResourceString(R.string.failed_network_alert)) + } + + private fun prepareLoginRegistrationDashboard(firebaseToken: String?) { + interactor.getAppPreferenceInterface().loginTime = Date() + welcomeView.updateCurrentProcess(interactor.getResourceString(R.string.getting_session)) + interactor.getCompositeDisposable() + .add(interactor.getApiCallManager().getSessionGeneric(firebaseToken) + .flatMapCompletable { sessionResponse: GenericResponseClass -> + Completable.fromSingle(Single.fromCallable { + when (val result = sessionResponse.callResult()) { + is CallResult.Error -> {} + is CallResult.Success -> { + logger.debug("Successfully added token $firebaseToken to ${result.data.userName}.") + if (interactor.getAppPreferenceInterface() + .getDeviceUUID() == null) { + logger.debug("No device id is found for the current user, generating and saving UUID") + interactor.getAppPreferenceInterface().setDeviceUUID(UUID.randomUUID().toString()) + } + } + } + interactor.getUserRepository().reload(sessionResponse.dataClass, null) + true + }) + }.andThen(updateStaticIps()) + .doOnComplete { welcomeView.updateCurrentProcess(interactor.getResourceString(R.string.getting_server_credentials)) } + .andThen(interactor.getConnectionDataUpdater().update()) + .doOnComplete { welcomeView.updateCurrentProcess(interactor.getResourceString(R.string.getting_server_list)) } + .andThen(interactor.getServerListUpdater().update()) + .andThen(Completable.fromAction { + interactor.getPreferenceChangeObserver().postCityServerChange() + }).andThen(interactor.updateUserData()).onErrorResumeNext { throwable: Throwable -> + logger.error("Prepare dashboard: {}", throwable.message) + Completable.fromAction { + interactor.getPreferenceChangeObserver().postCityServerChange() + }.andThen(interactor.updateUserData()) + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + Util.removeLastSelectedLocation() + interactor.getWorkManager().onAppStart() + interactor.getWorkManager().onAppMovedToForeground() + interactor.getWorkManager().updateNodeLatencies() + welcomeView.gotoHomeActivity(true) + } + + override fun onError(e: Throwable) { + welcomeView.prepareUiForApiCallFinished() + logger.error("Prepare dashboard: {}", e.message) + } + }) + ) + } + + private fun updateStaticIps(): Completable { + val user = interactor.getUserRepository().user.value + return if (user != null && user.sipCount > 0) { + interactor.getStaticListUpdater().update() + } else { + Completable.fromAction {} + } + } + + private fun validateLoginInputs( + username: String, password: String, email: String, + isLogin: Boolean + ): Boolean { + logger.info("Validating login credentials") + welcomeView.clearInputErrors() + + //Empty username + if (TextUtils.isEmpty(username)) { + logger.info("[username] is empty, displaying toast to the user...") + welcomeView.setUsernameError(interactor.getResourceString(R.string.username_empty)) + welcomeView.showToast(interactor.getResourceString(R.string.enter_username)) + return false + } + + //Invalid username + if (!isLogin && !validateUsernameCharacters(username)) { + logger.info("[username] has invalid characters in , displaying toast to the user...") + welcomeView.setUsernameError(interactor.getResourceString(R.string.login_with_username)) + welcomeView.showToast(interactor.getResourceString(R.string.login_with_username)) + return false + } + + //Empty Password + if (TextUtils.isEmpty(password)) { + logger.info("[password] is empty, displaying toast to the user...") + welcomeView.setPasswordError(interactor.getResourceString(R.string.password_empty)) + welcomeView.showToast(interactor.getResourceString(R.string.enter_password)) + return false + } + if (!TextUtils.isEmpty(email) && !Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + logger.info("[Email] is invalid, displaying toast to the user...") + welcomeView.setEmailError(interactor.getResourceString(R.string.invalid_email_format)) + welcomeView.showToast(interactor.getResourceString(R.string.invalid_email_format)) + return false + } + if (!isRussian()) { + if (!isLogin && password.length < 8) { + logger.info("[Password] is small, displaying toast to the user...") + welcomeView.setPasswordError(interactor.getResourceString(R.string.small_password)) + welcomeView.showToast(interactor.getResourceString(R.string.small_password)) + return false + } + // Sign up and claim account password minimum strength enforce. + if (!isLogin && !evaluatePassword(password)) { + logger.info("[Password] is weak, displaying toast to the user...") + welcomeView.setPasswordError(interactor.getResourceString(R.string.weak_password)) + welcomeView.showToast(interactor.getResourceString(R.string.weak_password)) + return false + } + if (!isLogin && CommonPasswordChecker.isAMatch(password)) { + logger.info("[Password] matches worst password list, displaying toast to the user...") + welcomeView.setPasswordError(interactor.getResourceString(R.string.common_password)) + welcomeView.showToast(interactor.getResourceString(R.string.common_password)) + return false + } + } + if (!WindUtilities.isOnline()) { + logger.info("User is not connected to internet.") + welcomeView.setLoginRegistrationError(interactor.getResourceString(R.string.no_internet)) + return false + } + if (!WindUtilities.isOnline()) { + logger.info("User is not connected to internet.") + welcomeView.setLoginRegistrationError(interactor.getResourceString(R.string.no_internet)) + return false + } + return true + } + + private fun validateUsernameCharacters(username: String): Boolean { + return username.matches(Regex("[a-zA-Z0-9_-]*")) + } + + private fun isRussian(): Boolean { + return RegionLocator.isCountry("ru") + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomeView.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomeView.kt new file mode 100644 index 000000000..f05fa5123 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/WelcomeView.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome + +import java.io.File + +interface WelcomeView { + fun clearInputErrors() + fun goToSignUp() + fun gotoHomeActivity(clearTop: Boolean) + fun hideSoftKeyboard() + fun launchShareIntent(file: File) + fun prepareUiForApiCallFinished() + fun prepareUiForApiCallStart() + fun setEmailError(errorMessage: String) + fun setFaFieldsVisibility(visible: Int) + fun setLoginRegistrationError(error: String) + fun setPasswordError(error: String) + fun setTwoFaError(errorMessage: String) + fun setUsernameError(error: String) + fun showError(error: String) + fun showFailedAlert(error: String) + fun showNoEmailAttentionFragment( + username: String, + password: String, + accountClaim: Boolean, + pro: Boolean, + voucherCode: String + ) + + fun showToast(message: String) + fun updateCurrentProcess(mCurrentCall: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/EmergencyConnectFragment.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/EmergencyConnectFragment.kt new file mode 100644 index 000000000..6657e0e55 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/EmergencyConnectFragment.kt @@ -0,0 +1,105 @@ +package com.windscribe.mobile.welcome.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.windscribe.mobile.R +import com.windscribe.mobile.databinding.FragmentEmergencyConnectBinding +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.mobile.welcome.state.EmergencyConnectUIState +import com.windscribe.mobile.welcome.viewmodal.EmergencyConnectViewModal +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class EmergencyConnectFragment : Fragment() { + private var _binding: FragmentEmergencyConnectBinding? = null + private val viewModal: EmergencyConnectViewModal? by lazy { + return@lazy (activity as? WelcomeActivity)?.emergencyConnectViewModal?.value + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + _binding = FragmentEmergencyConnectBinding.inflate(inflater, container, false) + return _binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bindViews() + bindState() + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + private fun bindState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModal?.uiState?.collectLatest { + when (it) { + EmergencyConnectUIState.Disconnected -> { + _binding?.tvDescription?.visibility = View.VISIBLE + _binding?.tvStatus?.visibility = View.INVISIBLE + _binding?.progressBar?.visibility = View.INVISIBLE + _binding?.tvDescription?.text = + getString(R.string.emergency_connect_description) + _binding?.ok?.text = getString(R.string.connect) + + } + EmergencyConnectUIState.Connecting -> { + _binding?.tvDescription?.visibility = View.INVISIBLE + _binding?.tvStatus?.visibility = View.VISIBLE + _binding?.progressBar?.visibility = View.VISIBLE + _binding?.ok?.text = getString(R.string.disconnect) + } + EmergencyConnectUIState.Connected -> { + _binding?.tvDescription?.visibility = View.VISIBLE + _binding?.tvStatus?.visibility = View.INVISIBLE + _binding?.progressBar?.visibility = View.INVISIBLE + _binding?.tvDescription?.text = + getString(R.string.emergency_connected_description) + _binding?.ok?.text = getString(R.string.disconnect) + } + } + } + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModal?.connectionProgressText?.collect { + _binding?.tvStatus?.text = it + } + } + } + } + + private fun bindViews() { + _binding?.ok?.setOnClickListener { + viewModal?.connectButtonClick() + } + _binding?.cancel?.setOnClickListener { + activity?.supportFragmentManager?.popBackStack() + } + _binding?.closeIcon?.setOnClickListener { + activity?.supportFragmentManager?.popBackStack() + } + } + + companion object { + private const val backStackKey = "EmergencyConnectFragment" + fun show(manager: FragmentManager, container: Int) { + val fragment = EmergencyConnectFragment() + manager.beginTransaction().addToBackStack(backStackKey).add(container, fragment) + .commit() + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/FragmentCallback.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/FragmentCallback.kt new file mode 100644 index 000000000..aec8af4ac --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/FragmentCallback.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome.fragment + +interface FragmentCallback { + fun onAccountClaimButtonClick( + username: String, + password: String, + email: String, + ignoreEmptyEmail: Boolean, + voucherCode: String + ) + + fun onBackButtonPressed() + fun onContinueWithOutAccountClick() + fun onForgotPasswordClick() + fun onLoginButtonClick(username: String, password: String, twoFa: String) + fun onLoginClick() + fun onEmergencyClick() + fun onSignUpButtonClick( + username: String, + password: String, + email: String, + referralUsername: String, + ignoreEmptyEmail: Boolean, + voucherCode: String + ) + + fun onSkipToHomeClick() +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/LoginFragment.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/LoginFragment.kt new file mode 100644 index 000000000..c6d213cde --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/LoginFragment.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome.fragment + +import android.content.Context +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.text.method.PasswordTransformationMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.fragment.app.Fragment +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnCheckedChanged +import butterknife.OnClick +import com.windscribe.mobile.R +import java.util.concurrent.atomic.AtomicBoolean + +class LoginFragment : Fragment(), TextWatcher, WelcomeActivityCallback { + @BindView(R.id.loginButton) + lateinit var loginButton: Button + + @BindView(R.id.password) + lateinit var passwordEditText: EditText + + @BindView(R.id.password_error) + lateinit var passwordErrorView: ImageView + + @BindView(R.id.password_visibility_toggle) + lateinit var passwordVisibilityToggle: AppCompatCheckBox + + @BindView(R.id.nav_title) + lateinit var titleView: TextView + + @BindView(R.id.two_fa) + lateinit var twoFaEditText: EditText + + @BindView(R.id.two_fa_error) + lateinit var twoFaErrorView: ImageView + + @BindView(R.id.two_fa_hint) + lateinit var twoFaHintView: TextView + + @BindView(R.id.twoFaToggle) + lateinit var twoFaToggle: Button + + @BindView(R.id.username) + lateinit var usernameEditText: EditText + + @BindView(R.id.username_error) + lateinit var usernameErrorView: ImageView + + @BindView(R.id.two_fa_description) + lateinit var twoFaDescriptionView: TextView + + private val ignoreEditTextChange = AtomicBoolean(false) + private var fragmentCallBack: FragmentCallback? = null + override fun onAttach(context: Context) { + if (activity is FragmentCallback) { + fragmentCallBack = activity as FragmentCallback + } + super.onAttach(context) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.fragment_login, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + titleView.text = getString(R.string.login) + addEditTextChangeListener() + } + + override fun afterTextChanged(s: Editable) { + resetNextButtonView() + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + if (!ignoreEditTextChange.getAndSet(false)) { + clearInputErrors() + } + } + + override fun clearInputErrors() { + twoFaDescriptionView.text = getString(R.string.two_fa_description) + twoFaDescriptionView.setTextColor(resources.getColor(R.color.colorWhite50)) + twoFaErrorView.visibility = View.INVISIBLE + usernameErrorView.visibility = View.INVISIBLE + passwordErrorView.visibility = View.INVISIBLE + usernameEditText.setTextColor(resources.getColor(R.color.colorWhite)) + passwordEditText.setTextColor(resources.getColor(R.color.colorWhite)) + } + + @OnClick(R.id.forgot_password) + fun onForgotPasswordClick() { + fragmentCallBack?.onForgotPasswordClick() + } + + @OnClick(R.id.loginButton) + fun onLoginButtonClick() { + fragmentCallBack?.onLoginButtonClick(usernameEditText.text.toString() + .trim { it <= ' ' }, + passwordEditText.text.toString().trim { it <= ' ' }, + twoFaEditText.text.toString().trim { it <= ' ' }) + } + + @OnClick(R.id.nav_button) + fun onNavButtonClick() { + requireActivity().onBackPressed() + } + + @OnCheckedChanged(R.id.password_visibility_toggle) + fun onPasswordVisibilityToggleChanged() { + ignoreEditTextChange.set(true) + if (passwordVisibilityToggle.isChecked) { + passwordEditText.transformationMethod = null + } else { + passwordEditText.transformationMethod = PasswordTransformationMethod() + } + passwordEditText.setSelection(passwordEditText.text.length) + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + + @OnClick(R.id.twoFaToggle) + fun onTwoFaToggleClick() { + if (twoFaEditText.visibility != View.VISIBLE) { + setTwoFaVisibility(View.VISIBLE) + } + } + + @OnClick(R.id.two_fa_hint) + fun onTwoFaHintClick() { + if (twoFaEditText.visibility == View.VISIBLE) { + setTwoFaVisibility(View.GONE) + } + } + + override fun setLoginError(error: String) { + twoFaDescriptionView.visibility = View.VISIBLE + twoFaDescriptionView.text = error + twoFaDescriptionView.setTextColor(resources.getColor(R.color.colorRed)) + usernameErrorView.visibility = View.VISIBLE + passwordErrorView.visibility = View.VISIBLE + usernameEditText.setTextColor(resources.getColor(R.color.colorRed)) + passwordEditText.setTextColor(resources.getColor(R.color.colorRed)) + } + + override fun setPasswordError(error: String) { + twoFaDescriptionView.visibility = View.VISIBLE + twoFaDescriptionView.text = error + twoFaDescriptionView.setTextColor(resources.getColor(R.color.colorRed)) + passwordErrorView.visibility = View.VISIBLE + passwordEditText.setTextColor(resources.getColor(R.color.colorRed)) + } + + fun setTwoFaError(errorMessage: String) { + twoFaDescriptionView.visibility = View.VISIBLE + twoFaDescriptionView.text = errorMessage + twoFaDescriptionView.setTextColor(resources.getColor(R.color.colorRed)) + twoFaErrorView.visibility = View.VISIBLE + } + + fun setTwoFaVisibility(visibility: Int) { + twoFaDescriptionView.visibility = visibility + twoFaEditText.visibility = visibility + twoFaHintView.visibility = visibility + twoFaToggle.visibility = + if (visibility == View.VISIBLE) View.GONE else View.VISIBLE + } + + override fun setUsernameError(error: String) { + twoFaDescriptionView.visibility = View.VISIBLE + twoFaDescriptionView.text = error + twoFaDescriptionView.setTextColor(resources.getColor(R.color.colorRed)) + usernameErrorView.visibility = View.VISIBLE + usernameEditText.setTextColor(resources.getColor(R.color.colorRed)) + } + + private fun addEditTextChangeListener() { + usernameEditText.addTextChangedListener(this) + passwordEditText.addTextChangedListener(this) + twoFaEditText.addTextChangedListener(this) + } + + private fun resetNextButtonView() { + val enable = usernameEditText.text.length > 2 && passwordEditText.text.length > 3 + loginButton.isEnabled = enable + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/NoEmailAttentionFragment.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/NoEmailAttentionFragment.kt new file mode 100644 index 000000000..f3aed3fd4 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/NoEmailAttentionFragment.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome.fragment + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import butterknife.ButterKnife +import butterknife.OnClick +import com.windscribe.mobile.R + +class NoEmailAttentionFragment : Fragment { + private var accountClaim = false + private var isPro = false + private var fragmentCallBack: FragmentCallback? = null + private var password = "" + private var username = "" + private var voucherCode = "" + + constructor(accountClaim: Boolean, username: String, password: String, isPro: Boolean, voucherCode: String) { + this.accountClaim = accountClaim + this.username = username + this.password = password + this.isPro = isPro + this.voucherCode = voucherCode + } + + constructor() + + override fun onAttach(context: Context) { + if (activity is FragmentCallback) { + fragmentCallBack = activity as FragmentCallback + } + super.onAttach(context) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.fragment_no_email_attention, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val warningText = view.findViewById(R.id.warningText) + if (isPro) { + warningText.setText(R.string.warning_no_email_pro_account) + } + } + + @OnClick(R.id.backButton) + fun onBackButtonClicked() { + fragmentCallBack?.onBackButtonPressed() + } + + @OnClick(R.id.continue_without_email) + fun onContinueWithoutEmailButtonClicked() { + if (accountClaim) { + fragmentCallBack?.onAccountClaimButtonClick(username, password, "", true, voucherCode) + } else { + fragmentCallBack?.onSignUpButtonClick(username, password, "", "", true, voucherCode) + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/SignUpFragment.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/SignUpFragment.kt new file mode 100644 index 000000000..a472d54ea --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/SignUpFragment.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome.fragment + +import android.content.Context +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.text.method.PasswordTransformationMethod +import android.util.Patterns +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.appcompat.widget.AppCompatCheckBox +import androidx.appcompat.widget.AppCompatEditText +import androidx.fragment.app.Fragment +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnCheckedChanged +import butterknife.OnClick +import com.windscribe.mobile.R +import java.util.concurrent.atomic.AtomicBoolean + +class SignUpFragment : Fragment(), TextWatcher, + WelcomeActivityCallback { + @BindView(R.id.email_sub_description) + lateinit var addEmailLabel: TextView + + @BindView(R.id.nav_button) + lateinit var backButton: ImageButton + + @BindView(R.id.username_error_description) + lateinit var usernameErrorDescription: TextView + + @BindView(R.id.email_description) + lateinit var emailDescriptionView: TextView + + @BindView(R.id.password_error_description) + lateinit var passwordErrorDescription: TextView + + @BindView(R.id.password_description) + lateinit var passwordDescription: TextView + + @BindView(R.id.email) + lateinit var emailEditText: EditText + + @BindView(R.id.email_error) + lateinit var emailErrorView: ImageView + + @BindView(R.id.password) + lateinit var passwordEditText: EditText + + @BindView(R.id.password_error) + lateinit var passwordErrorView: ImageView + + @BindView(R.id.password_visibility_toggle) + lateinit var passwordVisibilityToggle: AppCompatCheckBox + + @BindView(R.id.set_up_later_button) + lateinit var setUpButton: Button + + @BindView(R.id.loginButton) + lateinit var signUpButton: Button + + @BindView(R.id.nav_title) + lateinit var titleView: TextView + + @BindView(R.id.username) + lateinit var usernameEditText: EditText + + @BindView(R.id.username_error) + lateinit var usernameErrorView: ImageView + + @BindView(R.id.first_referral_description_prefix) + lateinit var firstReferralDescriptionPrefix: ImageView + + @BindView(R.id.first_referral_description) + lateinit var firstReferralDescription: TextView + + @BindView(R.id.second_referral_description_prefix) + lateinit var secondReferralDescriptionPrefix: ImageView + + @BindView(R.id.second_referral_description) + lateinit var secondReferralDescription: TextView + + @BindView(R.id.referral_collapse_icon) + lateinit var referralCollapseIcon: ImageView + + @BindView(R.id.referral_username) + lateinit var referralUsernameEditText: AppCompatEditText + + @BindView(R.id.voucher_collapse_icon) + lateinit var voucherCollapseIcon: ImageView + + @BindView(R.id.voucher_hint) + lateinit var voucherHint: TextView + + @BindView(R.id.confirm_email_explainer) + lateinit var confirmEmailExplainer: TextView + + @BindView(R.id.scrollable_container) + lateinit var scrollableContainer: ScrollView + + @BindView(R.id.referral_title) + lateinit var referralTitle: TextView + + @BindView(R.id.bottom_focus) + lateinit var bottomFocusView: ImageView + + @BindView(R.id.voucher) + lateinit var voucher: AppCompatEditText + + private var isUserPro = false + private var isAccountSetUpLayout = false + private var fragmentCallBack: FragmentCallback? = null + private var skipToHome = false + private var showReferralViews = false + private var showVoucherViews = false + private val ignoreEditTextChange = AtomicBoolean(false) + + override fun onAttach(context: Context) { + if (activity is FragmentCallback) { + fragmentCallBack = activity as FragmentCallback + } + super.onAttach(context) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (arguments != null) { + isAccountSetUpLayout = + requireArguments().getString("startFragmentName", "SignUp") == "AccountSetUp" + skipToHome = requireArguments().getBoolean("skipToHome", false) + isUserPro = requireArguments().getBoolean("userPro", false) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.fragment_sign_up, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (isAccountSetUpLayout) { + titleView.text = getString(R.string.account_set_up) + setUpButton.visibility = View.VISIBLE + if (isUserPro) { + addEmailLabel.visibility = View.GONE + } + if (skipToHome) { + backButton.visibility = View.INVISIBLE + } + hideReferralView() + } else { + titleView.text = getString(R.string.sign_up) + setUpButton.visibility = View.GONE + } + addEditTextChangeListener() + } + + override fun afterTextChanged(s: Editable) { + if (!ignoreEditTextChange.getAndSet(false)) { + resetNextButtonView() + } + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + if (!ignoreEditTextChange.getAndSet(false)) { + clearInputErrors() + } + resetReferralUsernameView() + } + + private fun resetReferralUsernameView() { + if (Patterns.EMAIL_ADDRESS.matcher(emailEditText.text) + .matches() && referralUsernameEditText.text.toString() != getString(R.string.please_provide_email_first) + ) { + referralUsernameEditText.setTextColor(resources.getColor(R.color.colorWhite50)) + referralUsernameEditText.isEnabled = true + } else if (Patterns.EMAIL_ADDRESS.matcher(emailEditText.text) + .matches() && referralUsernameEditText.text.toString() == getString(R.string.please_provide_email_first) + ) { + referralUsernameEditText.setTextColor(resources.getColor(R.color.colorWhite50)) + referralUsernameEditText.setText("") + referralUsernameEditText.isEnabled = true + } else { + referralUsernameEditText.setTextColor(resources.getColor(R.color.colorRed)) + referralUsernameEditText.setText(getString(R.string.please_provide_email_first)) + referralUsernameEditText.isEnabled = false + } + } + + override fun clearInputErrors() { + emailDescriptionView.setTextColor(resources.getColor(R.color.colorWhite50)) + emailDescriptionView.text = getString(R.string.email_description) + emailErrorView.visibility = View.INVISIBLE + usernameErrorView.visibility = View.INVISIBLE + passwordErrorView.visibility = View.INVISIBLE + usernameEditText.setTextColor(resources.getColor(R.color.colorWhite)) + passwordEditText.setTextColor(resources.getColor(R.color.colorWhite)) + referralTitle.setTextColor(resources.getColor(R.color.colorWhite50)) + usernameErrorDescription.visibility = View.GONE + usernameErrorDescription.text = "" + passwordErrorDescription.visibility = View.GONE + passwordErrorDescription.text = "" + passwordDescription.visibility = View.VISIBLE + } + + @OnClick(R.id.nav_button, R.id.set_up_later_button) + fun onNavButtonClick() { + if (skipToHome) { + fragmentCallBack?.onSkipToHomeClick() + } else { + requireActivity().onBackPressed() + } + } + + @OnCheckedChanged(R.id.password_visibility_toggle) + fun onPasswordVisibilityToggleChanged() { + ignoreEditTextChange.set(true) + if (passwordVisibilityToggle.isChecked) { + passwordEditText.transformationMethod = null + } else { + passwordEditText.transformationMethod = PasswordTransformationMethod() + } + passwordEditText.setSelection(passwordEditText.text.length) + } + + @OnClick(R.id.loginButton) + fun onSignUpButtonClick() { + if (isAccountSetUpLayout) { + fragmentCallBack?.onAccountClaimButtonClick( + usernameEditText.text.toString().trim { it <= ' ' }, + passwordEditText.text.toString().trim { it <= ' ' }, + emailEditText.text.toString().trim { it <= ' ' }, + false, + voucher.text.toString().trim {it <= ' '}, + ) + } else { + val referral = referralUsernameEditText.text.toString().trim { it <= ' ' } + val email = emailEditText.text.toString().trim { it <= ' ' } + fragmentCallBack?.onSignUpButtonClick( + usernameEditText.text.toString().trim { it <= ' ' }, + passwordEditText.text.toString().trim { it <= ' ' }, + email, + referral, + false, + voucher.text.toString().trim {it <= ' '}, + ) + } + } + + @OnClick(R.id.referral_collapse_icon) + fun onReferralCollapseIconClick() { + toggleReferralViewVisibility() + } + + @OnClick(R.id.voucher_collapse_icon) + fun onVoucherCollapseIconClick() { + toggleVoucherViewVisibility() + } + + private fun toggleVoucherViewVisibility() { + voucherCollapseIcon.rotation = if (showVoucherViews.not()) { + 0F + } else { + 180F + } + voucher.visibility = + if (!showVoucherViews) View.VISIBLE else View.GONE + voucherHint.visibility = + if (!showVoucherViews) View.VISIBLE else View.GONE + showVoucherViews = showVoucherViews.not() + if (showVoucherViews) { + bottomFocusView.requestFocus() + } else { + usernameEditText.requestFocus() + } + } + + private fun toggleReferralViewVisibility() { + referralCollapseIcon.rotation = if (showReferralViews.not()) { + 0F + } else { + 180F + } + firstReferralDescriptionPrefix.visibility = + if (!showReferralViews) View.VISIBLE else View.GONE + firstReferralDescription.visibility = + if (!showReferralViews) View.VISIBLE else View.GONE + secondReferralDescriptionPrefix.visibility = + if (!showReferralViews) View.VISIBLE else View.GONE + secondReferralDescription.visibility = + if (!showReferralViews) View.VISIBLE else View.GONE + referralUsernameEditText.visibility = + if (!showReferralViews) View.VISIBLE else View.GONE + confirmEmailExplainer.visibility = + if (!showReferralViews) View.VISIBLE else View.GONE + showReferralViews = showReferralViews.not() + if (showReferralViews) { + bottomFocusView.requestFocus() + } else { + usernameEditText.requestFocus() + } + } + + private fun hideReferralView() { + referralTitle.visibility = View.GONE + referralCollapseIcon.visibility = View.GONE + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} + fun setEmailError(errorMessage: String?) { + emailDescriptionView.setTextColor(resources.getColor(R.color.colorRed)) + emailDescriptionView.text = errorMessage + emailErrorView.visibility = View.VISIBLE + } + + override fun setLoginError(error: String) { + emailDescriptionView.setTextColor(resources.getColor(R.color.colorRed)) + emailDescriptionView.text = error + usernameEditText.error = error + usernameErrorView.visibility = View.VISIBLE + passwordErrorView.visibility = View.VISIBLE + emailErrorView.visibility = View.VISIBLE + usernameEditText.setTextColor(resources.getColor(R.color.colorRed)) + passwordEditText.setTextColor(resources.getColor(R.color.colorRed)) + } + + override fun setPasswordError(error: String) { + passwordErrorDescription.visibility = View.VISIBLE + passwordErrorDescription.setTextColor(resources.getColor(R.color.colorRed)) + passwordErrorDescription.text = error + passwordErrorView.visibility = View.VISIBLE + passwordEditText.setTextColor(resources.getColor(R.color.colorRed)) + passwordDescription.visibility = View.GONE + } + + override fun setUsernameError(error: String) { + usernameErrorDescription.visibility = View.VISIBLE + usernameErrorDescription.setTextColor(resources.getColor(R.color.colorRed)) + usernameErrorDescription.text = error + usernameErrorView.visibility = View.VISIBLE + usernameEditText.setTextColor(resources.getColor(R.color.colorRed)) + } + + private fun addEditTextChangeListener() { + usernameEditText.addTextChangedListener(this) + passwordEditText.addTextChangedListener(this) + emailEditText.addTextChangedListener(this) + } + + override fun onDestroyView() { + usernameEditText.removeTextChangedListener(this) + passwordEditText.removeTextChangedListener(this) + emailEditText.removeTextChangedListener(this) + super.onDestroyView() + } + + private fun resetNextButtonView() { + + } + + companion object { + fun newInstance(userPro: Boolean): SignUpFragment { + val args = Bundle() + args.putBoolean("userPro", userPro) + val fragment = SignUpFragment() + fragment.arguments = args + return fragment + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/WelcomeActivityCallback.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/WelcomeActivityCallback.kt new file mode 100644 index 000000000..4eb6061be --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/WelcomeActivityCallback.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome.fragment + +interface WelcomeActivityCallback { + fun clearInputErrors() + fun setLoginError(error: String) + fun setPasswordError(error: String) + fun setUsernameError(error: String) +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/WelcomeFragment.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/WelcomeFragment.kt new file mode 100644 index 000000000..de6d06adc --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/fragment/WelcomeFragment.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.welcome.fragment + +import android.animation.Animator +import android.animation.ValueAnimator +import android.content.Context +import android.os.Bundle +import android.os.Handler +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateInterpolator +import android.widget.ImageView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentPagerAdapter +import androidx.lifecycle.lifecycleScope +import androidx.viewpager.widget.PagerAdapter +import androidx.viewpager.widget.ViewPager +import androidx.viewpager.widget.ViewPager.OnPageChangeListener +import butterknife.BindView +import butterknife.ButterKnife +import butterknife.OnClick +import com.bumptech.glide.Glide +import com.google.android.material.tabs.TabLayout +import com.windscribe.mobile.R +import com.windscribe.mobile.fragments.FeatureFragments +import com.windscribe.mobile.fragments.FeaturePageTransformer +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.mobile.welcome.state.EmergencyConnectUIState +import com.windscribe.mobile.welcome.viewmodal.EmergencyConnectViewModal +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.* + +class WelcomeFragment : Fragment(), OnPageChangeListener { + @BindView(R.id.logo) + lateinit var logo: ImageView + + @BindView(R.id.emergencyConnectButton) + lateinit var emergencyConnectButton: ImageView + + @BindView(R.id.feature_pager) + lateinit var mViewPager: ViewPager + + @BindView(R.id.featureTabDots) + lateinit var tabLayout: TabLayout + + private var fragmentCallback: FragmentCallback? = null + private var autoPagingRunnable: Runnable? = null + private var pagerAdapter: PagerAdapter? = null + private var scrollState = 0 + private var slideLeft = true + private var pagerTimer: Timer? = null + private val viewModal: EmergencyConnectViewModal? by lazy { + return@lazy (activity as? WelcomeActivity)?.emergencyConnectViewModal?.value + } + + override fun onAttach(context: Context) { + if (activity is FragmentCallback) { + fragmentCallback = activity as FragmentCallback? + } + super.onAttach(context) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.fragment_welcome, container, false) + ButterKnife.bind(this, view) + return view + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setGif() + setPagerAdapter() + setUpAutoPaging() + lifecycleScope.launch { + viewModal?.uiState?.collectLatest { state -> + if (state != EmergencyConnectUIState.Connected) { + emergencyConnectButton.setImageResource(R.drawable.emergency_icon) + } else { + emergencyConnectButton.setImageResource(R.drawable.emergency_icon_blue) + } + } + } + } + + override fun onDestroyView() { + stopPagerSchedule() + super.onDestroyView() + } + + override fun onPageScrollStateChanged(state: Int) { + scrollState = state + } + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {} + override fun onPageSelected(position: Int) {} + private fun setPagerAdapter() { + pagerAdapter = object : + FragmentPagerAdapter(childFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + override fun getCount(): Int { + return 4 + } + + override fun getItem(position: Int): Fragment { + return FeatureFragments.newInstance(position) + } + } + tabLayout.setupWithViewPager(mViewPager, true) + mViewPager.addOnPageChangeListener(this) + mViewPager.setPageTransformer(false, FeaturePageTransformer()) + mViewPager.adapter = pagerAdapter + } + + private fun setUpAutoPaging() { + autoPagingRunnable = Runnable { + if (!mViewPager.isFakeDragging && scrollState == 0) { + mViewPager.beginFakeDrag() + val valueAnimator = ValueAnimator.ofFloat(0f, 1f) + valueAnimator.interpolator = AccelerateInterpolator(1.5f) + valueAnimator.duration = 2000 + valueAnimator.addListener(object : Animator.AnimatorListener { + override fun onAnimationCancel(animator: Animator) { + mViewPager.endFakeDrag() + } + + override fun onAnimationEnd(animator: Animator) { + try { + if (mViewPager.isFakeDragging) { + mViewPager.endFakeDrag() + } + if (mViewPager.currentItem == (pagerAdapter?.count ?: 0) - 1) { + slideLeft = false + mViewPager.currentItem = 0 + } else if (mViewPager.currentItem == 0) { + slideLeft = true + } + } catch (ignored: Exception) { + } + } + + override fun onAnimationRepeat(animator: Animator) {} + override fun onAnimationStart(animator: Animator) { + mViewPager.beginFakeDrag() + } + }) + valueAnimator.addUpdateListener { valueAnimator1: ValueAnimator -> + if (pagerAdapter == null) { + return@addUpdateListener + } + try { + if (mViewPager.isFakeDragging && (pagerAdapter?.count ?: 0) > 0) { + if (slideLeft) { + mViewPager.fakeDragBy( + -valueAnimator1.animatedFraction * mViewPager.width / 2 + ) + } + } + } catch (ignored: Exception) { + } + } + valueAnimator.start() + } + } + startPagerSchedule() + } + + private fun startPagerSchedule() { + val handler = Handler() + pagerTimer = Timer() + pagerTimer?.schedule(object : TimerTask() { + override fun run() { + autoPagingRunnable?.let { handler.post(it) } + } + }, 3000, 3000) + } + + private fun stopPagerSchedule() { + pagerTimer?.cancel() + pagerTimer = null + } + + @OnClick(R.id.get_started_button) + fun onGetStartedButtonClick() { + fragmentCallback?.onContinueWithOutAccountClick() + } + + @OnClick(R.id.loginButton) + fun onLoginButtonClick() { + fragmentCallback?.onLoginClick() + } + + @OnClick(R.id.emergencyConnectButton) + fun onEmergencyButtonClick() { + fragmentCallback?.onEmergencyClick() + } + + private fun setGif() { + Glide.with(this).load(R.raw.wsbadge).into(logo) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/state/EmergencyConnectUIState.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/state/EmergencyConnectUIState.kt new file mode 100644 index 000000000..cdce56d33 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/state/EmergencyConnectUIState.kt @@ -0,0 +1,5 @@ +package com.windscribe.mobile.welcome.state + +enum class EmergencyConnectUIState { + Disconnected, Connecting, Connected +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/welcome/viewmodal/EmergencyConnectViewModal.kt b/mobile/src/main/java/com/windscribe/mobile/welcome/viewmodal/EmergencyConnectViewModal.kt new file mode 100644 index 000000000..44bbbba28 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/welcome/viewmodal/EmergencyConnectViewModal.kt @@ -0,0 +1,94 @@ +package com.windscribe.mobile.welcome.viewmodal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.windscribe.mobile.welcome.state.EmergencyConnectUIState +import com.windscribe.vpn.backend.VPNState.Status.* +import com.windscribe.vpn.backend.utils.WindVpnController +import com.windscribe.vpn.state.VPNConnectionStateManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import org.slf4j.LoggerFactory + +class EmergencyConnectViewModal( + private val scope: CoroutineScope, + private val windVpnController: WindVpnController, + private val vpnConnectionStateManager: VPNConnectionStateManager +) : ViewModel() { + private val logger = LoggerFactory.getLogger("emergency_connect") + private var _uiState = MutableStateFlow(EmergencyConnectUIState.Disconnected) + val uiState: StateFlow = _uiState + private var _connectionProgressText = MutableStateFlow("Resolving e-connect domain..") + val connectionProgressText: StateFlow = _connectionProgressText + private var connectingJob: Job? = null + + init { + observeConnectionState() + } + + private fun observeConnectionState() { + viewModelScope.launch { + vpnConnectionStateManager.state.collectLatest { + when (it.status) { + Connecting -> _uiState.emit(EmergencyConnectUIState.Connecting) + Connected -> _uiState.emit(EmergencyConnectUIState.Connected) + Disconnected -> _uiState.emit(EmergencyConnectUIState.Disconnected) + Disconnecting -> _uiState.emit(EmergencyConnectUIState.Disconnected) + RequiresUserInput -> _uiState.emit(EmergencyConnectUIState.Disconnected) + else -> {} + } + } + } + } + + fun connectButtonClick() { + logger.debug("User clicked connect button with current state: ${uiState.value}") + if (uiState.value == EmergencyConnectUIState.Connected || uiState.value == EmergencyConnectUIState.Connecting) { + disconnect() + } else { + connect() + } + } + + fun disconnect() { + scope.launch { + connectingJob?.cancel() + windVpnController.disconnectAsync() + } + } + + private fun connect() { + connectingJob = scope.launch { + _uiState.emit(EmergencyConnectUIState.Connecting) + windVpnController.connectUsingEmergencyProfile { progress -> + _connectionProgressText.value = progress + }.onSuccess { + logger.debug("Successfully connected to emergency server.") + }.onFailure { + logger.error("Failure to connect using emergency vpn profiles: $it") + _uiState.emit(EmergencyConnectUIState.Disconnected) + } + } + } + + companion object { + fun provideFactory( + scope: CoroutineScope, + windVpnController: WindVpnController, + vpnConnectionStateManager: VPNConnectionStateManager + ) = object : ViewModelProvider.NewInstanceFactory() { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(EmergencyConnectViewModal::class.java)) { + return EmergencyConnectViewModal( + scope, windVpnController, vpnConnectionStateManager + ) as T + } + return super.create(modelClass) + } + } + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/windscribe/FragmentClickListener.kt b/mobile/src/main/java/com/windscribe/mobile/windscribe/FragmentClickListener.kt new file mode 100644 index 000000000..8e57cbb36 --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/windscribe/FragmentClickListener.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.windscribe + +interface FragmentClickListener { + fun onAddConfigClick() + fun onRefreshPingsForAllServers() + fun onRefreshPingsForConfigServers() + fun onRefreshPingsForFavouritesServers() + fun onRefreshPingsForStaticServers() + fun onRefreshPingsForStreamingServers() + fun onReloadClick() + fun onStaticIpClick() + fun onUpgradeClicked() + fun setServerListToolbarElevation(elevation: Int) +} diff --git a/mobile/src/main/java/com/windscribe/mobile/windscribe/PhoneApplication.kt b/mobile/src/main/java/com/windscribe/mobile/windscribe/PhoneApplication.kt new file mode 100644 index 000000000..221535fcc --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/windscribe/PhoneApplication.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.windscribe + +import android.content.Intent +import android.view.ViewGroup +import androidx.core.view.children +import androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_FADE +import com.windscribe.mobile.R +import com.windscribe.mobile.connectionmode.* +import com.windscribe.mobile.splash.SplashActivity +import com.windscribe.mobile.upgradeactivity.UpgradeActivity +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.vpn.Windscribe +import com.windscribe.vpn.Windscribe.ApplicationInterface +import com.windscribe.vpn.autoconnection.AutoConnectionModeCallback +import com.windscribe.vpn.autoconnection.FragmentType +import com.windscribe.vpn.autoconnection.ProtocolInformation +import com.windscribe.vpn.constants.PreferencesKeyConstants + +class PhoneApplication : Windscribe(), ApplicationInterface { + override fun onCreate() { + applicationInterface = this + super.onCreate() + setTheme() + } + + override val homeIntent: Intent + get() = Intent(appContext, WindscribeActivity::class.java) + override val splashIntent: Intent + get() = Intent(appContext, SplashActivity::class.java) + override val upgradeIntent: Intent + get() = Intent(appContext, UpgradeActivity::class.java) + override val welcomeIntent: Intent + get() = Intent(appContext, WelcomeActivity::class.java) + override val isTV: Boolean + get() = false + + override fun setTheme() { + val savedThem = preference.selectedTheme + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + setTheme(R.style.DarkTheme) + } else { + setTheme(R.style.LightTheme) + } + } + + override fun launchFragment( + protocolInformationList: List, + fragmentType: FragmentType, + autoConnectionModeCallback: AutoConnectionModeCallback, + protocolInformation: ProtocolInformation? + ): Boolean { + return if (activeActivity == null) { + false + } else { + val viewGroup: ViewGroup = + activeActivity?.findViewById(android.R.id.content) as ViewGroup + if (viewGroup.children.count() > 0 && viewGroup.children.first().id != -1) { + val fragment = when (fragmentType) { + FragmentType.ConnectionFailure -> ConnectionFailureFragment.newInstance( + protocolInformationList, autoConnectionModeCallback + ) + FragmentType.ConnectionChange -> ConnectionChangeFragment.newInstance( + protocolInformationList, autoConnectionModeCallback + ) + FragmentType.SetupAsPreferredProtocol -> SetupPreferredProtocolFragment.newInstance( + protocolInformation, autoConnectionModeCallback + ) + FragmentType.DebugLogSent -> DebugLogSentFragment.newInstance( + autoConnectionModeCallback + ) + FragmentType.AllProtocolFailed -> AllProtocolFailedFragment.newInstance( + autoConnectionModeCallback + ) + } + activeActivity?.supportFragmentManager?.beginTransaction() + ?.setTransition(TRANSIT_FRAGMENT_FADE) + ?.add(viewGroup.children.first().id, fragment, "")?.commit() + } + true + } + } +} diff --git a/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribeActivity.kt b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribeActivity.kt new file mode 100644 index 000000000..4d2ca69bb --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribeActivity.kt @@ -0,0 +1,1970 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.windscribe + +import android.animation.Animator +import android.animation.ArgbEvaluator +import android.animation.LayoutTransition +import android.animation.ValueAnimator +import android.app.ActivityOptions +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BlurMaskFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.transition.AutoTransition +import android.transition.Slide +import android.view.* +import android.view.animation.AccelerateInterpolator +import android.widget.* +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.res.ResourcesCompat +import androidx.viewpager.widget.ViewPager +import androidx.viewpager.widget.ViewPager.* +import butterknife.BindView +import butterknife.OnClick +import butterknife.OnItemSelected +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.* +import com.windscribe.mobile.base.BaseActivity +import com.windscribe.mobile.connectionsettings.ConnectionSettingsActivity +import com.windscribe.mobile.connectionui.* +import com.windscribe.mobile.custom_view.CustomDialog +import com.windscribe.mobile.custom_view.CustomDrawableCrossFadeFactory +import com.windscribe.mobile.di.ActivityModule +import com.windscribe.mobile.dialogs.* +import com.windscribe.mobile.fragments.SearchFragment +import com.windscribe.mobile.fragments.ServerListFragment +import com.windscribe.mobile.mainmenu.MainMenuActivity +import com.windscribe.mobile.newsfeedactivity.NewsFeedActivity +import com.windscribe.mobile.upgradeactivity.UpgradeActivity +import com.windscribe.mobile.utils.PermissionManager +import com.windscribe.mobile.welcome.WelcomeActivity +import com.windscribe.vpn.backend.utils.WindVpnController +import com.windscribe.vpn.commonutils.ThemeUtils +import com.windscribe.vpn.commonutils.WindUtilities +import com.windscribe.vpn.constants.AnimConstants +import com.windscribe.vpn.constants.AnimConstants.VPN_CONNECTING_ANIMATION_DELAY +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.constants.NetworkKeyConstants.getWebsiteLink +import com.windscribe.vpn.constants.NotificationConstants +import com.windscribe.vpn.constants.RateDialogConstants +import com.windscribe.vpn.constants.RateDialogConstants.PLAY_STORE_URL +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import com.windscribe.vpn.repository.ServerListRepository +import com.windscribe.vpn.serverlist.entity.ConfigFile +import com.windscribe.vpn.serverlist.entity.ServerListData +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener +import com.windscribe.vpn.state.DeviceStateManager +import com.windscribe.vpn.state.DeviceStateManager.DeviceStateListener +import com.windscribe.vpn.state.PreferenceChangeObserver +import org.slf4j.LoggerFactory +import org.slf4j.Marker +import javax.inject.Inject +import javax.inject.Named + + +class WindscribeActivity : BaseActivity(), WindscribeView, OnPageChangeListener, + RateAppDialogCallback, EditConfigFileDialogCallback, FragmentClickListener, DeviceStateListener, NodeStatusDialogCallback, + AccountStatusDialogCallback, PowerWhitelistDialogCallback { + enum class NetworkLayoutState { + CLOSED, OPEN_1, OPEN_2, OPEN_3 + } + + @Inject + lateinit var customDialog: CustomDialog + + @Inject + lateinit var argbEvaluator: ArgbEvaluator + + @Inject + lateinit var deviceStateManager: DeviceStateManager + + @Inject + lateinit var preferenceChangeObserver: PreferenceChangeObserver + + @Inject + lateinit var vpnController: WindVpnController + + @Inject + lateinit var presenter: WindscribePresenter + + @Inject + lateinit var serverListRepository: ServerListRepository + + @Inject + @Named("serverListFragments") + lateinit var serverListFragments: List + + @JvmField + @BindView(R.id.auto_secure_divider) + var autoSecureDivider: ImageView? = null + + @JvmField + @BindView(R.id.auto_secure_toggle) + var autoSecureToggle: ImageView? = null + + @JvmField + @BindView(R.id.cl_auto_secure) + var clAutoSecure: ConstraintLayout? = null + + @JvmField + @BindView(R.id.cl_port) + var clPort: ConstraintLayout? = null + + @JvmField + @BindView(R.id.cl_preferred) + var clPreferred: ConstraintLayout? = null + + @JvmField + @BindView(R.id.cl_preferred_protocol) + var clPreferredProtocol: ConstraintLayout? = null + + @JvmField + @BindView(R.id.cl_protocol) + var clProtocol: ConstraintLayout? = null + + @JvmField + @BindView(R.id.collapse_expand_icon) + var collapseExpandExpandIcon: ImageView? = null + + @JvmField + @BindView(R.id.connecting_icon) + var connectionIcon: ProgressBar? = null + + @JvmField + @BindView(R.id.tv_connection_state) + var connectionState: TextView? = null + + @JvmField + @BindView(R.id.tv_current_port) + var currentPort: TextView? = null + + @JvmField + @BindView(R.id.tv_current_protocol) + var currentProtocol: TextView? = null + + @JvmField + @BindView(R.id.flag_dimensions_guide) + var flagDimensionsGuideView: ImageView? = null + + @JvmField + @BindView(R.id.img_hamburger_menu) + var hamburgerIcon: ImageView? = null + + @JvmField + @BindView(R.id.bottom_gradient) + var bottomGradient: ImageView? = null + + @JvmField + @BindView(R.id.connection_gradient) + var connectionGradient: ImageView? = null + + @JvmField + @BindView(R.id.cl_windscribe_main) + var constraintLayoutMain: ConstraintLayout? = null + + @JvmField + @BindView(R.id.server_list_tool_bar) + var constraintLayoutServerList: ConstraintLayout? = null + + @JvmField + @BindView(R.id.flag) + var flagView: ImageView? = null + + @JvmField + @BindView(R.id.userAccountStatusIcon) + var imgAccountGarryEmotion: ImageView? = null + + @JvmField + @BindView(R.id.img_config_loc_list) + var imgConfigLocList: ImageView? = null + + @JvmField + @BindView(R.id.img_server_list_all) + var imgServerListAll: ImageView? = null + + @JvmField + @BindView(R.id.img_server_list_favorites) + var imgServerListFavorites: ImageView? = null + + @JvmField + @BindView(R.id.img_server_list_flix) + var imgServerListFlix: ImageView? = null + + @JvmField + @BindView(R.id.img_static_ip_list) + var imgStaticIpList: ImageView? = null + + @JvmField + @BindView(R.id.location_list_fragment_pager) + var locationFragmentViewPager: ViewPager? = null + + @JvmField + @BindView(R.id.safe_unsafe_icon) + var lockIcon: ImageView? = null + + @JvmField + @BindView(R.id.progress_bar_recyclerview) + var progressBarRecyclerView: ProgressBar? = null + + @JvmField + @BindView(R.id.toolbar_background_slope) + var slopedView: ImageView? = null + + @JvmField + @BindView(R.id.network_name) + var textViewConnectedNetworkName: TextView? = null + + @JvmField + @BindView(R.id.ip_address) + var textViewIpAddress: TextView? = null + + @JvmField + @BindView(R.id.tv_connected_city_name) + var textViewLocationName: TextView? = null + + @JvmField + @BindView(R.id.tv_connected_city_nick_name) + var textViewLocationNick: TextView? = null + + @JvmField + @BindView(R.id.toolbar_background_square) + var toolBarSquare: ImageView? = null + + @JvmField + @BindView(R.id.network_icon) + var networkIcon: ImageView? = null + + @JvmField + @BindView(R.id.text_view_notification) + var notificationCountView: TextView? = null + + @JvmField + @BindView(R.id.on_off_button) + var onOffButton: ImageView? = null + + @JvmField + @BindView(R.id.on_off_progress_bar) + var onOffProgressBar: ProgressBar? = null + + @JvmField + @BindView(R.id.on_off_ring) + var onOffRing: ImageView? = null + + @JvmField + @BindView(R.id.tv_port) + var port: TextView? = null + private var portAdapter: ArrayAdapter? = null + + @JvmField + @BindView(R.id.port_protocol_divider) + var portProtocolDivider: ImageView? = null + + @JvmField + @BindView(R.id.spinner_port) + var portSpinner: Spinner? = null + + @JvmField + @BindView(R.id.img_preferred_protocol_status) + var preferredProtocolStatus: ImageView? = null + + @JvmField + @BindView(R.id.tv_decoy_label) + var tvDecoy: TextView? = null + + @JvmField + @BindView(R.id.tv_decoy_divider) + var decoyDivider: ImageView? = null + + @JvmField + @BindView(R.id.img_decoy_traffic_arrow) + var decoyArrow: ImageView? = null + + @JvmField + @BindView(R.id.anti_censor_ship_status) + var antiCensorShipIcon: ImageView? = null + + @JvmField + @BindView(R.id.img_protocol_change_arrow) + var changeProtocolArrow: ImageView? = null + + @JvmField + @BindView(R.id.preferred_protocol_toggle) + var preferredProtocolToggle: ImageView? = null + + @JvmField + @BindView(R.id.tv_protocol) + var protocol: TextView? = null + + @JvmField + @BindView(R.id.spinner_protocol) + var protocolSpinner: Spinner? = null + + @JvmField + @BindView(R.id.server_list_toolbar) + var serverListToolbar: ConstraintLayout? = null + + @JvmField + @BindView(R.id.top_gradient) + var topGradient: ImageView? = null + + @JvmField + @BindView(R.id.autoSecureLabel) + var tvAutoSecureLabel: TextView? = null + + @JvmField + @BindView(R.id.tv_port_label) + var tvPortLabel: TextView? = null + + @JvmField + @BindView(R.id.preferredProtocolLabel) + var tvPreferredProtocolLabel: TextView? = null + + @JvmField + @BindView(R.id.tv_protocol_label) + var tvProtocolLabel: TextView? = null + + @Inject + lateinit var permissionManager: PermissionManager + + private var protocolAdapter: ArrayAdapter? = null + + var transition: AutoTransition? = null + + val constraintSetMain = ConstraintSet() + + private val constraintSetServerList = ConstraintSet() + + private var lastFlag = 0 + + private val logger = LoggerFactory.getLogger("basic") + + override var uiConnectionState: ConnectionUiState? = null + private set + private var lastCustomBackgroundPath: String? = null + private val drawableCrossFadeFactory = + CustomDrawableCrossFadeFactory.Builder(1500).setCrossFadeEnabled(true).build() + override var isBannedLayoutShown = false + private set + override var networkLayoutState = NetworkLayoutState.CLOSED + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + logger.info("Loading home screen.") + setActivityModule(ActivityModule(this, this)).inject(this) + setContentLayout(R.layout.activity_windscribe, true) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + val layoutTransition = constraintLayoutMain?.layoutTransition + layoutTransition?.enableTransitionType(LayoutTransition.CHANGING) + presenter.setMainCustomConstraints() + setServerListView(false) + permissionManager.register(this) + registerDataChangeObserver() + activityScope { presenter.observeVPNState() } + activityScope { presenter.observeNextProtocolToConnect() } + activityScope { presenter.observeConnectedProtocol() } + activityScope { presenter.observeStaticRegions() } + activityScope { presenter.observeAllLocations() } + activityScope { presenter.observerSelectedLocation() } + activityScope { presenter.observeDecoyTrafficState() } + activityScope { presenter.showShareLinkDialog() } + activityScope { presenter.observeLatency() } + activityScope { presenter.observeLocationUIInvalidation() } + activityScope { presenter.observeConnectionCount() } + presenter.registerNetworkInfoListener() + presenter.handlePushNotification(intent.extras) + presenter.observeUserData(this) + logger.info("Home screen loaded.") + } + + override fun onStart() { + super.onStart() + if (presenter.userHasAccess()) { + presenter.onStart() + if (intent != null && intent.action != null && (intent.action == NotificationConstants.DISCONNECT_VPN_INTENT)) { + logger.info("App launched from disconnect vpn intent.") + presenter.onDisconnectIntentReceived() + } + deviceStateManager.addListener(this) + } else { + presenter.logoutFromCurrentSession() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == FILE_PICK_REQUEST && resultCode == RESULT_OK && data != null) { + logger.debug("Picked custom config file.") + presenter.loadConfigFile(data) + } + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + logger.debug("Home screen: onRestoreInstanceState.") + setServerListView(true) + } + + override fun onResume() { + super.onResume() + if (!coldLoad.getAndSet(false)) { + setLanguage() + presenter.onHotStart() + } + presenter.checkForWgIpChange() + presenter.checkPendingAccountUpgrades() + } + + override fun onStop() { + super.onStop() + logger.info("Home screen: onStop un-registering network and vpn status listener.") + deviceStateManager.removeListener(this) + } + + override fun onDestroy() { + logger.info("Home screen: onDestroy releasing resources.") + locationFragmentViewPager?.currentItem?.let { + presenter.saveLastSelectedTabIndex(it) + } + presenter.onDestroy() + super.onDestroy() + } + + fun adjustToolBarHeight(adjustBy: Int) { + val toolBar = findViewById(R.id.toolbar_windscribe) + val toolBarHeight = toolBar.layoutParams.height + adjustBy + constraintSetMain.constrainHeight(R.id.toolbar_windscribe, toolBarHeight) + constraintSetMain.setMargin(R.id.on_off_button, ConstraintSet.TOP, toolBarHeight / 2) + constraintSetMain.applyTo(constraintLayoutMain) + } + + override fun checkNodeStatus() { + logger.debug("User clicked on check node status button.") + presenter.onCheckNodeStatusClick() + } + + override fun exitSearchLayout() { + val fragment = supportFragmentManager.findFragmentById(R.id.cl_windscribe_main) + if (fragment is SearchFragment) { + logger.debug("Closing search layout.") + fragment.setSearchView(false) + } + } + + private fun getColorFromTheme(id: Int, defaultValue: Int): Int { + return ThemeUtils.getColor(this, id, defaultValue) + } + + override val flagViewHeight: Int + get() = flagDimensionsGuideView?.measuredHeight ?: 0 + override val flagViewWidth: Int + get() = flagDimensionsGuideView?.measuredWidth ?: 0 + + override fun gotoLoginRegistrationActivity() { + logger.debug("Moving to welcome screen.") + val intent = WelcomeActivity.getStartIntent(this) + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + startActivity(intent) + finish() + } + + override fun hideProgressView() { + runOnUiThread { + if (customDialog.isShowing) { + customDialog.hide() + } + } + } + + override fun hideRecyclerViewProgressBar() { + runOnUiThread { progressBarRecyclerView?.visibility = View.GONE } + } + + override fun neverAskAgainClicked() { + presenter.saveRateDialogPreference(RateDialogConstants.STATUS_NEVER_ASK) + } + + override fun onAddConfigClick() { + logger.debug("User clicked on Add custom config.") + presenter.onAddConfigLocation() + } + + @OnClick(R.id.auto_secure_info) + fun onAutoSecureInfoClick() { + presenter.onAutoSecureInfoClick() + } + + @OnClick(R.id.auto_secure_toggle) + fun onAutoSecureToggleClick() { + logger.debug("User clicked on auto secure toggle") + presenter.onAutoSecureToggleClick() + } + + override fun onBackPressed() { + if (supportFragmentManager.findFragmentById(R.id.cl_windscribe_main) == null) { + finishAfterTransition() + } else { + super.onBackPressed() + } + } + + @OnClick(R.id.collapse_expand_icon) + fun onCollapseExpandClick() { + logger.debug("User clicked on collapse/expand icon") + presenter.onCollapseExpandIconClick() + } + + override fun onConfigFileUpdated(configFile: ConfigFile) { + presenter.updateConfigFile(configFile) + } + + @OnClick(R.id.on_off_button) + fun onConnectButtonClick() { + logger.debug("User clicked on connect button.") + presenter.onConnectClicked() + onOffButton?.isEnabled = false + // Disable connect button to avoid mismatched state between animations. + Looper.myLooper()?.let { Handler(it).postDelayed({ onOffButton?.isEnabled = true }, 1000) } + } + + @OnClick(R.id.tv_current_port, R.id.img_port_drop_down_btn) + fun onCurrentPortClick() { + portSpinner?.performClick() + } + + @OnClick(R.id.tv_current_protocol, R.id.img_protocol_drop_down_btn) + fun onCurrentProtocolClick() { + protocolSpinner?.performClick() + } + + @OnClick(R.id.ip_address) + fun onIpClick() { + if (textViewIpAddress?.alpha != 0.0F) { + presenter.onIpClicked() + } + } + + @OnClick(R.id.img_hamburger_menu) + fun onMenuClicked() { + logger.info("User clicked menu...") + presenter.onMenuButtonClicked() + } + + @OnClick(R.id.network_icon) + fun onNetworkIconClick() { + + } + + @OnClick(R.id.network_name) + fun onNetworkNameClick() { + presenter.onNetworkNameClick() + } + + override fun onNetworkStateChanged() { + presenter.onNetworkStateChanged() + } + + @OnClick(R.id.text_view_notification, R.id.img_windscribe_logo) + fun onNotificationClick() { + logger.debug("User clicked news feed icon.") + presenter.onNewsFeedItemClick() + } + + override fun onPageScrollStateChanged(i: Int) {} + override fun onPageScrolled(i: Int, v: Float, i1: Int) {} + override fun onPageSelected(i: Int) { + when (i) { + 0 -> { + performSwipeHapticFeedback(imgServerListAll) + onShowAllServerClick() + } + + 1 -> { + performSwipeHapticFeedback(imgServerListFavorites) + onShowFavoritesClicked() + } + + 2 -> { + performSwipeHapticFeedback(imgServerListFlix) + onShowFlixListClick() + } + + 3 -> { + performSwipeHapticFeedback(imgStaticIpList) + onShowStaticIpList() + } + + 4 -> { + performSwipeHapticFeedback(imgConfigLocList) + onShowConfigLocList() + } + } + } + + @OnItemSelected(R.id.spinner_port) + fun onPortSelected(position: Int) { + portAdapter?.let { + val selected = it.getItem(position) + currentPort?.text = selected + selected?.let { presenter.onPortSelected(selected) } + } + } + + @OnClick(R.id.preferred_protocol_info) + fun onPreferredProtocolInfoClick() { + presenter.onPreferredProtocolInfoClick() + } + + @OnClick(R.id.preferred_protocol_toggle) + fun onPreferredProtocolToggleClick() { + logger.debug("User clicked on preferred protocol toggle") + presenter.onPreferredProtocolToggleClick() + } + + @OnItemSelected(R.id.spinner_protocol) + fun onProtocolSelected(position: Int) { + protocolAdapter?.let { + val selected = it.getItem(position) + currentProtocol?.text = selected + selected?.let { presenter.onProtocolSelected(selected) } + } + } + + override fun onRefreshPingsForAllServers() { + cancelRefreshing(0) + presenter.onRefreshPingsForAllServers() + } + + override fun onRefreshPingsForConfigServers() { + cancelRefreshing(4) + presenter.onRefreshPingsForConfigServers() + } + + override fun onRefreshPingsForFavouritesServers() { + cancelRefreshing(1) + presenter.onRefreshPingsForFavouritesServers() + } + + override fun onRefreshPingsForStaticServers() { + cancelRefreshing(3) + presenter.onRefreshPingsForStaticServers() + } + + override fun onRefreshPingsForStreamingServers() { + cancelRefreshing(2) + presenter.onRefreshPingsForStreamingServers() + } + + override fun onReloadClick() { + logger.debug("User clicked on reload list...") + presenter.onReloadClick() + } + + override fun onRenewPlanClick() { + logger.debug("User clicked to renew and upgrade plan...") + presenter.onRenewPlanClicked() + } + + @OnClick(R.id.img_search_list) + fun onSearchBtnClick() { + logger.debug("User clicked on search button...") + presenter.onSearchButtonClicked() + } + + @OnClick(R.id.img_server_list_all) + fun onShowAllServerClick() { + logger.debug("User clicked show all servers...") + if (locationFragmentViewPager?.currentItem != 0) { + logger.debug("Setting pager item to 0: Server List Fragment") + locationFragmentViewPager?.currentItem = 0 + } + presenter.onShowAllServerListClicked() + } + + @OnClick(R.id.img_config_loc_list) + fun onShowConfigLocList() { + logger.debug("User clicked show config loc list...") + if (locationFragmentViewPager?.currentItem != 4) { + // Change fragment to config list fragment + logger.debug("Setting pager item to 4: Config Loc Fragment") + locationFragmentViewPager?.currentItem = 4 + } + presenter.onShowConfigLocListClicked() + } + + @OnClick(R.id.img_server_list_favorites) + fun onShowFavoritesClicked() { + if (locationFragmentViewPager?.currentItem != 1) { + // Change fragment to all server list fragment + logger.debug("Setting pager item to 1: Favourites list fragment") + locationFragmentViewPager?.currentItem = 1 + } + presenter.onShowFavoritesClicked() + } + + @OnClick(R.id.img_server_list_flix) + fun onShowFlixListClick() { + if (locationFragmentViewPager?.currentItem != 2) { + // Change fragment to all server list fragment + logger.debug("Setting pager item to 2: Flix List Fragment") + locationFragmentViewPager?.currentItem = 2 + } + presenter.onShowFlixListClicked() + } + + @OnClick(R.id.img_static_ip_list) + fun onShowStaticIpList() { + if (locationFragmentViewPager?.currentItem != 3) { + logger.debug("Setting pager item to 3: Static IP Fragment") + locationFragmentViewPager?.currentItem = 3 + } + presenter.onShowStaticIpListClicked() + } + + override fun onStaticIpClick() { + presenter.onAddStaticIPClicked() + } + + override fun onSubmitUsernameAndPassword(configFile: ConfigFile) { + presenter.updateConfigFileConnect(configFile) + } + + override fun onUpgradeClicked() { + logger.debug("User clicked on upgrade button...") + presenter.onUpgradeClicked() + } + + override fun openEditConfigFileDialog(configFile: ConfigFile) { + EditConfigFileDialog.show(this, configFile) + } + + override fun openFileChooser() { + val pickIntent = Intent(Intent.ACTION_OPEN_DOCUMENT) + pickIntent.type = "*/*" + if (pickIntent.resolveActivity(packageManager) != null) { + startActivityForResult(pickIntent, FILE_PICK_REQUEST) + } else { + Toast.makeText(this, "Unable to access shared storage.", Toast.LENGTH_SHORT).show() + } + } + + override fun openHelpUrl() { + logger.debug("Showing help me page in browser") + openURLInBrowser(getWebsiteLink(NetworkKeyConstants.URL_HELP_ME)) + } + + override fun openMenuActivity() { + val intent = MainMenuActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun openConnectionActivity() { + val intent = ConnectionSettingsActivity.getStartIntent(this) + val options = ActivityOptions.makeSceneTransitionAnimation(this) + startActivity(intent, options.toBundle()) + } + + override fun openNewsFeedActivity(showPopUp: Boolean, popUp: Int) { + val intent = NewsFeedActivity.getStartIntent(this@WindscribeActivity, showPopUp, popUp) + startActivity(intent) + } + + override fun openNodeStatusPage(url: String) { + logger.debug("Opening node status url in browser.") + openURLInBrowser(url) + } + + override fun openProvideUsernameAndPasswordDialog(configFile: ConfigFile) { + UsernameAndPasswordRequestDialog.show(this, configFile) + } + + override fun openStaticIPUrl(url: String) { + openURLInBrowser(url) + } + + override fun openUpgradeActivity() { + startActivity(UpgradeActivity.getStartIntent(this)) + } + + override fun performButtonClickHapticFeedback() { + if (presenter.isHapticFeedbackEnabled) { + hamburgerIcon?.isHapticFeedbackEnabled = true + hamburgerIcon?.performHapticFeedback( + HapticFeedbackConstants.VIRTUAL_KEY, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + } + + override fun performConfirmConnectionHapticFeedback() { + runOnUiThread { + if (presenter.isHapticFeedbackEnabled) { + onOffButton?.isHapticFeedbackEnabled = true + onOffButton?.performHapticFeedback( + HapticFeedbackConstants.VIRTUAL_KEY, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + } + } + + private fun performSwipeHapticFeedback(view: View?) { + if (presenter.isHapticFeedbackEnabled) { + view?.isHapticFeedbackEnabled = true + view?.performHapticFeedback( + HapticFeedbackConstants.VIRTUAL_KEY, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + } + + override fun rateLaterClicked() { + presenter.saveRateDialogPreference(RateDialogConstants.STATUS_ASK_LATER) + } + + override fun rateNowClicked() { + presenter.saveRateDialogPreference(RateDialogConstants.STATUS_ALREADY_ASKED) + val urlIntent = Intent(Intent.ACTION_VIEW) + urlIntent.data = Uri.parse(PLAY_STORE_URL) + try { + packageManager.getPackageInfo(PLAY_STORE_URL, 0) + urlIntent.setPackage(RateDialogConstants.PLAY_STORE_PACKAGE) + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + } + startActivity(urlIntent) + } + + override fun scrollTo(scrollTo: Int) { + if (serverListFragments[0].recyclerView != null) { + serverListFragments[0].scrollTo(scrollTo) + } + val fragment = supportFragmentManager.findFragmentById(R.id.cl_windscribe_main) + if (fragment is SearchFragment) { + fragment.scrollTo(scrollTo) + } + } + + override fun setAdapter(adapter: RegionsAdapter) { + if (serverListFragments[0].recyclerView != null) { + serverListFragments[0].clearErrors() + serverListFragments[0].recyclerView?.adapter = adapter + } + } + + override fun setConfigLocListAdapter(configLocListAdapter: ConfigAdapter?) { + if (serverListFragments[4].recyclerView != null) { + serverListFragments[4].clearErrors() + serverListFragments[4].recyclerView?.adapter = configLocListAdapter + serverListFragments[4].addSwipeListener() + } + } + + override fun setConnectionStateText(connectionStateText: String) { + runOnUiThread { connectionState?.text = connectionStateText } + } + + private fun setConnectionUIState(state: ConnectionUiState?) { + uiConnectionState = state + } + + override fun setCountryFlag(flagIconResource: Int) { + setupLayoutForAppFlagBackground() + clearFlagAnimation() + lastFlag = flagIconResource + flagView?.setImageResource(flagIconResource) + flagView?.isFocusable = true + } + + override fun setFavouriteAdapter(favouriteAdapter: FavouriteAdapter?) { + if (serverListFragments[1].recyclerView != null) { + serverListFragments[1].clearErrors() + serverListFragments[1].recyclerView?.adapter = favouriteAdapter + } + } + + override fun setIpAddress(ipAddress: String) { + runOnUiThread { textViewIpAddress?.text = ipAddress } + } + + override fun setIpBlur(blur: Boolean) { + textViewIpAddress?.let { + it.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + if (blur) { + val radius = it.textSize / 3 + val filter = BlurMaskFilter(radius, BlurMaskFilter.Blur.NORMAL) + it.paint.maskFilter = filter + } else { + it.paint.maskFilter = null + } + } + } + + override fun setNetworkNameBlur(blur: Boolean) { + textViewConnectedNetworkName?.let { + it.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + if (blur) { + val radius = it.textSize / 3 + val filter = BlurMaskFilter(radius, BlurMaskFilter.Blur.NORMAL) + it.paint.maskFilter = filter + } else { + it.paint.maskFilter = null + } + } + } + + override fun setLastConnectionState(state: ConnectionUiState) { + setConnectionUIState(state) + setConnectionState(state) + } + + override fun setMainConstraints(customBackground: Boolean) { + constraintSetMain.clone(constraintLayoutMain) + if (customBackground) { + constraintSetMain.connect( + R.id.connection_gradient, + ConstraintSet.BOTTOM, + R.id.toolbar_windscribe, + ConstraintSet.BOTTOM + ) + } else { + constraintSetMain.connect( + R.id.connection_gradient, + ConstraintSet.BOTTOM, + R.id.cl_preferred_protocol, + ConstraintSet.TOP + ) + } + constraintSetMain.setVisibility( + R.id.top_gradient, + if (customBackground) ConstraintSet.INVISIBLE else ConstraintSet.VISIBLE + ) + topGradient?.visibility = if (customBackground) View.INVISIBLE else View.VISIBLE + constraintSetMain.setVisibility( + R.id.top_gradient_custom, + if (customBackground) ConstraintSet.VISIBLE else ConstraintSet.INVISIBLE + ) + findViewById(R.id.top_gradient_custom).visibility = + if (customBackground) View.VISIBLE else View.INVISIBLE + constraintSetMain.setVerticalBias(R.id.cl_flag, if (customBackground) 0.0f else 1.0f) + constraintSetMain.applyTo(constraintLayoutMain) + constraintSetServerList.clone(constraintLayoutServerList) + } + + override fun setNetworkLayout( + info: NetworkInfo?, state: NetworkLayoutState?, resetAdapter: Boolean + ) { + if (clPreferredProtocol?.layoutTransition?.isRunning == true) { + return + } + if (resetAdapter) { + protocolAdapter = null + portAdapter = null + } + when (state) { + NetworkLayoutState.CLOSED -> setNetworkLayoutCollapsed(info) + NetworkLayoutState.OPEN_1 -> setNetworkLayoutExpandedLevel1(info) + NetworkLayoutState.OPEN_2 -> setNetworkLayoutExpandedLevel2(info) + NetworkLayoutState.OPEN_3 -> setNetworkLayoutExpandedLevel3(info) + else -> {} + } + } + + private fun setNetworkLayoutCollapsed(networkInfo: NetworkInfo?) { + if (networkInfo != null) { + networkIcon?.setImageDrawable( + if (networkInfo.isAutoSecureOn) getDrawableFromTheme(R.drawable.ic_wifi_secure) else getDrawableFromTheme( + R.drawable.ic_wifi_unsecure + ) + ) + textViewConnectedNetworkName?.text = networkInfo.networkName + } else { + networkIcon?.setImageDrawable(getDrawableFromTheme(R.drawable.ic_wifi_secure)) + textViewConnectedNetworkName?.text = + if (WindUtilities.isOnline()) "Unknown Network" else getString(R.string.no_internet) + } + val checkForReconnect = + (networkLayoutState == NetworkLayoutState.OPEN_3 || networkLayoutState == NetworkLayoutState.OPEN_2) + networkLayoutState = NetworkLayoutState.CLOSED + animateBottomGradient(true) + collapseExpandExpandIcon?.animate()?.rotation(0f)?.alpha(0.5f)?.setDuration(300) + ?.withEndAction { presenter.onNetworkLayoutCollapsed(checkForReconnect) }?.start() + networkIcon?.alpha = 0.5f + lockIcon?.alpha = 1.0f + textViewIpAddress?.alpha = 0.5f + textViewConnectedNetworkName?.alpha = 0.5f + autoSecureDivider?.visibility = View.GONE + clAutoSecure?.visibility = View.GONE + clPreferred?.visibility = View.GONE + clProtocol?.visibility = View.GONE + clPort?.visibility = View.GONE + portProtocolDivider?.visibility = View.GONE + portAdapter = null + protocolAdapter = null + } + + private fun setNetworkLayoutExpandedLevel1(networkInfo: NetworkInfo?) { + networkLayoutState = NetworkLayoutState.OPEN_1 + animateBottomGradient(false) + collapseExpandExpandIcon?.animate()?.rotation(-180f)?.alpha(1.0f)?.setDuration(300)?.start() + autoSecureToggle?.setImageDrawable( + if (networkInfo?.isAutoSecureOn == true) getDrawableFromTheme(R.drawable.ic_toggle_button_on) else getDrawableFromTheme( + R.drawable.ic_toggle_button_off_dark + ) + ) + preferredProtocolToggle?.setImageDrawable( + if (networkInfo?.isPreferredOn == true) getDrawableFromTheme(R.drawable.ic_toggle_button_on) else getDrawableFromTheme( + R.drawable.ic_toggle_button_off_dark + ) + ) + networkIcon?.setImageDrawable( + if (networkInfo?.isAutoSecureOn == true) getDrawableFromTheme(R.drawable.ic_wifi_secure) else getDrawableFromTheme( + R.drawable.ic_wifi_unsecure + ) + ) + textViewConnectedNetworkName?.text = networkInfo?.networkName + networkIcon?.alpha = 1.0f + lockIcon?.alpha = 0.0f + textViewIpAddress?.alpha = 0.0f + textViewConnectedNetworkName?.alpha = 1.0f + clAutoSecure?.visibility = View.VISIBLE + autoSecureDivider?.visibility = + if (networkInfo?.isAutoSecureOn == true) View.VISIBLE else View.GONE + clPreferred?.visibility = + if (networkInfo?.isAutoSecureOn == true) View.VISIBLE else View.GONE + clProtocol?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + clPort?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + portProtocolDivider?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + portAdapter = null + protocolAdapter = null + } + + private fun setNetworkLayoutExpandedLevel2(networkInfo: NetworkInfo?) { + networkLayoutState = NetworkLayoutState.OPEN_2 + animateBottomGradient(false) + collapseExpandExpandIcon?.animate()?.rotation(-180f)?.alpha(1.0f)?.setDuration(300)?.start() + autoSecureToggle?.setImageDrawable( + if (networkInfo?.isAutoSecureOn == true) getDrawableFromTheme(R.drawable.ic_toggle_button_on) else getDrawableFromTheme( + R.drawable.ic_toggle_button_off_dark + ) + ) + preferredProtocolToggle?.setImageDrawable( + if (networkInfo?.isPreferredOn == true) getDrawableFromTheme(R.drawable.ic_toggle_button_on) else getDrawableFromTheme( + R.drawable.ic_toggle_button_off_dark + ) + ) + networkIcon?.setImageDrawable( + if (networkInfo?.isAutoSecureOn == true) getDrawableFromTheme(R.drawable.ic_wifi_secure) else getDrawableFromTheme( + R.drawable.ic_wifi_unsecure + ) + ) + textViewConnectedNetworkName?.text = networkInfo?.networkName + networkIcon?.alpha = 1.0f + lockIcon?.alpha = 0.0f + textViewIpAddress?.alpha = 0.0f + textViewConnectedNetworkName?.alpha = 1.0f + clAutoSecure?.visibility = View.VISIBLE + autoSecureDivider?.visibility = + if (networkInfo?.isAutoSecureOn == true) View.VISIBLE else View.GONE + clPreferred?.visibility = + if (networkInfo?.isAutoSecureOn == true) View.VISIBLE else View.GONE + clProtocol?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + clPort?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + portProtocolDivider?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + } + + private fun setNetworkLayoutExpandedLevel3(networkInfo: NetworkInfo?) { + if (protocolAdapter == null) { + if (presenter.isConnectedOrConnecting && networkLayoutState == NetworkLayoutState.OPEN_2) { + networkInfo?.protocol = presenter.selectedProtocol + networkInfo?.port = presenter.selectedPort + } + if (networkInfo != null) { + presenter.setProtocolAdapter(networkInfo.protocol) + } + } + networkLayoutState = NetworkLayoutState.OPEN_3 + animateBottomGradient(false) + collapseExpandExpandIcon?.animate()?.rotation(-180f)?.alpha(1.0f)?.setDuration(300)?.start() + autoSecureToggle?.setImageDrawable( + if (networkInfo?.isAutoSecureOn == true) getDrawableFromTheme(R.drawable.ic_toggle_button_on) else getDrawableFromTheme( + R.drawable.ic_toggle_button_off_dark + ) + ) + preferredProtocolToggle?.setImageDrawable( + if (networkInfo?.isPreferredOn == true) getDrawableFromTheme(R.drawable.ic_toggle_button_on) else getDrawableFromTheme( + R.drawable.ic_toggle_button_off_dark + ) + ) + networkIcon?.setImageDrawable( + if (networkInfo?.isAutoSecureOn == true) getDrawableFromTheme(R.drawable.ic_wifi_secure) else getDrawableFromTheme( + R.drawable.ic_wifi_unsecure + ) + ) + textViewConnectedNetworkName?.text = networkInfo?.networkName + networkIcon?.alpha = 1.0f + lockIcon?.alpha = 0.0f + textViewIpAddress?.alpha = 0.0f + textViewConnectedNetworkName?.alpha = 1.0f + clAutoSecure?.visibility = View.VISIBLE + autoSecureDivider?.visibility = + if (networkInfo?.isAutoSecureOn == true) View.VISIBLE else View.GONE + clPreferred?.visibility = + if (networkInfo?.isAutoSecureOn == true) View.VISIBLE else View.GONE + clProtocol?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + clPort?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + portProtocolDivider?.visibility = + if (networkInfo?.isAutoSecureOn == true && networkInfo.isPreferredOn) View.VISIBLE else View.GONE + } + + override fun setPortAndProtocol(protocol: String, port: String) { + this.protocol?.text = protocol + this.port?.text = port + } + + override fun setRefreshLayout(refreshing: Boolean) { + runOnUiThread { + serverListFragments.let { + for (serverListFragment in it) { + serverListFragment.setRefreshingLayout(refreshing) + } + } + } + } + + private fun setRefreshLayoutEnabled(enabled: Boolean) { + runOnUiThread { + serverListFragments.let { + if (it.isNotEmpty()) { + for (serverListFragment in it) { + serverListFragment.setSwipeRefreshLayoutEnabled(enabled) + } + } + } + } + } + + override fun setServerListToolbarElevation(elevation: Int) { + serverListToolbar?.elevation = elevation.toFloat() + } + + override fun setStaticRegionAdapter(staticRegionAdapter: StaticRegionAdapter) { + serverListFragments.let { + if (it[3].recyclerView != null) { + it[3].clearErrors() + it[3].recyclerView?.adapter = staticRegionAdapter + } + } + } + + override fun setStreamingNodeAdapter(streamingNodeAdapter: StreamingNodeAdapter) { + serverListFragments.let { + if (it[2].recyclerView != null) { + it[2].clearErrors() + it[2].recyclerView?.adapter = streamingNodeAdapter + } + } + } + + override fun setUpLayoutForNodeUnderMaintenance(isStaticLocation: Boolean) { + NodeStatusDialog.show(this, isStaticLocation) + } + + override fun setupAccountStatusBanned() { + AccountStatusDialogData( + title = resources.getString(R.string.you_ve_been_banned), + icon = R.drawable.garry_angry, + description = resources.getString(R.string.you_ve_violated_our_terms), + showSkipButton = false, + skipText = "", + showUpgradeButton = true, + upgradeText = resources.getString(R.string.ok), + bannedLayout = true + ).let { + AccountStatusDialog.show(this, it) + } + } + + override fun setupAccountStatusExpired(resetDate: String) { + AccountStatusDialogData( + title = resources.getString(R.string.you_re_out_of_data), + icon = R.drawable.garry_nodata, + description = resources.getString(R.string.upgrade_to_stay_protected, resetDate), + showSkipButton = true, + skipText = resources.getString(R.string.upgrade_later), + showUpgradeButton = true, + upgradeText = resources.getString(R.string.upgrade), + ).let { + AccountStatusDialog.show(this, it) + } + } + + override fun setupAccountStatusOkay() { + AccountStatusDialog.hide(this) + } + + override fun setupLayoutConnected(state: ConnectedState) { + setConnectionUIState(state) + runOnUiThread { + lockIcon?.setImageResource(state.lockIconResource) + setToolBarColors(state.flagGradientEndColor) + lockIcon?.setImageResource(state.lockIconResource) + setOnOffButton(state) + setConnectionState(state) + constraintSetMain.applyTo(constraintLayoutMain) + } + setRefreshLayoutEnabled(false) + } + + override fun setupLayoutConnecting(state: ConnectingState) { + setConnectionUIState(state) + runOnUiThread { + setToolBarColors(state.flagGradientEndColor) + lockIcon?.setImageResource(state.lockIconResource) + setOnOffButton(state) + setConnectionState(state) + constraintSetMain.applyTo(constraintLayoutMain) + } + setRefreshLayoutEnabled(false) + } + + override fun setupLayoutDisconnected(connectionState: DisconnectedState) { + setConnectionUIState(connectionState) + runOnUiThread { + setToolBarColors(connectionState.flagGradientEndColor) + lockIcon?.setImageResource(connectionState.lockIconResource) + setOnOffButton(connectionState) + setConnectionState(connectionState) + if (connectionState.isCustomBackgroundEnabled) { + connectionState.disconnectedFlagPath?.let { + setupLayoutForCustomBackground(it) + } + } else { + setCountryFlag(connectionState.flag) + } + constraintSetMain.applyTo(constraintLayoutMain) + } + setRefreshLayoutEnabled(true) + } + + override fun setupLayoutDisconnecting( + connectionState: String, connectionStateTextColor: Int + ) { + runOnUiThread { + setConnectionStateText(connectionState) + this.connectionState?.setTextColor(connectionStateTextColor) + } + } + + override fun setupLayoutForCustomBackground(path: String) { + if (lastCustomBackgroundPath != null && lastCustomBackgroundPath == path) { + return + } + lastCustomBackgroundPath = path + Glide.with(this).load(path).skipMemoryCache(true) + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any, + target: Target, + isFirstResource: Boolean + ): Boolean { + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any, + target: Target, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + if (resource != null) { + flagView?.layoutParams?.height = flagDimensionsGuideView?.measuredHeight + flagView?.scaleType = ImageView.ScaleType.FIT_XY + } + constraintSetMain.setVisibility(R.id.top_gradient, ConstraintSet.INVISIBLE) + findViewById(R.id.top_gradient).visibility = View.INVISIBLE + constraintSetMain.setVisibility( + R.id.top_gradient_custom, ConstraintSet.VISIBLE + ) + findViewById(R.id.top_gradient_custom).visibility = View.VISIBLE + constraintSetMain.setVerticalBias(R.id.cl_flag, 0.0f) + constraintSetMain.connect( + R.id.connection_gradient, + ConstraintSet.BOTTOM, + R.id.toolbar_windscribe, + ConstraintSet.BOTTOM + ) + constraintSetMain.applyTo(constraintLayoutMain) + return false + } + }).diskCacheStrategy(DiskCacheStrategy.ALL) + .transition(DrawableTransitionOptions.with(drawableCrossFadeFactory)).into(flagView!!) + setTextShadows() + } + + override fun setupLayoutForFreeUser(dataLeft: String, upgradeLabel: String, color: Int) { + for (fragment in serverListFragments) { + fragment.showUpgradeLayout(color, upgradeLabel, dataLeft) + } + serverListFragments[3].hideUpgradeLayout() + serverListFragments[4].hideUpgradeLayout() + } + + override fun setupLayoutForProUser() { + serverListFragments.let { + for (fragment in it) { + fragment.hideUpgradeLayout() + } + } + } + + override fun setupLayoutForReconnect(connectionState: String, connectionStateTextColor: Int) { + runOnUiThread { + setConnectionStateText(connectionState) + this.connectionState?.setTextColor(connectionStateTextColor) + } + } + + override fun setupLayoutUnsecuredNetwork(uiState: ConnectionUiState) { + uiConnectionState = uiState + runOnUiThread { + runOnUiThread { + lockIcon?.setImageResource(uiState.lockIconResource) + setToolBarColors(uiState.flagGradientEndColor) + setOnOffButton(uiState) + setConnectionState(uiState) + constraintSetMain.setVisibility(R.id.on_off_progress_bar, ConstraintSet.GONE) + constraintSetMain.setVisibility(R.id.on_off_progress_bar, ConstraintSet.VISIBLE) + constraintSetMain.setVisibility(R.id.on_off_ring, ConstraintSet.GONE) + } + } + } + + override fun setupPortMapAdapter(savedPort: String, ports: List) { + portAdapter = ArrayAdapter(this, R.layout.drop_down_layout, R.id.tv_drop_down, ports) + portSpinner?.adapter = portAdapter + portSpinner?.isSelected = false + portAdapter?.let { + portSpinner?.setSelection(it.getPosition(savedPort)) + } + currentPort?.text = savedPort + } + + override fun setupProtocolAdapter(savedProtocol: String, protocols: Array) { + protocolAdapter = + ArrayAdapter(this, R.layout.drop_down_layout, R.id.tv_drop_down, protocols) + protocolAdapter?.let { + protocolSpinner?.adapter = protocolAdapter + protocolSpinner?.isSelected = false + protocolSpinner?.setSelection(it.getPosition(savedProtocol)) + currentProtocol?.text = savedProtocol + } + } + + override fun setupSearchLayout( + groups: List>, + serverListData: ServerListData, + listViewClickListener: ListViewClickListener + ) { + val fragment = supportFragmentManager.findFragmentById(R.id.cl_windscribe_main) + if (fragment is SearchFragment) { + return + } + try { + val searchFragment = + SearchFragment.newInstance(groups, serverListData, listViewClickListener) + searchFragment.enterTransition = Slide(Gravity.BOTTOM).addTarget(R.id.search_layout) + supportFragmentManager.beginTransaction() + .replace(R.id.cl_windscribe_main, searchFragment).addToBackStack(null).commit() + } catch (e: IllegalStateException) { + logger.error("Illegal state to add search layout.") + } + } + + override fun showConfigLocAdapterLoadError(errorText: String, configCount: Int) { + serverListFragments.let { + if (it[4].recyclerView != null) { + it[4].setAddMoreConfigLayout(errorText, configCount) + } + } + } + + override fun showDialog(message: String) { + val alertDialog = AlertDialog.Builder(this, R.style.alert_dialog_theme).setCancelable(true) + .setMessage(message) + .setPositiveButton(getString(R.string.ok)) { dialog: DialogInterface, _: Int -> dialog.cancel() } + .create() + alertDialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + alertDialog.show() + } + + override fun showFavouriteAdapterLoadError(errorText: String) { + serverListFragments.let { + if (it[1].recyclerView != null) { + it[1].setErrorNoItems(errorText) + } + } + } + + override fun showListBarSelectTransition(resourceSelected: Int) { + runOnUiThread { + constraintSetServerList.connect( + R.id.img_server_list_selection_mask, + ConstraintSet.START, + resourceSelected, + ConstraintSet.START + ) + constraintSetServerList.connect( + R.id.img_server_list_selection_mask, + ConstraintSet.END, + resourceSelected, + ConstraintSet.END + ) + when (resourceSelected) { + R.id.img_server_list_all -> setBarSelected( + serverAll = true, + favNav = false, + flixLoc = false, + staticIp = false, + configLoc = false + ) + + R.id.img_server_list_favorites -> setBarSelected( + serverAll = false, + favNav = true, + flixLoc = false, + staticIp = false, + configLoc = false + ) + + R.id.img_server_list_flix -> setBarSelected( + serverAll = false, + favNav = false, + flixLoc = true, + staticIp = false, + configLoc = false + ) + + R.id.img_static_ip_list -> setBarSelected( + serverAll = false, + favNav = false, + flixLoc = false, + staticIp = true, + configLoc = false + ) + + R.id.img_config_loc_list -> setBarSelected( + serverAll = false, + favNav = false, + flixLoc = false, + staticIp = false, + configLoc = true + ) + } + transition = AutoTransition() + transition?.duration = AnimConstants.CONNECTION_MODE_ANIM_DURATION + android.transition.TransitionManager.beginDelayedTransition( + constraintLayoutServerList, transition + ) + constraintSetServerList.applyTo(constraintLayoutServerList) + } + } + + override fun showNotificationCount(count: Int) { + if (count > 0) { + notificationCountView?.text = count.toString() + notificationCountView?.visibility = View.VISIBLE + } else { + notificationCountView?.visibility = View.INVISIBLE + } + } + + override fun showRecyclerViewProgressBar() { + runOnUiThread { + serverListFragments.let { + if (it[0].recyclerView != null) { + it[0].clearErrors() + } + } + progressBarRecyclerView?.visibility = View.VISIBLE + val color = getColorFromTheme(R.attr.progressBarColor, R.color.colorWhite40) + progressBarRecyclerView?.indeterminateDrawable?.colorFilter = + PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + } + + override fun showReloadError(error: String) { + runOnUiThread { + serverListFragments.let { + if (it[0].recyclerView != null) { + it[0].setLoadRetry(error) + } + } + } + } + + override fun showStaticIpAdapterLoadError( + errorText: String, buttonText: String, deviceName: String + ) { + serverListFragments.let { + if (it[3].recyclerView != null) { + it[3].setErrorNoStaticIp(buttonText, errorText, deviceName) + } + } + } + + override fun showToast(toastMessage: String) { + Toast.makeText(this, toastMessage, Toast.LENGTH_SHORT).show() + } + + override fun startVpnConnectedAnimation(state: ConnectedAnimationState) { + setConnectionUIState(state) + runOnUiThread { + lockIcon?.setImageResource(state.lockIconResource) + setOnOffButton(state) + setConnectionState(state) + constraintSetMain.applyTo(constraintLayoutMain) + clearFlagAnimation() + if (state.isCustomBackgroundEnabled) { + state.connectedFlagPath?.let { setupLayoutForCustomBackground(it) } + } else { + clearFlagAnimation() + flagView?.setImageResource(state.flag) + } + // Gradient Animation + val valueAnimator = ValueAnimator.ofFloat(0f, 1f) + valueAnimator.addUpdateListener { + val gradientColor = argbEvaluator.evaluate( + valueAnimator.animatedFraction, + state.flagGradientStartColor, + state.flagGradientEndColor + ) as Int + setToolBarColors(gradientColor) + connectionState?.setTextColor( + (argbEvaluator.evaluate( + valueAnimator.animatedFraction, + state.connectionStateStatusStartColor, + state.connectionStateStatusEndColor + ) as Int) + ) + } + valueAnimator.addListener(object : Animator.AnimatorListener { + override fun onAnimationCancel(animation: Animator) { + valueAnimator.removeAllListeners() + presenter.onConnectedAnimationCompleted() + } + + override fun onAnimationEnd(animation: Animator) { + valueAnimator.removeAllListeners() + presenter.onConnectedAnimationCompleted() + } + + override fun onAnimationRepeat(animation: Animator) {} + override fun onAnimationStart(animation: Animator) {} + }) + valueAnimator.duration = 1000 + valueAnimator.start() + } + } + + private var connectingAnimation: ValueAnimator? = null + override fun clearConnectingAnimation() { + connectingAnimation?.cancel() + } + + override fun startVpnConnectingAnimation(state: ConnectingAnimationState) { + setConnectionUIState(state) + runOnUiThread { + lockIcon?.setImageResource(state.lockIconResource) + setToolBarColors(state.flagGradientEndColor) + lockIcon?.setImageResource(state.lockIconResource) + setOnOffButton(state) + setConnectionState(state) + constraintSetMain.applyTo(constraintLayoutMain) + if (state.isCustomBackgroundEnabled) { + state.disconnectedFlagPath?.let { setupLayoutForCustomBackground(it) } + } else { + animateFlag(state) + } + // Gradient Animation + connectingAnimation = ValueAnimator.ofFloat(0f, 1f) + connectingAnimation?.let { valueAnimator -> + valueAnimator.addUpdateListener { + val gradientColor = argbEvaluator.evaluate( + valueAnimator.animatedFraction, + state.flagGradientStartColor, + state.flagGradientEndColor + ) as Int + setToolBarColors(gradientColor) + connectionState?.setTextColor( + (argbEvaluator.evaluate( + valueAnimator.animatedFraction, + state.connectionStateStatusStartColor, + state.connectionStateStatusEndColor + ) as Int) + ) + } + valueAnimator.addListener(object : Animator.AnimatorListener { + override fun onAnimationCancel(animation: Animator) { + valueAnimator.removeAllListeners() + if (uiConnectionState is ConnectingAnimationState) { + presenter.onConnectingAnimationCancelled() + } + } + + override fun onAnimationEnd(animation: Animator) { + valueAnimator.removeAllListeners() + if (uiConnectionState is ConnectingAnimationState) { + presenter.onConnectingAnimationCompleted() + } + } + + override fun onAnimationRepeat(animation: Animator) {} + override fun onAnimationStart(animation: Animator) {} + }) + valueAnimator.duration = VPN_CONNECTING_ANIMATION_DELAY + valueAnimator.start() + } + } + } + + override fun updateLocationName(nodeName: String, nodeNickName: String) { + logger.info("Updating selected location. Name: $nodeName Nickname: $nodeNickName") + textViewLocationName?.text = nodeName + textViewLocationNick?.text = nodeNickName + } + + override fun updateProgressView(text: String) { + runOnUiThread { + if (!customDialog.isShowing) { + customDialog.show() + customDialog.setOwnerActivity(this@WindscribeActivity) + customDialog.setCancelable(true) + customDialog.setCanceledOnTouchOutside(false) + } + customDialog.setTitle(text) + } + } + + override fun updateSearchAdapter(serverListData: ServerListData) { + val fragment = supportFragmentManager.findFragmentById(R.id.cl_windscribe_main) + if (fragment is SearchFragment) { + fragment.updateDataSet(serverListData) + } + } + + private fun animateBottomGradient(hide: Boolean) { + if (hide) { + Looper.myLooper()?.let { + Handler(it).postDelayed({ + bottomGradient?.clearAnimation() + bottomGradient?.animate()?.alpha(0.0f)?.setDuration(200)?.start() + }, 300) + } + } else { + bottomGradient?.clearAnimation() + bottomGradient?.animate()?.alpha(1.0f)?.setDuration(70)?.start() + } + } + + private fun animateFlag(state: ConnectionUiState) { + flagView?.clearAnimation() + flagView?.y = 0f + topGradient?.y = 0f + flagView?.alpha = if (state.isCustomBackgroundEnabled) 1.0f else 0.5f + if (lastFlag != state.flag) { + lastFlag = state.flag + flagView?.let { + it.animate().translationYBy(VPN_CONNECTING_ANIMATION_DELAY.toFloat()) + .setInterpolator(AccelerateInterpolator()) + .setDuration(AnimConstants.FLAG_IMAGE_ANIMATION_PERIOD).withEndAction { + it.setImageResource(state.flag) + it.animate().translationYBy((-1 * VPN_CONNECTING_ANIMATION_DELAY).toFloat()) + .setInterpolator(AccelerateInterpolator()).duration = + AnimConstants.FLAG_IMAGE_ANIMATION_PERIOD + } + } + + topGradient?.let { + it.animate().translationYBy(VPN_CONNECTING_ANIMATION_DELAY.toFloat()) + .setInterpolator(AccelerateInterpolator()) + .setDuration(AnimConstants.FLAG_IMAGE_ANIMATION_PERIOD).withEndAction { + it.animate().translationYBy((-1 * VPN_CONNECTING_ANIMATION_DELAY).toFloat()) + .setInterpolator(AccelerateInterpolator()).duration = + AnimConstants.FLAG_IMAGE_ANIMATION_PERIOD + } + } + } else { + flagView?.setImageResource(state.flag) + } + } + + private fun cancelRefreshing(ignore: Int) { + serverListFragments.let { + for (i in it.indices) { + if (i != ignore) { + it[i].setRefreshingLayout(false) + } + } + } + } + + private fun clearFlagAnimation() { + flagView?.clearAnimation() + topGradient?.clearAnimation() + flagView?.y = 0f + topGradient?.y = 0f + } + + private fun clearTextShadows() { + val shadowViews = arrayOf( + textViewConnectedNetworkName, protocol, port, textViewLocationName, textViewLocationNick + ) + for (view in shadowViews) { + view?.setShadowLayer(0f, 0f, 0f, resources.getColor(R.color.colorDeepBlue25)) + } + } + + private fun getDrawableFromTheme(resourceId: Int): Drawable? { + return ResourcesCompat.getDrawable(resources, resourceId, theme) + } + + private fun registerDataChangeObserver() { + preferenceChangeObserver.addConfigListObserver( + this + ) { presenter.loadConfigLocations() } + preferenceChangeObserver.addLanguageChangeObserver( + this + ) { presenter.onLanguageChanged() } + preferenceChangeObserver.addLocationSettingsChangeObserver(this) { + presenter.onLocationSettingsChanged() + } + preferenceChangeObserver.addAntiCensorShipStatusChangeObserver(this) { + presenter.onAntiCensorShipStatusChanged() + } + } + + private fun setBarSelected( + serverAll: Boolean, favNav: Boolean, flixLoc: Boolean, staticIp: Boolean, configLoc: Boolean + ) { + imgServerListAll?.isSelected = serverAll + imgServerListFavorites?.isSelected = favNav + imgServerListFlix?.isSelected = flixLoc + imgStaticIpList?.isSelected = staticIp + imgConfigLocList?.isSelected = configLoc + } + + private fun setConnectionState(state: ConnectionUiState) { + toolBarSquare?.setImageDrawable(state.headerBackgroundLeft) + slopedView?.setImageDrawable(state.headerBackgroundRight) + setProgressBarDrawable(connectionIcon, state.connectionStatusIcon) + setConnectionStateText(state.connectionStateStatusText) + connectionState?.setTextColor(state.connectionStateStatusEndColor) + connectionState?.background = state.connectionStatusBackground + protocol?.setTextColor(state.portAndProtocolEndTextColor) + port?.setTextColor(state.portAndProtocolEndTextColor) + connectionIcon?.isIndeterminate = state.rotateConnectingIcon() + connectionIcon?.visibility = state.connectingIconVisibility + preferredProtocolStatus?.setImageDrawable(state.preferredProtocolStatusDrawable) + preferredProtocolStatus?.visibility = state.preferredProtocolStatusVisibility + tvDecoy?.visibility = state.decoyTrafficBadgeVisibility + decoyDivider?.visibility = state.decoyTrafficBadgeVisibility + decoyArrow?.visibility = state.decoyTrafficBadgeVisibility + if (state.decoyTrafficBadgeVisibility != VISIBLE && state is ConnectedState) { + changeProtocolArrow?.visibility = VISIBLE + } else { + changeProtocolArrow?.visibility = GONE + } + antiCensorShipIcon?.visibility = state.antiCensorShipStatusVisibility + antiCensorShipIcon?.setImageDrawable(state.antiCensorShipStatusDrawable) + if (lastFlag != state.flag && state.isCustomBackgroundEnabled.not()) { + flagView?.setImageResource(state.flag) + } + constraintSetMain.setAlpha(R.id.tv_protocol, state.badgeViewAlpha) + } + + private fun setLastServerTabSelected() { + val lastIndex = presenter.lastSelectedTabIndex + locationFragmentViewPager?.currentItem = lastIndex + } + + private fun setOnOffButton(state: ConnectionUiState) { + setProgressBarDrawable(onOffProgressBar, state.progressRingResource) + onOffButton?.setImageResource(state.onOffButtonResource) + onOffProgressBar?.visibility = state.progressRingVisibility + constraintSetMain.setVisibility(R.id.on_off_ring, state.connectedCenterIconVisibility) + constraintSetMain.setVisibility(R.id.on_off_progress_bar, state.progressRingVisibility) + } + + private fun setProgressBarDrawable(progressBar: ProgressBar?, drawable: Drawable?) { + progressBar?.let { + val bounds = + progressBar.indeterminateDrawable.bounds // re-use bounds from current drawable + progressBar.indeterminateDrawable = drawable // set new drawable + progressBar.indeterminateDrawable.bounds = bounds + } + } + + private fun setServerListView(reload: Boolean) { + val pagerAdapter = ServerListFragmentPager( + supportFragmentManager, serverListFragments + ) + locationFragmentViewPager?.offscreenPageLimit = 4 + locationFragmentViewPager?.adapter = pagerAdapter + locationFragmentViewPager?.addOnPageChangeListener(this) + serverListFragments.let { + for (item in it) { + item.setFragmentClickListener(this) + } + } + setLastServerTabSelected() + constraintLayoutMain?.let { + it.viewTreeObserver.addOnGlobalLayoutListener(object : + ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + it.viewTreeObserver.removeOnGlobalLayoutListener(this) + if (!reload) { + logger.debug("Activity layout drawing completed.") + presenter.init() + } + //Set adapters if they were created before view was ready. + presenter.setAdapters() + } + }) + } + } + + private fun setTextShadows() { + val shadowViews = arrayOf( + textViewConnectedNetworkName, protocol, port, textViewLocationName, textViewLocationNick + ) + for (view in shadowViews) { + view?.setShadowLayer(0.01f, 0f, 6f, resources.getColor(R.color.colorDeepBlue25)) + } + } + + private fun setToolBarColors(gradientColor: Int) { + connectionGradient?.let { + val drawable = it.drawable as GradientDrawable + drawable.colors = + intArrayOf(resources.getColor(android.R.color.transparent), gradientColor) + } + } + + private fun setupLayoutForAppFlagBackground() { + lastCustomBackgroundPath = null + flagView?.let { + it.layoutParams.height = ConstraintSet.WRAP_CONTENT + it.scaleType = ImageView.ScaleType.FIT_CENTER + } + constraintSetMain.connect( + R.id.connection_gradient, + ConstraintSet.BOTTOM, + R.id.cl_preferred_protocol, + ConstraintSet.TOP + ) + constraintSetMain.setVisibility(R.id.top_gradient_custom, ConstraintSet.INVISIBLE) + findViewById(R.id.top_gradient_custom).visibility = View.INVISIBLE + constraintSetMain.setVisibility(R.id.top_gradient, ConstraintSet.VISIBLE) + findViewById(R.id.top_gradient).visibility = View.VISIBLE + constraintSetMain.setVerticalBias(R.id.cl_flag, 1.0f) + constraintSetMain.applyTo(constraintLayoutMain) + clearTextShadows() + } + + override fun setDecoyTrafficInfoVisibility(visibility: Int) { + decoyArrow?.visibility = visibility + decoyDivider?.visibility = visibility + tvDecoy?.visibility = visibility + } + + override fun showShareLinkDialog() { + ShareAppLinkDialog.show(this) + } + + @OnClick(R.id.tv_decoy_label, R.id.img_decoy_traffic_arrow) + fun onDecoyTrafficClick() { + presenter.onDecoyTrafficClick() + } + + @OnClick(R.id.img_protocol_change_arrow) + fun onProtocolChangeClick() { + presenter.onProtocolChangeClick() + } + + override fun setCensorShipIconVisibility(visible: Int) { + antiCensorShipIcon?.visibility = visible + } + + @RequiresApi(Build.VERSION_CODES.M) + override fun launchBatteryOptimizationActivity() { + PowerWhitelistDialog.show(this) + } + + private val addToPowerWhitelist = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { _: ActivityResult? -> } + + override fun neverAskPowerWhiteListPermissionAgain() { + presenter.neverAskPowerWhiteListPermissionAgain() + } + + override fun askPowerWhiteListPermissionLater() { + presenter.askPowerWhiteListPermissionLater() + } + + override fun askForPowerWhiteListPermission() { + val intent = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:$packageName") + ) + addToPowerWhitelist.launch(intent) + } + + companion object { + @JvmStatic + fun getStartIntent(context: Context): Intent { + return Intent(context, WindscribeActivity::class.java) + } + } + +} diff --git a/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribePresenter.kt b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribePresenter.kt new file mode 100644 index 000000000..9b71be18f --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribePresenter.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.windscribe + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.windscribe.vpn.serverlist.entity.ConfigFile + +interface WindscribePresenter { + val lastSelectedTabIndex: Int + val selectedPort: String + val selectedProtocol: String + fun handlePushNotification(extras: Bundle?) + fun init() + val isConnectedOrConnecting: Boolean + val isHapticFeedbackEnabled: Boolean + fun loadConfigLocations() + fun logoutFromCurrentSession() + suspend fun observeNextProtocolToConnect() + suspend fun observeVPNState() + fun onAddConfigLocation() + fun onAddStaticIPClicked() + fun onAutoSecureInfoClick() + fun onAutoSecureToggleClick() + fun onCheckNodeStatusClick() + fun onConfigFileContentReceived( + name: String, + content: String, + username: String, + password: String + ) + + fun onConnectClicked() + fun onConnectedAnimationCompleted() + fun onConnectingAnimationCompleted() + fun onDestroy() + fun onDisconnectIntentReceived() + fun onHotStart() + fun onIpClicked() + fun onLanguageChanged() + fun onMenuButtonClicked() + fun onNetworkLayoutCollapsed(checkForReconnect: Boolean) + fun onNetworkStateChanged() + fun onNewsFeedItemClick() + fun onPortSelected(port: String) + fun onPreferredProtocolInfoClick() + fun onPreferredProtocolToggleClick() + fun onProtocolSelected(protocol: String) + fun onRefreshPingsForAllServers() + fun onRefreshPingsForConfigServers() + fun onRefreshPingsForFavouritesServers() + fun onRefreshPingsForStaticServers() + fun onRefreshPingsForStreamingServers() + fun onReloadClick() + fun onRenewPlanClicked() + fun onSearchButtonClicked() + fun onShowAllServerListClicked() + fun onShowConfigLocListClicked() + fun onShowFavoritesClicked() + fun onShowFlixListClicked() + fun onShowStaticIpListClicked() + fun onStart() + fun onUpgradeClicked() + fun registerNetworkInfoListener() + fun reloadNetworkInfo() + fun saveLastSelectedTabIndex(index: Int) + fun saveRateDialogPreference(type: Int) + fun setMainCustomConstraints() + fun setProtocolAdapter(protocol: String) + fun setTheme(context: Context) + fun onCollapseExpandIconClick() + fun updateConfigFile(configFile: ConfigFile) + fun updateConfigFileConnect(configFile: ConfigFile) + fun updateLatency() + fun userHasAccess(): Boolean + fun observeUserData(windscribeActivity: WindscribeActivity) + suspend fun observeStaticRegions() + suspend fun observeAllLocations() + suspend fun observerSelectedLocation() + suspend fun observeDecoyTrafficState() + suspend fun observeLatency() + suspend fun observeLocationUIInvalidation() + fun setAdapters() + fun onNetworkNameClick() + fun loadConfigFile(data: Intent) + fun onDecoyTrafficClick() + fun onProtocolChangeClick() + suspend fun observeConnectedProtocol() + suspend fun showShareLinkDialog() + fun onLocationSettingsChanged() + fun checkForWgIpChange() + fun checkPendingAccountUpgrades() + fun onAntiCensorShipStatusChanged() + fun onConnectingAnimationCancelled() + suspend fun observeConnectionCount() + fun neverAskPowerWhiteListPermissionAgain() + fun askPowerWhiteListPermissionLater() +} diff --git a/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribePresenterImpl.kt b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribePresenterImpl.kt new file mode 100644 index 000000000..a42e7bdfc --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribePresenterImpl.kt @@ -0,0 +1,2294 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.windscribe + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.location.LocationManager +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.util.Pair +import android.view.View +import androidx.documentfile.provider.DocumentFile +import androidx.recyclerview.widget.RecyclerView +import com.google.common.io.CharStreams +import com.windscribe.mobile.R +import com.windscribe.mobile.adapter.ConfigAdapter +import com.windscribe.mobile.adapter.FavouriteAdapter +import com.windscribe.mobile.adapter.RegionsAdapter +import com.windscribe.mobile.adapter.StaticRegionAdapter +import com.windscribe.mobile.adapter.StreamingNodeAdapter +import com.windscribe.mobile.connectionui.ConnectedAnimationState +import com.windscribe.mobile.connectionui.ConnectedState +import com.windscribe.mobile.connectionui.ConnectingAnimationState +import com.windscribe.mobile.connectionui.ConnectingState +import com.windscribe.mobile.connectionui.ConnectionOptions +import com.windscribe.mobile.connectionui.ConnectionOptionsBuilder +import com.windscribe.mobile.connectionui.DisconnectedState +import com.windscribe.mobile.connectionui.FailedProtocol +import com.windscribe.mobile.connectionui.UnsecuredProtocol +import com.windscribe.mobile.listeners.ProtocolClickListener +import com.windscribe.mobile.utils.PermissionManager +import com.windscribe.mobile.utils.UiUtil.getDataRemainingColor +import com.windscribe.mobile.windscribe.WindscribeActivity.NetworkLayoutState +import com.windscribe.vpn.ActivityInteractor +import com.windscribe.vpn.ActivityInteractorImpl.PortMapLoadCallback +import com.windscribe.vpn.Windscribe.Companion.appContext +import com.windscribe.vpn.api.response.PortMapResponse +import com.windscribe.vpn.api.response.PushNotificationAction +import com.windscribe.vpn.autoconnection.ProtocolInformation +import com.windscribe.vpn.backend.Util +import com.windscribe.vpn.backend.Util.getSavedLocation +import com.windscribe.vpn.backend.VPNState +import com.windscribe.vpn.backend.openvpn.OpenVPNConfigParser +import com.windscribe.vpn.backend.utils.LastSelectedLocation +import com.windscribe.vpn.backend.utils.ProtocolConfig +import com.windscribe.vpn.backend.utils.SelectedLocationType +import com.windscribe.vpn.commonutils.FlagIconResource +import com.windscribe.vpn.commonutils.WindUtilities +import com.windscribe.vpn.constants.NetworkKeyConstants +import com.windscribe.vpn.constants.NetworkKeyConstants.NODE_STATUS_URL +import com.windscribe.vpn.constants.NetworkKeyConstants.getWebsiteLink +import com.windscribe.vpn.constants.PreferencesKeyConstants +import com.windscribe.vpn.constants.PreferencesKeyConstants.AZ_LIST_SELECTION_MODE +import com.windscribe.vpn.constants.PreferencesKeyConstants.LATENCY_LIST_SELECTION_MODE +import com.windscribe.vpn.constants.PreferencesKeyConstants.PROTO_WIRE_GUARD +import com.windscribe.vpn.constants.UserStatusConstants +import com.windscribe.vpn.constants.UserStatusConstants.ACCOUNT_STATUS_OK +import com.windscribe.vpn.errormodel.WindError.Companion.instance +import com.windscribe.vpn.exceptions.BackgroundLocationPermissionNotAvailable +import com.windscribe.vpn.exceptions.NoLocationPermissionException +import com.windscribe.vpn.exceptions.NoNetworkException +import com.windscribe.vpn.exceptions.WindScribeException +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import com.windscribe.vpn.localdatabase.tables.PopupNotificationTable +import com.windscribe.vpn.localdatabase.tables.WindNotification +import com.windscribe.vpn.model.User +import com.windscribe.vpn.repository.LatencyRepository +import com.windscribe.vpn.serverlist.entity.City +import com.windscribe.vpn.serverlist.entity.CityAndRegion +import com.windscribe.vpn.serverlist.entity.ConfigFile +import com.windscribe.vpn.serverlist.entity.Favourite +import com.windscribe.vpn.serverlist.entity.Group +import com.windscribe.vpn.serverlist.entity.PingTime +import com.windscribe.vpn.serverlist.entity.RegionAndCities +import com.windscribe.vpn.serverlist.entity.ServerListData +import com.windscribe.vpn.serverlist.entity.StaticRegion +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener +import com.windscribe.vpn.serverlist.sort.ByCityName +import com.windscribe.vpn.serverlist.sort.ByConfigName +import com.windscribe.vpn.serverlist.sort.ByLatency +import com.windscribe.vpn.serverlist.sort.ByRegionName +import com.windscribe.vpn.serverlist.sort.ByStaticRegionName +import com.windscribe.vpn.services.DeviceStateService.Companion.enqueueWork +import com.windscribe.vpn.state.NetworkInfoListener +import inet.ipaddr.AddressStringException +import inet.ipaddr.IPAddressString +import io.reactivex.Completable +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.observers.DisposableCompletableObserver +import io.reactivex.observers.DisposableSingleObserver +import io.reactivex.schedulers.Schedulers +import io.reactivex.subscribers.DisposableSubscriber +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.IOException +import java.io.InputStreamReader +import java.util.Collections +import java.util.Date +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Consumer +import javax.inject.Inject + + +class WindscribePresenterImpl @Inject constructor( + private var windscribeView: WindscribeView, + private var interactor: ActivityInteractor, + private val permissionManager: PermissionManager +) : WindscribePresenter, ListViewClickListener, ProtocolClickListener, NetworkInfoListener { + + // Adapters + private var adapter: RegionsAdapter? = null + private var configAdapter: ConfigAdapter? = null + private var favouriteAdapter: FavouriteAdapter? = null + private var staticRegionAdapter: StaticRegionAdapter? = null + private var streamingNodeAdapter: StreamingNodeAdapter? = null + + // Connection + private var lastVPNState = VPNState.Status.Disconnected + private var selectedLocation: LastSelectedLocation? = null + private val flagIcons: Map = FlagIconResource.flagIcons + private var networkInformation: NetworkInfo? = null + private val onUserDataUpdate = AtomicBoolean() + private val logger = LoggerFactory.getLogger("basic") + private var connectingFromServerList = false + + override fun onStart() {} + + override fun onDestroy() { + interactor.getNetworkInfoManager().removeNetworkInfoListener(this) + if (!interactor.getCompositeDisposable().isDisposed) { + interactor.getCompositeDisposable().dispose() + } + streamingNodeAdapter = null + favouriteAdapter = null + staticRegionAdapter = null + adapter = null + } + + override fun observeUserData(windscribeActivity: WindscribeActivity) { + interactor.getUserRepository().user.observe(windscribeActivity) { + setAccountStatus(it) + setUserStatus(it) + } + } + + override suspend fun observeDecoyTrafficState() { + interactor.getDecoyTrafficController().state.collectLatest { + if (interactor.getVpnConnectionStateManager().isVPNActive()) { + if (it) { + windscribeView.setDecoyTrafficInfoVisibility(View.VISIBLE) + } else { + windscribeView.setDecoyTrafficInfoVisibility(View.GONE) + } + } + } + } + + override fun addToFavourite( + cityId: Int, + position: Int, + adapter: RecyclerView.Adapter + ) { + val favourite = Favourite() + favourite.id = cityId + interactor.getCompositeDisposable() + .add(interactor.addToFavourites(favourite).flatMap { interactor.getFavourites() } + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe({ favourites: List -> + resetAdapters( + favourites, + interactor.getResourceString(R.string.added_to_favourites), + position, + adapter + ) + }) { throwable: Throwable -> + logger.debug( + String.format( + "Failed to add to favourites. : %s", throwable.localizedMessage + ) + ) + windscribeView.showToast("Failed to add to favourites.") + }) + } + + override val lastSelectedTabIndex: Int + get() = interactor.getAppPreferenceInterface().lastSelectedTabIndex + + override fun deleteConfigFile(configFile: ConfigFile) { + interactor.getCompositeDisposable().add( + interactor.deleteConfigFile(configFile.getPrimaryKey()) + .observeOn(AndroidSchedulers.mainThread()).subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + interactor.getPreferenceChangeObserver().postConfigListChange() + logger.debug("Config deleted successfully") + windscribeView.showToast("Config deleted successfully") + } + + override fun onError(e: Throwable) { + logger.error(e.toString()) + windscribeView.showToast("Error deleting config file.") + } + }) + ) + } + + override fun editConfigFile(file: ConfigFile) { + windscribeView.openEditConfigFileDialog(file) + } + + val connectionOptions: ConnectionOptions = ConnectionOptionsBuilder().build() + + override val selectedPort: String + get() = interactor.getAppPreferenceInterface().selectedPort + override val selectedProtocol: String + get() = interactor.getAppPreferenceInterface().selectedProtocol + + override fun handlePushNotification(extras: Bundle?) { + if (extras != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + extras.keySet().forEach(Consumer { s: String -> + logger.debug("$s " + extras.getString(s, "----")) + }) + } + } + if (extras != null && extras.containsKey("type") && "promo" == extras.getString("type")) { + val pushNotificationAction = PushNotificationAction( + extras.getString("pcpid")!!, + extras.getString("promo_code")!!, + extras.getString("type")!! + ) + appContext.appLifeCycleObserver.pushNotificationAction = pushNotificationAction + logger.debug("App Launch by push notification with promo action. Taking user to upgrade") + windscribeView.openUpgradeActivity() + } + } + + override fun init() { + interactor.getAppPreferenceInterface().isReconnecting = false + // User data + onUserDataUpdate.set(false) + // Set ip from local Storage + setIpFromLocalStorage() + // Config locations + interactor.getPreferenceChangeObserver().postConfigListChange() + // Notifications + updateNotificationCount() + windscribeView.setIpBlur(interactor.getAppPreferenceInterface().blurIp) + windscribeView.setNetworkNameBlur(interactor.getAppPreferenceInterface().blurNetworkName) + addNotificationChangeListener() + calculateFlagDimensions() + interactor.getUserRepository().user.value?.let { + setUserStatus(it) + setAccountStatus(it) + } + } + + override fun setAdapters() { + adapter?.let { windscribeView.setAdapter(it) } + favouriteAdapter?.let { windscribeView.setFavouriteAdapter(it) } + streamingNodeAdapter?.let { windscribeView.setStreamingNodeAdapter(it) } + staticRegionAdapter?.let { windscribeView.setStaticRegionAdapter(it) } + configAdapter?.let { windscribeView.setConfigLocListAdapter(it) } + if (staticRegionAdapter == null) { + windscribeView.showStaticIpAdapterLoadError( + "No Static IP's", interactor.getResourceString(R.string.add_static_ip), "" + ) + } + } + + override val isConnectedOrConnecting: Boolean + get() { + val status = interactor.getVpnConnectionStateManager().state.value.status + return status === VPNState.Status.Connected || status === VPNState.Status.Connecting + } + override val isHapticFeedbackEnabled: Boolean + get() = interactor.getAppPreferenceInterface().isHapticFeedbackEnabled + + override fun loadConfigLocations() { + val serverListData = ServerListData() + interactor.getCompositeDisposable().add( + interactor.getAllPings().flatMap { pingTestResults: List -> + serverListData.pingTimes = pingTestResults + interactor.getAllConfigs() + }.onErrorResumeNext( + interactor.getAllConfigs() + ).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver>() { + override fun onError(e: Throwable) { + windscribeView.hideRecyclerViewProgressBar() + windscribeView.setConfigLocListAdapter(null) + logger.debug("Error getting config locations..") + windscribeView.showConfigLocAdapterLoadError( + interactor.getResourceString(R.string.no_custom_configs), 0 + ) + } + + override fun onSuccess(configFiles: List) { + val selection = interactor.getAppPreferenceInterface().selection + if (selection == LATENCY_LIST_SELECTION_MODE) { + Collections.sort(configFiles) { o1: ConfigFile, o2: ConfigFile -> + serverListData.pingTimes + getPingTimeFromCity( + o1.getPrimaryKey(), serverListData + ) - getPingTimeFromCity( + o2.getPrimaryKey(), serverListData + ) + } + } else if (selection == AZ_LIST_SELECTION_MODE) { + Collections.sort(configFiles, ByConfigName()) + } + serverListData.setShowLatencyInMs(interactor.getAppPreferenceInterface().showLatencyInMS) + serverListData.setShowLocationHealth( + interactor.getAppPreferenceInterface().isShowLocationHealthEnabled + ) + serverListData.flags = flagIcons + serverListData.isProUser = + interactor.getAppPreferenceInterface().userStatus == 1 + if (configFiles.isNotEmpty()) { + configAdapter = ConfigAdapter( + configFiles, serverListData, this@WindscribePresenterImpl + ) + windscribeView.setConfigLocListAdapter(configAdapter!!) + windscribeView.showConfigLocAdapterLoadError( + "", configFiles.size + ) + } else { + windscribeView.setConfigLocListAdapter(null) + configAdapter = null + windscribeView.showConfigLocAdapterLoadError( + interactor.getResourceString(R.string.no_custom_configs), 0 + ) + } + windscribeView.hideRecyclerViewProgressBar() + } + }) + ) + } + + override suspend fun observeAllLocations() { + interactor.getServerListUpdater().regions.collectLatest { + val updatedServerListHash = interactor.getAppPreferenceInterface().locationHash + if (adapter?.serverListData?.serverListHash != updatedServerListHash) { + if (it.isNotEmpty()) { + loadServerList(it.toMutableList(), updatedServerListHash) + } + } + } + } + + private val latencyAtomic = AtomicBoolean(true) + override suspend fun observeLatency() { + interactor.getLatencyRepository().latencyEvent.collectLatest { + if (latencyAtomic.getAndSet(false)) return@collectLatest + when (it.second) { + LatencyRepository.LatencyType.Servers -> { + interactor.getServerListUpdater().invalidateServerListUI() + } + + LatencyRepository.LatencyType.StaticIp -> { + interactor.getStaticListUpdater().load() + } + + LatencyRepository.LatencyType.Config -> { + loadConfigLocations() + } + } + } + } + + private fun loadServerList(regions: MutableList, serverListHash: String?) { + windscribeView.showRecyclerViewProgressBar() + val serverListData = ServerListData() + val oneTimeCompositeDisposable = CompositeDisposable() + oneTimeCompositeDisposable.add( + interactor.getAllPings().onErrorReturnItem(ArrayList()).flatMap { + serverListData.pingTimes = it + interactor.getFavourites() + }.onErrorReturnItem(ArrayList()).flatMap { + serverListData.favourites = it + interactor.getLocationProvider().bestLocation + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) { + windscribeView.hideRecyclerViewProgressBar() + val error = + if (e is WindScribeException) e.message else "Unknown error loading while loading server list." + windscribeView.showReloadError(error!!) + if (!oneTimeCompositeDisposable.isDisposed) { + oneTimeCompositeDisposable.dispose() + } + } + + override fun onSuccess(cityAndRegion: CityAndRegion) { + if (selectedLocation == null) { + val coordinatesArray = + cityAndRegion.city.coordinates.split(",".toRegex()).toTypedArray() + selectedLocation = LastSelectedLocation( + cityAndRegion.city.getId(), + cityAndRegion.city.nodeName, + cityAndRegion.city.nickName, + cityAndRegion.region.countryCode, + coordinatesArray[0], + coordinatesArray[1] + ) + } + updateLocationUI(selectedLocation, true) + serverListData.setShowLatencyInMs(interactor.getAppPreferenceInterface().showLatencyInMS) + serverListData.setShowLocationHealth( + interactor.getAppPreferenceInterface().isShowLocationHealthEnabled + ) + serverListData.serverListHash = serverListHash + serverListData.flags = flagIcons + serverListData.bestLocation = cityAndRegion + serverListData.isProUser = + interactor.getAppPreferenceInterface().userStatus == 1 + logger.debug(if (serverListData.isProUser) "Setting server list for pro user" else "Setting server list for free user") + setAllServerView(regions, serverListData) + setFavouriteServerView(serverListData) + if (!oneTimeCompositeDisposable.isDisposed) { + oneTimeCompositeDisposable.dispose() + } + } + }) + ) + } + + override suspend fun observeStaticRegions() { + interactor.getStaticListUpdater().regions.collectLatest { + loadStaticServers(it.toMutableList()) + } + } + + @SuppressLint("NotifyDataSetChanged") + fun loadStaticServers(regions: MutableList) { + interactor.getCompositeDisposable() + .add( + interactor.getAllPings().onErrorReturnItem(ArrayList()).flatMap { + val dataDetails = ServerListData() + dataDetails.pingTimes = it + dataDetails.setShowLatencyInMs(interactor.getAppPreferenceInterface().showLatencyInMS) + dataDetails.setShowLocationHealth( + interactor.getAppPreferenceInterface().isShowLocationHealthEnabled + ) + dataDetails.flags = flagIcons + dataDetails.isProUser = interactor.getAppPreferenceInterface().userStatus == 1 + Single.fromCallable { dataDetails } + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) { + logger.debug("Error loading static server list:$e") + } + + override fun onSuccess(serverListData: ServerListData) { + val selection = interactor.getAppPreferenceInterface().selection + if (selection == LATENCY_LIST_SELECTION_MODE) { + regions.sortWith { o1: StaticRegion, o2: StaticRegion -> + serverListData.pingTimes + getPingTimeFromCity( + o1.id, serverListData + ) - getPingTimeFromCity( + o2.id, serverListData + ) + } + } else if (selection == AZ_LIST_SELECTION_MODE) { + Collections.sort(regions, ByStaticRegionName()) + } + if (regions.size > 0) { + logger.debug("Setting static ip adapter with " + regions.size + " items.") + staticRegionAdapter = StaticRegionAdapter( + regions, serverListData, this@WindscribePresenterImpl + ) + staticRegionAdapter?.let { + windscribeView.setStaticRegionAdapter(it) + } + var deviceName = "" + if (regions[0].deviceName != null) { + deviceName = regions[0].deviceName + } + windscribeView.showStaticIpAdapterLoadError( + "", + interactor.getResourceString(R.string.add_static_ip), + deviceName + ) + } else { + staticRegionAdapter?.let { staticRegionAdapter -> + staticRegionAdapter.setStaticIpList(null) + staticRegionAdapter.notifyDataSetChanged() + } + logger.debug(if (staticRegionAdapter != null) "Removing static ip adapter." else "Setting no static ip error.") + windscribeView.showStaticIpAdapterLoadError( + "No Static IP's", + interactor.getResourceString(R.string.add_static_ip), + "" + ) + } + } + }) + ) + } + + override fun logoutFromCurrentSession() { + logger.debug("Logging user out of current session.") + interactor.getAppPreferenceInterface().clearAllData() + if (interactor.getVpnConnectionStateManager().isVPNActive()) { + logger.info("VPN is active, stopping the current connection...") + interactor.getMainScope().launch { interactor.getVPNController().disconnectAsync() } + windscribeView.gotoLoginRegistrationActivity() + } else { + windscribeView.gotoLoginRegistrationActivity() + } + } + + override suspend fun observeNextProtocolToConnect() { + interactor.getAutoConnectionManager().nextInLineProtocol.collectLatest { protocol -> + setProtocolAndPortOptions(protocol) + } + } + + private fun setCustomConfigPortAndProtocol() { + selectedLocation?.cityId?.let { + interactor.getConfigFile(it).flatMap { + return@flatMap Single.fromCallable { + if (WindUtilities.getConfigType(it.content) == WindUtilities.ConfigType.OpenVPN) { + Util.getProtocolInformationFromOpenVPNConfig(it.content) + } else { + Util.getProtocolInformationFromWireguardConfig(it.content) + } + } + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe { protocolInfo, error -> + if (error != null) { + logger.debug("Unable to get Protocol info from custom config. ${error.message}") + } else if (protocolInfo != null) { + windscribeView.setPortAndProtocol( + Util.getProtocolLabel(protocolInfo.protocol), protocolInfo.port + ) + } + } + } + } + + override suspend fun observeConnectedProtocol() { + interactor.getAutoConnectionManager().connectedProtocol.collectLatest { protocol -> + if (interactor.getVpnConnectionStateManager().isVPNActive()) { + protocol?.let { + updatePreferredProtocol(it) + windscribeView.setPortAndProtocol(Util.getProtocolLabel(it.protocol), it.port) + } + } + } + } + + private fun updatePreferredProtocol(protocol: ProtocolInformation) { + connectionOptions.isPreferred = isPreferred(protocol) + windscribeView.uiConnectionState?.let { state -> + state.connectionOptions = connectionOptions + windscribeView.setLastConnectionState(state) + } + } + + private fun isPreferred(selectedProtocol: ProtocolInformation): Boolean { + return interactor.getNetworkInfoManager().networkInfo?.let { + return if (WindUtilities.getSourceTypeBlocking() == SelectedLocationType.CustomConfiguredProfile) { + false + } else { + (it.isPreferredOn && selectedProtocol.protocol == it.protocol && selectedProtocol.port == it.port) + } + } ?: false + } + + override suspend fun observeVPNState() { + selectedLocation = Util.getLastSelectedLocation(appContext) + interactor.getVpnConnectionStateManager().state.collectLatest { vpnState -> + if (vpnState.status == VPNState.Status.Disconnected && interactor.getAppPreferenceInterface().isReconnecting && interactor.getAppPreferenceInterface().globalUserConnectionPreference) { + return@collectLatest + } + lastVPNState = vpnState.status + when (vpnState.status) { + VPNState.Status.Connected -> { + windscribeView.setRefreshLayout(false) + vpnState.ip?.let { + onVpnIpReceived(it) + } ?: kotlin.run { + onVpnIpReceived("--.--.--.--") + } + } + + VPNState.Status.Connecting -> onVPNConnecting() + VPNState.Status.Disconnected -> onVPNDisconnected() + VPNState.Status.Disconnecting -> onVPNDisconnecting() + VPNState.Status.RequiresUserInput -> onVpnRequiresUserInput() + VPNState.Status.InvalidSession -> windscribeView.gotoLoginRegistrationActivity() + VPNState.Status.ProtocolSwitch -> {} + VPNState.Status.UnsecuredNetwork -> onUnsecuredNetwork() + } + } + } + + override fun onAddConfigLocation() { + windscribeView.openFileChooser() + } + + override fun onAddStaticIPClicked() { + logger.info("Opening static ip URL...") + windscribeView.openStaticIPUrl(getWebsiteLink(NetworkKeyConstants.URL_ADD_STATIC_IP)) + } + + override fun onAutoSecureInfoClick() { + windscribeView.showDialog(interactor.getResourceString(R.string.auto_secure_description)) + } + + override fun onAutoSecureToggleClick() { + interactor.saveWhiteListedNetwork(true) + networkInformation?.let { + it.isAutoSecureOn = !it.isAutoSecureOn + interactor.getNetworkInfoManager().updateNetworkInfo(it) + } + } + + override fun onCheckNodeStatusClick() { + windscribeView.openNodeStatusPage(getWebsiteLink(NODE_STATUS_URL)) + } + + override fun onCityClick(cityId: Int) { + windscribeView.exitSearchLayout() + logger.debug("User clicked on city.") + selectedLocation?.cityId?.let { + if (it == cityId && (interactor.getVpnConnectionStateManager() + .isVPNActive() || connectingFromServerList) + ) { + return@let + } + connectingFromServerList = true + connectToCity(cityId) + } + } + + override fun onConfigFileClicked(configFile: ConfigFile) { + if (configFile.username == null && WindUtilities.getConfigType(configFile.content) == WindUtilities.ConfigType.OpenVPN) { + windscribeView.openProvideUsernameAndPasswordDialog(configFile) + } else { + connectToConfiguredLocation(configFile.getPrimaryKey()) + } + } + + override fun onConfigFileContentReceived( + name: String, content: String, username: String, password: String + ) { + val configFile = ConfigFile(0, name, content, username, password, true) + addConfigFileToDatabase(configFile) + } + + override fun onConnectClicked() { + logger.debug( + "Connection UI State: {} Last connection State: {}", + windscribeView.uiConnectionState?.javaClass?.simpleName, + lastVPNState + ) + interactor.getAutoConnectionManager().stop() + when (windscribeView.uiConnectionState) { + is ConnectingState -> { + stopVpnFromUI() + } + + is ConnectedState -> { + stopVpnFromUI() + } + + is ConnectedAnimationState -> {} + is ConnectingAnimationState -> {} + is FailedProtocol -> { + logger.debug("Stopping protocol switch service.") + interactor.getMainScope().launch { + interactor.getVPNController().disconnectAsync() + } + } + + is UnsecuredProtocol -> { + logger.debug("Stopping standby network service.") + stopVpnFromUI() + } + + else -> { + selectedLocation?.let { + logger.debug("Starting Connection.") + val sourceType = WindUtilities.getSourceTypeBlocking() + if (sourceType != null) { + when (sourceType) { + SelectedLocationType.StaticIp -> connectToStaticIp( + it.cityId + ) + + SelectedLocationType.CustomConfiguredProfile -> connectToConfiguredLocation( + it.cityId + ) + + SelectedLocationType.CityLocation -> connectToCity(it.cityId) + } + } + } ?: kotlin.run { + logger.debug("No saved location found. wait for server list to refresh.") + windscribeView.showToast("Server list is not ready.") + } + } + } + } + + override fun onConnectedAnimationCompleted() { + selectedLocation?.let { + windscribeView.setupLayoutConnected( + ConnectedState(it, connectionOptions, appContext) + ) + } + } + + override fun onConnectingAnimationCompleted() { + selectedLocation?.let { + windscribeView.setupLayoutConnecting( + ConnectingState( + it, connectionOptions, appContext + ) + ) + } + } + + override fun onConnectingAnimationCancelled() { + selectedLocation?.let { + windscribeView.setCountryFlag(FlagIconResource.getFlag(it.countryCode)) + windscribeView.setupLayoutConnecting( + ConnectingState( + it, connectionOptions, appContext + ) + ) + } + } + + override fun onDisconnectIntentReceived() { + stopVpnFromUI() + } + + override fun onHotStart() { + // setConnectionLayout(); + checkLoginStatus() + + // Update Notification count + updateNotificationCount() + + interactor.getPreferenceChangeObserver().postConfigListChange() + } + + override fun onIpClicked() { + val blurIp = !interactor.getAppPreferenceInterface().blurIp + interactor.getAppPreferenceInterface().blurIp = blurIp + windscribeView.setIpBlur(blurIp) + } + + override fun onNetworkNameClick() { + permissionManager.withForegroundLocationPermission { error -> + if (error != null) { + logger.debug(error) + } else { + val blurNetworkName = !interactor.getAppPreferenceInterface().blurNetworkName + interactor.getAppPreferenceInterface().blurNetworkName = blurNetworkName + windscribeView.setNetworkNameBlur(blurNetworkName) + } + } + } + + override fun onLanguageChanged() { + } + + // UI Items onClick Methods + override fun onMenuButtonClicked() { + windscribeView.performButtonClickHapticFeedback() + if (windscribeView.networkLayoutState != NetworkLayoutState.CLOSED) { + windscribeView.setNetworkLayout(networkInformation, NetworkLayoutState.CLOSED, true) + } + logger.debug("Opening main menu activity...") + windscribeView.openMenuActivity() + } + + override fun onNetworkInfoUpdate(networkInfo: NetworkInfo?, userReload: Boolean) { + networkInformation = networkInfo + if (networkInformation != null && userReload) { + if (networkInformation?.isAutoSecureOn != true) { + logger.debug("Setting closed Preferred layout.") + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.OPEN_1, false + ) + } else if (networkInformation?.isPreferredOn != true) { + logger.debug("Setting open 2 Preferred layout.") + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.OPEN_2, false + ) + } else { + logger.debug("Setting open 3 Preferred layout.") + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.OPEN_3, false + ) + } + } else { + logger.debug("Setting Closed Preferred layout.") + windscribeView.setNetworkLayout(networkInformation, NetworkLayoutState.CLOSED, true) + } + setProtocolAndPortOptions(interactor.getAutoConnectionManager().nextInLineProtocol.value) + } + + private fun setProtocolAndPortOptions(protocol: ProtocolInformation?) { + protocol?.let { + if (interactor.getVpnConnectionStateManager().isVPNActive().not()) { + updatePreferredProtocol(it) + if (WindUtilities.getSourceTypeBlocking() == SelectedLocationType.CustomConfiguredProfile) { + setCustomConfigPortAndProtocol() + } else { + windscribeView.setPortAndProtocol( + Util.getProtocolLabel(it.protocol), it.port + ) + } + } + } + } + + override fun onNetworkLayoutCollapsed(checkForReconnect: Boolean) { + if (checkForReconnect) { + logger.debug("Network Layout collapsed.") + val connectionPreference = + interactor.getAppPreferenceInterface().globalUserConnectionPreference + if (networkInformation != null && connectionPreference && WindUtilities.getSourceTypeBlocking() !== SelectedLocationType.CustomConfiguredProfile) { + if (isNetworkInfoChanged && (networkInformation?.isAutoSecureOn == true) && networkInformation?.isPreferredOn == true) { + if (interactor.getVpnConnectionStateManager().isVPNConnected()) { + interactor.getAppPreferenceInterface().globalUserConnectionPreference = true + logger.debug("Preferred protocol and port info change Now connecting.") + interactor.getMainScope().launch { + interactor.getAutoConnectionManager().connectInForeground() + } + } + } + } + } + enqueueWork(appContext) + } + + /* + * On every network change + * Add network to database + * Set Ui based on network + * */ + override fun onNetworkStateChanged() { + if (WindUtilities.isOnline() && !interactor.getVpnConnectionStateManager() + .isVPNActive() && !interactor.getAppPreferenceInterface().isReconnecting + ) { + interactor.getWorkManager().updateNodeLatencies() + } + setIpFromLocalStorage() + } + + override fun onNewsFeedItemClick() { + logger.debug("Opening news feed activity...") + windscribeView.openNewsFeedActivity(false, -1) + } + + override fun onPortSelected(port: String) { + networkInformation?.let { + it.port = port + interactor.getNetworkInfoManager().updateNetworkInfo(it) + } + } + + override fun onPreferredProtocolInfoClick() { + windscribeView.showDialog(interactor.getResourceString(R.string.preferred_protocol_description)) + } + + override fun onPreferredProtocolToggleClick() { + networkInformation?.let { + it.isPreferredOn = !it.isPreferredOn + interactor.getNetworkInfoManager().updateNetworkInfo(it) + } + } + + override fun onProtocolSelected(protocol: String) { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + portMapResponse.let { + for (portMap in portMapResponse.portmap) { + if (protocol == portMap.heading) { + networkInformation?.let { + it.protocol = portMap.protocol + windscribeView.setupPortMapAdapter( + it.port, portMap.ports + ) + interactor.getNetworkInfoManager().updateNetworkInfo(it) + } + } + } + } + } + }) + } + + override fun onProtocolSelected(protocolConfig: ProtocolConfig?) { + protocolConfig?.let { + interactor.getVPNController().connectAsync() + } + } + + override fun onRefreshPingsForAllServers() { + if (canNotUpdatePings()) { + return + } + logger.debug("Starting ping testing for all nodes.") + interactor.getActivityScope().launch { + withContext(interactor.getMainScope().coroutineContext) { + return@withContext interactor.getLatencyRepository().updateAllServerLatencies() + } + windscribeView.setRefreshLayout(false) + logger.debug("Ping testing finished successfully.") + } + } + + override fun onRefreshPingsForConfigServers() { + logger.debug("Starting ping testing for custom nodes.") + interactor.getActivityScope().launch { + withContext(interactor.getMainScope().coroutineContext) { + return@withContext interactor.getLatencyRepository().updateConfigLatencies() + } + windscribeView.setRefreshLayout(false) + logger.debug("Ping testing finished successfully.") + } + } + + override fun onRefreshPingsForFavouritesServers() { + if (canNotUpdatePings()) { + return + } + logger.debug("Starting ping testing for favourite nodes.") + interactor.getActivityScope().launch { + withContext(interactor.getMainScope().coroutineContext) { + return@withContext interactor.getLatencyRepository().updateFavouriteCityLatencies() + } + windscribeView.setRefreshLayout(false) + logger.debug("Ping testing finished successfully.") + } + } + + override fun onRefreshPingsForStaticServers() { + if (canNotUpdatePings()) { + return + } + logger.debug("Starting ping testing for static nodes.") + interactor.getActivityScope().launch { + withContext(interactor.getMainScope().coroutineContext) { + return@withContext interactor.getLatencyRepository().updateStaticIpLatency() + } + windscribeView.setRefreshLayout(false) + logger.debug("Ping testing finished successfully.") + } + } + + override fun onRefreshPingsForStreamingServers() { + if (canNotUpdatePings()) { + return + } + logger.debug("Starting ping testing for streaming nodes.") + interactor.getActivityScope().launch { + withContext(interactor.getMainScope().coroutineContext) { + return@withContext interactor.getLatencyRepository() + .updateStreamingServerLatencies() + } + windscribeView.setRefreshLayout(false) + logger.debug("Ping testing finished successfully.") + } + } + + /* + During destructive migration if application had no internet user can use reload the server list. + */ + override fun onReloadClick() { + logger.debug("User clicked on reload server list.") + windscribeView.showRecyclerViewProgressBar() + interactor.getMainScope().launch { interactor.getVPNController().disconnectAsync() } + interactor.getAppPreferenceInterface().setUserAccountUpdateRequired(true) + interactor.getCompositeDisposable().add( + interactor.getConnectionDataUpdater().update() + .andThen(interactor.getServerListUpdater().update()) + .andThen(Completable.fromAction { interactor.getUserRepository().reload() }) + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + windscribeView.hideRecyclerViewProgressBar() + logger.debug("Server list, connection data and static ip data is updated successfully.") + windscribeView.showToast("Updated successfully.") + interactor.getAppPreferenceInterface().migrationRequired = false + interactor.getAppPreferenceInterface().setUserAccountUpdateRequired(false) + } + + override fun onError(e: Throwable) { + windscribeView.hideRecyclerViewProgressBar() + logger.error("Server list update failed.$e") + windscribeView.showToast("Check your internet connection.") + windscribeView.showReloadError("Error loading server list") + } + }) + ) + } + + override fun onRenewPlanClicked() { + when (interactor.getUserAccountStatus()) { + ACCOUNT_STATUS_OK -> { + logger.info("Account status okay, opening upgrade activity...") + windscribeView.openUpgradeActivity() + } + + UserStatusConstants.ACCOUNT_STATUS_BANNED -> { + logger.info("Account status banned!") + windscribeView.showToast("(OnClick) Placeholder for learning more") + } + + UserStatusConstants.ACCOUNT_STATUS_EXPIRED -> { + logger.info("Account status is expired, opening upgrade activity...") + windscribeView.openUpgradeActivity() + } + } + } + + override fun onSearchButtonClicked() { + adapter?.let { adapter -> + adapter.groups?.let { + val searchGroups = adapter.groupsList + streamingNodeAdapter?.groupsList?.let { groupsList -> + searchGroups.addAll(groupsList) + } + windscribeView.setupSearchLayout( + searchGroups, adapter.serverListData, this@WindscribePresenterImpl + ) + } + } + } + + override fun onShowAllServerListClicked() { + windscribeView.showListBarSelectTransition(R.id.img_server_list_all) + } + + override fun onShowConfigLocListClicked() { + windscribeView.showListBarSelectTransition(R.id.img_config_loc_list) + resetConfigEditState() + } + + override fun onShowFavoritesClicked() { + windscribeView.showListBarSelectTransition(R.id.img_server_list_favorites) + } + + override fun onShowFlixListClicked() { + windscribeView.showListBarSelectTransition(R.id.img_server_list_flix) + } + + @SuppressLint("NotifyDataSetChanged") + private fun resetConfigEditState() { + var itemsBeingEdited = 0 + configAdapter?.configFiles?.filter { + it.type == 2 + }?.forEach { + it.type = 1 + itemsBeingEdited++ + } + if (itemsBeingEdited > 0) { + configAdapter?.notifyDataSetChanged() + } + } + + override fun onShowStaticIpListClicked() { + windscribeView.showListBarSelectTransition(R.id.img_static_ip_list) + } + + /* + * Connect to static IP + * @param StaticIpID + * */ + override fun onStaticIpClick(staticIpId: Int) { + logger.debug("User clicked on static ip from list") + connectToStaticIp(staticIpId) + } + + override fun onUnavailableRegion(isStaticIP: Boolean) { + windscribeView.exitSearchLayout() + windscribeView.setUpLayoutForNodeUnderMaintenance(isStaticIP) + } + + override fun onUpgradeClicked() { + logger.debug("Opening upgrade activity...") + windscribeView.openUpgradeActivity() + } + + private fun onVPNConnecting() { + windscribeView.setRefreshLayout(false) + selectedLocation?.let { + if (windscribeView.uiConnectionState !is ConnectingAnimationState) { + logger.debug("Changing UI state to connecting.") + windscribeView.startVpnConnectingAnimation( + ConnectingAnimationState( + it, connectionOptions, appContext + ) + ) + } else { + updateLocationUI(it, true) + } + } + } + + private fun onUnsecuredNetwork() { + selectedLocation?.let { + windscribeView.setupLayoutUnsecuredNetwork( + UnsecuredProtocol( + it, connectionOptions, appContext + ) + ) + } + } + + private fun onVPNDisconnected() { + connectingFromServerList = false + if (interactor.getAppPreferenceInterface().isReconnecting) return + if (windscribeView.uiConnectionState is ConnectedState) { + windscribeView.performConfirmConnectionHapticFeedback() + } + if (windscribeView.uiConnectionState !is DisconnectedState) { + logger.debug("Changing UI state to Disconnected") + selectedLocation?.let { + windscribeView.clearConnectingAnimation() + windscribeView.setupLayoutDisconnected( + DisconnectedState( + it, connectionOptions, appContext + ) + ) + setIpAddress() + updateLocationUI(it, false) + } + } + } + + private fun onVPNDisconnecting() { + windscribeView.setupLayoutDisconnecting( + interactor.getResourceString(R.string.disconnecting), + interactor.getColorResource(R.color.colorLightBlue) + ) + } + + private fun onVpnIpReceived(ip: String) { + logger.info("Connection with the server is established.") + connectingFromServerList = false + interactor.getAppPreferenceInterface().isReconnecting = false + windscribeView.setIpAddress(ip.trim { it <= ' ' }) + windscribeView.performConfirmConnectionHapticFeedback() + interactor.getCompositeDisposable().add(getSavedLocation().filter { + interactor.getVpnConnectionStateManager().isVPNActive() + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe({ location: LastSelectedLocation -> onLastSelectedLocationLoaded(location) }) { throwable: Throwable -> + onLastSelectedLocationLoadFailed( + throwable + ) + }) + } + + private fun onVpnRequiresUserInput() { + val locationSourceType = WindUtilities.getSourceTypeBlocking() + if (locationSourceType === SelectedLocationType.CustomConfiguredProfile) { + val cityId = interactor.getLocationProvider().selectedCity.value + interactor.getCompositeDisposable().add( + interactor.getConfigFile(cityId).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) {} + override fun onSuccess(configFile: ConfigFile) { + windscribeView.openProvideUsernameAndPasswordDialog(configFile) + } + }) + ) + } + } + + override fun registerNetworkInfoListener() { + interactor.getNetworkInfoManager().addNetworkInfoListener(this) + interactor.getNetworkInfoManager().reload() + } + + override fun reloadNetworkInfo() { + interactor.getNetworkInfoManager().reload(true) + } + + /* + * Remove from favourite list + * @Param cityID + * */ + override fun removeFromFavourite( + cityId: Int, + position: Int, + adapter: RecyclerView.Adapter + ) { + val favourite = Favourite() + favourite.id = cityId + interactor.getCompositeDisposable() + .add(Completable.fromAction { interactor.deleteFavourite(favourite) } + .andThen(interactor.getFavourites()).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ favourites: List -> + resetAdapters( + favourites, + interactor.getResourceString(R.string.remove_from_favourites), + position, + adapter + ) + }) { throwable: Throwable -> + logger.debug( + String.format( + "Failed to remove from favourites. : %s", throwable.localizedMessage + ) + ) + windscribeView.showToast("Failed to remove from favourites.") + }) + } + + override fun saveLastSelectedTabIndex(index: Int) { + interactor.getAppPreferenceInterface().saveLastSelectedServerTabIndex(index) + } + + override fun saveRateDialogPreference(type: Int) { + interactor.saveRateAppPreference(type) + interactor.setRateDialogUpdateTime() + } + + override fun setMainCustomConstraints() { + val customAppBackground = interactor.getAppPreferenceInterface().isCustomBackground + windscribeView.setMainConstraints(customAppBackground) + } + + override fun setProtocolAdapter(protocol: String) { + interactor.loadPortMap(object : PortMapLoadCallback { + override fun onFinished(portMapResponse: PortMapResponse) { + portMapResponse.let { + val protocols: MutableList = ArrayList() + var heading: String? = null + for (portMap in it.portmap) { + protocols.add(portMap.heading) + if (protocol == portMap.protocol) { + heading = portMap.heading + } + } + heading?.let { + windscribeView.setupProtocolAdapter( + heading, protocols.toTypedArray() + ) + } + } + } + }) + } + + override fun setScrollTo(scrollTo: Int) { + windscribeView.scrollTo(scrollTo) + } + + override suspend fun observerSelectedLocation() { + interactor.getCompositeDisposable().add(Single.fromCallable { + return@fromCallable Util.getLastSelectedLocation(appContext) + ?: throw Exception("No saved location found") + }.onErrorResumeNext(interactor.getLocationProvider().bestLocation.flatMap { + val coordinatesArray = it.city.coordinates.split(",".toRegex()).toTypedArray() + val location = LastSelectedLocation( + it.city.id, + it.city.nodeName, + it.city.nickName, + it.region.countryCode, + coordinatesArray[0], + coordinatesArray[1] + ) + Util.saveSelectedLocation(location) + return@flatMap Single.fromCallable { location } + }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe({ + selectedLocation = it + updateLocationUI(selectedLocation, true) + }, {}) + ) + } + + /* + * Set theme Dark/Light + * */ + override fun setTheme(context: Context) { + val savedThem = interactor.getAppPreferenceInterface().selectedTheme + logger.debug("Setting theme to $savedThem") + if (savedThem == PreferencesKeyConstants.DARK_THEME) { + context.setTheme(R.style.DarkTheme) + } else { + context.setTheme(R.style.LightTheme) + } + } + + private fun setPreferredNetworkLayout() { + if (windscribeView.networkLayoutState === NetworkLayoutState.CLOSED) { + if (networkInformation?.isAutoSecureOn != true) { + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.OPEN_1, false + ) + } else if (networkInformation?.isPreferredOn != true) { + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.OPEN_2, false + ) + } else { + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.OPEN_3, false + ) + } + } else { + windscribeView.setNetworkLayout( + networkInformation, NetworkLayoutState.CLOSED, false + ) + } + } + + override fun onCollapseExpandIconClick() { + try { + WindUtilities.getNetworkName() + setPreferredNetworkLayout() + } catch (e: WindScribeException) { + logger.debug(e.message) + when (e) { + is NoNetworkException -> { + windscribeView.setNetworkLayout(null, NetworkLayoutState.CLOSED, false) + windscribeView.showToast("No Network") + } + + is BackgroundLocationPermissionNotAvailable, is NoLocationPermissionException -> { + if (!isGPSEnabled(appContext)) { + windscribeView.showToast("Location service is disabled. Enable it to use preferred protocols.") + } + windscribeView.setNetworkLayout(null, NetworkLayoutState.CLOSED, false) + permissionManager.withForegroundLocationPermission { error -> + if (error != null) { + logger.debug(error) + } else { + interactor.getNetworkInfoManager().reload(true) + } + } + } + + else -> { + logger.info("Unknown error.") + } + } + } catch (e: Exception) { + logger.info(e.toString()) + } + } + + private fun isGPSEnabled(context: Context): Boolean { + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return lm.isProviderEnabled(LocationManager.GPS_PROVIDER) + } + + override fun updateConfigFile(configFile: ConfigFile) { + interactor.getCompositeDisposable().add( + interactor.addConfigFile(configFile).observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableCompletableObserver() { + @SuppressLint("NotifyDataSetChanged") + override fun onComplete() { + windscribeView.showToast("Updated profile") + configAdapter?.notifyDataSetChanged() + } + + override fun onError(e: Throwable) { + logger.error(e.toString()) + windscribeView.showToast("Error updating config file.") + } + }) + ) + } + + override fun updateConfigFileConnect(configFile: ConfigFile) { + interactor.getCompositeDisposable().add( + interactor.addConfigFile(configFile).observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + connectToConfiguredLocation(configFile.getPrimaryKey()) + interactor.getPreferenceChangeObserver().postConfigListChange() + } + + override fun onError(e: Throwable) { + logger.error(e.toString()) + windscribeView.showToast("Error updating config file.") + } + }) + ) + } + + override fun updateLatency() { + if (adapter == null) { + return + } + interactor.getCompositeDisposable() + .add( + interactor.getAllPings().flatMap { pingTimes: List -> + interactor.getLocationProvider().bestLocation.flatMap { cityAndRegion: CityAndRegion -> + Single.fromCallable { + Pair( + pingTimes, cityAndRegion + ) + } + } + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : + DisposableSingleObserver, CityAndRegion>>() { + override fun onError(e: Throwable) {} + override fun onSuccess(pair: Pair, CityAndRegion>) { + adapter?.let { + val serverListData = it.serverListData + serverListData.pingTimes = pair.first + serverListData.bestLocation = pair.second + updateServerListData(serverListData) + } + } + }) + ) + } + + override fun userHasAccess(): Boolean { + return interactor.getAppPreferenceInterface().sessionHash != null + } + + private fun validIpAddress(str: String?): Boolean { + val addressString = IPAddressString(str) + return try { + addressString.toAddress() + true + } catch (e: AddressStringException) { + logger.debug(e.localizedMessage) + false + } + } + + private fun addConfigFileToDatabase(configFile: ConfigFile) { + windscribeView.showRecyclerViewProgressBar() + interactor.getCompositeDisposable() + .add( + interactor.getMaxPrimaryKey().onErrorReturnItem(20000) + .flatMapCompletable { max: Int -> + configFile.setPrimaryKey(max + 1) + interactor.addConfigFile(configFile) + }.observeOn(AndroidSchedulers.mainThread()).subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableCompletableObserver() { + override fun onComplete() { + logger.debug("Config added successfully to database.") + interactor.getActivityScope().launch { + withContext(interactor.getMainScope().coroutineContext) { + interactor.getLatencyRepository().updateConfigLatencies() + } + windscribeView.showToast(interactor.getResourceString(R.string.config_added)) + interactor.getPreferenceChangeObserver().postConfigListChange() + } + } + + override fun onError(e: Throwable) { + windscribeView.hideRecyclerViewProgressBar() + logger.error(e.toString()) + windscribeView.showToast("Error adding config file.") + } + }) + ) + } + + private fun addNotificationChangeListener() { + interactor.getCompositeDisposable().add( + interactor.getNotifications(interactor.getAppPreferenceInterface().userName) + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSubscriber>() { + override fun onComplete() { + logger.debug("Registering notification listener finishing.") + } + + override fun onError(t: Throwable) { + logger.debug( + "Error reading popup notification table. StackTrace: " + instance.convertThrowableToString( + t + ) + ) + } + + override fun onNext(popupNotificationTables: List) { + updateNotificationCount() + checkForPopNotification(popupNotificationTables) + } + }) + ) + } + + private fun calculateFlagDimensions() { + interactor.getAppPreferenceInterface().flagViewWidth = windscribeView.flagViewWidth + interactor.getAppPreferenceInterface().flagViewHeight = windscribeView.flagViewHeight + } + + private fun canNotUpdatePings(): Boolean { + if (!WindUtilities.isOnline()) { + windscribeView.setRefreshLayout(false) + windscribeView.showToast("No network available") + return true + } else if (interactor.getVpnConnectionStateManager().isVPNActive()) { + windscribeView.setRefreshLayout(false) + windscribeView.showToast("Disconnect from VPN") + return true + } + return false + } + + /* + * Check if we can connect + * */ + private fun checkEligibility(isPro: Int, isStaticIp: Boolean, serverStatus: Int): Boolean { + // Check Internet + if (!windscribeView.isConnectedToNetwork) { + logger.info("Error: no internet available.") + windscribeView.showToast(interactor.getResourceString(R.string.no_internet)) + return false + } + + // Does user own this location + if (interactor.getAppPreferenceInterface().userStatus != UserStatusConstants.USER_STATUS_PREMIUM && isPro == 1 && !isStaticIp) { + logger.info("Location is pro but user is not. Opening upgrade activity.") + windscribeView.openUpgradeActivity() + return false + } + + // User account status + if (interactor.getUserAccountStatus() == UserStatusConstants.ACCOUNT_STATUS_EXPIRED && !isStaticIp) { + logger.info("Error: account status is expired.") + val resetDate = interactor.getUserRepository().user.value?.nextResetDate() ?: "" + windscribeView.setupAccountStatusExpired(resetDate) + return false + } + if (interactor.getUserAccountStatus() == UserStatusConstants.ACCOUNT_STATUS_BANNED) { + logger.info("Error: account status is banned.") + windscribeView.setupAccountStatusBanned() + return false + } + + // Set Static status + interactor.getAppPreferenceInterface().setConnectingToStaticIP(isStaticIp) + interactor.getAppPreferenceInterface().setConnectingToConfiguredLocation(false) + + // Check Network security + val whiteListOverride = interactor.getAppPreferenceInterface().whiteListedNetwork + networkInformation?.let { + if (!it.isAutoSecureOn && whiteListOverride != null) { + interactor.saveWhiteListedNetwork(true) + } + } + if (serverStatus == NetworkKeyConstants.SERVER_STATUS_TEMPORARILY_UNAVAILABLE) { + logger.info("Error: Server is temporary unavailable.") + windscribeView.showToast("Location temporary unavailable.") + return false + } + return true + } + + private fun checkForPopNotification(popupNotificationTables: List) { + for (popupNotification in popupNotificationTables) { + val alreadySeen = interactor.getAppPreferenceInterface() + .isNotificationAlreadyShown(popupNotification.notificationId.toString()) + if (!alreadySeen && popupNotification.popUpStatus == 1) { + logger.info("New popup notification received, showing notification...") + interactor.getAppPreferenceInterface() + .saveNotificationId(popupNotification.notificationId.toString()) + windscribeView.openNewsFeedActivity(true, popupNotification.notificationId) + break + } + } + } + + private fun checkLoginStatus() { + val session = interactor.getAppPreferenceInterface().sessionHash + if (session == null) { + logoutFromCurrentSession() + } + } + + /* + * Gets city node + * Check if we can connect + * start connection + * @Param ID + * */ + private fun connectToCity(cityId: Int) { + interactor.getCompositeDisposable().add( + interactor.getCityAndRegionByID(cityId).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) { + logger.debug("Could not find selected location in database.") + windscribeView.showToast("Error") + } + + override fun onSuccess(cityAndRegion: CityAndRegion) { + val serverStatus = cityAndRegion.region.status + val eligibleToConnect = checkEligibility( + cityAndRegion.city.pro, false, serverStatus + ) + if (eligibleToConnect) { + interactor.getAppPreferenceInterface().globalUserConnectionPreference = + true + interactor.getAppPreferenceInterface().setConnectingToStaticIP(false) + interactor.getAppPreferenceInterface() + .setConnectingToConfiguredLocation(false) + val coordinatesArray = + cityAndRegion.city.coordinates.split(",".toRegex()).toTypedArray() + selectedLocation = LastSelectedLocation( + cityAndRegion.city.getId(), + cityAndRegion.city.nodeName, + cityAndRegion.city.nickName, + cityAndRegion.region.countryCode, + coordinatesArray[0], + coordinatesArray[1] + ) + updateLocationUI(selectedLocation, false) + logger.debug("Attempting to connect") + interactor.getMainScope().launch { + interactor.getAutoConnectionManager().connectInForeground() + } + } else { + logger.error("User can not connect to location right now.") + } + } + }) + ) + } + + private fun connectToConfiguredLocation(id: Int) { + interactor.getCompositeDisposable().add( + interactor.getConfigFile(id).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) { + windscribeView.showToast("Error connecting to location") + } + + override fun onSuccess(configFile: ConfigFile) { + interactor.getLocationProvider().setSelectedCity(configFile.getPrimaryKey()) + selectedLocation = LastSelectedLocation( + configFile.getPrimaryKey(), "Custom Config", configFile.name, "", "", "" + ) + updateLocationUI(selectedLocation, false) + interactor.getAppPreferenceInterface().globalUserConnectionPreference = true + interactor.getAppPreferenceInterface() + .setConnectingToConfiguredLocation(true) + interactor.getAppPreferenceInterface().setConnectingToStaticIP(false) + interactor.getVPNController().connectAsync() + } + }) + ) + } + + private fun connectToStaticIp(staticId: Int) { + interactor.getCompositeDisposable().add( + interactor.getStaticRegionByID(staticId).subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableSingleObserver() { + override fun onError(e: Throwable) { + logger.debug("Could not find static ip in database") + windscribeView.showToast("Error connecting to Location") + } + + override fun onSuccess(staticRegion: StaticRegion) { + val eligibleToConnect = checkEligibility(1, true, 1) + if (eligibleToConnect) { + interactor.getAppPreferenceInterface().globalUserConnectionPreference = + true + interactor.getAppPreferenceInterface().setConnectingToStaticIP(true) + interactor.getAppPreferenceInterface() + .setConnectingToConfiguredLocation(false) + selectedLocation = LastSelectedLocation( + staticRegion.id, + staticRegion.cityName, + staticRegion.staticIp, + staticRegion.countryCode, + "", + "" + ) + updateLocationUI(selectedLocation, false) + logger.debug("Attempting to connect..") + interactor.getMainScope().launch { + interactor.getAutoConnectionManager().connectInForeground() + } + } else { + logger.error("User can not connect to location right now.") + } + } + }) + ) + } + + private fun elapsedOneDayAfterLogin(): Boolean { + val milliSeconds1 = interactor.getAppPreferenceInterface().loginTime?.time ?: Date().time + val milliSeconds2 = Date().time + val periodSeconds = (milliSeconds2 - milliSeconds1) / 1000 + val elapsedDays = periodSeconds / 60 / 60 / 24 + return elapsedDays > 0 + } + + private fun setIpAddress() { + if (windscribeView.isConnectedToNetwork) { + interactor.getCompositeDisposable().add( + interactor.getApiCallManager().checkConnectivityAndIpAddress() + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe({ response -> + response.dataClass?.let { + if (validIpAddress(it.userIp)) { + windscribeView.setIpAddress(getModifiedIpAddress(it.userIp)) + } + } + response.errorClass?.let { + logger.error("Server returned error response when getting user ip.") + windscribeView.setIpAddress("---.---.---.---") + } + }, { + logger.error("Network call to get ip failed ${it.message}") + windscribeView.setIpAddress("---.---.---.---") + }) + ) + } else { + logger.debug("Network is not available. Ip update failed...") + windscribeView.setIpAddress("---.---.---.---") + } + } + + private fun getModifiedIpAddress(ipResponse: String): String { + var ipAddress: String? + if (ipResponse.length >= 32) { + logger.info("Ipv6 address. Truncating and saving ip data...") + ipAddress = ipResponse.replace("0000".toRegex(), "0") + ipAddress = ipAddress.replace("000".toRegex(), "") + ipAddress = ipAddress.replace("00".toRegex(), "") + } else { + ipAddress = ipResponse + } + interactor.getAppPreferenceInterface() + .saveResponseStringData(PreferencesKeyConstants.USER_IP, ipAddress) + return ipAddress + } + + private fun getPingTimeFromCity(id: Int, serverListData: ServerListData): Int { + return serverListData.pingTimes.let { + return@let it.firstOrNull { ping -> ping.id == id } + }?.pingTime ?: -1 + } + + private fun getTotal(cities: List, serverListData: ServerListData): Int { + var total = 0 + var index = 0 + for (city in cities) { + for (pingTime in serverListData.pingTimes) { + if (pingTime.id == city.getId()) { + total += pingTime.getPingTime() + index++ + } + } + } + if (index == 0) { + return 2000 + } + val average = total / index + return if (average == -1) { + 2000 + } else average + } + + private val isNetworkInfoChanged: Boolean + get() { + logger.debug(interactor.getAppPreferenceInterface().selectedProtocol) + logger.debug(networkInformation.toString()) + return (interactor.getAppPreferenceInterface().selectedProtocol != networkInformation?.protocol) or (interactor.getAppPreferenceInterface().selectedPort != networkInformation?.port) + } + + private fun onLastSelectedLocationLoadFailed(throwable: Throwable) { + logger.error( + "Error getting connected profile.StackTrace: " + instance.convertThrowableToString( + throwable + ) + ) + selectedLocation?.let { + windscribeView.startVpnConnectedAnimation( + ConnectedAnimationState(it, connectionOptions, appContext) + ) + updateLocationUI(it, true) + } + } + + private fun onLastSelectedLocationLoaded(location: LastSelectedLocation) { + selectedLocation = location + selectedLocation?.let { + windscribeView.startVpnConnectedAnimation( + ConnectedAnimationState( + it, connectionOptions, appContext + ) + ) + } + interactor.getCompositeDisposable().add(Single.fromCallable { + interactor.getLocationProvider().setSelectedCity(location.cityId) + return@fromCallable interactor.getAppPreferenceInterface().connectedFlagPath ?: "" + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe { flagPath: String -> + if (interactor.getAppPreferenceInterface().isCustomBackground) { + if (flagPath.isEmpty()) { + windscribeView.setCountryFlag(R.drawable.dummy_flag) + } else { + windscribeView.setupLayoutForCustomBackground(flagPath) + } + } + windscribeView.updateLocationName(location.nodeName, location.nickName) + }) + } + + private fun onNotificationResponse(windNotifications: List) { + var count = 0 + for ((notificationId) in windNotifications) { + if (!interactor.getAppPreferenceInterface().isNotificationAlreadyShown( + notificationId.toString() + ) + ) { + count++ + } + } + windscribeView.showNotificationCount(count) + } + + private fun onNotificationResponseError() { + logger.error("Error updating notification count: setting notification count to 0") + windscribeView.showNotificationCount(0) + } + + /* + * Reset adapters on data change + * */ + @SuppressLint("NotifyDataSetChanged") + private fun resetAdapters( + favourites: List, + message: String, + position: Int, + changedAdapter: RecyclerView.Adapter + ) { + logger.debug(message) + windscribeView.showToast(message) + logger.debug("Resetting list adapters.") + adapter?.serverListData?.favourites = favourites + streamingNodeAdapter?.serverListData?.favourites = favourites + changedAdapter.notifyItemChanged(position) + if (changedAdapter !is RegionsAdapter) { + adapter?.notifyDataSetChanged() + } + if (changedAdapter !is StreamingNodeAdapter) { + streamingNodeAdapter?.notifyDataSetChanged() + } + adapter?.serverListData?.let { + setFavouriteServerView(it) + } + adapter?.let { + windscribeView.updateSearchAdapter(it.serverListData) + } + } + + private fun setAllServerView( + regionAndCities: List, serverListData: ServerListData + ) { + // All Server list + val normalGroups: MutableList = ArrayList() + // Streaming server list + val streamingGroups: MutableList = ArrayList() + + // Populate normal and streaming regions + for (regionAndCity in regionAndCities) { + val total = getTotal(regionAndCity.cities, serverListData) + Collections.sort(regionAndCity.cities, ByCityName()) + if (regionAndCity.region != null && (regionAndCity.region.locationType == "streaming")) { + streamingGroups.add( + Group( + regionAndCity.region.name, regionAndCity.region, regionAndCity.cities, total + ) + ) + } else if (regionAndCity.region != null) { + normalGroups.add( + Group( + regionAndCity.region.name, regionAndCity.region, regionAndCity.cities, total + ) + ) + } + } + + // Sort Normal regions + val selection = interactor.getAppPreferenceInterface().selection + if (selection == LATENCY_LIST_SELECTION_MODE) { + Collections.sort(normalGroups, ByLatency()) + } else if (selection == AZ_LIST_SELECTION_MODE) { + Collections.sort(normalGroups, ByRegionName()) + } + if (selection == LATENCY_LIST_SELECTION_MODE) { + Collections.sort(streamingGroups, ByLatency()) + } else if (selection == AZ_LIST_SELECTION_MODE) { + Collections.sort(streamingGroups, ByRegionName()) + } + // Add best location to normal region adapter + normalGroups.add(0, Group("Best Location", null, null, 0)) + + // Normal region adapter + adapter = RegionsAdapter(normalGroups, serverListData, this) + windscribeView.setAdapter(adapter!!) + // Streaming Adapter + streamingNodeAdapter = StreamingNodeAdapter(streamingGroups, serverListData, this) + windscribeView.setStreamingNodeAdapter(streamingNodeAdapter!!) + // Check for errors. + if (normalGroups.size <= 1) { + windscribeView.showReloadError("Error loading server list") + } + windscribeView.hideRecyclerViewProgressBar() + } + + private fun setFavouriteServerView(serverListData: ServerListData) { + val favIds = IntArray(serverListData.favourites.size) + for (i in serverListData.favourites.indices) { + favIds[i] = serverListData.favourites[i].id + } + interactor.getCompositeDisposable().add( + interactor.getCityByID(favIds).observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .subscribeWith(object : DisposableSingleObserver?>() { + override fun onError(e: Throwable) { + logger.error("Error setting favourite adapter.") + windscribeView.setFavouriteAdapter(null) + windscribeView.showFavouriteAdapterLoadError( + interactor.getResourceString(R.string.no_favourites) + ) + } + + override fun onSuccess(cities: List) { + // Sort Normal regions + val selection = interactor.getAppPreferenceInterface().selection + if (selection == LATENCY_LIST_SELECTION_MODE) { + + Collections.sort(cities) { o1: City, o2: City -> + serverListData.pingTimes + getPingTimeFromCity( + o1.getId(), serverListData + ) - getPingTimeFromCity( + o2.getId(), serverListData + ) + } + } else if (selection == AZ_LIST_SELECTION_MODE) { + Collections.sort(cities, ByCityName()) + } + if (cities.isNotEmpty()) { + favouriteAdapter = FavouriteAdapter( + cities, serverListData, this@WindscribePresenterImpl + ) + windscribeView.setFavouriteAdapter(favouriteAdapter!!) + } else { + favouriteAdapter = null + windscribeView.setFavouriteAdapter(null) + windscribeView.showFavouriteAdapterLoadError( + interactor.getResourceString(R.string.no_favourites) + ) + } + } + }) + ) + } + + private fun setIpFromLocalStorage() { + val ipAddress = interactor.getAppPreferenceInterface() + .getResponseString(PreferencesKeyConstants.USER_IP) + if (ipAddress != null && interactor.getVpnConnectionStateManager().isVPNActive()) { + windscribeView.setIpAddress(ipAddress) + } + if (!interactor.getVpnConnectionStateManager().isVPNActive() || ipAddress == null) { + windscribeView.setIpAddress("---.---.---.---") + setIpAddress() + } + } + + + var disconnectJob: Job? = null + private fun stopVpnFromUI() { + logger.debug("Disconnecting using connect button.") + disconnectJob = interactor.getMainScope().launch { + interactor.saveWhiteListedNetwork(true) + interactor.getAppPreferenceInterface().globalUserConnectionPreference = false + interactor.getAppPreferenceInterface().isReconnecting = false + interactor.getVPNController().disconnectAsync() + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateServerListData(serverListData: ServerListData) { + if (adapter != null) { + adapter?.serverListData = serverListData + adapter?.notifyDataSetChanged() + } + if (favouriteAdapter != null) { + favouriteAdapter?.setDataDetails(serverListData) + favouriteAdapter?.notifyDataSetChanged() + } + if (streamingNodeAdapter != null) { + streamingNodeAdapter?.serverListData = serverListData + streamingNodeAdapter?.notifyDataSetChanged() + } + if (staticRegionAdapter != null) { + staticRegionAdapter?.setDataDetails(serverListData) + staticRegionAdapter?.notifyDataSetChanged() + } + } + + private fun updateLocationUI(lastSelectedLocation: LastSelectedLocation?, updateFlag: Boolean) { + if (lastSelectedLocation != null) { + // Save city and update location + interactor.getLocationProvider().setSelectedCity(lastSelectedLocation.cityId) + windscribeView.updateLocationName( + lastSelectedLocation.nodeName, lastSelectedLocation.nickName + ) + // Custom flag + val customBackground = interactor.getAppPreferenceInterface().isCustomBackground + if (customBackground) { + val path = if (interactor.getVpnConnectionStateManager() + .isVPNActive() + ) interactor.getAppPreferenceInterface().connectedFlagPath else interactor.getAppPreferenceInterface().disConnectedFlagPath + path?.let { + windscribeView.setupLayoutForCustomBackground(path) + } ?: kotlin.run { + windscribeView.setCountryFlag(R.drawable.dummy_flag) + } + } else { + // Country flag + if (updateFlag && flagIcons.containsKey(lastSelectedLocation.countryCode)) { + flagIcons[lastSelectedLocation.countryCode]?.let { + windscribeView.setCountryFlag(it) + } + } + } + // Rebuild state if not available. + if (windscribeView.uiConnectionState == null) { + windscribeView.setLastConnectionState( + DisconnectedState( + lastSelectedLocation, connectionOptions, appContext + ) + ) + } + } + } + + private fun updateNotificationCount() { + interactor.getCompositeDisposable() + .add(interactor.getWindNotifications().subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ windNotifications: List -> + onNotificationResponse( + windNotifications + ) + }) { onNotificationResponseError() }) + } + + private fun setAccountStatus(user: User) { + when (user.accountStatus) { + User.AccountStatus.Okay -> { + windscribeView.setupAccountStatusOkay() + } + + User.AccountStatus.Banned -> { + if (interactor.getVpnConnectionStateManager().isVPNActive()) { + interactor.getMainScope() + .launch { interactor.getVPNController().disconnectAsync() } + } + windscribeView.setupAccountStatusBanned() + } + + else -> { + val previousAccountStatus = + interactor.getAppPreferenceInterface().getPreviousAccountStatus(user.userName) + if (user.accountStatusToInt != previousAccountStatus) { + interactor.getAppPreferenceInterface() + .setPreviousAccountStatus(user.userName, user.accountStatusToInt) + if (user.accountStatus == User.AccountStatus.Expired) { + setUserStatus(user) + if (interactor.getVpnConnectionStateManager().isVPNActive()) { + interactor.getMainScope() + .launch { interactor.getVPNController().disconnectAsync() } + } + val resetDate = interactor.getUserRepository().user.value?.nextResetDate() ?: "" + windscribeView.setupAccountStatusExpired(resetDate) + } + } + } + } + } + + private fun setUserStatus(user: User) { + logger.debug("{}", user) + if (user.maxData != -1L) { + user.dataLeft.let { + val dataRemaining = interactor.getDataLeftString(R.string.data_left, it) + windscribeView.setupLayoutForFreeUser( + dataRemaining, + interactor.getResourceString(R.string.get_more_data), + getDataRemainingColor(it, user.maxData) + ) + } + } else { + windscribeView.setupLayoutForProUser() + } + } + + override fun loadConfigFile(data: Intent) { + try { + val fileUri = data.data + val inputStream = appContext.contentResolver.openInputStream(fileUri!!) + inputStream?.use { + val documentFile = DocumentFile.fromSingleUri(appContext, fileUri) + val fileName = validatedConfigFileName(documentFile) ?: return + val content = CharStreams.toString(InputStreamReader(inputStream)) + var username = "" + var password = "" + try { + val configParser = OpenVPNConfigParser() + username = configParser.getEmbeddedUsername(InputStreamReader(inputStream)) + password = configParser.getEmbeddedPassword(InputStreamReader(inputStream)) + } catch (ignored: Exception) { + } + logger.info("Successfully read file.") + onConfigFileContentReceived( + fileName, content, username, password + ) + } + + } catch (e: IOException) { + logger.info(e.toString()) + } + } + + private fun validatedConfigFileName(documentFile: DocumentFile?): String? { + if (documentFile == null) { + windscribeView.showToast("Choose a valid config file") + return null + } + if (documentFile.length() > 1024 * 12) { + windscribeView.showToast("File is larger than 12KB") + return null + } + val fileName = documentFile.name + val existingFile = configAdapter?.configFiles?.firstOrNull { it.name == fileName } + if (existingFile != null) { + windscribeView.showToast("A file with same name already exists") + return null + } + if (fileName != null && fileName.length > 35) { + windscribeView.showToast("File name is too long. Maximum 35 characters allowed.") + return null + } + if (fileName != null && fileName.endsWith(".conf") or fileName.endsWith(".ovpn")) { + return fileName + } + windscribeView.showToast("Choose valid .ovpn or .conf file.") + return null + } + + override suspend fun showShareLinkDialog() { + interactor.getUserRepository().user.value?.let { + delay(4000) + if (it.isGhost.not() && it.isPro.not() && it.daysRegisteredSince > 30 && interactor.getAppPreferenceInterface().getConnectionCount() >= 10 && interactor.getAppPreferenceInterface().alreadyShownShareAppLink.not()) { + windscribeView.showShareLinkDialog() + } + } + } + + override fun onDecoyTrafficClick() { + if (interactor.getVpnConnectionStateManager().isVPNConnected()) { + if (interactor.getAppPreferenceInterface().isDecoyTrafficOn) { + windscribeView.openConnectionActivity() + } + } + } + + override fun onProtocolChangeClick() { + if (WindUtilities.getSourceTypeBlocking() == SelectedLocationType.CustomConfiguredProfile) { + windscribeView.showToast(interactor.getResourceString(R.string.protocol_change_is_not_available_for_custom_config)) + } else if (interactor.getVpnConnectionStateManager().isVPNConnected()) { + interactor.getMainScope().launch { + interactor.getAutoConnectionManager().changeProtocolInForeground() + } + } + } + + override fun onLocationSettingsChanged() { + selectedLocation?.let { + updateLocationUI(it, true) + } + } + + /** + * Check for user ip when app resumes if connected to Wg. + * Dynamic wg may change ip on network changes. + */ + override fun checkForWgIpChange() { + if (interactor.getVpnConnectionStateManager() + .isVPNConnected() && interactor.getAppPreferenceInterface().selectedProtocol == PROTO_WIRE_GUARD + ) { + logger.debug("Checking dynamic wg ip change.") + interactor.getCompositeDisposable() + .add(interactor.getApiCallManager().checkConnectivityAndIpAddress() + .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe { response, _ -> + response?.dataClass?.let { ip -> + if (validIpAddress(ip.userIp)) { + val updatedIpAddress = getModifiedIpAddress(ip.userIp) + interactor.getAppPreferenceInterface().saveResponseStringData( + PreferencesKeyConstants.USER_IP, updatedIpAddress + ) + logger.debug("Updating ip address to $updatedIpAddress") + windscribeView.setIpAddress(updatedIpAddress) + } else { + logger.error("Invalid ip returned from Api $ip") + } + } ?: kotlin.run { + logger.error("Failed to get ip from API.") + } + }) + } + } + + override fun checkPendingAccountUpgrades() { + interactor.getReceiptValidator().checkPendingAccountUpgrades() + } + + override fun onAntiCensorShipStatusChanged() { + if (interactor.getAppPreferenceInterface().isAntiCensorshipOn) { + windscribeView.setCensorShipIconVisibility(View.VISIBLE) + } else { + windscribeView.setCensorShipIconVisibility(View.GONE) + } + } + + override suspend fun observeLocationUIInvalidation() { + interactor.getServerListUpdater().locationUIInvalidation.collectLatest { + if (adapter?.serverListData != null) { + adapter?.serverListData?.serverListHash = "" + interactor.getServerListUpdater().load() + } + } + } + + override suspend fun observeConnectionCount() { + interactor.getVpnConnectionStateManager().connectionCount + .filter { count -> + val showCount = interactor.getAppPreferenceInterface().getPowerWhiteListDialogCount() + count > 1 && !isIgnoringBatteryOptimizations(appContext) && showCount < 3 + }.collectLatest { + if (!isIgnoringBatteryOptimizations(appContext) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + windscribeView.launchBatteryOptimizationActivity() + } + } + } + + private fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val manager = + context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val name = context.applicationContext.packageName + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return manager.isIgnoringBatteryOptimizations(name) + } + return true + } + + override fun neverAskPowerWhiteListPermissionAgain() { + interactor.getAppPreferenceInterface().setPowerWhiteListDialogCount(3) + } + + override fun askPowerWhiteListPermissionLater() { + val count = interactor.getAppPreferenceInterface().getPowerWhiteListDialogCount() + interactor.getAppPreferenceInterface().setPowerWhiteListDialogCount( count + 1) + } +} \ No newline at end of file diff --git a/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribeView.kt b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribeView.kt new file mode 100644 index 000000000..46ea359ad --- /dev/null +++ b/mobile/src/main/java/com/windscribe/mobile/windscribe/WindscribeView.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021 Windscribe Limited. + */ +package com.windscribe.mobile.windscribe + +import com.thoughtbot.expandablerecyclerview.models.ExpandableGroup +import com.windscribe.mobile.adapter.ConfigAdapter +import com.windscribe.mobile.adapter.FavouriteAdapter +import com.windscribe.mobile.adapter.RegionsAdapter +import com.windscribe.mobile.adapter.StaticRegionAdapter +import com.windscribe.mobile.adapter.StreamingNodeAdapter +import com.windscribe.mobile.connectionui.ConnectedAnimationState +import com.windscribe.mobile.connectionui.ConnectedState +import com.windscribe.mobile.connectionui.ConnectingAnimationState +import com.windscribe.mobile.connectionui.ConnectingState +import com.windscribe.mobile.connectionui.ConnectionUiState +import com.windscribe.mobile.connectionui.DisconnectedState +import com.windscribe.mobile.windscribe.WindscribeActivity.NetworkLayoutState +import com.windscribe.vpn.localdatabase.tables.NetworkInfo +import com.windscribe.vpn.serverlist.entity.ConfigFile +import com.windscribe.vpn.serverlist.entity.ServerListData +import com.windscribe.vpn.serverlist.interfaces.ListViewClickListener + +interface WindscribeView { + fun exitSearchLayout() + val flagViewHeight: Int + val flagViewWidth: Int + val networkLayoutState: NetworkLayoutState? + val uiConnectionState: ConnectionUiState? + fun gotoLoginRegistrationActivity() + fun hideProgressView() + fun hideRecyclerViewProgressBar() + val isBannedLayoutShown: Boolean + val isConnectedToNetwork: Boolean + fun openEditConfigFileDialog(configFile: ConfigFile) + fun openFileChooser() + fun openHelpUrl() + fun openMenuActivity() + fun openConnectionActivity() + fun openNewsFeedActivity(showPopUp: Boolean, popUp: Int) + fun openNodeStatusPage(url: String) + fun openProvideUsernameAndPasswordDialog(configFile: ConfigFile) + fun openStaticIPUrl(url: String) + fun openUpgradeActivity() + fun performButtonClickHapticFeedback() + fun performConfirmConnectionHapticFeedback() + fun scrollTo(scrollTo: Int) + fun setAdapter(adapter: RegionsAdapter) + fun setConfigLocListAdapter(configLocListAdapter: ConfigAdapter?) + fun setConnectionStateText(connectionStateText: String) + fun setCountryFlag(flagIconResource: Int) + fun setFavouriteAdapter(favouriteAdapter: FavouriteAdapter?) + fun setIpAddress(ipAddress: String) + fun setIpBlur(blur: Boolean) + fun setNetworkNameBlur(blur: Boolean) + fun setLastConnectionState(state: ConnectionUiState) + fun setMainConstraints(customBackground: Boolean) + fun setNetworkLayout( + info: NetworkInfo?, + state: NetworkLayoutState?, + resetAdapter: Boolean + ) + + fun setPortAndProtocol(protocol: String, port: String) + fun setRefreshLayout(refreshing: Boolean) + fun setStaticRegionAdapter(staticRegionAdapter: StaticRegionAdapter) + fun setStreamingNodeAdapter(streamingNodeAdapter: StreamingNodeAdapter) + fun setUpLayoutForNodeUnderMaintenance(isStaticLocation: Boolean) + fun setupAccountStatusBanned() + fun setupAccountStatusExpired(resetDate: String) + fun setupLayoutConnected(state: ConnectedState) + fun setupLayoutConnecting(state: ConnectingState) + fun setupLayoutDisconnected(connectionState: DisconnectedState) + fun setupLayoutDisconnecting(connectionState: String, connectionStateTextColor: Int) + fun setupLayoutForCustomBackground(path: String) + fun setupLayoutForFreeUser(dataLeft: String, upgradeLabel: String, color: Int) + fun setupLayoutForProUser() + fun setupLayoutForReconnect(connectionState: String, connectionStateTextColor: Int) + fun setupLayoutUnsecuredNetwork(uiState: ConnectionUiState) + fun setupPortMapAdapter(savedPort: String, ports: List) + fun setupProtocolAdapter(savedProtocol: String, protocols: Array) + fun setupSearchLayout( + groups: List>, + serverListData: ServerListData, + listViewClickListener: ListViewClickListener + ) + + fun showConfigLocAdapterLoadError(errorText: String, configCount: Int) + fun showDialog(message: String) + fun showFavouriteAdapterLoadError(errorText: String) + fun showListBarSelectTransition(resourceSelected: Int) + fun showNotificationCount(count: Int) + fun showRecyclerViewProgressBar() + fun showReloadError(error: String) + fun showStaticIpAdapterLoadError(errorText: String, buttonText: String, deviceName: String) + fun showToast(toastMessage: String) + fun startVpnConnectedAnimation(state: ConnectedAnimationState) + fun startVpnConnectingAnimation(state: ConnectingAnimationState) + fun updateLocationName(nodeName: String, nodeNickName: String) + fun updateProgressView(text: String) + fun updateSearchAdapter(serverListData: ServerListData) + fun clearConnectingAnimation() + fun setDecoyTrafficInfoVisibility(visibility: Int) + fun showShareLinkDialog() + fun setupAccountStatusOkay() + fun setCensorShipIconVisibility(visible: Int) + fun launchBatteryOptimizationActivity() +} diff --git a/mobile/src/main/res/animator/button_press_cyberpunk.xml b/mobile/src/main/res/animator/button_press_cyberpunk.xml new file mode 100644 index 000000000..43237d1d7 --- /dev/null +++ b/mobile/src/main/res/animator/button_press_cyberpunk.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/color/modal_text_selection.xml b/mobile/src/main/res/color/modal_text_selection.xml new file mode 100644 index 000000000..2ae416fb6 --- /dev/null +++ b/mobile/src/main/res/color/modal_text_selection.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/drawable-hdpi/ws_logo.png b/mobile/src/main/res/drawable-hdpi/mogavpn_logo.png similarity index 100% rename from mobile/src/main/res/drawable-hdpi/ws_logo.png rename to mobile/src/main/res/drawable-hdpi/mogavpn_logo.png diff --git a/mobile/src/main/res/drawable-mdpi/ws_logo.png b/mobile/src/main/res/drawable-mdpi/mogavpn_logo.png similarity index 100% rename from mobile/src/main/res/drawable-mdpi/ws_logo.png rename to mobile/src/main/res/drawable-mdpi/mogavpn_logo.png diff --git a/mobile/src/main/res/drawable-xhdpi/ws_logo.png b/mobile/src/main/res/drawable-xhdpi/mogavpn_logo.png similarity index 100% rename from mobile/src/main/res/drawable-xhdpi/ws_logo.png rename to mobile/src/main/res/drawable-xhdpi/mogavpn_logo.png diff --git a/mobile/src/main/res/drawable-xxhdpi/ws_logo.png b/mobile/src/main/res/drawable-xxhdpi/mogavpn_logo.png similarity index 100% rename from mobile/src/main/res/drawable-xxhdpi/ws_logo.png rename to mobile/src/main/res/drawable-xxhdpi/mogavpn_logo.png diff --git a/mobile/src/main/res/drawable-xxxhdpi/ws_logo.png b/mobile/src/main/res/drawable-xxxhdpi/mogavpn_logo.png similarity index 100% rename from mobile/src/main/res/drawable-xxxhdpi/ws_logo.png rename to mobile/src/main/res/drawable-xxxhdpi/mogavpn_logo.png diff --git a/mobile/src/main/res/drawable/add_button_normal.xml b/mobile/src/main/res/drawable/add_button_normal.xml index 70f449856..f5ed6432a 100644 --- a/mobile/src/main/res/drawable/add_button_normal.xml +++ b/mobile/src/main/res/drawable/add_button_normal.xml @@ -13,5 +13,5 @@ android:topRightRadius="@dimen/padding_32" /> + android:color="@color/cyberpunk_accent_cyan" /> \ No newline at end of file diff --git a/mobile/src/main/res/drawable/add_button_normal_dark.xml b/mobile/src/main/res/drawable/add_button_normal_dark.xml index 348e2c8fb..7affb2d7b 100644 --- a/mobile/src/main/res/drawable/add_button_normal_dark.xml +++ b/mobile/src/main/res/drawable/add_button_normal_dark.xml @@ -13,5 +13,5 @@ android:topRightRadius="@dimen/padding_32" /> + android:color="@color/cyberpunk_accent_pink" /> \ No newline at end of file diff --git a/mobile/src/main/res/drawable/btn_alpha_deep_blue.xml b/mobile/src/main/res/drawable/btn_alpha_deep_blue.xml index 814bb9198..6c0f940a3 100644 --- a/mobile/src/main/res/drawable/btn_alpha_deep_blue.xml +++ b/mobile/src/main/res/drawable/btn_alpha_deep_blue.xml @@ -5,7 +5,7 @@ - + - + - + - + \ No newline at end of file diff --git a/mobile/src/main/res/drawable/checked_radio_circle.xml b/mobile/src/main/res/drawable/checked_radio_circle.xml index 9b73a45cd..48866dfdd 100644 --- a/mobile/src/main/res/drawable/checked_radio_circle.xml +++ b/mobile/src/main/res/drawable/checked_radio_circle.xml @@ -5,5 +5,5 @@ android:viewportHeight="20"> + android:fillColor="@color/cyberpunk_accent_cyan"/> diff --git a/mobile/src/main/res/drawable/connection_gradient.xml b/mobile/src/main/res/drawable/connection_gradient.xml index 2dbe708ee..b3d147dcb 100644 --- a/mobile/src/main/res/drawable/connection_gradient.xml +++ b/mobile/src/main/res/drawable/connection_gradient.xml @@ -7,6 +7,6 @@ + android:startColor="@android:color/transparent" + android:endColor="#8000FFFF" /> diff --git a/mobile/src/main/res/drawable/cursor_background.xml b/mobile/src/main/res/drawable/cursor_background.xml index 04aa31be0..22de6dbab 100644 --- a/mobile/src/main/res/drawable/cursor_background.xml +++ b/mobile/src/main/res/drawable/cursor_background.xml @@ -6,5 +6,5 @@ - + \ No newline at end of file diff --git a/mobile/src/main/res/drawable/cyberpunk_alpha_blue_background.xml b/mobile/src/main/res/drawable/cyberpunk_alpha_blue_background.xml new file mode 100644 index 000000000..3e71b93f3 --- /dev/null +++ b/mobile/src/main/res/drawable/cyberpunk_alpha_blue_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/mobile/src/main/res/drawable/cyberpunk_button_off.xml b/mobile/src/main/res/drawable/cyberpunk_button_off.xml new file mode 100644 index 000000000..dcc342f14 --- /dev/null +++ b/mobile/src/main/res/drawable/cyberpunk_button_off.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/cyberpunk_button_on.xml b/mobile/src/main/res/drawable/cyberpunk_button_on.xml new file mode 100644 index 000000000..1567079cb --- /dev/null +++ b/mobile/src/main/res/drawable/cyberpunk_button_on.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/cyberpunk_infopanel_background.xml b/mobile/src/main/res/drawable/cyberpunk_infopanel_background.xml new file mode 100644 index 000000000..3c3b50754 --- /dev/null +++ b/mobile/src/main/res/drawable/cyberpunk_infopanel_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/mobile/src/main/res/drawable/cyberpunk_main_background.xml b/mobile/src/main/res/drawable/cyberpunk_main_background.xml new file mode 100644 index 000000000..b3e31109b --- /dev/null +++ b/mobile/src/main/res/drawable/cyberpunk_main_background.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_connecting_ring.xml b/mobile/src/main/res/drawable/ic_connecting_ring.xml index ac3a23f71..ad1ab528a 100644 --- a/mobile/src/main/res/drawable/ic_connecting_ring.xml +++ b/mobile/src/main/res/drawable/ic_connecting_ring.xml @@ -8,7 +8,7 @@ android:viewportHeight="96" android:viewportWidth="96"> diff --git a/mobile/src/main/res/drawable/ic_edit_icon.xml b/mobile/src/main/res/drawable/ic_edit_icon.xml index 8f36e3255..cc4f6567e 100644 --- a/mobile/src/main/res/drawable/ic_edit_icon.xml +++ b/mobile/src/main/res/drawable/ic_edit_icon.xml @@ -9,7 +9,7 @@ android:viewportWidth="16"> diff --git a/mobile/src/main/res/drawable/ic_error.xml b/mobile/src/main/res/drawable/ic_error.xml new file mode 100644 index 000000000..9f7d77cab --- /dev/null +++ b/mobile/src/main/res/drawable/ic_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/drawable/ic_faved_icon.xml b/mobile/src/main/res/drawable/ic_faved_icon.xml index aacf5bfff..1da762db0 100644 --- a/mobile/src/main/res/drawable/ic_faved_icon.xml +++ b/mobile/src/main/res/drawable/ic_faved_icon.xml @@ -8,7 +8,7 @@ android:viewportHeight="16" android:viewportWidth="32"> diff --git a/mobile/src/main/res/drawable/ic_hide_password.xml b/mobile/src/main/res/drawable/ic_hide_password.xml index cddb69f71..21f830938 100644 --- a/mobile/src/main/res/drawable/ic_hide_password.xml +++ b/mobile/src/main/res/drawable/ic_hide_password.xml @@ -8,19 +8,19 @@ android:viewportHeight="24" android:viewportWidth="24"> diff --git a/mobile/src/main/res/drawable/ic_radio_button_not_selected.xml b/mobile/src/main/res/drawable/ic_radio_button_not_selected.xml index 209d7f1ca..6e79cc643 100644 --- a/mobile/src/main/res/drawable/ic_radio_button_not_selected.xml +++ b/mobile/src/main/res/drawable/ic_radio_button_not_selected.xml @@ -8,11 +8,10 @@ android:viewportHeight="16" android:viewportWidth="16"> diff --git a/mobile/src/main/res/drawable/ic_show_password.xml b/mobile/src/main/res/drawable/ic_show_password.xml index c919e5308..439776452 100644 --- a/mobile/src/main/res/drawable/ic_show_password.xml +++ b/mobile/src/main/res/drawable/ic_show_password.xml @@ -8,13 +8,13 @@ android:viewportHeight="24" android:viewportWidth="24"> diff --git a/mobile/src/main/res/drawable/ic_toggle_button_off.xml b/mobile/src/main/res/drawable/ic_toggle_button_off.xml index e298ca194..5ce9ae6d1 100644 --- a/mobile/src/main/res/drawable/ic_toggle_button_off.xml +++ b/mobile/src/main/res/drawable/ic_toggle_button_off.xml @@ -6,13 +6,13 @@ \ No newline at end of file diff --git a/mobile/src/main/res/drawable/ic_toggle_button_on.xml b/mobile/src/main/res/drawable/ic_toggle_button_on.xml index acbd227df..0373f0ecd 100644 --- a/mobile/src/main/res/drawable/ic_toggle_button_on.xml +++ b/mobile/src/main/res/drawable/ic_toggle_button_on.xml @@ -6,13 +6,13 @@ \ No newline at end of file diff --git a/mobile/src/main/res/drawable/icon_round_background.xml b/mobile/src/main/res/drawable/icon_round_background.xml index 0c0b64bc8..c42879c51 100644 --- a/mobile/src/main/res/drawable/icon_round_background.xml +++ b/mobile/src/main/res/drawable/icon_round_background.xml @@ -5,7 +5,7 @@ - + diff --git a/mobile/src/main/res/drawable/input_box_background.xml b/mobile/src/main/res/drawable/input_box_background.xml new file mode 100644 index 000000000..64f4b88b9 --- /dev/null +++ b/mobile/src/main/res/drawable/input_box_background.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/list_item_background_cyberpunk.xml b/mobile/src/main/res/drawable/list_item_background_cyberpunk.xml new file mode 100644 index 000000000..6226e8201 --- /dev/null +++ b/mobile/src/main/res/drawable/list_item_background_cyberpunk.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/drawable/next_button_disabled.xml b/mobile/src/main/res/drawable/next_button_disabled.xml index 525234757..fd79a68a5 100644 --- a/mobile/src/main/res/drawable/next_button_disabled.xml +++ b/mobile/src/main/res/drawable/next_button_disabled.xml @@ -7,6 +7,6 @@ + android:color="#80E0E0E0" /> \ No newline at end of file diff --git a/mobile/src/main/res/drawable/off_button.xml b/mobile/src/main/res/drawable/off_button.xml index 63c0d9935..e81ce1927 100644 --- a/mobile/src/main/res/drawable/off_button.xml +++ b/mobile/src/main/res/drawable/off_button.xml @@ -6,7 +6,7 @@ diff --git a/mobile/src/main/res/drawable/on_button.xml b/mobile/src/main/res/drawable/on_button.xml index 08d46f647..f6bfe0d62 100644 --- a/mobile/src/main/res/drawable/on_button.xml +++ b/mobile/src/main/res/drawable/on_button.xml @@ -6,7 +6,7 @@ diff --git a/mobile/src/main/res/drawable/password_visibility_toggle.xml b/mobile/src/main/res/drawable/password_visibility_toggle.xml new file mode 100644 index 000000000..f5db6f4a1 --- /dev/null +++ b/mobile/src/main/res/drawable/password_visibility_toggle.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/mobile/src/main/res/drawable/ripple_square.xml b/mobile/src/main/res/drawable/ripple_square.xml index 0681d5967..5b5862353 100644 --- a/mobile/src/main/res/drawable/ripple_square.xml +++ b/mobile/src/main/res/drawable/ripple_square.xml @@ -4,10 +4,10 @@ --> + android:color="#2600FFFF"> - + - + \ No newline at end of file diff --git a/mobile/src/main/res/drawable/warning_action_ripple.xml b/mobile/src/main/res/drawable/warning_action_ripple.xml index 32547b974..99d37d99b 100644 --- a/mobile/src/main/res/drawable/warning_action_ripple.xml +++ b/mobile/src/main/res/drawable/warning_action_ripple.xml @@ -4,10 +4,10 @@ --> + android:color="#66FFFF00"> - + diff --git a/mobile/src/main/res/font/space_mono_bold.ttf b/mobile/src/main/res/font/space_mono_bold.ttf new file mode 100644 index 000000000..46ec1ee13 --- /dev/null +++ b/mobile/src/main/res/font/space_mono_bold.ttf @@ -0,0 +1 @@ +This is a placeholder for SpaceMono-Bold.ttf. A real TTF file is needed for rendering. diff --git a/mobile/src/main/res/font/space_mono_bold.xml b/mobile/src/main/res/font/space_mono_bold.xml new file mode 100644 index 000000000..aaebf3b6e --- /dev/null +++ b/mobile/src/main/res/font/space_mono_bold.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/src/main/res/font/space_mono_family.xml b/mobile/src/main/res/font/space_mono_family.xml new file mode 100644 index 000000000..c424f2391 --- /dev/null +++ b/mobile/src/main/res/font/space_mono_family.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/mobile/src/main/res/font/space_mono_regular.ttf b/mobile/src/main/res/font/space_mono_regular.ttf new file mode 100644 index 000000000..391037d76 --- /dev/null +++ b/mobile/src/main/res/font/space_mono_regular.ttf @@ -0,0 +1 @@ +This is a placeholder for SpaceMono-Regular.ttf. A real TTF file is needed for rendering. diff --git a/mobile/src/main/res/font/space_mono_regular.xml b/mobile/src/main/res/font/space_mono_regular.xml new file mode 100644 index 000000000..7ffda13b6 --- /dev/null +++ b/mobile/src/main/res/font/space_mono_regular.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile/src/main/res/layout/account_account_info.xml b/mobile/src/main/res/layout/account_account_info.xml new file mode 100644 index 000000000..ed14494a1 --- /dev/null +++ b/mobile/src/main/res/layout/account_account_info.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/account_login_info.xml b/mobile/src/main/res/layout/account_login_info.xml new file mode 100644 index 000000000..26579333a --- /dev/null +++ b/mobile/src/main/res/layout/account_login_info.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/activity_about.xml b/mobile/src/main/res/layout/activity_about.xml new file mode 100644 index 000000000..69f668798 --- /dev/null +++ b/mobile/src/main/res/layout/activity_about.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/activity_account.xml b/mobile/src/main/res/layout/activity_account.xml new file mode 100644 index 000000000..f3c285110 --- /dev/null +++ b/mobile/src/main/res/layout/activity_account.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src/main/res/layout/activity_add_email_address.xml b/mobile/src/main/res/layout/activity_add_email_address.xml new file mode 100644 index 000000000..f14efb0c7 --- /dev/null +++ b/mobile/src/main/res/layout/activity_add_email_address.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + +