Improve social login handling for logged in users

This commit is contained in:
Phillip Thelen 2021-11-10 15:12:29 +01:00
parent bb01dc9b7d
commit 3b6251f114
23 changed files with 840 additions and 625 deletions

View file

@ -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">
<nav-graph android:value="@navigation/navigation" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -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">
<intent-filter android:label="@string/app_name">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -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">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.activities.MainActivity" />
@ -209,18 +212,21 @@
<action android:name="REJECT_QUEST_INVITE"/>
</intent-filter>
</receiver>
<receiver android:name=".receivers.TaskAlarmBootReceiver" android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<receiver android:name=".receivers.TaskAlarmBootReceiver" android:permission="android.permission.RECEIVE_BOOT_COMPLETED"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<activity android:name=".ui.activities.AddTaskWidgetActivity">
<activity android:name=".ui.activities.AddTaskWidgetActivity"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
<activity android:name=".ui.activities.HabitButtonWidgetActivity">
<activity android:name=".ui.activities.HabitButtonWidgetActivity"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
@ -248,7 +254,8 @@
</provider>
<receiver android:name=".widget.AvatarStatsWidgetProvider"
android:label="@string/stats_widget_label">
android:label="@string/stats_widget_label"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
@ -256,7 +263,8 @@
android:resource="@xml/avatar_widget_info" />
</receiver>
<receiver android:name=".widget.DailiesWidgetProvider"
android:label="@string/widget_dailies">
android:label="@string/widget_dailies"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
@ -264,7 +272,8 @@
android:resource="@xml/daily_list_widget_info" />
</receiver>
<receiver android:name=".widget.TodoListWidgetProvider"
android:label="@string/widget_todo_list">
android:label="@string/widget_todo_list"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
@ -272,7 +281,8 @@
android:resource="@xml/todo_list_widget_info" />
</receiver>
<receiver android:name=".widget.HabitButtonWidgetProvider"
android:label="@string/widget_habit_button">
android:label="@string/widget_habit_button"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>

View file

@ -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"
}

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Layout for a visually child-like Preference in a PreferenceActivity. -->
<!--suppress AndroidDomInspection -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:baselineAligned="false"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeight"
android:paddingEnd="16dip"
android:paddingStart="16dip"
android:orientation="horizontal">
<RelativeLayout
android:layout_width="0dip"
android:layout_height="wrap_content"
android:layout_marginBottom="6dip"
android:layout_marginStart="16dip"
android:layout_marginEnd="6dip"
android:layout_marginTop="6dip"
android:layout_weight="1" >
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="@color/text_primary"
tools:text="Title"/>
<TextView
android:id="@android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignStart="@android:id/title"
android:layout_below="@android:id/title"
android:maxLines="4"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary" />
</RelativeLayout>
<TextView android:id="@+id/extra_label"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="0dp"
android:layout_gravity="center"
android:textColor="@color/text_secondary"
android:background="@color/transparent"
android:paddingEnd="0dp"/>
</LinearLayout>

View file

@ -1184,4 +1184,16 @@
<string name="damage_pending">%.01f dmg pending</string>
<string name="x_remaining">%s remaining</string>
<string name="sale_ends_in">Sale ends in %s</string>
<string name="my_account">My Account</string>
<string name="public_profile">Public Profile</string>
<string name="about_me">About Me</string>
<string name="api">API</string>
<string name="account_info">Account Info</string>
<string name="login_methods">Login Methods</string>
<string name="apple">Apple</string>
<string name="not_connected">Not connected</string>
<string name="add_password">Add password</string>
<string name="connect">Connect</string>
<string name="disconnect">Disconnect</string>
<string name="add">Add</string>
</resources>

