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"