diff --git a/Habitica/AndroidManifest.xml b/Habitica/AndroidManifest.xml
index e16895b12..c99fdc7c8 100644
--- a/Habitica/AndroidManifest.xml
+++ b/Habitica/AndroidManifest.xml
@@ -34,7 +34,8 @@
android:label="@string/app_name"
android:theme="@style/LaunchAppTheme"
android:windowSoftInputMode="stateHidden|adjustResize"
- android:configChanges="orientation|screenSize">
+ android:configChanges="orientation|screenSize"
+ android:exported="true">
@@ -54,7 +55,8 @@
android:name=".ui.activities.PrefsActivity"
android:parentActivityName=".ui.activities.MainActivity"
android:label="@string/PS_settings_title"
- tools:ignore="UnusedAttribute">
+ tools:ignore="UnusedAttribute"
+ android:exported="true">
@@ -160,7 +162,8 @@
android:name=".ui.activities.FullProfileActivity"
android:parentActivityName=".ui.activities.MainActivity"
android:theme="@style/MainAppTheme"
- tools:ignore="UnusedAttribute">
+ tools:ignore="UnusedAttribute"
+ android:exported="true">
@@ -209,18 +212,21 @@
-
+
-
+
-
+
@@ -248,7 +254,8 @@
+ android:label="@string/stats_widget_label"
+ android:exported="false">
@@ -256,7 +263,8 @@
android:resource="@xml/avatar_widget_info" />
+ android:label="@string/widget_dailies"
+ android:exported="false">
@@ -264,7 +272,8 @@
android:resource="@xml/daily_list_widget_info" />
+ android:label="@string/widget_todo_list"
+ android:exported="false">
@@ -272,7 +281,8 @@
android:resource="@xml/todo_list_widget_info" />
+ android:label="@string/widget_habit_button"
+ android:exported="false">
diff --git a/Habitica/build.gradle b/Habitica/build.gradle
index 17e152c6a..f6551fab6 100644
--- a/Habitica/build.gradle
+++ b/Habitica/build.gradle
@@ -16,7 +16,7 @@ buildscript {
jcenter()
}
dependencies {
- classpath 'com.android.tools.build:gradle:7.0.2'
+ classpath 'com.android.tools.build:gradle:7.0.3'
classpath 'net.sourceforge.pmd:pmd-java:5.5.3'
}
}
@@ -98,8 +98,6 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation "io.mockk:mockk-android:1.12.0"
- debugImplementation 'androidx.test:monitor:1.4.0'
- debugImplementation "androidx.fragment:fragment-testing:1.3.6"
//Leak Detection
@@ -112,13 +110,13 @@ dependencies {
implementation 'com.google.firebase:firebase-config'
implementation 'com.google.firebase:firebase-perf'
implementation 'com.google.android.gms:play-services-auth:19.2.0'
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.30"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.31"
implementation 'com.nex3z:flow-layout:1.2.2'
- implementation 'androidx.core:core-ktx:1.6.0'
- implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
- implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"
- implementation "androidx.lifecycle:lifecycle-common-java8:2.3.1"
+ implementation 'androidx.core:core-ktx:1.7.0'
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"
+ implementation "androidx.lifecycle:lifecycle-common-java8:2.4.0"
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation "androidx.fragment:fragment-ktx:1.3.6"
@@ -139,7 +137,7 @@ dependencies {
}
android {
- compileSdkVersion 30
+ compileSdkVersion 31
buildToolsVersion '30.0.2'
testOptions {
unitTests {
@@ -155,8 +153,10 @@ android {
buildConfigField "String", "TESTING_LEVEL", "\"production\""
resConfigs "en", "bg", "de", "en-rGB", "es", "fr", "hr-rHR", "in", "it", "iw", "ja", "ko", "lt", "nl", "pl", "pt-rBR", "pt-rPT", "ru", "tr", "zh", "zh-rTW"
- versionCode 3077
- versionName "3.4.1"
+ versionCode 3093
+ versionName "3.4.1.1"
+
+ targetSdkVersion 31
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/Habitica/res/layout/preference_button.xml b/Habitica/res/layout/preference_button.xml
new file mode 100644
index 000000000..99079219a
--- /dev/null
+++ b/Habitica/res/layout/preference_button.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Habitica/res/values/strings.xml b/Habitica/res/values/strings.xml
index 784016d1d..26103d02d 100644
--- a/Habitica/res/values/strings.xml
+++ b/Habitica/res/values/strings.xml
@@ -1184,4 +1184,16 @@
%.01f dmg pending
%s remaining
Sale ends in %s
+ My Account
+ Public Profile
+ About Me
+ API
+ Account Info
+ Login Methods
+ Apple
+ Not connected
+ Add password
+ Connect
+ Disconnect
+ Add
diff --git a/Habitica/res/xml/preferences_fragment.xml b/Habitica/res/xml/preferences_fragment.xml
index 81112e50c..aed0579de 100644
--- a/Habitica/res/xml/preferences_fragment.xml
+++ b/Habitica/res/xml/preferences_fragment.xml
@@ -1,8 +1,7 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
+ android:title="@string/my_account">
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
-
-
-
-
-
-
>
+ @DELETE("user/auth/social/{network}")
+ fun disconnectSocial(@Path("network") network: String): Flowable>
+
@POST("user/auth/apple")
fun loginApple(@Body auth: Map): Flowable>
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/components/UserComponent.java b/Habitica/src/main/java/com/habitrpg/android/habitica/components/UserComponent.java
index 17fc2e8de..229e96376 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/components/UserComponent.java
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/components/UserComponent.java
@@ -60,11 +60,9 @@ import com.habitrpg.android.habitica.ui.fragments.inventory.stable.MountDetailRe
import com.habitrpg.android.habitica.ui.fragments.inventory.stable.PetDetailRecyclerFragment;
import com.habitrpg.android.habitica.ui.fragments.inventory.stable.StableFragment;
import com.habitrpg.android.habitica.ui.fragments.inventory.stable.StableRecyclerFragment;
-import com.habitrpg.android.habitica.ui.fragments.preferences.APIPreferenceFragment;
-import com.habitrpg.android.habitica.ui.fragments.preferences.AuthenticationPreferenceFragment;
+import com.habitrpg.android.habitica.ui.fragments.preferences.AccountPreferenceFragment;
import com.habitrpg.android.habitica.ui.fragments.preferences.EmailNotificationsPreferencesFragment;
import com.habitrpg.android.habitica.ui.fragments.preferences.PreferencesFragment;
-import com.habitrpg.android.habitica.ui.fragments.preferences.ProfilePreferencesFragment;
import com.habitrpg.android.habitica.ui.fragments.preferences.PushNotificationsPreferencesFragment;
import com.habitrpg.android.habitica.ui.fragments.purchases.GemsPurchaseFragment;
import com.habitrpg.android.habitica.ui.fragments.purchases.GiftBalanceGemsFragment;
@@ -100,6 +98,7 @@ import com.habitrpg.android.habitica.ui.fragments.support.SupportMainFragment;
import com.habitrpg.android.habitica.ui.fragments.tasks.TaskRecyclerViewFragment;
import com.habitrpg.android.habitica.ui.fragments.tasks.TasksFragment;
import com.habitrpg.android.habitica.ui.fragments.tasks.TeamBoardFragment;
+import com.habitrpg.android.habitica.ui.viewmodels.AuthenticationViewModel;
import com.habitrpg.android.habitica.ui.viewmodels.GroupViewModel;
import com.habitrpg.android.habitica.ui.viewmodels.InboxViewModel;
import com.habitrpg.android.habitica.ui.viewmodels.NotificationsViewModel;
@@ -277,12 +276,6 @@ public interface UserComponent {
void inject(FixCharacterValuesActivity fixCharacterValuesActivity);
- void inject(AuthenticationPreferenceFragment authenticationPreferenceFragment);
-
- void inject(ProfilePreferencesFragment profilePreferencesFragment);
-
- void inject(APIPreferenceFragment apiPreferenceFragment);
-
void inject(StatsFragment statsFragment);
void inject(BulkAllocateStatsDialog bulkAllocateStatsDialog);
@@ -352,4 +345,7 @@ public interface UserComponent {
void inject(@NotNull EquipmentOverviewViewModel equipmentOverviewViewModel);
void inject(@NotNull AvatarStatsWidgetFactory avatarStatsWidgetFactory);
+ void inject(@NotNull AccountPreferenceFragment accountPreferenceFragment);
+
+ void inject(@NotNull AuthenticationViewModel authenticationViewModel);
}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt
index c3a7eed58..a36504e0b 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/ApiClient.kt
@@ -102,6 +102,8 @@ interface ApiClient {
fun connectUser(username: String, password: String): Flowable
fun connectSocial(network: String, userId: String, accessToken: String): Flowable
+ fun disconnectSocial(network: String): Flowable
+
fun loginApple(authToken: String): Flowable
fun sleep(): Flowable
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt
index 942b46c64..a38588643 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/data/implementation/ApiClientImpl.kt
@@ -176,6 +176,10 @@ class ApiClientImpl // private OnHabitsAPIResult mResultListener;
return this.apiService.connectSocial(auth).compose(configureApiCallObserver())
}
+ override fun disconnectSocial(network: String): Flowable {
+ return this.apiService.disconnectSocial(network).compose(configureApiCallObserver())
+ }
+
override fun loginApple(authToken: String): Flowable {
return apiService.loginApple(mapOf(Pair("code", authToken))).compose(configureApiCallObserver())
}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/Authentication.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/Authentication.kt
index 3c0c3a45e..b5dc64574 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/Authentication.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/Authentication.kt
@@ -3,17 +3,38 @@ package com.habitrpg.android.habitica.models.user
import com.google.gson.annotations.SerializedName
import com.habitrpg.android.habitica.models.BaseObject
import com.habitrpg.android.habitica.models.auth.LocalAuthentication
+import com.habitrpg.android.habitica.models.user.auth.SocialAuthentication
import io.realm.RealmObject
import io.realm.annotations.RealmClass
@RealmClass(embedded = true)
open class Authentication : RealmObject(), BaseObject {
+ fun findFirstSocialEmail(): String? {
+ for (auth in listOf(googleAuthentication, appleAuthentication, facebookAuthentication)) {
+ if (auth?.emails?.isNotEmpty() == true) {
+ return auth.emails.first()
+ }
+ }
+ return null
+ }
+
+ val hasPassword: Boolean
+ get() = localAuthentication?.email != null
@SerializedName("local")
var localAuthentication: LocalAuthentication? = null
+ @SerializedName("google")
+ var googleAuthentication: SocialAuthentication? = null
+ @SerializedName("apple")
+ var appleAuthentication: SocialAuthentication? = null
+ @SerializedName("facebook")
+ var facebookAuthentication: SocialAuthentication? = null
- var hasFacebookAuth = false
- var hasGoogleAuth = false
- var hasAppleAuth = false
+ val hasGoogleAuth: Boolean
+ get() = googleAuthentication?.emails?.isEmpty() == false
+ val hasAppleAuth: Boolean
+ get() = appleAuthentication?.emails?.isEmpty() == false
+ val hasFacebookAuth: Boolean
+ get() = facebookAuthentication?.emails?.isEmpty() == false
var timestamps: AuthenticationTimestamps? = null
}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/auth/SocialAuthentication.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/auth/SocialAuthentication.kt
new file mode 100644
index 000000000..9014e5f70
--- /dev/null
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/models/user/auth/SocialAuthentication.kt
@@ -0,0 +1,11 @@
+package com.habitrpg.android.habitica.models.user.auth
+
+import com.habitrpg.android.habitica.models.BaseObject
+import io.realm.RealmList
+import io.realm.RealmObject
+import io.realm.annotations.RealmClass
+
+@RealmClass(embedded = true)
+open class SocialAuthentication : RealmObject(), BaseObject {
+ var emails: RealmList = RealmList()
+}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/GiftSubscriptionActivity.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/GiftSubscriptionActivity.kt
index ac5cf699f..2496bd4e6 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/GiftSubscriptionActivity.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/GiftSubscriptionActivity.kt
@@ -23,6 +23,7 @@ import com.habitrpg.android.habitica.ui.views.subscriptions.SubscriptionOptionVi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe
import org.solovyev.android.checkout.Inventory
import org.solovyev.android.checkout.Sku
@@ -109,8 +110,10 @@ class GiftSubscriptionActivity : BaseActivity() {
CoroutineScope(Dispatchers.IO).launch {
val subscriptions = purchaseHandler?.getAllGiftSubscriptionProducts()
skus = subscriptions?.skus ?: return@launch
- for (sku in skus) {
- updateButtonLabel(sku, sku.price, subscriptions)
+ withContext(Dispatchers.Main) {
+ for (sku in skus) {
+ updateButtonLabel(sku, sku.price, subscriptions)
+ }
}
}
}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/LoginActivity.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/LoginActivity.kt
index 79d2d07de..fd66dfb5b 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/LoginActivity.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/LoginActivity.kt
@@ -3,7 +3,6 @@ package com.habitrpg.android.habitica.ui.activities
import android.accounts.AccountManager
import android.animation.*
import android.app.Activity
-import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
@@ -21,48 +20,27 @@ import android.widget.EditText
import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
-import androidx.core.content.edit
import androidx.preference.PreferenceManager
-import com.facebook.AccessToken
-import com.facebook.CallbackManager
-import com.facebook.FacebookCallback
-import com.facebook.FacebookException
-import com.facebook.FacebookSdk
-import com.facebook.login.LoginManager
-import com.facebook.login.LoginResult
-import com.google.android.gms.auth.GoogleAuthException
-import com.google.android.gms.auth.GoogleAuthUtil
-import com.google.android.gms.auth.GooglePlayServicesAvailabilityException
-import com.google.android.gms.auth.UserRecoverableAuthException
import com.google.android.gms.common.*
import com.google.firebase.analytics.FirebaseAnalytics
-import com.habitrpg.android.habitica.BuildConfig
-import com.habitrpg.android.habitica.HabiticaBaseApplication
import com.habitrpg.android.habitica.R
-import com.habitrpg.android.habitica.api.HostConfig
import com.habitrpg.android.habitica.components.UserComponent
import com.habitrpg.android.habitica.data.ApiClient
import com.habitrpg.android.habitica.data.UserRepository
import com.habitrpg.android.habitica.databinding.ActivityLoginBinding
import com.habitrpg.android.habitica.extensions.addCancelButton
-import com.habitrpg.android.habitica.extensions.addCloseButton
import com.habitrpg.android.habitica.extensions.addOkButton
import com.habitrpg.android.habitica.extensions.updateStatusBarColor
import com.habitrpg.android.habitica.helpers.*
import com.habitrpg.android.habitica.models.auth.UserAuthResponse
-import com.habitrpg.android.habitica.proxy.AnalyticsManager
import com.habitrpg.android.habitica.ui.helpers.dismissKeyboard
+import com.habitrpg.android.habitica.ui.viewmodels.AuthenticationViewModel
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
-import com.willowtreeapps.signinwithapplebutton.SignInWithAppleConfiguration
-import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.exceptions.Exceptions
-import io.reactivex.rxjava3.functions.Consumer
-import io.reactivex.rxjava3.schedulers.Schedulers
-import java.io.IOException
import javax.inject.Inject
-class LoginActivity : BaseActivity(), Consumer {
+class LoginActivity : BaseActivity() {
+ private lateinit var viewModel: AuthenticationViewModel
private lateinit var binding: ActivityLoginBinding
@Inject
@@ -70,23 +48,13 @@ class LoginActivity : BaseActivity(), Consumer {
@Inject
lateinit var sharedPrefs: SharedPreferences
@Inject
- lateinit var hostConfig: HostConfig
- @Inject
internal lateinit var userRepository: UserRepository
@Inject
- @JvmField
- var keyHelper: KeyHelper? = null
- @Inject
- lateinit var analyticsManager: AnalyticsManager
- @Inject
lateinit var configManager: AppConfigManager
private var isRegistering: Boolean = false
private var isShowingForm: Boolean = false
- private var callbackManager = CallbackManager.Factory.create()
- private var googleEmail: String? = null
- private var loginManager = LoginManager.getInstance()
private val loginClick = View.OnClickListener {
binding.PBAsyncTask.visibility = View.VISIBLE
@@ -105,7 +73,7 @@ class LoginActivity : BaseActivity(), Consumer {
}
apiClient.registerUser(username, email, password, confirmPassword)
.subscribe(
- this@LoginActivity,
+ { handleAuthResponse(it) },
{
hideProgress()
RxErrorHandler.reportError(it)
@@ -119,7 +87,7 @@ class LoginActivity : BaseActivity(), Consumer {
return@OnClickListener
}
apiClient.connectUser(username, password).subscribe(
- this@LoginActivity,
+ { handleAuthResponse(it) },
{
hideProgress()
RxErrorHandler.reportError(it)
@@ -140,11 +108,14 @@ class LoginActivity : BaseActivity(), Consumer {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ viewModel = AuthenticationViewModel()
supportActionBar?.hide()
// Set default values to avoid null-responses when requesting unedited settings
PreferenceManager.setDefaultValues(this, R.xml.preferences_fragment, false)
- setupFacebookLogin()
+ viewModel.setupFacebookLogin {
+ handleAuthResponse(it)
+ }
binding.loginBtn.setOnClickListener(loginClick)
@@ -168,26 +139,12 @@ class LoginActivity : BaseActivity(), Consumer {
binding.showLoginButton.setOnClickListener { showLoginButtonClicked() }
binding.backButton.setOnClickListener { backButtonClicked() }
binding.forgotPassword.setOnClickListener { onForgotPasswordClicked() }
- binding.fbLoginButton.setOnClickListener { handleFacebookLogin() }
- binding.googleLoginButton.setOnClickListener { handleGoogleLogin() }
+ binding.fbLoginButton.setOnClickListener { viewModel.handleFacebookLogin(this) }
+ binding.googleLoginButton.setOnClickListener { viewModel.handleGoogleLogin(this, pickAccountResult) }
binding.appleLoginButton.setOnClickListener {
- val configuration = SignInWithAppleConfiguration(
- clientId = BuildConfig.APPLE_AUTH_CLIENT_ID,
- redirectUri = "${hostConfig.address}/api/v4/user/auth/apple",
- scope = "name email"
- )
- val fragmentTag = "SignInWithAppleButton-SignInWebViewDialogFragment"
-
- SignInWithAppleService(supportFragmentManager, fragmentTag, configuration) { result ->
- when (result) {
- is SignInWithAppleResult.Success -> {
- val response = UserAuthResponse()
- response.id = result.userID
- response.apiToken = result.apiKey
- response.newUser = result.newUser
- }
- }
- }.show()
+ viewModel.connectApple(supportFragmentManager) {
+ handleAuthResponse(it)
+ }
}
}
@@ -196,28 +153,6 @@ class LoginActivity : BaseActivity(), Consumer {
window.updateStatusBarColor(R.color.black_20_alpha, false)
}
- private fun setupFacebookLogin() {
- callbackManager = CallbackManager.Factory.create()
- loginManager.registerCallback(
- callbackManager,
- object : FacebookCallback {
- override fun onSuccess(loginResult: LoginResult) {
- val accessToken = AccessToken.getCurrentAccessToken()
- compositeSubscription.add(
- apiClient.connectSocial("facebook", accessToken?.userId ?: "", accessToken?.token ?: "")
- .subscribe(this@LoginActivity, RxErrorHandler.handleEmptyError())
- )
- }
-
- override fun onCancel() { /* no-on */ }
-
- override fun onError(exception: FacebookException) {
- RxErrorHandler.reportError(exception)
- }
- }
- )
- }
-
override fun onBackPressed() {
if (isShowingForm) {
hideForm()
@@ -293,19 +228,34 @@ class LoginActivity : BaseActivity(), Consumer {
this.resetLayout()
}
+ private fun handleAuthResponse(response: UserAuthResponse) {
+ hideProgress()
+ dismissKeyboard()
+ viewModel.handleAuthResponse(response)
+
+ if (isRegistering) {
+ FirebaseAnalytics.getInstance(this).logEvent("user_registered", null)
+ }
+ compositeSubscription.add(
+ userRepository.retrieveUser(withTasks = true, forced = true)
+ .subscribe(
+ {
+ if (response.newUser) {
+ this.startSetupActivity()
+ } else {
+ this.startMainActivity()
+ AmplitudeManager.sendEvent("login", AmplitudeManager.EVENT_CATEGORY_BEHAVIOUR, AmplitudeManager.EVENT_HITTYPE_EVENT)
+ }
+ },
+ RxErrorHandler.handleEmptyError()
+ )
+ )
+ }
+
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
- callbackManager.onActivityResult(requestCode, resultCode, data)
-
- if (requestCode == FacebookSdk.getCallbackRequestCodeOffset()) {
- // This is necessary because the regular login callback is not called for some reason
- val accessToken = AccessToken.getCurrentAccessToken()
- if (accessToken != null && accessToken.token != null) {
- compositeSubscription.add(
- apiClient.connectSocial("facebook", accessToken.userId, accessToken.token)
- .subscribe(this@LoginActivity, { hideProgress() })
- )
- }
+ viewModel.onActivityResult(requestCode, resultCode, data) {
+ handleAuthResponse(it)
}
}
@@ -316,27 +266,6 @@ class LoginActivity : BaseActivity(), Consumer {
return super.onOptionsItemSelected(item)
}
- @Throws(Exception::class)
- private fun saveTokens(api: String, user: String) {
- this.apiClient.updateAuthenticationCredentials(user, api)
- sharedPrefs.edit {
- putString(getString(R.string.SP_userID), user)
- val encryptedKey = if (keyHelper != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- try {
- keyHelper?.encrypt(api)
- } catch (e: Exception) {
- null
- }
- } else null
- if (encryptedKey?.length ?: 0 > 5) {
- putString(user, encryptedKey)
- } else {
- // Something might have gone wrong with encryption, so fall back to this.
- putString(getString(R.string.SP_APIToken), api)
- }
- }
- }
-
private fun hideProgress() {
runOnUiThread {
binding.PBAsyncTask.visibility = View.GONE
@@ -356,141 +285,22 @@ class LoginActivity : BaseActivity(), Consumer {
alert.show()
}
- override fun accept(userAuthResponse: UserAuthResponse) {
- hideProgress()
- dismissKeyboard()
- try {
- saveTokens(userAuthResponse.apiToken, userAuthResponse.id)
- } catch (e: Exception) {
- analyticsManager.logException(e)
- }
-
- HabiticaBaseApplication.reloadUserComponent()
-
- if (isRegistering) {
- FirebaseAnalytics.getInstance(this).logEvent("user_registered", null)
- }
-
- compositeSubscription.add(
- userRepository.retrieveUser(withTasks = true, forced = true)
- .subscribe(
- {
- if (userAuthResponse.newUser) {
- this.startSetupActivity()
- } else {
- this.startMainActivity()
- AmplitudeManager.sendEvent("login", AmplitudeManager.EVENT_CATEGORY_BEHAVIOUR, AmplitudeManager.EVENT_HITTYPE_EVENT)
- }
- },
- RxErrorHandler.handleEmptyError()
- )
- )
- }
-
- private fun handleFacebookLogin() {
- loginManager.logInWithReadPermissions(this, listOf("user_friends"))
- }
-
- private fun handleGoogleLogin() {
- if (!checkPlayServices()) {
- return
- }
- val accountTypes = arrayOf("com.google")
- val intent = AccountManager.newChooseAccountIntent(
- null, null,
- accountTypes, true, null, null, null, null
- )
- try {
- pickAccountResult.launch(intent)
- } catch (e: ActivityNotFoundException) {
- val alert = HabiticaAlertDialog(this)
- alert.setTitle(R.string.authentication_error_title)
- alert.setMessage(R.string.google_services_missing)
- alert.addCloseButton()
- alert.show()
- }
- }
-
private val pickAccountResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
- googleEmail = it?.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
- handleGoogleLoginResult()
+ viewModel.googleEmail = it?.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
+ viewModel.handleGoogleLoginResult(this, recoverFromPlayServicesErrorResult) {
+ handleAuthResponse(it)
+ }
}
}
- private fun handleGoogleLoginResult() {
- val scopesString = Scopes.PROFILE + " " + Scopes.EMAIL
- val scopes = "oauth2:$scopesString"
- compositeSubscription.add(
- Flowable.defer {
- try {
- @Suppress("Deprecation")
- return@defer Flowable.just(GoogleAuthUtil.getToken(this, googleEmail, scopes))
- } catch (e: IOException) {
- throw Exceptions.propagate(e)
- } catch (e: GoogleAuthException) {
- throw Exceptions.propagate(e)
- } catch (e: UserRecoverableException) {
- return@defer Flowable.empty()
- }
- }
- .subscribeOn(Schedulers.io())
- .flatMap { token -> apiClient.connectSocial("google", googleEmail ?: "", token) }
- .subscribe(
- this@LoginActivity,
- { throwable ->
- hideProgress()
- throwable.cause?.let {
- if (GoogleAuthException::class.java.isAssignableFrom(it.javaClass)) {
- handleGoogleAuthException(throwable.cause as GoogleAuthException)
- }
- }
- }
- )
- )
- }
-
- private fun handleGoogleAuthException(e: Exception) {
- if (e is GooglePlayServicesAvailabilityException) {
- // The Google Play services APK is old, disabled, or not present.
- // Show a dialog created by Google Play services that allows
- // the user to update the APK
- val statusCode = e
- .connectionStatusCode
- GoogleApiAvailability.getInstance()
- @Suppress("DEPRECATION")
- GooglePlayServicesUtil.showErrorDialogFragment(
- statusCode,
- this@LoginActivity,
- REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR
- ) {
- }
- } else if (e is UserRecoverableAuthException) {
- // Unable to authenticate, such as when the user has not yet granted
- // the app access to the account, but the user can fix this.
- // Forward the user to an activity in Google Play services.
- val intent = e.intent
- recoverFromPlayServicesErrorResult.launch(intent)
- }
- }
-
- private val recoverFromPlayServicesErrorResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ private val recoverFromPlayServicesErrorResult = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode != Activity.RESULT_CANCELED) {
- handleGoogleLoginResult()
- }
- }
-
- private fun checkPlayServices(): Boolean {
- val googleAPI = GoogleApiAvailability.getInstance()
- val result = googleAPI.isGooglePlayServicesAvailable(this)
- if (result != ConnectionResult.SUCCESS) {
- if (googleAPI.isUserResolvableError(result)) {
- googleAPI.getErrorDialog(this, result, PLAY_SERVICES_RESOLUTION_REQUEST).show()
+ viewModel.handleGoogleLoginResult(this, null) {
+ handleAuthResponse(it)
}
- return false
}
-
- return true
}
private fun newGameButtonClicked() {
@@ -629,8 +439,6 @@ class LoginActivity : BaseActivity(), Consumer {
companion object {
internal const val REQUEST_CODE_PICK_ACCOUNT = 1000
- private const val REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR = 1001
- private const val PLAY_SERVICES_RESOLUTION_REQUEST = 9000
fun show(v: View) {
v.visibility = View.VISIBLE
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/PrefsActivity.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/PrefsActivity.kt
index b468fe561..cd98445e7 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/PrefsActivity.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/PrefsActivity.kt
@@ -61,9 +61,7 @@ class PrefsActivity : BaseActivity(), PreferenceFragmentCompat.OnPreferenceStart
private fun createNextPage(preferenceScreen: PreferenceScreen): PreferenceFragmentCompat? =
when (preferenceScreen.key) {
- "profile" -> ProfilePreferencesFragment()
- "authentication" -> AuthenticationPreferenceFragment()
- "api" -> APIPreferenceFragment()
+ "my_account" -> AccountPreferenceFragment()
"pushNotifications" -> PushNotificationsPreferencesFragment()
"emailNotifications" -> EmailNotificationsPreferencesFragment()
else -> null
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/adapter/NavigationDrawerAdapter.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/adapter/NavigationDrawerAdapter.kt
index be167f316..247c15ece 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/adapter/NavigationDrawerAdapter.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/adapter/NavigationDrawerAdapter.kt
@@ -68,16 +68,8 @@ class NavigationDrawerAdapter(tintColor: Int, backgroundTintColor: Int) : Recycl
fun updateItem(item: HabiticaDrawerItem) {
val position = getItemPosition(item.identifier)
- if (position == -1) {
- items.add(item)
- notifyItemInserted(items.size - 1)
- } else {
- items[position] = item
- val visiblePosition = getVisibleItemPosition(item.identifier)
- if (visiblePosition in 0 until itemCount) {
- notifyItemChanged(visiblePosition)
- }
- }
+ items[position] = item
+ notifyDataSetChanged()
}
fun updateItems(newItems: List) {
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/APIPreferenceFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/APIPreferenceFragment.kt
deleted file mode 100644
index 37a5728ef..000000000
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/APIPreferenceFragment.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.habitrpg.android.habitica.ui.fragments.preferences
-
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
-import android.os.Bundle
-import android.widget.Toast
-import androidx.preference.Preference
-import com.habitrpg.android.habitica.HabiticaBaseApplication
-import com.habitrpg.android.habitica.R
-import com.habitrpg.android.habitica.api.HostConfig
-import javax.inject.Inject
-
-class APIPreferenceFragment : BasePreferencesFragment() {
- @Inject
- lateinit var hostConfig: HostConfig
-
- private val apiPreferences: List
- get() = listOf(getString(R.string.SP_APIToken), getString(R.string.SP_userID))
-
- override fun onCreate(savedInstanceState: Bundle?) {
- HabiticaBaseApplication.userComponent?.inject(this)
- super.onCreate(savedInstanceState)
- }
-
- override fun onPreferenceTreeClick(preference: Preference): Boolean {
- val clipMan = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
- clipMan?.setPrimaryClip(
- if (preference.key == getString(R.string.SP_APIToken)) {
- ClipData.newPlainText(preference.key, hostConfig.apiKey)
- } else {
- ClipData.newPlainText(preference.key, preference.summary)
- }
- )
- Toast.makeText(activity, "Copied " + preference.key + " to clipboard.", Toast.LENGTH_SHORT).show()
- return super.onPreferenceTreeClick(preference)
- }
-
- override fun setupPreferences() {
- for ((key, value) in preferenceScreen.sharedPreferences.all) {
- if (apiPreferences.contains(key) && value != null) {
- findPreference(key)?.summary = value.toString()
- }
- }
- }
-}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AuthenticationPreferenceFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt
similarity index 52%
rename from Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AuthenticationPreferenceFragment.kt
rename to Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt
index 0b10a91a1..b79e3befd 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AuthenticationPreferenceFragment.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/AccountPreferenceFragment.kt
@@ -1,40 +1,46 @@
package com.habitrpg.android.habitica.ui.fragments.preferences
+import android.accounts.AccountManager
+import android.app.Activity
import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
import android.os.Bundle
import android.text.InputType
+import android.view.View
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
-import androidx.core.os.bundleOf
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import androidx.preference.EditTextPreference
import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
import com.google.android.material.textfield.TextInputLayout
import com.habitrpg.android.habitica.HabiticaBaseApplication
import com.habitrpg.android.habitica.R
+import com.habitrpg.android.habitica.api.HostConfig
import com.habitrpg.android.habitica.data.ApiClient
import com.habitrpg.android.habitica.extensions.addCancelButton
-import com.habitrpg.android.habitica.extensions.addCloseButton
import com.habitrpg.android.habitica.extensions.dpToPx
import com.habitrpg.android.habitica.extensions.layoutInflater
-import com.habitrpg.android.habitica.helpers.AppConfigManager
-import com.habitrpg.android.habitica.helpers.MainNavigationController
import com.habitrpg.android.habitica.helpers.RxErrorHandler
import com.habitrpg.android.habitica.models.user.User
+import com.habitrpg.android.habitica.ui.viewmodels.AuthenticationViewModel
+import com.habitrpg.android.habitica.ui.views.ExtraLabelPreference
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaProgressDialog
-import com.habitrpg.android.habitica.ui.views.subscriptions.SubscriptionDetailsView
import javax.inject.Inject
-class AuthenticationPreferenceFragment : BasePreferencesFragment() {
-
+class AccountPreferenceFragment: BasePreferencesFragment(),
+ SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
- lateinit var configManager: AppConfigManager
-
+ lateinit var hostConfig: HostConfig
@Inject
lateinit var apiClient: ApiClient
+ private lateinit var viewModel: AuthenticationViewModel
+
override var user: User? = null
set(value) {
field = value
@@ -44,65 +50,184 @@ class AuthenticationPreferenceFragment : BasePreferencesFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
HabiticaBaseApplication.userComponent?.inject(this)
super.onCreate(savedInstanceState)
+ viewModel = AuthenticationViewModel()
+ findPreference("confirm_username")?.isVisible = user?.flags?.verifiedUsername == false
- findPreference("login_name")?.title = context?.getString(R.string.username)
- findPreference("confirm_username")?.isVisible = user?.flags?.verifiedUsername != true
- }
-
- private fun updateUserFields() {
- configurePreference(findPreference("login_name"), user?.authentication?.localAuthentication?.username, false)
- configurePreference(findPreference("email"), user?.authentication?.localAuthentication?.email, true)
- findPreference("change_password")?.isVisible = user?.authentication?.localAuthentication?.email?.isNotEmpty() == true
- findPreference("add_local_auth")?.isVisible = user?.authentication?.localAuthentication?.email?.isNotEmpty() != true
- findPreference("confirm_username")?.isVisible = user?.flags?.verifiedUsername != true
- val preference = findPreference("authentication_methods")
- val methods = mutableListOf()
- if (user?.authentication?.localAuthentication?.email != null) {
- context?.getString(R.string.local)?.let { methods.add(it) }
- }
- if (user?.authentication?.hasFacebookAuth == true) { context?.getString(R.string.facebook)?.let { methods.add(it) } }
- if (user?.authentication?.hasGoogleAuth == true) { context?.getString(R.string.google)?.let { methods.add(it) } }
- if (user?.authentication?.hasAppleAuth == true) { context?.getString(R.string.apple_sign_in)?.let { methods.add(it) } }
- preference?.summary = methods.joinToString(", ")
- }
-
- private fun configurePreference(preference: Preference?, value: String?, hideIfEmpty: Boolean) {
- preference?.summary = value
- if (hideIfEmpty) {
- preference?.isVisible = value?.isNotEmpty() == true
- }
+ viewModel.setupFacebookLogin { viewModel.handleAuthResponse(it) }
}
override fun setupPreferences() {
updateUserFields()
}
- override fun onPreferenceTreeClick(preference: Preference): Boolean {
- when (preference.key) {
- "login_name" -> showLoginNameDialog()
+ override fun onResume() {
+ super.onResume()
+ preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
+ }
+
+ override fun onPause() {
+ preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
+ super.onPause()
+ }
+
+
+ private fun updateUserFields() {
+ val user = user ?: return
+ configurePreference(findPreference("username"), user.authentication?.localAuthentication?.username)
+ configurePreference(findPreference("email"), user.authentication?.localAuthentication?.email)
+ findPreference("confirm_username")?.isVisible = user.flags?.verifiedUsername != true
+
+ val passwordPref = findPreference("password")
+ if (user.authentication?.hasPassword == true) {
+ passwordPref?.summary = "··········"
+ passwordPref?.extraText = getString(R.string.change_password)
+ } else {
+ passwordPref?.summary = getString(R.string.not_set)
+ passwordPref?.extraText = getString(R.string.add_password)
+ }
+ val googlePref = findPreference("google_auth")
+ if (user.authentication?.hasGoogleAuth == true) {
+ googlePref?.summary = user.authentication?.googleAuthentication?.emails?.first()
+ googlePref?.extraText = getString(R.string.disconnect)
+ googlePref?.extraTextColor = context?.let { ContextCompat.getColor(it, R.color.text_red) }
+ } else {
+ googlePref?.summary = getString(R.string.not_connected)
+ googlePref?.extraText = getString(R.string.connect)
+ }
+ val applePref = findPreference("apple_auth")
+ if (user.authentication?.hasGoogleAuth == true) {
+ applePref?.summary = user.authentication?.appleAuthentication?.emails?.first()
+ applePref?.extraText = getString(R.string.disconnect)
+ applePref?.extraTextColor = context?.let { ContextCompat.getColor(it, R.color.text_red) }
+ } else {
+ applePref?.summary = getString(R.string.not_connected)
+ applePref?.extraText = getString(R.string.connect)
+ }
+ val facebookPref = findPreference("facebook_auth")
+ if (user.authentication?.hasFacebookAuth == true) {
+ facebookPref?.summary = user.authentication?.facebookAuthentication?.emails?.first()
+ facebookPref?.extraText = getString(R.string.disconnect)
+ facebookPref?.extraTextColor = context?.let { ContextCompat.getColor(it, R.color.text_red) }
+ } else {
+ facebookPref?.summary = getString(R.string.not_connected)
+ facebookPref?.extraText = getString(R.string.connect)
+ }
+
+ configurePreference(findPreference("display_name"), user.profile?.name)
+ configurePreference(findPreference("photo_url"), user.profile?.imageUrl)
+ configurePreference(findPreference("about"), user.profile?.blurb)
+
+ configurePreference(findPreference("UserID"), user.id)
+ }
+
+
+ private fun configurePreference(preference: Preference?, value: String?) {
+ (preference as? EditTextPreference)?.let {
+ it.text = value
+
+ }
+ preference?.summary = value
+ }
+
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String) {
+ val profileCategory = findPreference("profile") as? PreferenceCategory
+ configurePreference(profileCategory?.findPreference(key), sharedPreferences?.getString(key, ""))
+ if (sharedPreferences != null) {
+ val newValue = sharedPreferences.getString(key, "") ?: return
+ when (key) {
+ "display_name" -> updateUser("profile.name", newValue, user?.profile?.name)
+ "photo_url" -> updateUser("profile.imageUrl", newValue, user?.profile?.imageUrl)
+ "about" -> updateUser("profile.blurb", newValue, user?.profile?.blurb)
+ }
+ }
+ }
+
+ override fun onPreferenceTreeClick(preference: Preference?): Boolean {
+ when(preference?.key) {
+ "username" -> showLoginNameDialog()
"confirm_username" -> showConfirmUsernameDialog()
"email" -> showEmailDialog()
- "change_password" -> showChangePasswordDialog()
- "subscription_status" -> {
- val plan = user?.purchased?.plan
- if (plan?.isActive == true) {
- showSubscriptionStatusDialog()
- return super.onPreferenceTreeClick(preference)
+ "password" -> {
+ if (user?.authentication?.hasPassword == true) {
+ showChangePasswordDialog()
+ } else {
+ showAddPasswordDialog()
+ }
+ }
+ "UserID" -> {
+ copyValue(getString(R.string.SP_userID), user?.id)
+ return true
+ }
+ "ApiToken" -> {
+ copyValue(getString(R.string.SP_APIToken_title), hostConfig.apiKey)
+ return true
+ }
+ "google_auth" -> {
+ if (user?.authentication?.hasGoogleAuth == true) {
+ apiClient.disconnectSocial("google").subscribe({}, RxErrorHandler.handleEmptyError())
+ } else {
+ activity?.let {
+ viewModel.handleGoogleLogin(it, pickAccountResult)
+ }
+ }
+ }
+ "apple_auth" -> {
+ if (user?.authentication?.hasAppleAuth == true) {
+ apiClient.disconnectSocial("apple").subscribe({}, RxErrorHandler.handleEmptyError())
+ } else {
+ viewModel.connectApple(parentFragmentManager) {
+ viewModel.handleAuthResponse(it)
+ }
+ }
+ }
+ "facebook_auth" -> {
+ if (user?.authentication?.hasFacebookAuth == true) {
+ apiClient.disconnectSocial("facebook").subscribe({}, RxErrorHandler.handleEmptyError())
+ } else {
+ viewModel.handleFacebookLogin(this)
}
- MainNavigationController.navigate(R.id.gemPurchaseActivity, bundleOf(Pair("openSubscription", true)))
}
"reset_account" -> showAccountResetConfirmation()
"delete_account" -> showAccountDeleteConfirmation()
- "add_local_auth" -> showAddLocalAuthDialog()
- else -> {
- val clipMan = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
- clipMan?.setPrimaryClip(ClipData.newPlainText(preference.key, preference.summary))
- Toast.makeText(activity, "Copied " + preference.key + " to clipboard.", Toast.LENGTH_SHORT).show()
- }
}
return super.onPreferenceTreeClick(preference)
}
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ viewModel.onActivityResult(requestCode, resultCode, data) {
+ viewModel.handleAuthResponse(it)
+ }
+ }
+
+ private val pickAccountResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ viewModel.googleEmail = it?.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
+ activity?.let { it1 ->
+ viewModel.handleGoogleLoginResult(it1, recoverFromPlayServicesErrorResult) {
+ viewModel.handleAuthResponse(it)
+ }
+ }
+ }
+ }
+
+ private val recoverFromPlayServicesErrorResult = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode != Activity.RESULT_CANCELED) {
+ activity?.let { it1 ->
+ viewModel.handleGoogleLoginResult(it1, null) {
+ viewModel.handleAuthResponse(it)
+ }
+ }
+ }
+ }
+
+ private fun updateUser(path: String, newValue: String, oldValue: String?) {
+ if (newValue != oldValue) {
+ compositeSubscription.add(userRepository.updateUser(path, newValue).subscribe({}, RxErrorHandler.handleEmptyError()))
+ }
+ }
+
private fun showChangePasswordDialog() {
val inflater = context?.layoutInflater
val view = inflater?.inflate(R.layout.dialog_edittext_change_pw, null)
@@ -128,6 +253,33 @@ class AuthenticationPreferenceFragment : BasePreferencesFragment() {
}
}
+ private fun showAddPasswordDialog() {
+ val inflater = context?.layoutInflater
+ val view = inflater?.inflate(R.layout.dialog_edittext_change_pw, null)
+ val oldPasswordEditText = view?.findViewById(R.id.editText)
+ oldPasswordEditText?.visibility = View.GONE
+ val passwordEditText = view?.findViewById(R.id.passwordEditText)
+ val passwordRepeatEditText = view?.findViewById(R.id.passwordRepeatEditText)
+ context?.let { context ->
+ val dialog = HabiticaAlertDialog(context)
+ dialog.setTitle(R.string.add_password)
+ dialog.addButton(R.string.add, true) { _, _ ->
+ val email = user?.authentication?.findFirstSocialEmail()
+ apiClient.registerUser(user?.username ?: "", email ?: "", passwordEditText?.text.toString(), passwordRepeatEditText?.text.toString())
+ .subscribe(
+ {
+ Toast.makeText(activity, R.string.password_changed, Toast.LENGTH_SHORT).show()
+ },
+ RxErrorHandler.handleEmptyError()
+ )
+ }
+ dialog.addCancelButton()
+ dialog.setAdditionalContentView(view)
+ dialog.setAdditionalContentSidePadding(12)
+ dialog.show()
+ }
+ }
+
private fun showEmailDialog() {
val inflater = context?.layoutInflater
val view = inflater?.inflate(R.layout.dialog_edittext_confirm_pw, null)
@@ -142,7 +294,7 @@ class AuthenticationPreferenceFragment : BasePreferencesFragment() {
userRepository.updateEmail(emailEditText?.text.toString(), passwordEditText?.text.toString())
.subscribe(
{
- configurePreference(findPreference("email"), emailEditText?.text.toString(), true)
+ configurePreference(findPreference("email"), emailEditText?.text.toString())
},
RxErrorHandler.handleEmptyError()
)
@@ -167,7 +319,7 @@ class AuthenticationPreferenceFragment : BasePreferencesFragment() {
userRepository.updateLoginName(loginNameEditText?.text.toString())
.subscribe(
{
- configurePreference(findPreference("login_name"), loginNameEditText?.text.toString(), true)
+ configurePreference(findPreference("username"), loginNameEditText?.text.toString())
},
RxErrorHandler.handleEmptyError()
)
@@ -205,36 +357,6 @@ class AuthenticationPreferenceFragment : BasePreferencesFragment() {
}
}
- private fun showAddLocalAuthDialog() {
- val inflater = context?.layoutInflater
- val view = inflater?.inflate(R.layout.dialog_edittext_add_local_auth, null)
- val emailEditText = view?.findViewById(R.id.emailTitleTextView)
- val passwordEditText = view?.findViewById(R.id.passwordEditText)
- val passwordRepeatEditText = view?.findViewById(R.id.passwordRepeatEditText)
- context?.let { context ->
- val dialog = HabiticaAlertDialog(context)
- dialog.setTitle(R.string.add_local_authentication)
- dialog.addButton(R.string.save, true) { thisDialog, _ ->
- if (passwordEditText?.text == passwordRepeatEditText?.text) {
- return@addButton
- }
- thisDialog.dismiss()
- apiClient.registerUser(user?.username ?: "", emailEditText?.text.toString(), passwordEditText?.text.toString(), passwordRepeatEditText?.text.toString())
- .flatMap { userRepository.retrieveUser(false) }
- .subscribe(
- {
- configurePreference(findPreference("email"), emailEditText?.text.toString(), true)
- },
- RxErrorHandler.handleEmptyError()
- )
- }
- dialog.addCancelButton()
- dialog.setAdditionalContentView(view)
- dialog.setAdditionalContentSidePadding(12.dpToPx(context))
- dialog.show()
- }
- }
-
private fun deleteAccount(password: String) {
val dialog = HabiticaProgressDialog.show(context, R.string.deleting_account)
compositeSubscription.add(
@@ -286,17 +408,8 @@ class AuthenticationPreferenceFragment : BasePreferencesFragment() {
)
}
- private fun showSubscriptionStatusDialog() {
- context?.let { context ->
- val view = SubscriptionDetailsView(context)
- user?.purchased?.plan?.let {
- view.setPlan(it)
- }
- val dialog = HabiticaAlertDialog(context)
- dialog.setAdditionalContentView(view)
- dialog.setTitle(R.string.subscription_status)
- dialog.addCloseButton()
- dialog.show()
- }
+ private fun copyValue(name: String, value: CharSequence?) {
+ ClipData.newPlainText(name, value)
+ Toast.makeText(activity, "Copied $name to clipboard.", Toast.LENGTH_SHORT).show()
}
-}
+}
\ No newline at end of file
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ProfilePreferencesFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ProfilePreferencesFragment.kt
deleted file mode 100644
index 4483fe7a6..000000000
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/preferences/ProfilePreferencesFragment.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.habitrpg.android.habitica.ui.fragments.preferences
-
-import android.content.SharedPreferences
-import android.os.Bundle
-import androidx.preference.EditTextPreference
-import androidx.preference.Preference
-import androidx.preference.PreferenceCategory
-import com.habitrpg.android.habitica.HabiticaBaseApplication
-import com.habitrpg.android.habitica.helpers.RxErrorHandler
-import com.habitrpg.android.habitica.models.user.User
-import io.reactivex.rxjava3.core.Flowable
-
-class ProfilePreferencesFragment : BasePreferencesFragment(), SharedPreferences.OnSharedPreferenceChangeListener {
-
- override var user: User? = null
- set(value) {
- field = value
- updateUserFields()
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- HabiticaBaseApplication.userComponent?.inject(this)
- super.onCreate(savedInstanceState)
- }
-
- override fun onResume() {
- super.onResume()
- preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
- }
-
- override fun onPause() {
- preferenceManager.sharedPreferences.unregisterOnSharedPreferenceChangeListener(this)
- super.onPause()
- }
-
- private fun updateUserFields() {
- configurePreference(findPreference("display_name"), user?.profile?.name)
- configurePreference(findPreference("photo_url"), user?.profile?.imageUrl)
- configurePreference(findPreference("about"), user?.profile?.blurb)
- }
-
- private fun configurePreference(preference: Preference?, value: String?) {
- val editPreference = preference as? EditTextPreference
- editPreference?.text = value
- preference?.summary = value
- }
-
- override fun setupPreferences() {
- updateUserFields()
- }
-
- override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String) {
- val profileCategory = findPreference("profile") as? PreferenceCategory
- configurePreference(profileCategory?.findPreference(key), sharedPreferences?.getString(key, ""))
- if (sharedPreferences != null) {
- val newValue = sharedPreferences.getString(key, "") ?: return
- val observable: Flowable? = when (key) {
- "display_name" -> {
- if (newValue != user?.profile?.name) {
- userRepository.updateUser("profile.name", newValue)
- } else {
- null
- }
- }
- "photo_url" -> {
- if (newValue != user?.profile?.imageUrl) {
- userRepository.updateUser("profile.imageUrl", newValue)
- } else {
- null
- }
- }
- "about" -> {
- if (newValue != user?.profile?.blurb) {
- userRepository.updateUser("profile.blurb", newValue)
- } else {
- null
- }
- }
- else -> null
- }
- observable?.subscribe({}, RxErrorHandler.handleEmptyError())?.let { compositeSubscription.add(it) }
- }
- }
-}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/purchases/GiftBalanceGemsFragment.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/purchases/GiftBalanceGemsFragment.kt
index 1502dbfc7..0e341cfcc 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/purchases/GiftBalanceGemsFragment.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/fragments/purchases/GiftBalanceGemsFragment.kt
@@ -34,13 +34,18 @@ class GiftBalanceGemsFragment : BaseFragment() {
set(value) {
field = value
field?.let {
- binding?.avatarView?.setAvatar(it)
- binding?.displayNameTextview?.username = it.profile?.name
- binding?.displayNameTextview?.tier = it.contributor?.level ?: 0
- binding?.usernameTextview?.text = "@${it.username}"
+ updateMemberViews()
}
}
+ private fun updateMemberViews() {
+ val it = giftedMember ?: return
+ binding?.avatarView?.setAvatar(it)
+ binding?.displayNameTextview?.username = it.profile?.name
+ binding?.displayNameTextview?.tier = it.contributor?.level ?: 0
+ binding?.usernameTextview?.text = it.formattedUsername
+ }
+
var onCompleted: (() -> Unit)? = null
override fun injectFragment(component: UserComponent) {
@@ -50,6 +55,7 @@ class GiftBalanceGemsFragment : BaseFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.giftButton?.setOnClickListener { sendGift() }
+ updateMemberViews()
}
private fun sendGift() {
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt
new file mode 100644
index 000000000..63b0da2b6
--- /dev/null
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/viewmodels/AuthenticationViewModel.kt
@@ -0,0 +1,288 @@
+package com.habitrpg.android.habitica.ui.viewmodels
+
+import android.accounts.AccountManager
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Build
+import androidx.activity.result.ActivityResultLauncher
+import androidx.core.content.edit
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.FragmentManager
+import com.facebook.AccessToken
+import com.facebook.CallbackManager
+import com.facebook.FacebookCallback
+import com.facebook.FacebookException
+import com.facebook.FacebookSdk
+import com.facebook.login.LoginManager
+import com.facebook.login.LoginResult
+import com.google.android.gms.auth.GoogleAuthException
+import com.google.android.gms.auth.GoogleAuthUtil
+import com.google.android.gms.auth.GooglePlayServicesAvailabilityException
+import com.google.android.gms.auth.UserRecoverableAuthException
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.GooglePlayServicesUtil
+import com.google.android.gms.common.Scopes
+import com.google.android.gms.common.UserRecoverableException
+import com.habitrpg.android.habitica.BuildConfig
+import com.habitrpg.android.habitica.HabiticaBaseApplication
+import com.habitrpg.android.habitica.R
+import com.habitrpg.android.habitica.api.HostConfig
+import com.habitrpg.android.habitica.data.ApiClient
+import com.habitrpg.android.habitica.extensions.addCloseButton
+import com.habitrpg.android.habitica.helpers.KeyHelper
+import com.habitrpg.android.habitica.helpers.RxErrorHandler
+import com.habitrpg.android.habitica.helpers.SignInWithAppleResult
+import com.habitrpg.android.habitica.helpers.SignInWithAppleService
+import com.habitrpg.android.habitica.models.auth.UserAuthResponse
+import com.habitrpg.android.habitica.proxy.AnalyticsManager
+import com.habitrpg.android.habitica.ui.views.dialogs.HabiticaAlertDialog
+import com.willowtreeapps.signinwithapplebutton.SignInWithAppleConfiguration
+import io.reactivex.rxjava3.core.Flowable
+import io.reactivex.rxjava3.disposables.CompositeDisposable
+import io.reactivex.rxjava3.exceptions.Exceptions
+import io.reactivex.rxjava3.schedulers.Schedulers
+import java.io.IOException
+import javax.inject.Inject
+
+class AuthenticationViewModel() {
+ @Inject
+ internal lateinit var apiClient: ApiClient
+ @Inject
+ internal lateinit var sharedPrefs: SharedPreferences
+ @Inject
+ internal lateinit var hostConfig: HostConfig
+ @Inject
+ internal lateinit var analyticsManager: AnalyticsManager
+ @Inject
+ @JvmField
+ var keyHelper: KeyHelper? = null
+
+ private var compositeSubscription = CompositeDisposable()
+
+
+ private var callbackManager = CallbackManager.Factory.create()
+ var googleEmail: String? = null
+ private var loginManager = LoginManager.getInstance()
+
+ init {
+ HabiticaBaseApplication.userComponent?.inject(this)
+ }
+
+ fun connectApple(fragmentManager: FragmentManager, onSuccess: (UserAuthResponse) -> Unit) {
+ val configuration = SignInWithAppleConfiguration(
+ clientId = BuildConfig.APPLE_AUTH_CLIENT_ID,
+ redirectUri = "${hostConfig.address}/api/v4/user/auth/apple",
+ scope = "name email"
+ )
+ val fragmentTag = "SignInWithAppleButton-SignInWebViewDialogFragment"
+
+ SignInWithAppleService(fragmentManager, fragmentTag, configuration) { result ->
+ when (result) {
+ is SignInWithAppleResult.Success -> {
+ val response = UserAuthResponse()
+ response.id = result.userID
+ response.apiToken = result.apiKey
+ response.newUser = result.newUser
+ onSuccess(response)
+ }
+ }
+ }.show()
+ }
+
+ fun setupFacebookLogin(onSuccess: (UserAuthResponse) -> Unit) {
+ callbackManager = CallbackManager.Factory.create()
+ loginManager.registerCallback(
+ callbackManager,
+ object : FacebookCallback {
+ override fun onSuccess(loginResult: LoginResult) {
+ val accessToken = AccessToken.getCurrentAccessToken()
+ compositeSubscription.add(
+ apiClient.connectSocial("facebook", accessToken?.userId ?: "", accessToken?.token ?: "")
+ .subscribe({
+ onSuccess(it)
+ }, RxErrorHandler.handleEmptyError())
+ )
+ }
+
+ override fun onCancel() { /* no-on */ }
+
+ override fun onError(exception: FacebookException) {
+ RxErrorHandler.reportError(exception)
+ }
+ }
+ )
+ }
+
+ fun handleFacebookLogin(activity: Activity) {
+ loginManager.logInWithReadPermissions(activity, listOf("user_friends"))
+ }
+
+ fun handleFacebookLogin(fragment: Fragment) {
+ loginManager.logInWithReadPermissions(fragment, listOf("user_friends"))
+ }
+
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?, onSuccess: (UserAuthResponse) -> Unit) {
+ callbackManager.onActivityResult(requestCode, resultCode, data)
+
+ if (requestCode == FacebookSdk.getCallbackRequestCodeOffset()) {
+ // This is necessary because the regular login callback is not called for some reason
+ val accessToken = AccessToken.getCurrentAccessToken()
+ if (accessToken?.token != null) {
+ compositeSubscription.add(
+ apiClient.connectSocial("facebook", accessToken.userId, accessToken.token)
+ .subscribe({
+ onSuccess(it)
+ }, { })
+ )
+ }
+ }
+ }
+
+ fun handleGoogleLogin(
+ activity: Activity,
+ pickAccountResult: ActivityResultLauncher
+ ) {
+ if (!checkPlayServices(activity)) {
+ return
+ }
+ val accountTypes = arrayOf("com.google")
+ val intent = AccountManager.newChooseAccountIntent(
+ null, null,
+ accountTypes, true, null, null, null, null
+ )
+ try {
+ pickAccountResult.launch(intent)
+ } catch (e: ActivityNotFoundException) {
+ val alert = HabiticaAlertDialog(activity)
+ alert.setTitle(R.string.authentication_error_title)
+ alert.setMessage(R.string.google_services_missing)
+ alert.addCloseButton()
+ alert.show()
+ }
+ }
+
+ fun handleGoogleLoginResult(
+ activity: Activity,
+ recoverFromPlayServicesErrorResult: ActivityResultLauncher?,
+ onSuccess: (UserAuthResponse) -> Unit
+ ) {
+ val scopesString = Scopes.PROFILE + " " + Scopes.EMAIL
+ val scopes = "oauth2:$scopesString"
+ compositeSubscription.add(
+ Flowable.defer {
+ try {
+ @Suppress("Deprecation")
+ return@defer Flowable.just(GoogleAuthUtil.getToken(activity, googleEmail, scopes))
+ } catch (e: IOException) {
+ throw Exceptions.propagate(e)
+ } catch (e: GoogleAuthException) {
+ throw Exceptions.propagate(e)
+ } catch (e: UserRecoverableException) {
+ return@defer Flowable.empty()
+ }
+ }
+ .subscribeOn(Schedulers.io())
+ .flatMap { token -> apiClient.connectSocial("google", googleEmail ?: "", token) }
+ .subscribe(
+ {
+ onSuccess(it)
+ },
+ { throwable ->
+ if (recoverFromPlayServicesErrorResult == null) return@subscribe
+ throwable.cause?.let {
+ if (GoogleAuthException::class.java.isAssignableFrom(it.javaClass)) {
+ handleGoogleAuthException(throwable.cause as GoogleAuthException,
+ activity,
+ recoverFromPlayServicesErrorResult)
+ }
+ }
+ }
+ )
+ )
+ }
+
+ private fun handleGoogleAuthException(
+ e: Exception,
+ activity: Activity,
+ recoverFromPlayServicesErrorResult: ActivityResultLauncher
+ ) {
+ if (e is GooglePlayServicesAvailabilityException) {
+ // The Google Play services APK is old, disabled, or not present.
+ // Show a dialog created by Google Play services that allows
+ // the user to update the APK
+ val statusCode = e
+ .connectionStatusCode
+ GoogleApiAvailability.getInstance()
+ @Suppress("DEPRECATION")
+ GooglePlayServicesUtil.showErrorDialogFragment(
+ statusCode,
+ activity,
+ AuthenticationViewModel.REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR
+ ) {
+ }
+ } else if (e is UserRecoverableAuthException) {
+ // Unable to authenticate, such as when the user has not yet granted
+ // the app access to the account, but the user can fix this.
+ // Forward the user to an activity in Google Play services.
+ val intent = e.intent
+ recoverFromPlayServicesErrorResult.launch(intent)
+ }
+ }
+
+
+ private fun checkPlayServices(activity: Activity): Boolean {
+ val googleAPI = GoogleApiAvailability.getInstance()
+ val result = googleAPI.isGooglePlayServicesAvailable(activity)
+ if (result != ConnectionResult.SUCCESS) {
+ if (googleAPI.isUserResolvableError(result)) {
+ googleAPI.getErrorDialog(activity, result,
+ AuthenticationViewModel.PLAY_SERVICES_RESOLUTION_REQUEST
+ ).show()
+ }
+ return false
+ }
+
+ return true
+ }
+
+ fun handleAuthResponse(userAuthResponse: UserAuthResponse) {
+ try {
+ saveTokens(userAuthResponse.apiToken, userAuthResponse.id)
+ } catch (e: Exception) {
+ analyticsManager.logException(e)
+ }
+
+ HabiticaBaseApplication.reloadUserComponent()
+
+ }
+
+ @Throws(Exception::class)
+ private fun saveTokens(api: String, user: String) {
+ this.apiClient.updateAuthenticationCredentials(user, api)
+ sharedPrefs.edit {
+ putString("UserID", user)
+ val encryptedKey = if (keyHelper != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ keyHelper?.encrypt(api)
+ } catch (e: Exception) {
+ null
+ }
+ } else null
+ if (encryptedKey?.length ?: 0 > 5) {
+ putString(user, encryptedKey)
+ } else {
+ // Something might have gone wrong with encryption, so fall back to this.
+ putString("ApiToken", api)
+ }
+ }
+ }
+
+ companion object {
+ private const val REQUEST_CODE_RECOVER_FROM_PLAY_SERVICES_ERROR = 1001
+ private const val PLAY_SERVICES_RESOLUTION_REQUEST = 9000
+ }
+}
\ No newline at end of file
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ExtraLabelPreference.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ExtraLabelPreference.kt
new file mode 100644
index 000000000..54ba9940e
--- /dev/null
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/ExtraLabelPreference.kt
@@ -0,0 +1,29 @@
+package com.habitrpg.android.habitica.ui.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.TextView
+import androidx.preference.Preference
+import androidx.preference.PreferenceViewHolder
+import com.habitrpg.android.habitica.R
+
+class ExtraLabelPreference(
+ context: Context?,
+ attrs: AttributeSet?
+) : Preference(context, attrs) {
+ var extraText: String? = null
+ var extraTextColor: Int? = null
+
+ init {
+ layoutResource = R.layout.preference_button
+ }
+
+ override fun onBindViewHolder(holder: PreferenceViewHolder?) {
+ super.onBindViewHolder(holder)
+ val textView = holder?.itemView?.findViewById(R.id.extra_label)
+ textView?.text = extraText
+ extraTextColor?.let {
+ textView?.setTextColor(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/utils/UserDeserializer.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/utils/UserDeserializer.kt
index 677334b7d..186339f11 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/utils/UserDeserializer.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/utils/UserDeserializer.kt
@@ -105,15 +105,6 @@ class UserDeserializer : JsonDeserializer {
}
if (obj.has("auth")) {
user.authentication = context.deserialize(obj.get("auth"), Authentication::class.java)
- if (obj.getAsJsonObject("auth").has("facebook") && obj.getAsJsonObject("auth").getAsJsonObject("facebook").has("emails")) {
- user.authentication?.hasFacebookAuth = true
- }
- if (obj.getAsJsonObject("auth").has("google") && obj.getAsJsonObject("auth").getAsJsonObject("google").has("emails")) {
- user.authentication?.hasGoogleAuth = true
- }
- if (obj.getAsJsonObject("auth").has("apple") && obj.getAsJsonObject("auth").getAsJsonObject("apple").has("email")) {
- user.authentication?.hasAppleAuth = true
- }
}
if (obj.has("flags")) {
user.flags = context.deserialize(obj.get("flags"), Flags::class.java)
diff --git a/build.gradle b/build.gradle
index b069f1240..ffe6ad3b3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = '1.5.30'
+ ext.kotlin_version = '1.5.31'
repositories {
google()
@@ -9,10 +9,10 @@ buildscript {
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
- classpath 'com.android.tools.build:gradle:7.0.2'
+ classpath 'com.android.tools.build:gradle:7.0.3'
classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
classpath 'com.google.gms:google-services:4.3.10'
- classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
+ classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.0'
classpath "io.realm:realm-gradle-plugin:10.8.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.18.1"