View file

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:title="@string/PS_contact_title">
xmlns:tools="http://schemas.android.com/tools">
<PreferenceCategory
android:title="@string/pref_account_header"
@ -10,111 +9,114 @@
android:layout="@layout/preference_category">
<PreferenceScreen
android:key="profile"
android:key="my_account"
android:layout="@layout/preference_child_summary"
android:summary="@string/profile_summary"
android:title="@string/profile">
<EditTextPreference
android:key="display_name"
android:layout="@layout/preference_child_summary"
android:negativeButtonText="@string/cancel"
android:positiveButtonText="@string/save"
android:title="@string/display_name" />
<EditTextPreference
android:key="photo_url"
android:layout="@layout/preference_child_summary"
android:negativeButtonText="@string/cancel"
android:positiveButtonText="@string/save"
android:title="@string/photo_url" />
<EditTextPreference
android:key="about"
android:layout="@layout/preference_child_summary"
android:negativeButtonText="@string/cancel"
android:positiveButtonText="@string/save"
android:title="@string/about" />
</PreferenceScreen>
android:title="@string/my_account">
<PreferenceCategory
android:title="@string/account_info"
android:key="account_info"
android:layout="@layout/preference_category">
<Preference
android:key="username"
android:title="@string/username"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary" />
<Preference android:title="@string/confirm_username"
android:key="confirm_username"
app:isPreferenceVisible="false"
android:layout="@layout/preference_child_summary" />
<Preference
android:key="email"
android:title="@string/email"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary"/>
</PreferenceCategory>
<PreferenceScreen
android:key="authentication"
android:title="@string/authentication"
android:summary="@string/authentication_summary"
app:key="authentication"
android:layout="@layout/preference_child_summary">
<Preference
android:key="login_name"
android:title="@string/login_name"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary" />
<Preference android:title="@string/confirm_username"
android:key="confirm_username"
android:layout="@layout/preference_child_summary" />
<PreferenceCategory
android:title="@string/login_methods"
android:key="account_info"
android:layout="@layout/preference_category">
<com.habitrpg.android.habitica.ui.views.ExtraLabelPreference
android:title="@string/password"
android:key="password"
android:layout="@layout/preference_button"
/>
<com.habitrpg.android.habitica.ui.views.ExtraLabelPreference
android:title="@string/google"
android:key="google_auth"
android:layout="@layout/preference_button"
/>
<com.habitrpg.android.habitica.ui.views.ExtraLabelPreference
android:title="@string/facebook"
android:key="facebook_auth"
/>
<com.habitrpg.android.habitica.ui.views.ExtraLabelPreference
android:title="@string/apple"
android:key="apple_auth"
/>
</PreferenceCategory>
<PreferenceCategory
android:title="@string/public_profile"
android:key="public_profile"
android:layout="@layout/preference_category">
<EditTextPreference
android:key="display_name"
android:layout="@layout/preference_child_summary"
android:negativeButtonText="@string/cancel"
android:positiveButtonText="@string/save"
android:title="@string/display_name" />
<EditTextPreference
android:key="about"
android:layout="@layout/preference_child_summary"
android:negativeButtonText="@string/cancel"
android:positiveButtonText="@string/save"
android:title="@string/about_me" />
<EditTextPreference
android:key="photo_url"
android:layout="@layout/preference_child_summary"
android:negativeButtonText="@string/cancel"
android:positiveButtonText="@string/save"
android:title="@string/photo_url" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/api"
android:key="api"
android:layout="@layout/preference_category">
<Preference
android:key="user_id"
android:title="@string/SP_userID_title"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:summary="@string/SP_userID_summary"
android:layout="@layout/preference_child_summary"/>
<Preference android:title="@string/change_password"
android:key="change_password"
android:layout="@layout/preference_child_summary" />
<Preference
android:key="email"
android:title="@string/email"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary"/>
<Preference
android:key="add_local_auth"
android:title="@string/add_local_authentication"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary"/>
<Preference
android:key="authentication_methods"
android:title="@string/authentication_methods"
android:selectable="false"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary"/>
<Preference
android:key="subscription_status"
android:title="@string/subscription_status"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:layout="@layout/preference_child_summary"/>
<Preference
android:key="api_token"
android:title="@string/SP_APIToken_title"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:summary="@string/SP_APIToken_summary"
android:layout="@layout/preference_child_summary"/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/danger_zone"
android:layout="@layout/preference_category">
<Preference android:title="@string/reset_account"
android:persistent="false"
android:key="reset_account"
android:layout="@layout/preference_child_summary" />
<Preference android:title="@string/delete_account"
android:persistent="false"
android:key="delete_account"
android:layout="@layout/preference_child_summary" />
</PreferenceCategory>
</PreferenceScreen>
<PreferenceScreen android:title="API"
android:key="api"
android:layout="@layout/preference_child_summary">
<Preference
android:key="@string/SP_userID"
android:title="@string/SP_userID_title"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:summary="@string/SP_userID_summary"
android:layout="@layout/preference_child_summary"/>
<Preference
android:key="@string/SP_APIToken"
android:title="@string/SP_APIToken_title"
android:selectable="true"
android:persistent="false"
android:shouldDisableView="false"
android:summary="@string/SP_APIToken_summary"
android:layout="@layout/preference_child_summary"/>
</PreferenceScreen>
<Preference
android:key="choose_class"
tools:title="Change Class"

View file

@ -139,6 +139,9 @@ interface ApiService {
@POST("user/auth/social")
fun connectSocial(@Body auth: UserAuthSocial): Flowable<HabitResponse<UserAuthResponse>>
@DELETE("user/auth/social/{network}")
fun disconnectSocial(@Path("network") network: String): Flowable<HabitResponse<Void>>
@POST("user/auth/apple")
fun loginApple(@Body auth: Map<String, Any>): Flowable<HabitResponse<UserAuthResponse>>

View file

@ -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);
}

View file

@ -102,6 +102,8 @@ interface ApiClient {
fun connectUser(username: String, password: String): Flowable<UserAuthResponse>
fun connectSocial(network: String, userId: String, accessToken: String): Flowable<UserAuthResponse>
fun disconnectSocial(network: String): Flowable<Void>
fun loginApple(authToken: String): Flowable<UserAuthResponse>
fun sleep(): Flowable<Boolean>

View file

@ -176,6 +176,10 @@ class ApiClientImpl // private OnHabitsAPIResult mResultListener;
return this.apiService.connectSocial(auth).compose(configureApiCallObserver())
}
override fun disconnectSocial(network: String): Flowable<Void> {
return this.apiService.disconnectSocial(network).compose(configureApiCallObserver())
}
override fun loginApple(authToken: String): Flowable<UserAuthResponse> {
return apiService.loginApple(mapOf(Pair("code", authToken))).compose(configureApiCallObserver())
}

View file

@ -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
}

View file

@ -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<String> = RealmList()
}

View file

@ -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)
}
}
}
}

View file

@ -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<UserAuthResponse> {
class LoginActivity : BaseActivity() {
private lateinit var viewModel: AuthenticationViewModel
private lateinit var binding: ActivityLoginBinding
@Inject
@ -70,23 +48,13 @@ class LoginActivity : BaseActivity(), Consumer<UserAuthResponse> {
@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<UserAuthResponse> {
}
apiClient.registerUser(username, email, password, confirmPassword)
.subscribe(
this@LoginActivity,
{ handleAuthResponse(it) },
{
hideProgress()
RxErrorHandler.reportError(it)
@ -119,7 +87,7 @@ class LoginActivity : BaseActivity(), Consumer<UserAuthResponse> {
return@OnClickListener
}
apiClient.connectUser(username, password).subscribe(
this@LoginActivity,
{ handleAuthResponse(it) },
{
hideProgress()
RxErrorHandler.reportError(it)
@ -140,11 +108,14 @@ class LoginActivity : BaseActivity(), Consumer<UserAuthResponse> {
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<UserAuthResponse> {
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<UserAuthResponse> {
window.updateStatusBarColor(R.color.black_20_alpha, false)
}
private fun setupFacebookLogin() {
callbackManager = CallbackManager.Factory.create()
loginManager.registerCallback(
callbackManager,
object : FacebookCallback<LoginResult> {
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<UserAuthResponse> {
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<UserAuthResponse> {
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<UserAuthResponse> {
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<UserAuthResponse> {
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

View file

@ -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

View file

@ -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<HabiticaDrawerItem>) {

View file

@ -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<String>
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<Preference>(key)?.summary = value.toString()
}
}
}
}

View file

@ -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<Preference>("confirm_username")?.isVisible = user?.flags?.verifiedUsername == false
findPreference<Preference>("login_name")?.title = context?.getString(R.string.username)
findPreference<Preference>("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<Preference>("change_password")?.isVisible = user?.authentication?.localAuthentication?.email?.isNotEmpty() == true
findPreference<Preference>("add_local_auth")?.isVisible = user?.authentication?.localAuthentication?.email?.isNotEmpty() != true
findPreference<Preference>("confirm_username")?.isVisible = user?.flags?.verifiedUsername != true
val preference = findPreference<Preference>("authentication_methods")
val methods = mutableListOf<String>()
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<Preference>("confirm_username")?.isVisible = user.flags?.verifiedUsername != true
val passwordPref = findPreference<ExtraLabelPreference>("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<ExtraLabelPreference>("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<ExtraLabelPreference>("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<ExtraLabelPreference>("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<EditText>(R.id.editText)
oldPasswordEditText?.visibility = View.GONE
val passwordEditText = view?.findViewById<EditText>(R.id.passwordEditText)
val passwordRepeatEditText = view?.findViewById<EditText>(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<EditText>(R.id.emailTitleTextView)
val passwordEditText = view?.findViewById<EditText>(R.id.passwordEditText)
val passwordRepeatEditText = view?.findViewById<EditText>(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()
}
}
}

View file

@ -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<User>? = 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) }
}
}
}

View file

@ -34,13 +34,18 @@ class GiftBalanceGemsFragment : BaseFragment<FragmentGiftGemBalanceBinding>() {
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<FragmentGiftGemBalanceBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding?.giftButton?.setOnClickListener { sendGift() }
updateMemberViews()
}
private fun sendGift() {

View file

@ -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<LoginResult> {
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<Intent>
) {
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<Intent>?,
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<Intent>
) {
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
}
}

View file

@ -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<TextView>(R.id.extra_label)
textView?.text = extraText
extraTextColor?.let {
textView?.setTextColor(it)
}
}
}

View file

@ -105,15 +105,6 @@ class UserDeserializer : JsonDeserializer<User> {
}
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)

View file

@ -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"