diff --git a/WordPressLoginFlow/build.gradle b/WordPressLoginFlow/build.gradle new file mode 100644 index 000000000000..21309a637ece --- /dev/null +++ b/WordPressLoginFlow/build.gradle @@ -0,0 +1,123 @@ +plugins { + id "com.android.library" + id "org.jetbrains.kotlin.android" + id "org.jetbrains.kotlin.kapt" + id "com.automattic.android.publish-to-s3" +} + +repositories { + maven { + url "https://a8c-libs.s3.amazonaws.com/android" + content { + includeGroup "org.wordpress" + includeGroup "org.wordpress.fluxc" + includeGroup "org.wordpress.wellsql" + includeGroup "com.gravatar" + } + } + mavenCentral() + google() + jcenter() + maven { url "https://www.jitpack.io" } +} + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } + + namespace "org.wordpress.android.login" + + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + buildFeatures { + buildConfig true + } +} + +dependencies { + implementation ("org.wordpress:utils:$wordpressUtilsVersion") { + exclude group: "com.android.volley" + } + implementation ("com.gravatar:gravatar:$gravatarSdkVersion") + + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + + implementation "androidx.constraintlayout:constraintlayout:$androidxConstraintLayoutVersion" + implementation "com.google.android.material:material:$materialVersion" + + implementation "androidx.core:core:$androidxCoreVersion" + + api "com.google.android.gms:play-services-auth:$playServicesAuthVersion" + + implementation("org.wordpress:fluxc:$wordpressFluxCVersion") { + exclude group: "com.android.support" + exclude group: "org.wordpress", module: "utils" + } + + implementation "com.github.bumptech.glide:glide:$glideVersion" + kapt "com.github.bumptech.glide:compiler:$glideVersion" + + implementation "androidx.credentials:credentials:$credentialManagerVersion" + implementation "androidx.credentials:credentials-play-services-auth:$credentialManagerVersion" + + // Dagger + implementation "com.google.dagger:dagger:$daggerVersion" + kapt "com.google.dagger:dagger-compiler:$daggerVersion" + compileOnly "org.glassfish:javax.annotation:$javaxAnnotationVersion" + implementation "com.google.dagger:dagger-android-support:$daggerVersion" + kapt "com.google.dagger:dagger-android-processor:$daggerVersion" + + lintChecks "org.wordpress:lint:$wordpressLintVersion" + + testImplementation "junit:junit:$junitVersion" + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "androidx.arch.core:core-testing:$androidxArchCoreVersion" + testImplementation "org.robolectric:robolectric:$robolectricVersion" + testImplementation "org.assertj:assertj-core:$assertjVersion" +} + +// Add properties named "wp.xxx" to our BuildConfig +android.buildTypes.all { buildType -> + Properties gradleProperties = new Properties() + File propertiesFile = file("../gradle.properties") + if (propertiesFile.exists()) { + gradleProperties.load(new FileInputStream(propertiesFile)) + } else { + // Load defaults + gradleProperties.load(new FileInputStream(file("../gradle.properties-example"))) + } + gradleProperties.any { property -> + if (property.key.toLowerCase().startsWith("wp.")) { + buildType.buildConfigField "String", property.key.replace("wp.", "").replace(".", "_").toUpperCase(), + "\"${property.value}\"" + } + if (property.key.toLowerCase().startsWith("wp.res.")) { + buildType.resValue "string", property.key.replace("wp.res.", "").replace(".", "_").toLowerCase(), + "${property.value}" + } + } +} + +project.afterEvaluate { + publishing { + publications { + LoginPublication(MavenPublication) { + from components.release + + groupId "org.wordpress" + artifactId "login" + // version is set by 'publish-to-s3' plugin + } + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/AuthOptions.kt b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/AuthOptions.kt new file mode 100644 index 000000000000..2aa545f0a882 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/AuthOptions.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.login + +data class AuthOptions( + val isPasswordless: Boolean, + val isEmailVerified: Boolean +) diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/GoogleFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/GoogleFragment.java new file mode 100644 index 000000000000..3a2dfa665099 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/GoogleFragment.java @@ -0,0 +1,277 @@ +package org.wordpress.android.login; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; + +import com.google.android.gms.auth.api.Auth; +import com.google.android.gms.auth.api.signin.GoogleSignInOptions; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks; +import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.store.SiteStore; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.inject.Inject; + +import static android.app.Activity.RESULT_OK; + +public abstract class GoogleFragment extends Fragment implements ConnectionCallbacks, OnConnectionFailedListener { + private static final String STATE_SHOULD_RESOLVE_ERROR = "STATE_SHOULD_RESOLVE_ERROR"; + private static final String STATE_FINISHED = "STATE_FINISHED"; + private static final String STATE_DISPLAY_NAME = "STATE_DISPLAY_NAME"; + private static final String STATE_GOOGLE_EMAIL = "STATE_GOOGLE_EMAIL"; + private static final String STATE_GOOGLE_TOKEN_ID = "STATE_GOOGLE_TOKEN_ID"; + private static final String STATE_GOOGLE_PHOTO_URL = "STATE_GOOGLE_PHOTO_URL"; + private boolean mIsResolvingError; + private boolean mShouldResolveError; + /** + * This flag is used to store the information the finishFlow was called when the fragment wasn't attached to an + * activity (for example an EventBus event was received during ongoing configuration change). + */ + private boolean mFinished; + + private static final String STATE_RESOLVING_ERROR = "STATE_RESOLVING_ERROR"; + private static final int REQUEST_CONNECT = 1000; + + protected GoogleApiClient mGoogleApiClient; + protected GoogleListener mGoogleListener; + protected LoginListener mLoginListener; + protected String mDisplayName; + protected String mGoogleEmail; + protected String mIdToken; + protected String mPhotoUrl; + + protected ProgressDialog mProgressDialog; + + public static final String SERVICE_TYPE_GOOGLE = "google"; + + @Inject protected Dispatcher mDispatcher; + @Inject protected SiteStore mSiteStore; + + @Inject protected LoginAnalyticsListener mAnalyticsListener; + + public interface GoogleListener { + void onGoogleEmailSelected(String email); + void onGoogleLoginFinished(); + void onGoogleSignupFinished(String name, String email, String photoUrl, String username); + void onGoogleSignupError(String msg); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mDispatcher.register(this); + if (savedInstanceState != null) { + mIsResolvingError = savedInstanceState.getBoolean(STATE_RESOLVING_ERROR, false); + mShouldResolveError = savedInstanceState.getBoolean(STATE_SHOULD_RESOLVE_ERROR, false); + mFinished = savedInstanceState.getBoolean(STATE_FINISHED, false); + mDisplayName = savedInstanceState.getString(STATE_DISPLAY_NAME); + mGoogleEmail = savedInstanceState.getString(STATE_GOOGLE_EMAIL); + mIdToken = savedInstanceState.getString(STATE_GOOGLE_TOKEN_ID); + mPhotoUrl = savedInstanceState.getString(STATE_GOOGLE_PHOTO_URL); + } + + // Configure sign-in to request user's ID, basic profile, email address, and ID token. + // ID and basic profile are included in DEFAULT_SIGN_IN. + GoogleSignInOptions googleSignInOptions = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestServerAuthCode(getString(R.string.default_web_client_id)) + .requestIdToken(getString(R.string.default_web_client_id)) + .requestProfile() + .requestEmail() + .build(); + + // Build Google API client with access to sign-in API and options specified above. + mGoogleApiClient = new GoogleApiClient.Builder(getActivity().getApplicationContext()) + .addApi(Auth.GOOGLE_SIGN_IN_API, googleSignInOptions) + .addConnectionCallbacks(this) + .addOnConnectionFailedListener(this) + .build(); + + if (!mIsResolvingError) { + connectGoogleClient(); + } + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(STATE_RESOLVING_ERROR, mIsResolvingError); + outState.putBoolean(STATE_SHOULD_RESOLVE_ERROR, mShouldResolveError); + outState.putBoolean(STATE_FINISHED, mFinished); + outState.putString(STATE_DISPLAY_NAME, mDisplayName); + outState.putString(STATE_GOOGLE_EMAIL, mGoogleEmail); + outState.putString(STATE_GOOGLE_TOKEN_ID, mIdToken); + outState.putString(STATE_GOOGLE_PHOTO_URL, mPhotoUrl); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mProgressDialog = ProgressDialog.show(getActivity(), null, getProgressDialogText(), true, false, null); + mLoginListener = (LoginListener) context; + + try { + mGoogleListener = (GoogleListener) context; + } catch (ClassCastException exception) { + throw new ClassCastException(context.toString() + " must implement GoogleListener"); + } + if (mFinished) { + finishFlow(); + } + } + + @Override + public void onDetach() { + dismissProgressDialog(); + super.onDetach(); + } + + @Override + public void onDestroy() { + disconnectGoogleClient(); + AppLog.d(T.MAIN, "GOOGLE SIGNUP/LOGIN: disconnecting google client"); + mDispatcher.unregister(this); + super.onDestroy(); + } + + @Override + public void onResume() { + super.onResume(); + // Show account dialog when Google API onConnected callback returns before fragment is attached. + if (mGoogleApiClient != null && mGoogleApiClient.isConnected() && !mIsResolvingError && !mShouldResolveError) { + startFlow(); + } + } + + @Override + public void onConnected(Bundle bundle) { + // Indicates account was selected, that account has granted any required permissions, and a + // connection to Google Play services has been established. + if (mShouldResolveError) { + mShouldResolveError = false; + + // if the fragment is not attached to an activity, the process is started in the onResume + if (isAdded()) { + startFlow(); + } + } + } + + @Override + public void onConnectionFailed(@NonNull ConnectionResult connectionResult) { + // Could not connect to Google Play Services. The user needs to select an account, grant + // permissions or resolve an error in order to sign in. Refer to the documentation for + // ConnectionResult to see possible error codes. + if (!mIsResolvingError && mShouldResolveError) { + if (connectionResult.hasResolution()) { + try { + mIsResolvingError = true; + connectionResult.startResolutionForResult(getActivity(), REQUEST_CONNECT); + } catch (IntentSender.SendIntentException exception) { + mIsResolvingError = false; + mGoogleApiClient.connect(); + } + } else { + mIsResolvingError = false; + AppLog.e(AppLog.T.NUX, GoogleApiAvailability.getInstance().getErrorString( + connectionResult.getErrorCode())); + showError(getString(R.string.login_error_generic)); + } + } + } + + @Override + public void onConnectionSuspended(int i) { + // Connection to Google Play services was lost. GoogleApiClient will automatically attempt + // to re-connect. Any UI elements depending on connection to Google APIs should be hidden + // or disabled until onConnected is called again. + Log.w(LoginGoogleFragment.class.getSimpleName(), "onConnectionSuspended: " + i); + } + + public void connectGoogleClient() { + if (!mGoogleApiClient.isConnecting() && !mGoogleApiClient.isConnected()) { + mShouldResolveError = true; + mGoogleApiClient.connect(); + } else { + startFlow(); + } + } + + protected void disconnectGoogleClient() { + if (mGoogleApiClient.isConnected()) { + Auth.GoogleSignInApi.signOut(mGoogleApiClient); + mGoogleApiClient.disconnect(); + } + } + + protected abstract String getProgressDialogText(); + + protected abstract void startFlow(); + + protected void finishFlow() { + /* This flag might get lost when the finishFlow is called after the fragment's + onSaveInstanceState was called - however it's a very rare case, since the fragment is retained across + config changes. */ + mFinished = true; + if (getActivity() != null) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP/LOGIN: finishing signup/login"); + getActivity().getSupportFragmentManager().beginTransaction().remove(this).commitAllowingStateLoss(); + } + } + + protected void showError(String message) { + finishFlow(); + mGoogleListener.onGoogleSignupError(message); + } + + @Override + public void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + + switch (request) { + case REQUEST_CONNECT: + if (result != RESULT_OK) { + mShouldResolveError = false; + } + + if (!mGoogleApiClient.isConnecting() && !mGoogleApiClient.isConnected()) { + mGoogleApiClient.connect(); + } else { + startFlow(); + } + + mIsResolvingError = false; + break; + } + } + + private void dismissProgressDialog() { + if (mProgressDialog != null && mProgressDialog.isShowing()) { + mProgressDialog.dismiss(); + } + } + + // Remove scale from photo URL path string. Current URL matches /s96-c, which returns a 96 x 96 + // pixel image. Removing /s96-c from the string returns a 512 x 512 pixel image. Using regular + // expressions may help if the photo URL scale value in the returned path changes. + protected String removeScaleFromGooglePhotoUrl(String photoUrl) { + Pattern pattern = Pattern.compile("(/s[0-9]+-c)"); + Matcher matcher = pattern.matcher(photoUrl); + return matcher.find() ? photoUrl.replace(matcher.group(1), "") : photoUrl; + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/Login2FaFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/Login2FaFragment.java new file mode 100644 index 000000000000..f35e4e8c2f9a --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/Login2FaFragment.java @@ -0,0 +1,686 @@ +package org.wordpress.android.login; + +import static android.content.Context.CLIPBOARD_SERVICE; + +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.method.DigitsKeyListener; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateTwoFactorPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthenticationChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnSocialChanged; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialAuthPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialSmsPayload; +import org.wordpress.android.fluxc.store.AccountStore.StartWebauthnChallengePayload; +import org.wordpress.android.fluxc.store.AccountStore.WebauthnChallengeReceived; +import org.wordpress.android.fluxc.store.AccountStore.WebauthnPasskeyAuthenticated; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.login.webauthn.PasskeyRequest; +import org.wordpress.android.login.webauthn.PasskeyRequest.PasskeyRequestData; +import org.wordpress.android.login.widgets.WPLoginInputRow; +import org.wordpress.android.login.widgets.WPLoginInputRow.OnEditorCommitListener; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import dagger.android.support.AndroidSupportInjection; + +public class Login2FaFragment extends LoginBaseFormFragment implements TextWatcher, + OnEditorCommitListener { + private static final String KEY_2FA_TYPE = "KEY_2FA_TYPE"; + private static final String KEY_IN_PROGRESS_MESSAGE_ID = "KEY_IN_PROGRESS_MESSAGE_ID"; + private static final String KEY_NONCE_AUTHENTICATOR = "KEY_NONCE_AUTHENTICATOR"; + private static final String KEY_NONCE_BACKUP = "KEY_NONCE_BACKUP"; + private static final String KEY_NONCE_SMS = "KEY_NONCE_SMS"; + private static final String KEY_OLD_SITES_IDS = "KEY_OLD_SITES_IDS"; + private static final String KEY_SMS_NUMBER = "KEY_SMS_NUMBER"; + private static final String KEY_SMS_SENT = "KEY_SMS_SENT"; + + private static final String ARG_2FA_ID_TOKEN = "ARG_2FA_ID_TOKEN"; + private static final String ARG_2FA_IS_SOCIAL = "ARG_2FA_IS_SOCIAL"; + private static final String ARG_2FA_IS_SOCIAL_CONNECT = "ARG_2FA_IS_SOCIAL_CONNECT"; + private static final String ARG_2FA_NONCE_AUTHENTICATOR = "ARG_2FA_NONCE_AUTHENTICATOR"; + private static final String ARG_2FA_NONCE_BACKUP = "ARG_2FA_NONCE_BACKUP"; + private static final String ARG_2FA_NONCE_SMS = "ARG_2FA_NONCE_SMS"; + private static final String ARG_2FA_SOCIAL_SERVICE = "ARG_2FA_SOCIAL_SERVICE"; + private static final String ARG_2FA_USER_ID = "ARG_2FA_USER_ID"; + private static final String ARG_EMAIL_ADDRESS = "ARG_EMAIL_ADDRESS"; + private static final String ARG_PASSWORD = "ARG_PASSWORD"; + private static final String ARG_WEBAUTHN_NONCE = "WEBAUTHN_NONCE"; + private static final String ARG_2FA_SUPPORTED_AUTH_TYPES = "ARG_2FA_SUPPORTED_AUTH_TYPES"; + private static final int LENGTH_NONCE_AUTHENTICATOR = 6; + private static final int LENGTH_NONCE_BACKUP = 8; + private static final int LENGTH_NONCE_SMS = 7; + + private static final String TWO_FACTOR_TYPE_AUTHENTICATOR = "authenticator"; + private static final String TWO_FACTOR_TYPE_BACKUP = "backup"; + + public static final String TWO_FACTOR_TYPE_SMS = "sms"; + public static final String TAG = "login_2fa_fragment_tag"; + + private static final Pattern TWO_STEP_AUTH_CODE = Pattern.compile("^[0-9]{6}"); + + private WPLoginInputRow m2FaInput; + + private static final @StringRes int DEFAULT_PROGRESS_MESSAGE_ID = R.string.logging_in; + private @StringRes int mInProgressMessageId = DEFAULT_PROGRESS_MESSAGE_ID; + + ArrayList mOldSitesIDs; + + private Button mOtpButton; + private Button mSecurityKeyButton; + private String mEmailAddress; + private String mIdToken; + private String mNonce; + private String mNonceAuthenticator; + private String mWebauthnNonce; + private String mNonceBackup; + private String mNonceSms; + private String mPassword; + private String mPhoneNumber; + private String mService; + private String mType; + private String mUserId; + private TextView mLabel; + private boolean mIsSocialLogin; + private boolean mIsSocialLoginConnect; + private boolean mSentSmsCode; + private List mSupportedAuthTypes; + + public static Login2FaFragment newInstance(String emailAddress, String password) { + Login2FaFragment fragment = new Login2FaFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, emailAddress); + args.putString(ARG_PASSWORD, password); + fragment.setArguments(args); + return fragment; + } + + public static Login2FaFragment newInstance(String emailAddress, String password, + String userId, String webauthnNonce, + String authenticatorNonce, String backupNonce, + String smsNonce, List authTypes) { + Login2FaFragment fragment = new Login2FaFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, emailAddress); + args.putString(ARG_PASSWORD, password); + args.putString(ARG_2FA_USER_ID, userId); + args.putString(ARG_WEBAUTHN_NONCE, webauthnNonce); + args.putString(ARG_2FA_NONCE_AUTHENTICATOR, authenticatorNonce); + args.putString(ARG_2FA_NONCE_BACKUP, backupNonce); + args.putString(ARG_2FA_NONCE_SMS, smsNonce); + args.putStringArrayList(ARG_2FA_SUPPORTED_AUTH_TYPES, new ArrayList<>(authTypes)); + fragment.setArguments(args); + return fragment; + } + + public static Login2FaFragment newInstanceSocial(String emailAddress, String userId, + String nonceAuthenticator, String nonceBackup, + String nonceSms, String nonceWebauthn, + List authTypes) { + Login2FaFragment fragment = new Login2FaFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, emailAddress); + args.putString(ARG_2FA_USER_ID, userId); + args.putString(ARG_2FA_NONCE_AUTHENTICATOR, nonceAuthenticator); + args.putString(ARG_2FA_NONCE_BACKUP, nonceBackup); + args.putString(ARG_2FA_NONCE_SMS, nonceSms); + args.putString(ARG_WEBAUTHN_NONCE, nonceWebauthn); + args.putBoolean(ARG_2FA_IS_SOCIAL, true); + // Social account connected, connect call not needed. + args.putBoolean(ARG_2FA_IS_SOCIAL_CONNECT, false); + args.putStringArrayList(ARG_2FA_SUPPORTED_AUTH_TYPES, new ArrayList<>(authTypes)); + fragment.setArguments(args); + return fragment; + } + + public static Login2FaFragment newInstanceSocialConnect(String emailAddress, String password, + String idToken, String service) { + Login2FaFragment fragment = new Login2FaFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, emailAddress); + args.putString(ARG_PASSWORD, password); + args.putString(ARG_2FA_ID_TOKEN, idToken); + args.putBoolean(ARG_2FA_IS_SOCIAL_CONNECT, true); + args.putString(ARG_2FA_SOCIAL_SERVICE, service); + fragment.setArguments(args); + return fragment; + } + + @Override + protected @LayoutRes int getContentLayout() { + return R.layout.login_2fa_screen; + } + + @Override + protected @LayoutRes int getProgressBarText() { + return mInProgressMessageId; + } + + @Override + protected void setupLabel(@NonNull TextView label) { + label.setText(mSentSmsCode ? getString(R.string.enter_verification_code_sms, mPhoneNumber) + : getString(R.string.enter_verification_code)); + mLabel = label; + } + + @Override + protected void setupContent(ViewGroup rootView) { + // important for accessibility - talkback + getActivity().setTitle(R.string.verification_2fa_screen_title); + m2FaInput = rootView.findViewById(R.id.login_2fa_row); + m2FaInput.addTextChangedListener(this); + m2FaInput.setOnEditorCommitListener(this); + + // restrict the allowed input chars to just numbers + m2FaInput.getEditText().setKeyListener(DigitsKeyListener.getInstance("0123456789")); + + // If we didn't get a list of supported auth types, then the flow is not using webauthn, + // We should treat it as if SMS is enabled for the user + boolean isSmsEnabled = mSupportedAuthTypes.isEmpty() || mSupportedAuthTypes.contains(SupportedAuthTypes.PUSH); + mOtpButton = rootView.findViewById(R.id.login_otp_button); + mOtpButton.setVisibility(isSmsEnabled ? View.VISIBLE : View.GONE); + mOtpButton.setText(mSentSmsCode ? R.string.login_text_otp_another : R.string.login_text_otp); + mOtpButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (isAdded()) { + mAnalyticsListener.trackSendCodeWithTextClicked(); + doAuthAction(R.string.requesting_otp, "", true); + } + } + }); + + boolean isSecurityKeyEnabled = mSupportedAuthTypes.contains(SupportedAuthTypes.WEBAUTHN); + mSecurityKeyButton = rootView.findViewById(R.id.login_security_key_button); + mSecurityKeyButton.setVisibility(isSecurityKeyEnabled ? View.VISIBLE : View.GONE); + mSecurityKeyButton.setOnClickListener(view -> doAuthWithSecurityKeyAction()); + } + + @Override + protected void setupBottomButton(Button button) { + button.setOnClickListener(new OnClickListener() { + public void onClick(View v) { + next(); + } + }); + } + + @Override + protected void buildToolbar(Toolbar toolbar, ActionBar actionBar) { + actionBar.setTitle(R.string.log_in); + } + + @Override + protected EditText getEditTextToFocusOnStart() { + return m2FaInput.getEditText(); + } + + @Override + protected void onHelp() { + if (mLoginListener != null) { + mLoginListener.help2FaScreen(mEmailAddress); + } + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mEmailAddress = getArguments().getString(ARG_EMAIL_ADDRESS); + mPassword = getArguments().getString(ARG_PASSWORD); + mNonceAuthenticator = getArguments().getString(ARG_2FA_NONCE_AUTHENTICATOR); + mNonceBackup = getArguments().getString(ARG_2FA_NONCE_BACKUP); + mNonceSms = getArguments().getString(ARG_2FA_NONCE_SMS); + mUserId = getArguments().getString(ARG_2FA_USER_ID); + mIdToken = getArguments().getString(ARG_2FA_ID_TOKEN); + mIsSocialLogin = getArguments().getBoolean(ARG_2FA_IS_SOCIAL); + mIsSocialLoginConnect = getArguments().getBoolean(ARG_2FA_IS_SOCIAL_CONNECT); + mService = getArguments().getString(ARG_2FA_SOCIAL_SERVICE); + mWebauthnNonce = getArguments().getString(ARG_WEBAUTHN_NONCE); + mSupportedAuthTypes = handleSupportedAuthTypesParameter( + getArguments().getStringArrayList(ARG_2FA_SUPPORTED_AUTH_TYPES)); + + if (savedInstanceState != null) { + // Overwrite argument nonce values with saved state values on device rotation. + mNonceAuthenticator = savedInstanceState.getString(KEY_NONCE_AUTHENTICATOR); + mNonceBackup = savedInstanceState.getString(KEY_NONCE_BACKUP); + mNonceSms = savedInstanceState.getString(KEY_NONCE_SMS); + // Restore set two-factor authentication type value on device rotation. + mType = savedInstanceState.getString(KEY_2FA_TYPE); + mPhoneNumber = savedInstanceState.getString(KEY_SMS_NUMBER); + mSentSmsCode = savedInstanceState.getBoolean(KEY_SMS_SENT); + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + // retrieve mInProgressMessageId before super.onActivityCreated() so the string will be available to the + // progress bar helper if in progress + if (savedInstanceState != null) { + mInProgressMessageId = savedInstanceState.getInt(KEY_IN_PROGRESS_MESSAGE_ID, DEFAULT_PROGRESS_MESSAGE_ID); + mOldSitesIDs = savedInstanceState.getIntegerArrayList(KEY_OLD_SITES_IDS); + } else { + mAnalyticsListener.trackTwoFactorFormViewed(); + } + super.onActivityCreated(savedInstanceState); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putInt(KEY_IN_PROGRESS_MESSAGE_ID, mInProgressMessageId); + outState.putIntegerArrayList(KEY_OLD_SITES_IDS, mOldSitesIDs); + outState.putString(KEY_NONCE_AUTHENTICATOR, mNonceAuthenticator); + outState.putString(KEY_NONCE_BACKUP, mNonceBackup); + outState.putString(KEY_NONCE_SMS, mNonceSms); + outState.putString(KEY_2FA_TYPE, mType); + outState.putString(KEY_SMS_NUMBER, mPhoneNumber); + outState.putBoolean(KEY_SMS_SENT, mSentSmsCode); + } + + @Override + public void onResume() { + super.onResume(); + + // Insert authentication code if copied to clipboard + if (TextUtils.isEmpty(m2FaInput.getEditText().getText())) { + m2FaInput.setText(getAuthCodeFromClipboard()); + } + + updateContinueButtonEnabledStatus(); + } + + protected void next() { + mAnalyticsListener.trackSubmit2faCodeClicked(); + if (TextUtils.isEmpty(m2FaInput.getEditText().getText())) { + show2FaError(getString(R.string.login_empty_2fa)); + return; + } + + doAuthAction(R.string.logging_in, m2FaInput.getEditText().getText().toString(), false); + } + + private void doAuthAction(@StringRes int messageId, String twoStepCode, boolean shouldSendTwoStepSMS) { + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + + mInProgressMessageId = messageId; + startProgress(); + + mOldSitesIDs = SiteUtils.getCurrentSiteIds(mSiteStore, false); + + if (mIsSocialLogin) { + if (shouldSendTwoStepSMS) { + PushSocialSmsPayload payload = new PushSocialSmsPayload(mUserId, mNonceSms); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialSmsAction(payload)); + } else { + setAuthCodeTypeAndNonce(twoStepCode); + PushSocialAuthPayload payload = new PushSocialAuthPayload(mUserId, mType, mNonce, + twoStepCode); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialAuthAction(payload)); + } + } else { + AuthenticateTwoFactorPayload payload = new AuthenticateTwoFactorPayload(mEmailAddress, + mPassword, twoStepCode, shouldSendTwoStepSMS); + mDispatcher.dispatch(AuthenticationActionBuilder + .newAuthenticateTwoFactorAction(payload)); + } + } + + private String getAuthCodeFromClipboard() { + ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(CLIPBOARD_SERVICE); + + if (clipboard.getPrimaryClip() != null && clipboard.getPrimaryClip().getItemAt(0) != null + && clipboard.getPrimaryClip().getItemAt(0).getText() != null) { + String code = clipboard.getPrimaryClip().getItemAt(0).getText().toString(); + + final Matcher twoStepAuthCodeMatcher = TWO_STEP_AUTH_CODE.matcher(""); + twoStepAuthCodeMatcher.reset(code); + + if (!code.isEmpty() && twoStepAuthCodeMatcher.matches()) { + return code; + } + } + + return ""; + } + + private void setAuthCodeTypeAndNonce(String twoStepCode) { + switch (twoStepCode.length()) { + case LENGTH_NONCE_AUTHENTICATOR: + mType = TWO_FACTOR_TYPE_AUTHENTICATOR; + mNonce = mNonceAuthenticator; + break; + case LENGTH_NONCE_BACKUP: + mType = TWO_FACTOR_TYPE_BACKUP; + mNonce = mNonceBackup; + break; + case LENGTH_NONCE_SMS: + mType = TWO_FACTOR_TYPE_SMS; + mNonce = mNonceSms; + break; + } + } + + @Override + public void onEditorCommit() { + next(); + } + + @Override + public void afterTextChanged(Editable s) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + show2FaError(null); + updateContinueButtonEnabledStatus(); + } + + private void show2FaError(@Nullable String message) { + if (!TextUtils.isEmpty(message)) { + mAnalyticsListener.trackFailure(message); + } + m2FaInput.setError(message); + } + + private void updateContinueButtonEnabledStatus() { + String currentVerificationCode = m2FaInput.getEditText().getText().toString(); + getBottomButton().setEnabled(!currentVerificationCode.trim().isEmpty()); + } + + @Override + protected void endProgress() { + super.endProgress(); + mInProgressMessageId = DEFAULT_PROGRESS_MESSAGE_ID; + } + + private void handleAuthError(AuthenticationErrorType error, String errorMessage) { + switch (error) { + case INVALID_OTP: + show2FaError(getString(R.string.invalid_verification_code)); + break; + case NEEDS_2FA: + // we get this error when requesting a verification code sent via SMS so, just ignore it. + break; + case INVALID_REQUEST: + // TODO: FluxC: could be specific? + case WEBAUTHN_FAILED: + mAnalyticsListener.trackLoginSecurityKeyFailure(); + ToastUtils.showToast(getActivity(), + errorMessage == null ? getString(R.string.error_generic) : errorMessage); + break; + default: + AppLog.e(T.NUX, "Server response: " + errorMessage); + mAnalyticsListener.trackFailure(errorMessage); + ToastUtils.showToast(getActivity(), + errorMessage == null ? getString(R.string.error_generic) : errorMessage); + break; + } + } + + private void showErrorDialog(String message) { + mAnalyticsListener.trackFailure(message); + AlertDialog dialog = new MaterialAlertDialogBuilder(getActivity()) + .setMessage(message) + .setPositiveButton(R.string.login_error_button, null) + .create(); + dialog.show(); + } + + // OnChanged events + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthenticationChanged(OnAuthenticationChanged event) { + if (event.isError()) { + endProgress(); + + AppLog.e(T.API, "onAuthenticationChanged has error: " + event.error.type + " - " + event.error.message); + mAnalyticsListener.trackLoginFailed(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + + if (mIsSocialLogin) { + mAnalyticsListener.trackSocialFailure(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + } + + if (isAdded()) { + handleAuthError(event.error.type, event.error.message); + } + + return; + } + + AppLog.i(T.NUX, "onAuthenticationChanged: " + event.toString()); + + if (mIsSocialLoginConnect) { + PushSocialPayload payload = new PushSocialPayload(mIdToken, mService); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialConnectAction(payload)); + } else { + doFinishLogin(); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSocialChanged(OnSocialChanged event) { + if (event.isError()) { + switch (event.error.type) { + // Two-factor authentication code was incorrect; save new nonce for another try. + case INVALID_TWO_STEP_CODE: + endProgress(); + + switch (mType) { + case TWO_FACTOR_TYPE_AUTHENTICATOR: + mNonceAuthenticator = event.error.nonce; + break; + case TWO_FACTOR_TYPE_BACKUP: + mNonceBackup = event.error.nonce; + break; + case TWO_FACTOR_TYPE_SMS: + mNonceSms = event.error.nonce; + break; + } + + show2FaError(getString(R.string.invalid_verification_code)); + break; + // Two-factor authentication via SMS failed; show message, log error, + // and replace SMS nonce with response. + case INVALID_TWO_STEP_NONCE: + case NO_PHONE_NUMBER_FOR_ACCOUNT: + case SMS_AUTHENTICATION_UNAVAILABLE: + case SMS_CODE_THROTTLED: + endProgress(); + showErrorDialog(event.error.message); + AppLog.e(T.API, event.error.type + ": " + event.error.message); + mNonceSms = event.error.nonce; + break; + case UNABLE_CONNECT: + AppLog.e(T.API, "Unable to connect WordPress.com account to social account."); + break; + case USER_ALREADY_ASSOCIATED: + AppLog.e(T.API, "This social account is already associated with a WordPress.com account."); + break; + } + + // Finish login on social connect error. + if (mIsSocialLoginConnect) { + mAnalyticsListener.trackSocialConnectFailure(); + doFinishLogin(); + } + // Two-factor authentication code was sent via SMS to account phone number; replace SMS nonce with response. + } else if (!TextUtils.isEmpty(event.phoneNumber) && !TextUtils.isEmpty(event.nonce)) { + endProgress(); + mPhoneNumber = event.phoneNumber; + mNonceSms = event.nonce; + setTextForSms(); + } else { + if (mIsSocialLoginConnect) { + mAnalyticsListener.trackSocialConnectSuccess(); + } + doFinishLogin(); + } + } + + @Override + protected void onLoginFinished() { + mAnalyticsListener.trackAnalyticsSignIn(true); + + mLoginListener.startPostLoginServices(); + + if (mIsSocialLogin) { + mLoginListener.loggedInViaSocialAccount(mOldSitesIDs, false); + } else { + mLoginListener.loggedInViaPassword(mOldSitesIDs); + } + } + + private void setTextForSms() { + mLabel.setText(getString(R.string.enter_verification_code_sms, mPhoneNumber)); + mOtpButton.setText(getString(R.string.login_text_otp_another)); + mSentSmsCode = true; + } + + private void doAuthWithSecurityKeyAction() { + mAnalyticsListener.trackUseSecurityKeyClicked(); + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + + startProgress(); + mOldSitesIDs = SiteUtils.getCurrentSiteIds(mSiteStore, false); + + StartWebauthnChallengePayload payload = new StartWebauthnChallengePayload( + mUserId, mWebauthnNonce); + mDispatcher.dispatch(AuthenticationActionBuilder + .newStartSecurityKeyChallengeAction(payload)); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void onWebauthnChallengeReceived(WebauthnChallengeReceived event) { + if (event.isError()) { + handleWebauthnError(event.error.type, getString(R.string.login_error_security_key)); + return; + } + + PasskeyRequestData passkeyRequestData = new PasskeyRequestData( + event.mUserId, + event.getWebauthnNonce(), + event.mJsonResponse.toString() + ); + + PasskeyRequest.create( + requireContext(), + passkeyRequestData, + result -> { + mDispatcher.dispatch(result); + return null; + }, + error -> { + String errorMessage = getString(R.string.login_error_security_key); + handleWebauthnError(AuthenticationErrorType.WEBAUTHN_FAILED, errorMessage); + return null; + } + ); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSecurityKeyCheckFinished(WebauthnPasskeyAuthenticated event) { + if (event.isError()) { + handleWebauthnError(event.error.type, getString(R.string.login_error_security_key)); + return; + } + mAnalyticsListener.trackLoginSecurityKeySuccess(); + doFinishLogin(); + } + + private void handleWebauthnError(AuthenticationErrorType errorType, String errorMessage) { + endProgress(); + handleAuthError(errorType, errorMessage); + getParentFragmentManager().popBackStack(); + } + + @NonNull private ArrayList handleSupportedAuthTypesParameter( + ArrayList supportedTypes) { + ArrayList supportedAuthTypes = new ArrayList<>(); + if (supportedTypes != null) { + for (String type : supportedTypes) { + SupportedAuthTypes parsedType = SupportedAuthTypes.fromString(type); + if (parsedType != SupportedAuthTypes.UNKNOWN) { + supportedAuthTypes.add(parsedType); + } + } + } + return supportedAuthTypes; + } + + public enum SupportedAuthTypes { + WEBAUTHN, + BACKUP, + AUTHENTICATOR, + PUSH, + UNKNOWN; + + static SupportedAuthTypes fromString(String value) { + switch (value) { + case "webauthn": + return WEBAUTHN; + case "backup": + return BACKUP; + case "authenticator": + return AUTHENTICATOR; + case "push": + return PUSH; + default: + return UNKNOWN; + } + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginAnalyticsListener.kt b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginAnalyticsListener.kt new file mode 100644 index 000000000000..954cae5bd21d --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginAnalyticsListener.kt @@ -0,0 +1,88 @@ +package org.wordpress.android.login + +import java.util.Locale + +interface LoginAnalyticsListener { + fun trackAnalyticsSignIn(isWpcomLogin: Boolean) + fun trackCreatedAccount(username: String?, email: String?, source: CreatedAccountSource) + fun trackEmailFormViewed() + fun trackInsertedInvalidUrl() + fun trackLoginAccessed() + fun trackLoginAutofillCredentialsFilled() + fun trackLoginAutofillCredentialsUpdated() + fun trackLoginFailed(errorContext: String?, errorType: String?, errorDescription: String?) + fun trackLoginForgotPasswordClicked() + fun trackLoginMagicLinkExited() + fun trackLoginMagicLinkOpened() + fun trackLoginMagicLinkOpenEmailClientClicked() + fun trackLoginMagicLinkSucceeded() + fun trackLoginSocial2faNeeded() + fun trackLoginSocialSuccess() + fun trackMagicLinkFailed(properties: Map) + fun trackSignupMagicLinkOpenEmailClientViewed() + fun trackLoginMagicLinkOpenEmailClientViewed() + fun trackMagicLinkRequested() + fun trackMagicLinkRequestFormViewed() + fun trackPasswordFormViewed(isSocialChallenge: Boolean) + fun trackSignupCanceled() + fun trackSignupEmailButtonTapped() + fun trackSignupEmailToLogin() + fun trackSignupGoogleButtonTapped() + fun trackSignupMagicLinkFailed() + fun trackSignupMagicLinkOpened() + fun trackSignupMagicLinkOpenEmailClientClicked() + fun trackSignupMagicLinkSent() + fun trackSignupMagicLinkSucceeded() + fun trackSignupSocialAccountsNeedConnecting() + fun trackSignupSocialButtonFailure() + fun trackSignupSocialToLogin() + fun trackSignupTermsOfServiceTapped() + fun trackSocialButtonStart() + fun trackSocialAccountsNeedConnecting() + fun trackSocialButtonClick() + fun trackSocialButtonFailure() + fun trackSocialConnectFailure() + fun trackSocialConnectSuccess() + fun trackSocialErrorUnknownUser() + fun trackSocialFailure(errorContext: String?, errorType: String?, errorDescription: String?) + fun trackTwoFactorFormViewed() + fun trackUrlFormViewed() + fun trackUrlHelpScreenViewed() + fun trackUsernamePasswordFormViewed() + fun trackWpComBackgroundServiceUpdate(properties: Map) + fun trackConnectedSiteInfoRequested(url: String?) + fun trackConnectedSiteInfoFailed(url: String?, errorContext: String?, errorType: String?, errorDescription: String?) + fun trackConnectedSiteInfoSucceeded(properties: Map) + fun trackFailure(message: String?) + fun trackSendCodeWithTextClicked() + fun trackSubmit2faCodeClicked() + fun trackSubmitClicked() + fun trackRequestMagicLinkClick() + fun trackLoginWithPasswordClick() + fun trackShowHelpClick() + fun trackDismissDialog() + fun trackSelectEmailField() + fun trackPickEmailFromHint() + fun trackShowEmailHints() + fun emailFormScreenResumed() + fun trackSocialSignupConfirmationViewed() + fun trackCreateAccountClick() + fun emailPasswordFormScreenResumed() + fun siteAddressFormScreenResumed() + fun magicLinkRequestScreenResumed() + fun magicLinkSentScreenResumed() + fun usernamePasswordScreenResumed() + fun trackLogin2faNeeded() + fun trackLoginSecurityKeySuccess() + fun trackLoginSecurityKeyFailure() + fun trackUseSecurityKeyClicked() + + enum class CreatedAccountSource { + EMAIL, + GOOGLE; + + fun asPropertyMap() = hashMapOf( + "source" to name.lowercase(Locale.ROOT) + ) + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginBaseDiscoveryFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginBaseDiscoveryFragment.java new file mode 100644 index 000000000000..fc8ee334d9c1 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginBaseDiscoveryFragment.java @@ -0,0 +1,83 @@ +package org.wordpress.android.login; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryError; +import org.wordpress.android.fluxc.store.AccountStore.OnDiscoveryResponse; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.NetworkUtils; + +public abstract class LoginBaseDiscoveryFragment extends LoginBaseFormFragment { + LoginBaseDiscoveryListener mLoginBaseDiscoveryListener; + + public interface LoginBaseDiscoveryListener { + String getRequestedSiteAddress(); + void handleWpComDiscoveryError(String failedEndpoint); + void handleDiscoverySuccess(String endpointAddress); + void handleDiscoveryError(DiscoveryError error, String failedEndpoint); + } + + @Override + public void onDetach() { + super.onDetach(); + mLoginBaseDiscoveryListener = null; + } + + void initiateDiscovery() { + if (mLoginBaseDiscoveryListener == null || !NetworkUtils.checkConnection(getActivity())) { + // Fragment was detached or there's no active network connection + return; + } + + // Start the discovery process + mDispatcher.dispatch(AuthenticationActionBuilder.newDiscoverEndpointAction( + mLoginBaseDiscoveryListener.getRequestedSiteAddress())); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onDiscoverySucceeded(OnDiscoveryResponse event) { + if (mLoginBaseDiscoveryListener == null) { + // Ignore the event if the fragment is detached + return; + } + // hold the URL in a variable to use below otherwise it gets cleared up by endProgress + // bail if user canceled + String mRequestedSiteAddress = mLoginBaseDiscoveryListener.getRequestedSiteAddress(); + if (mRequestedSiteAddress == null) { + return; + } + + if (!isAdded()) { + return; + } + + if (event.isError()) { + if (isInProgress()) { + endProgress(); + } + + mAnalyticsListener.trackLoginFailed(event.getClass().getSimpleName(), + event.error.name(), event.error.toString()); + + AppLog.e(T.API, "onDiscoveryResponse has error: " + event.error.name() + + " - " + event.error.toString()); + handleDiscoveryError(event.error, event.failedEndpoint); + return; + } + + AppLog.i(T.NUX, "Discovery succeeded, endpoint: " + event.xmlRpcEndpoint); + mLoginBaseDiscoveryListener.handleDiscoverySuccess(event.xmlRpcEndpoint); + } + + private void handleDiscoveryError(DiscoveryError error, final String failedEndpoint) { + mAnalyticsListener.trackFailure(error.name() + " - " + failedEndpoint); + if (error == DiscoveryError.WORDPRESS_COM_SITE) { + mLoginBaseDiscoveryListener.handleWpComDiscoveryError(failedEndpoint); + } else { + mLoginBaseDiscoveryListener.handleDiscoveryError(error, failedEndpoint); + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginBaseFormFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginBaseFormFragment.java new file mode 100644 index 000000000000..b401ba931ab1 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginBaseFormFragment.java @@ -0,0 +1,386 @@ +package org.wordpress.android.login; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStub; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.CallSuper; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.action.AccountAction; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.generated.SiteActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore; +import org.wordpress.android.fluxc.store.AccountStore.AccountErrorType; +import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged; +import org.wordpress.android.fluxc.store.SiteStore; +import org.wordpress.android.fluxc.store.SiteStore.FetchSitesPayload; +import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged; +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.EditTextUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import javax.inject.Inject; + +public abstract class LoginBaseFormFragment extends Fragment implements TextWatcher { + private static final String KEY_IN_PROGRESS = "KEY_IN_PROGRESS"; + private static final String KEY_LOGIN_FINISHED = "KEY_LOGIN_FINISHED"; + + private Button mBottomButton; + private ProgressDialog mProgressDialog; + + protected LoginListenerType mLoginListener; + + private boolean mInProgress; + private boolean mLoginFinished; + + @Inject protected Dispatcher mDispatcher; + @Inject protected SiteStore mSiteStore; + @Inject protected AccountStore mAccountStore; + + @Inject protected LoginAnalyticsListener mAnalyticsListener; + + protected abstract @LayoutRes int getContentLayout(); + protected abstract void setupLabel(@NonNull TextView label); + protected abstract void setupContent(ViewGroup rootView); + protected abstract void setupBottomButton(Button button); + protected abstract @StringRes int getProgressBarText(); + + protected boolean listenForLogin() { + return true; + } + + protected EditText getEditTextToFocusOnStart() { + return null; + } + + protected boolean isInProgress() { + return mInProgress; + } + + protected Button getBottomButton() { + return mBottomButton; + } + + protected abstract void onHelp(); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setHasOptionsMenu(true); + } + + protected ViewGroup createMainView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.login_form_screen, container, false); + ViewStub formContainer = ((ViewStub) rootView.findViewById(R.id.login_form_content_stub)); + formContainer.setLayoutResource(getContentLayout()); + formContainer.inflate(); + return rootView; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + ViewGroup rootView = createMainView(inflater, container, savedInstanceState); + setupLabel((TextView) rootView.findViewById(R.id.label)); + setupContent(rootView); + mBottomButton = rootView.findViewById(R.id.bottom_button); + setupBottomButton(mBottomButton); + return rootView; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + Toolbar toolbar = (Toolbar) view.findViewById(R.id.toolbar); + ((AppCompatActivity) getActivity()).setSupportActionBar(toolbar); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + buildToolbar(toolbar, actionBar); + } + + if (savedInstanceState == null) { + EditTextUtils.showSoftInput(getEditTextToFocusOnStart()); + } + } + + protected void buildToolbar(Toolbar toolbar, ActionBar actionBar) { + View toolbarIcon = toolbar.findViewById(R.id.toolbar_icon); + if (toolbarIcon != null) { + toolbarIcon.setVisibility(View.VISIBLE); + } + actionBar.setDisplayShowTitleEnabled(false); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + mInProgress = savedInstanceState.getBoolean(KEY_IN_PROGRESS); + mLoginFinished = savedInstanceState.getBoolean(KEY_LOGIN_FINISHED); + + if (mInProgress) { + startProgress(); + } + } + } + + @Override + @SuppressWarnings("unchecked") + public void onAttach(Context context) { + super.onAttach(context); + + // this will throw if parent activity doesn't implement the login listener interface + mLoginListener = (LoginListenerType) context; + } + + @Override + public void onDetach() { + super.onDetach(); + mLoginListener = null; + } + + @Override + public void onStart() { + super.onStart(); + + if (listenForLogin()) { + mDispatcher.register(this); + } + } + + @Override + public void onStop() { + super.onStop(); + + if (listenForLogin()) { + mDispatcher.unregister(this); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putBoolean(KEY_IN_PROGRESS, mInProgress); + outState.putBoolean(KEY_LOGIN_FINISHED, mLoginFinished); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_login, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.help) { + mAnalyticsListener.trackShowHelpClick(); + onHelp(); + return true; + } + + return false; + } + + @Override + public void onDestroy() { + endProgress(); + super.onDestroy(); + } + + @Override public void onDestroyView() { + mBottomButton = null; + + if (mProgressDialog != null) { + mProgressDialog.setOnCancelListener(null); + mProgressDialog = null; + } + super.onDestroyView(); + } + + protected void startProgressIfNeeded() { + if (!isInProgress()) { + startProgress(); + } + } + + protected void startProgress() { + startProgress(true); + } + + protected void startProgress(boolean cancellable) { + if (mBottomButton != null) { + mBottomButton.setEnabled(false); + } + + mProgressDialog = + ProgressDialog.show(getActivity(), "", getActivity().getString(getProgressBarText()), true, cancellable, + new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialogInterface) { + endProgressIfNeeded(); + } + }); + mInProgress = true; + } + + protected void endProgressIfNeeded() { + if (isInProgress()) { + endProgress(); + } + } + + @CallSuper + protected void endProgress() { + mInProgress = false; + + if (mProgressDialog != null) { + mProgressDialog.cancel(); + mProgressDialog.setOnCancelListener(null); + mProgressDialog = null; + } + if (mBottomButton != null) { + mBottomButton.setEnabled(true); + } + } + + protected void doFinishLogin() { + if (mLoginFinished) { + onLoginFinished(false); + return; + } + + if (mProgressDialog == null) { + startProgress(); + } + + mProgressDialog.setCancelable(false); + mDispatcher.dispatch(AccountActionBuilder.newFetchAccountAction()); + } + + protected void onLoginFinished() { + } + + protected void onLoginFinished(boolean success) { + mLoginFinished = true; + + if (success && mLoginListener != null) { + onLoginFinished(); + } + + endProgress(); + } + + protected void saveCredentialsInSmartLock(LoginListener loginListener, String username, String password) { + // mUsername and mPassword are null when the user log in with a magic link + if (loginListener != null) { + loginListener.saveCredentialsInSmartLock(username, password, mAccountStore.getAccount().getDisplayName(), + Uri.parse(mAccountStore.getAccount().getAvatarUrl())); + } + } + + // OnChanged events + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAccountChanged(OnAccountChanged event) { + if (!isAdded() || mLoginFinished) { + return; + } + + if (event.isError()) { + AppLog.e(AppLog.T.API, "onAccountChanged has error: " + event.error.type + " - " + event.error.message); + if (event.error.type == AccountErrorType.SETTINGS_FETCH_REAUTHORIZATION_REQUIRED_ERROR) { + // This probably means we're logging in to 2FA-enabled account with a non-production WP.com client id. + // A few WordPress.com APIs like /me/settings/ won't work for this account. + ToastUtils.showToast(getContext(), R.string.error_disabled_apis, Duration.LONG); + } else { + ToastUtils.showToast(getContext(), R.string.error_fetch_my_profile, Duration.LONG); + onLoginFinished(false); + return; + } + } + + if (event.causeOfChange == AccountAction.FETCH_ACCOUNT) { + // The user's account info has been fetched and stored - next, fetch the user's settings + mDispatcher.dispatch(AccountActionBuilder.newFetchSettingsAction()); + } else if (event.causeOfChange == AccountAction.FETCH_SETTINGS) { + // The user's account settings have also been fetched and stored - now we can fetch the user's sites + FetchSitesPayload payload = + SiteUtils.getFetchSitesPayload(isJetpackAppLogin(), isWooAppLogin()); + mDispatcher.dispatch(SiteActionBuilder.newFetchSitesAction(payload)); + } + } + + protected boolean isJetpackAppLogin() { + if (!(mLoginListener instanceof LoginListener)) return false; + + LoginMode mode = ((LoginListener) mLoginListener).getLoginMode(); + return mode == LoginMode.JETPACK_LOGIN_ONLY || mode == LoginMode.JETPACK_SELFHOSTED; + } + + protected boolean isWooAppLogin() { + return (mLoginListener instanceof LoginListener) + && ((LoginListener) mLoginListener).getLoginMode() == LoginMode.WOO_LOGIN_MODE; + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSiteChanged(OnSiteChanged event) { + if (!isAdded() || mLoginFinished) { + return; + } + + if (event.isError()) { + AppLog.e(AppLog.T.API, "onSiteChanged has error: " + event.error.type + " - " + event.error.toString()); + if (!isAdded() || event.error.type != SiteErrorType.DUPLICATE_SITE) { + onLoginFinished(false); + return; + } + + if (event.rowsAffected == 0) { + // If there is a duplicate site and not any site has been added, show an error and + // stop the sign in process + ToastUtils.showToast(getContext(), R.string.cannot_add_duplicate_site); + onLoginFinished(false); + return; + } else { + // If there is a duplicate site, notify the user something could be wrong, + // but continue the sign in process + ToastUtils.showToast(getContext(), R.string.duplicate_site_detected); + } + } + + onLoginFinished(true); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginEmailFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginEmailFragment.java new file mode 100644 index 000000000000..b65dbc299fe1 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginEmailFragment.java @@ -0,0 +1,710 @@ +package org.wordpress.android.login; + +import android.app.PendingIntent; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender; +import android.os.Build; +import android.os.Bundle; +import android.text.Editable; +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Patterns; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.autofill.AutofillManager; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.Toolbar; + +import com.google.android.gms.auth.api.Auth; +import com.google.android.gms.auth.api.credentials.Credential; +import com.google.android.gms.auth.api.credentials.CredentialPickerConfig; +import com.google.android.gms.auth.api.credentials.HintRequest; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.GoogleApiClient.ConnectionCallbacks; +import com.google.android.gms.common.api.GoogleApiClient.OnConnectionFailedListener; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore.FetchAuthOptionsPayload; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthOptionsFetched; +import org.wordpress.android.login.util.ContextExtensionsKt; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.login.widgets.WPLoginInputRow; +import org.wordpress.android.login.widgets.WPLoginInputRow.OnEditorCommitListener; +import org.wordpress.android.util.ActivityUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.EditTextUtils; +import org.wordpress.android.util.HtmlUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static android.app.Activity.RESULT_OK; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginEmailFragment extends LoginBaseFormFragment implements TextWatcher, + OnEditorCommitListener, ConnectionCallbacks, OnConnectionFailedListener { + private static final String KEY_GOOGLE_EMAIL = "KEY_GOOGLE_EMAIL"; + private static final String KEY_HAS_DISMISSED_EMAIL_HINTS = "KEY_HAS_DISMISSED_EMAIL_HINTS"; + private static final String KEY_IS_DISPLAYING_EMAIL_HINTS = "KEY_IS_DISPLAYING_EMAIL_HINTS"; + private static final String KEY_IS_SOCIAL = "KEY_IS_SOCIAL"; + private static final String KEY_OLD_SITES_IDS = "KEY_OLD_SITES_IDS"; + private static final String KEY_REQUESTED_EMAIL = "KEY_REQUESTED_EMAIL"; + private static final String KEY_EMAIL_ERROR_RES = "KEY_EMAIL_ERROR_RES"; + private static final String LOG_TAG = LoginEmailFragment.class.getSimpleName(); + private static final int GOOGLE_API_CLIENT_ID = 1002; + private static final int EMAIL_CREDENTIALS_REQUEST_CODE = 25100; + + private static final String ARG_LOGIN_SITE_URL = "ARG_LOGIN_SITE_URL"; + private static final String ARG_SIGNUP_FROM_LOGIN_ENABLED = "ARG_SIGNUP_FROM_LOGIN_ENABLED"; + private static final String ARG_OPTIONAL_SITE_CREDS_LAYOUT = "ARG_OPTIONAL_SITE_CREDS_LAYOUT"; + private static final String ARG_HIDE_TOS = "ARG_HIDE_TOS"; + + public static final String TAG = "login_email_fragment_tag"; + public static final String TAG_SITE_CREDS_LAYOUT = "login_email_fragment_site_creds_layout_tag"; + public static final int MAX_EMAIL_LENGTH = 100; + + private ArrayList mOldSitesIDs = new ArrayList<>(); + private GoogleApiClient mGoogleApiClient; + private String mGoogleEmail; + private String mRequestedEmail; + private boolean mIsSocialLogin; + private Integer mCurrentEmailErrorRes = null; + private boolean mIsSignupFromLoginEnabled; + private boolean mOptionalSiteCredsLayout; + private boolean mHideTos; + + protected WPLoginInputRow mEmailInput; + protected boolean mHasDismissedEmailHints; + protected boolean mIsDisplayingEmailHints; + protected String mLoginSiteUrl; + + public static LoginEmailFragment newInstance(String url) { + return newInstance(url, false, false, false); + } + + public static LoginEmailFragment newInstance(String url, boolean optionalSiteCredsLayout) { + return newInstance(url, optionalSiteCredsLayout, false, false); + } + + public static LoginEmailFragment newInstance(boolean isSignupFromLoginEnabled) { + return newInstance(null, false, isSignupFromLoginEnabled, false); + } + + public static LoginEmailFragment newInstance(boolean isSignupFromLoginEnabled, boolean hideTos) { + return newInstance(null, false, isSignupFromLoginEnabled, hideTos); + } + + private static LoginEmailFragment newInstance(String url, + boolean optionalSiteCredsLayout, + boolean isSignupFromLoginEnabled, + boolean hideTos) { + LoginEmailFragment fragment = new LoginEmailFragment(); + Bundle args = new Bundle(); + args.putString(ARG_LOGIN_SITE_URL, url); + args.putBoolean(ARG_OPTIONAL_SITE_CREDS_LAYOUT, optionalSiteCredsLayout); + args.putBoolean(ARG_SIGNUP_FROM_LOGIN_ENABLED, isSignupFromLoginEnabled); + args.putBoolean(ARG_HIDE_TOS, hideTos); + fragment.setArguments(args); + return fragment; + } + + @Override + protected @LayoutRes int getContentLayout() { + if (mOptionalSiteCredsLayout) { + return R.layout.login_email_optional_site_creds_screen; + } else { + return R.layout.login_email_screen; + } + } + + @Override + protected @LayoutRes int getProgressBarText() { + return mIsSocialLogin ? R.string.logging_in : R.string.checking_email; + } + + @Override + protected void setupLabel(@NonNull TextView label) { + switch (mLoginListener.getLoginMode()) { + case WPCOM_LOGIN_DEEPLINK: + label.setText(R.string.login_log_in_for_deeplink); + break; + case SHARE_INTENT: + label.setText(R.string.login_log_in_for_share_intent); + break; + case FULL: + case WPCOM_LOGIN_ONLY: + case JETPACK_LOGIN_ONLY: + case JETPACK_SELFHOSTED: + case SELFHOSTED_ONLY: + if (!TextUtils.isEmpty(mLoginSiteUrl)) { + label.setText(Html.fromHtml( + getString(R.string.enter_email_for_site, mLoginSiteUrl))); + } else { + label.setText(R.string.enter_email_to_continue_wordpress_com); + } + break; + case WOO_LOGIN_MODE: + if (mOptionalSiteCredsLayout) { + String siteAddressClean = mLoginSiteUrl.replaceFirst("^(http[s]?://)", ""); + label.setText(Html.fromHtml( + getString(R.string.enter_email_for_site, siteAddressClean))); + } else { + label.setText(getString(R.string.enter_email_wordpress_com)); + } + break; + case JETPACK_STATS: + label.setText(R.string.login_to_to_connect_jetpack); + break; + case WPCOM_REAUTHENTICATE: + label.setText(R.string.auth_required); + break; + } + } + + @Override + protected void setupContent(ViewGroup rootView) { + // important for accessibility - talkback + getActivity().setTitle(R.string.email_address_login_title); + + setupEmailInput((WPLoginInputRow) rootView.findViewById(R.id.login_email_row)); + + if (mOptionalSiteCredsLayout) { + setupContinueButton((Button) rootView.findViewById(R.id.login_continue_button)); + setupSiteCredsButton((Button) rootView.findViewById(R.id.login_site_creds)); + setupFindEmailHelpButton( + (Button) rootView.findViewById(R.id.login_find_connected_email)); + } else { + setupContinueButton((Button) rootView.findViewById(R.id.login_continue_button)); + setupTosButtons( + (Button) rootView.findViewById(R.id.continue_tos), + (Button) rootView.findViewById(R.id.continue_with_google_tos)); + setupSocialButtons((Button) rootView.findViewById(R.id.continue_with_google)); + } + } + + private void setupEmailInput(WPLoginInputRow emailInput) { + mEmailInput = emailInput; + if (BuildConfig.DEBUG) { + mEmailInput.getEditText().setText(BuildConfig.DEBUG_WPCOM_LOGIN_EMAIL); + } + mEmailInput.post(new Runnable() { + @Override public void run() { + if (mEmailInput != null) { + mEmailInput.addTextChangedListener(LoginEmailFragment.this); + } + } + }); + + mEmailInput.setOnEditorCommitListener(this); + mEmailInput.getEditText().setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (hasFocus && !mIsDisplayingEmailHints && !mHasDismissedEmailHints) { + mAnalyticsListener.trackSelectEmailField(); + showHintPickerDialogIfNeeded(); + } + } + }); + mEmailInput.getEditText().setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + mAnalyticsListener.trackSelectEmailField(); + if (!mIsDisplayingEmailHints && !mHasDismissedEmailHints) { + mAnalyticsListener.trackSelectEmailField(); + showHintPickerDialogIfNeeded(); + } + } + }); + } + + private void setupContinueButton(Button continueButton) { + continueButton.setOnClickListener(new OnClickListener() { + public void onClick(View view) { + onContinueClicked(); + } + }); + } + + private void updateContinueButtonEnabledStatus() { + View view = getView(); + if (view != null) { + Button continueButton = (Button) view.findViewById(R.id.login_continue_button); + String currentEmail = mEmailInput.getEditText().getText().toString(); + continueButton.setEnabled(!currentEmail.trim().isEmpty()); + } + } + + @Override public void onDestroyView() { + mEmailInput = null; + + super.onDestroyView(); + } + + private void setupTosButtons(Button continueTosButton, Button continueWithGoogleTosButton) { + if (mHideTos) { + // Hide the TOS buttons + continueTosButton.setVisibility(View.GONE); + continueWithGoogleTosButton.setVisibility(View.GONE); + } else { + // Show the TOS buttons + continueTosButton.setVisibility(View.VISIBLE); + continueWithGoogleTosButton.setVisibility(View.VISIBLE); + + OnClickListener onClickListener = new OnClickListener() { + public void onClick(View view) { + mLoginListener.onTermsOfServiceClicked(); + } + }; + + continueTosButton.setOnClickListener(onClickListener); + continueTosButton.setText(formatTosText(R.string.continue_terms_of_service_text)); + + continueWithGoogleTosButton.setOnClickListener(onClickListener); + continueWithGoogleTosButton + .setText(formatTosText(R.string.continue_with_google_terms_of_service_text)); + } + } + + private void setupSocialButtons(Button continueWithGoogleButton) { + continueWithGoogleButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + onGoogleSigninClicked(); + } + }); + } + + private void setupSiteCredsButton(Button continueWithSiteCreds) { + continueWithSiteCreds.setOnClickListener(new OnClickListener() { + @Override public void onClick(View v) { + mLoginListener.loginViaSiteCredentials(mLoginSiteUrl); + } + }); + } + + private void setupFindEmailHelpButton(Button findConnectedEmail) { + findConnectedEmail.setOnClickListener(new OnClickListener() { + @Override public void onClick(View v) { + mLoginListener.showHelpFindingConnectedEmail(); + } + }); + } + + @Override + protected void setupBottomButton(Button button) { + button.setVisibility(View.GONE); + } + + private Spanned formatTosText(int stringResId) { + final int primaryColorResId = ContextExtensionsKt.getColorResIdFromAttribute(getContext(), + com.google.android.material.R.attr.colorSecondary); + final String primaryColorHtml = HtmlUtils.colorResToHtmlColor(getContext(), primaryColorResId); + return Html.fromHtml(getString(stringResId, "", "")); + } + + private void onContinueClicked() { + next(getCleanedEmail()); + } + + private void onGoogleSigninClicked() { + mAnalyticsListener.trackSocialButtonClick(); + ActivityUtils.hideKeyboardForced(mEmailInput.getEditText()); + + if (NetworkUtils.checkConnection(getActivity())) { + if (isAdded()) { + mOldSitesIDs = SiteUtils.getCurrentSiteIds(mSiteStore, false); + mIsSocialLogin = true; + mLoginListener.addGoogleLoginFragment(mIsSignupFromLoginEnabled); + } else { + AppLog.e(T.NUX, "Google login could not be started. LoginEmailFragment was not attached."); + showErrorDialog(getString(R.string.login_error_generic_start)); + } + } + } + + @Override + protected void onHelp() { + if (mLoginListener != null) { + if (mIsSocialLogin) { + // Send last email chosen from Google login if available. + mLoginListener.helpSocialEmailScreen(mGoogleEmail); + } else { + // Send exact string the user has inputted for email + mLoginListener.helpEmailScreen(EditTextUtils.getText(mEmailInput.getEditText())); + } + } + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Bundle args = getArguments(); + if (args != null) { + mLoginSiteUrl = args.getString(ARG_LOGIN_SITE_URL, ""); + mIsSignupFromLoginEnabled = args.getBoolean(ARG_SIGNUP_FROM_LOGIN_ENABLED, false); + mOptionalSiteCredsLayout = args.getBoolean(ARG_OPTIONAL_SITE_CREDS_LAYOUT, false); + mHideTos = args.getBoolean(ARG_HIDE_TOS, false); + } + } + + @Override + public void onStart() { + super.onStart(); + mGoogleApiClient = new GoogleApiClient.Builder(getActivity()) + .addConnectionCallbacks(LoginEmailFragment.this) + .enableAutoManage(getActivity(), GOOGLE_API_CLIENT_ID, LoginEmailFragment.this) + .addApi(Auth.CREDENTIALS_API) + .build(); + showEmailError(); + } + + @Override + public void onStop() { + super.onStop(); + if (mGoogleApiClient != null) { + mGoogleApiClient.stopAutoManage(getActivity()); + if (mGoogleApiClient.isConnected()) { + mGoogleApiClient.disconnect(); + } + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + mOldSitesIDs = savedInstanceState.getIntegerArrayList(KEY_OLD_SITES_IDS); + mRequestedEmail = savedInstanceState.getString(KEY_REQUESTED_EMAIL); + mGoogleEmail = savedInstanceState.getString(KEY_GOOGLE_EMAIL); + mIsSocialLogin = savedInstanceState.getBoolean(KEY_IS_SOCIAL); + mIsDisplayingEmailHints = savedInstanceState.getBoolean(KEY_IS_DISPLAYING_EMAIL_HINTS); + mHasDismissedEmailHints = savedInstanceState.getBoolean(KEY_HAS_DISMISSED_EMAIL_HINTS); + if (savedInstanceState.containsKey(KEY_EMAIL_ERROR_RES)) { + mCurrentEmailErrorRes = savedInstanceState.getInt(KEY_EMAIL_ERROR_RES); + } + } else { + mAnalyticsListener.trackEmailFormViewed(); + } + } + + @Override + public void onResume() { + super.onResume(); + mAnalyticsListener.emailFormScreenResumed(); + updateContinueButtonEnabledStatus(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putIntegerArrayList(KEY_OLD_SITES_IDS, mOldSitesIDs); + outState.putString(KEY_REQUESTED_EMAIL, mRequestedEmail); + outState.putString(KEY_GOOGLE_EMAIL, mGoogleEmail); + outState.putBoolean(KEY_IS_SOCIAL, mIsSocialLogin); + outState.putBoolean(KEY_IS_DISPLAYING_EMAIL_HINTS, mIsDisplayingEmailHints); + outState.putBoolean(KEY_HAS_DISMISSED_EMAIL_HINTS, mHasDismissedEmailHints); + if (mCurrentEmailErrorRes != null) { + outState.putInt(KEY_EMAIL_ERROR_RES, mCurrentEmailErrorRes); + } + } + + @Override + protected void buildToolbar(Toolbar toolbar, ActionBar actionBar) { + if (mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE) { + actionBar.setTitle(R.string.log_in); + return; + } + + if (mOptionalSiteCredsLayout) { + super.buildToolbar(toolbar, actionBar); + } else { + actionBar.setTitle(R.string.get_started); + } + } + + protected void next(String email) { + mAnalyticsListener.trackSubmitClicked(); + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + + if (isValidEmail(email)) { + clearEmailError(); + startProgress(); + mRequestedEmail = email; + mDispatcher.dispatch(AccountActionBuilder.newFetchAuthOptionsAction(new FetchAuthOptionsPayload(email))); + } else { + showEmailError(R.string.email_invalid); + } + } + + /** + * This is cleared every time the text is changed or the email is valid so that if the user rotates the device, they + * don't receive an unnecessary warning from a previous error. + */ + private void clearEmailError() { + mCurrentEmailErrorRes = null; + } + + private void showEmailError() { + if (mCurrentEmailErrorRes != null) { + showEmailError(mCurrentEmailErrorRes); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mLoginListener = null; + } + + private String getCleanedEmail() { + return EditTextUtils.getText(mEmailInput.getEditText()).trim(); + } + + private boolean isValidEmail(String email) { + Pattern emailRegExPattern = Patterns.EMAIL_ADDRESS; + Matcher matcher = emailRegExPattern.matcher(email); + + return matcher.find() && email.length() <= MAX_EMAIL_LENGTH; + } + + @Override + public void onEditorCommit() { + next(getCleanedEmail()); + } + + @Override + public void afterTextChanged(Editable s) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + mEmailInput.setError(null); + mIsSocialLogin = false; + clearEmailError(); + updateContinueButtonEnabledStatus(); + } + + private void showEmailError(int messageId) { + mCurrentEmailErrorRes = messageId; + String errorMessage = getString(messageId); + mAnalyticsListener.trackFailure(errorMessage); + mEmailInput.setError(errorMessage); + } + + private void showErrorDialog(String message) { + AlertDialog dialog = new MaterialAlertDialogBuilder(getActivity()) + .setMessage(message) + .setPositiveButton(R.string.login_error_button, null) + .create(); + dialog.show(); + } + + @Override + protected void endProgress() { + super.endProgress(); + mRequestedEmail = null; + } + + // OnChanged events + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthOptionsFetched(OnAuthOptionsFetched event) { + if (mRequestedEmail == null) { + // bail if user canceled + return; + } + + final String email = mRequestedEmail; + + if (isInProgress()) { + endProgress(); + } + + // hide the keyboard + ActivityUtils.hideKeyboardForced(mEmailInput); + + if (event.isError()) { + // report the error but don't bail yet. + AppLog.e(T.API, "OnAuthOptionsFetched has error: " + event.error.type + " - " + event.error.message); + + switch (event.error.type) { + case UNKNOWN_USER: + // This email does not correspond to an existing account + + // Will be true if in the Woo app and currently in the WPcom login + // flow. We need to check this to know if we should display the + // 'No WPcom account found' error screen. + boolean isWooWPcomLoginFlow = false; + if (mLoginListener != null + && mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE + && !mOptionalSiteCredsLayout) { + isWooWPcomLoginFlow = true; + } + + if (mIsSignupFromLoginEnabled || isWooWPcomLoginFlow) { + if (mLoginListener != null) { + mLoginListener.gotUnregisteredEmail(email); + } + } else { + mAnalyticsListener.trackFailure("Email not registered WP.com"); + showEmailError(R.string.email_not_registered_wpcom); + } + break; + case EMAIL_LOGIN_NOT_ALLOWED: + // As a security measure, this user needs to log in using an username and password + mAnalyticsListener.trackFailure("Login with username required"); + ToastUtils.showToast(getContext(), R.string.error_user_username_instead_of_email, Duration.LONG); + if (mLoginListener != null) { + mLoginListener.loginViaWpcomUsernameInstead(); + } + break; + case GENERIC_ERROR: + default: + showErrorDialog(getString(R.string.error_generic_network)); + } + } else { + if (mLoginListener != null) { + mLoginListener + .gotWpcomEmail(email, false, new AuthOptions(event.isPasswordless, event.isEmailVerified)); + } + } + } + + public void setGoogleEmail(String email) { + mGoogleEmail = email; + } + + public void finishLogin() { + doFinishLogin(); + } + + @Override + protected void onLoginFinished() { + mAnalyticsListener.trackAnalyticsSignIn(true); + mLoginListener.loggedInViaSocialAccount(mOldSitesIDs, false); + } + + @Override + public void onConnected(Bundle bundle) { + AppLog.d(T.NUX, LOG_TAG + ": Google API client connected"); + } + + @Override + public void onConnectionFailed(@NonNull ConnectionResult connectionResult) { + AppLog.d(T.NUX, LOG_TAG + ": Google API connection result: " + connectionResult); + } + + @Override + public void onConnectionSuspended(int i) { + AppLog.d(T.NUX, LOG_TAG + ": Google API client connection suspended"); + } + + private void showHintPickerDialogIfNeeded() { + // If autofill is available and enabled, we favor the active autofill service over the hint picker dialog. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + final AutofillManager autofillManager = requireContext().getSystemService(AutofillManager.class); + if (autofillManager != null && autofillManager.isEnabled()) { + AppLog.d(T.NUX, LOG_TAG + ": Autofill framework is enabled. Disabling hint picker dialog."); + return; + } + } + + AppLog.d(T.NUX, LOG_TAG + ": Autofill framework is unavailable or disabled. Showing hint picker dialog."); + + showHintPickerDialog(); + } + + private void showHintPickerDialog() { + GoogleApiAvailability googleApiAvailability = GoogleApiAvailability.getInstance(); + if (getContext() == null + || googleApiAvailability.isGooglePlayServicesAvailable(getContext()) != ConnectionResult.SUCCESS) { + AppLog.w(T.NUX, LOG_TAG + ": Couldn't start hint picker - Play Services unavailable"); + return; + } + HintRequest hintRequest = new HintRequest.Builder() + .setHintPickerConfig(new CredentialPickerConfig.Builder() + .setShowCancelButton(true) + .build()) + .setEmailAddressIdentifierSupported(true) + .build(); + + PendingIntent intent = Auth.CredentialsApi.getHintPickerIntent(mGoogleApiClient, hintRequest); + + try { + startIntentSenderForResult(intent.getIntentSender(), EMAIL_CREDENTIALS_REQUEST_CODE, null, 0, 0, 0, null); + mIsDisplayingEmailHints = true; + } catch (IntentSender.SendIntentException exception) { + AppLog.d(T.NUX, LOG_TAG + "Could not start email hint picker" + exception); + } catch (ActivityNotFoundException exception) { + AppLog.d(T.NUX, LOG_TAG + "Could not find any activity to handle email hint picker" + exception); + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == EMAIL_CREDENTIALS_REQUEST_CODE) { + if (mEmailInput == null) { + // Activity result received before the fragments onCreateView(), disregard result. + return; + } + + if (resultCode == RESULT_OK) { + Credential credential = data.getParcelableExtra(Credential.EXTRA_KEY); + mEmailInput.getEditText().setText(credential.getId()); + next(getCleanedEmail()); + } else { + mHasDismissedEmailHints = true; + mEmailInput.getEditText().postDelayed(new Runnable() { + @Override + public void run() { + if (isAdded()) { + ActivityUtils.showKeyboard(mEmailInput.getEditText()); + } + } + }, getResources().getInteger(android.R.integer.config_mediumAnimTime)); + } + + mIsDisplayingEmailHints = false; + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginEmailPasswordFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginEmailPasswordFragment.java new file mode 100644 index 000000000000..62cfc53e7503 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginEmailPasswordFragment.java @@ -0,0 +1,424 @@ +package org.wordpress.android.login; + +import android.content.Context; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.login.LoginWpcomService.LoginState; +import org.wordpress.android.login.LoginWpcomService.OnCredentialsOK; +import org.wordpress.android.login.LoginWpcomService.TwoFactorRequested; +import org.wordpress.android.login.util.AvatarHelper; +import org.wordpress.android.login.util.AvatarHelper.AvatarRequestListener; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.login.widgets.WPLoginInputRow; +import org.wordpress.android.login.widgets.WPLoginInputRow.OnEditorCommitListener; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.AutoForeground; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import java.util.ArrayList; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginEmailPasswordFragment extends LoginBaseFormFragment implements TextWatcher, + OnEditorCommitListener { + private static final String KEY_REQUESTED_PASSWORD = "KEY_REQUESTED_PASSWORD"; + private static final String KEY_OLD_SITES_IDS = "KEY_OLD_SITES_IDS"; + + protected static final String ARG_EMAIL_ADDRESS = "ARG_EMAIL_ADDRESS"; + protected static final String ARG_PASSWORD = "ARG_PASSWORD"; + protected static final String ARG_SOCIAL_ID_TOKEN = "ARG_SOCIAL_ID_TOKEN"; + protected static final String ARG_SOCIAL_LOGIN = "ARG_SOCIAL_LOGIN"; + protected static final String ARG_SOCIAL_SERVICE = "ARG_SOCIAL_SERVICE"; + protected static final String ARG_ALLOW_MAGIC_LINK = "ARG_ALLOW_MAGIC_LINK"; + protected static final String ARG_VERIFY_MAGIC_LINK_EMAIL = "ARG_VERIFY_MAGIC_LINK_EMAIL"; + + private static final String FORGOT_PASSWORD_URL_WPCOM = "https://wordpress.com/"; + + public static final String TAG = "login_email_password_fragment_tag"; + + private WPLoginInputRow mPasswordInput; + + private String mRequestedPassword; + ArrayList mOldSitesIDs; + + private String mEmailAddress; + private String mIdToken; + private String mPassword; + private String mService; + private boolean mIsSocialLogin; + private boolean mAllowMagicLink; + private boolean mVerifyMagicLinkEmail; + + private AutoForeground.ServiceEventConnection mServiceEventConnection; + + public static LoginEmailPasswordFragment newInstance(String emailAddress, String password, + String idToken, String service, + boolean isSocialLogin) { + return newInstance(emailAddress, password, idToken, service, isSocialLogin, false, false); + } + + public static LoginEmailPasswordFragment newInstance(String emailAddress, String password, String idToken, + String service, boolean isSocialLogin, boolean allowMagicLink, + boolean verifyMagicLinkEmail) { + LoginEmailPasswordFragment fragment = new LoginEmailPasswordFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, emailAddress); + args.putString(ARG_PASSWORD, password); + args.putString(ARG_SOCIAL_ID_TOKEN, idToken); + args.putString(ARG_SOCIAL_SERVICE, service); + args.putBoolean(ARG_SOCIAL_LOGIN, isSocialLogin); + args.putBoolean(ARG_ALLOW_MAGIC_LINK, allowMagicLink); + args.putBoolean(ARG_VERIFY_MAGIC_LINK_EMAIL, verifyMagicLinkEmail); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null) { + mEmailAddress = getArguments().getString(ARG_EMAIL_ADDRESS); + mPassword = getArguments().getString(ARG_PASSWORD); + mIdToken = getArguments().getString(ARG_SOCIAL_ID_TOKEN); + mService = getArguments().getString(ARG_SOCIAL_SERVICE); + mIsSocialLogin = getArguments().getBoolean(ARG_SOCIAL_LOGIN); + mAllowMagicLink = getArguments().getBoolean(ARG_ALLOW_MAGIC_LINK); + mVerifyMagicLinkEmail = getArguments().getBoolean(ARG_VERIFY_MAGIC_LINK_EMAIL); + } + + if (savedInstanceState == null) { + // cleanup the service state on first appearance + LoginWpcomService.clearLoginServiceState(); + } else { + mRequestedPassword = savedInstanceState.getString(KEY_REQUESTED_PASSWORD); + } + } + + @Override + public void onResume() { + super.onResume(); + mAnalyticsListener.emailPasswordFormScreenResumed(); + updatePrimaryButtonEnabledStatus(); + + // connect to the Service. We'll receive updates via EventBus. + mServiceEventConnection = new AutoForeground.ServiceEventConnection(getContext(), + LoginWpcomService.class, this); + + // install the change listener as late as possible so the UI can be setup (updated from the Service state) + // before triggering the state cleanup happening in the change listener. + mPasswordInput.addTextChangedListener(this); + } + + @Override + public void onPause() { + super.onPause(); + + // disconnect from the Service + mServiceEventConnection.disconnect(getContext(), this); + } + + private void updatePrimaryButtonEnabledStatus() { + String currentPassword = mPasswordInput.getEditText().getText().toString(); + getBottomButton().setEnabled(!currentPassword.trim().isEmpty()); + } + + @Override + protected boolean listenForLogin() { + return false; + } + + @Override + protected @LayoutRes int getContentLayout() { + return R.layout.login_email_password_screen; + } + + @Override + protected @LayoutRes int getProgressBarText() { + return R.string.logging_in; + } + + @Override + protected void setupLabel(@NonNull TextView label) { + label.setText(mIsSocialLogin ? R.string.enter_wpcom_password_google : R.string.enter_wpcom_password); + } + + @Override + protected void setupContent(ViewGroup rootView) { + // important for accessibility - talkback + getActivity().setTitle(R.string.selfhosted_site_login_title); + + mPasswordInput = rootView.findViewById(R.id.login_password_row); + mPasswordInput.setOnEditorCommitListener(this); + + rootView.findViewById(R.id.login_reset_password).setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (mLoginListener != null) { + mLoginListener.forgotPassword(FORGOT_PASSWORD_URL_WPCOM); + } + } + }); + + final View divider = rootView.findViewById(R.id.login_button_divider); + divider.setVisibility(mAllowMagicLink ? View.VISIBLE : View.GONE); + + final Button magicLinkButton = rootView.findViewById(R.id.login_get_email_link); + magicLinkButton.setVisibility(mAllowMagicLink ? View.VISIBLE : View.GONE); + magicLinkButton.setOnClickListener(new OnClickListener() { + @Override public void onClick(View v) { + if (mLoginListener != null) { + mAnalyticsListener.trackRequestMagicLinkClick(); + mLoginListener.useMagicLinkInstead(mEmailAddress, mVerifyMagicLinkEmail); + } + } + }); + + final ProgressBar avatarProgressBar = rootView.findViewById(R.id.avatar_progress); + final ImageView avatarView = rootView.findViewById(R.id.gravatar); + final TextView emailView = rootView.findViewById(R.id.email); + + emailView.setText(mEmailAddress); + + AvatarHelper.loadAvatarFromEmail(this, mEmailAddress, avatarView, new AvatarRequestListener() { + @Override public void onRequestFinished() { + avatarProgressBar.setVisibility(View.GONE); + } + }); + } + + @Override + protected void buildToolbar(Toolbar toolbar, ActionBar actionBar) { + actionBar.setTitle(R.string.log_in); + } + + @Override + protected void setupBottomButton(Button button) { + button.setOnClickListener(new OnClickListener() { + public void onClick(View v) { + next(); + } + }); + } + + @Override + protected EditText getEditTextToFocusOnStart() { + return mPasswordInput.getEditText(); + } + + @Override + protected void onHelp() { + if (mLoginListener != null) { + mLoginListener.helpEmailPasswordScreen(mEmailAddress); + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState == null) { + mAnalyticsListener.trackPasswordFormViewed(mIsSocialLogin); + + if (!TextUtils.isEmpty(mPassword)) { + mPasswordInput.setText(mPassword); + } else { + if (BuildConfig.DEBUG) { + mPasswordInput.getEditText().setText(BuildConfig.DEBUG_WPCOM_LOGIN_PASSWORD); + } + } + } else { + mOldSitesIDs = savedInstanceState.getIntegerArrayList(KEY_OLD_SITES_IDS); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putString(KEY_REQUESTED_PASSWORD, mRequestedPassword); + outState.putIntegerArrayList(KEY_OLD_SITES_IDS, mOldSitesIDs); + } + + protected void next() { + mAnalyticsListener.trackSubmitClicked(); + + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + + startProgress(false); + + mRequestedPassword = mPasswordInput.getEditText().getText().toString(); + + LoginWpcomService.loginWithEmailAndPassword( + getContext(), + mEmailAddress, + mRequestedPassword, + mIdToken, + mService, + mIsSocialLogin, + mLoginListener.getLoginMode() == LoginMode.JETPACK_LOGIN_ONLY, + mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE + ); + mOldSitesIDs = SiteUtils.getCurrentSiteIds(mSiteStore, false); + } + + @Override + public void onEditorCommit() { + mPasswordInput.setError(null); + next(); + } + + @Override + public void afterTextChanged(Editable s) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + mPasswordInput.setError(null); + + LoginWpcomService.clearLoginServiceState(); + updatePrimaryButtonEnabledStatus(); + } + + private void showPasswordError() { + String message = getString(R.string.password_incorrect); + mAnalyticsListener.trackFailure(message); + mPasswordInput.setError(message); + } + + private void showError(String error) { + mAnalyticsListener.trackFailure(error); + mPasswordInput.setError(error); + } + + @Override + protected void onLoginFinished() { + mAnalyticsListener.trackAnalyticsSignIn(true); + mLoginListener.startPostLoginServices(); + + if (mIsSocialLogin) { + mLoginListener.loggedInViaSocialAccount(mOldSitesIDs, false); + } else { + mLoginListener.loggedInViaPassword(mOldSitesIDs); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onCredentialsOK(OnCredentialsOK event) { + saveCredentialsInSmartLock(mLoginListener, mEmailAddress, mRequestedPassword); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onTwoFactorAuthStarted(TwoFactorRequested event) { + onLoginFinished(false); + mLoginListener.needs2fa(mEmailAddress, mRequestedPassword, event.userId, + event.webauthnNonce, event.authenticatorNonce, event.backupNonce, + event.pushNonce, event.supportedAuthTypes); + LoginWpcomService.clearLoginServiceState(); + } + + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + public void onLoginStateUpdated(LoginState loginState) { + AppLog.i(T.NUX, "Received state: " + loginState.getStepName()); + + switch (loginState.getStep()) { + case IDLE: + // nothing special to do, we'll start the service on next() + break; + case AUTHENTICATING: + case SOCIAL_LOGIN: + case FETCHING_ACCOUNT: + case FETCHING_SETTINGS: + case FETCHING_SITES: + if (!isInProgress()) { + startProgress(); + } + break; + case FAILURE_EMAIL_WRONG_PASSWORD: + onLoginFinished(false); + showPasswordError(); + break; + case FAILURE_2FA: + onLoginFinished(false); + mLoginListener.needs2fa(mEmailAddress, mRequestedPassword); + + // consume the state so we don't relauch the 2FA dialog if user backs up + LoginWpcomService.clearLoginServiceState(); + break; + case FAILURE_SOCIAL_2FA: + onLoginFinished(false); + mLoginListener.needs2faSocialConnect(mEmailAddress, mRequestedPassword, mIdToken, mService); + + // consume the state so we don't relauch the 2FA dialog if user backs up + LoginWpcomService.clearLoginServiceState(); + break; + case SECURITY_KEY_NEEDED: + onLoginFinished(false); + // consume the state so we don't relauch the 2FA dialog if user backs up + LoginWpcomService.clearLoginServiceState(); + break; + case FAILURE_FETCHING_ACCOUNT: + onLoginFinished(false); + showError(getString(R.string.error_fetch_my_profile)); + break; + case FAILURE_CANNOT_ADD_DUPLICATE_SITE: + onLoginFinished(false); + showError(getString(R.string.cannot_add_duplicate_site)); + break; + case FAILURE_USE_WPCOM_USERNAME_INSTEAD_OF_EMAIL: + onLoginFinished(false); + mLoginListener.loginViaWpcomUsernameInstead(); + ToastUtils.showToast(getContext(), R.string.error_user_username_instead_of_email, Duration.LONG); + + mAnalyticsListener.trackFailure(loginState.getStep().name()); + // consume the state so we don't re-redirect to username login if user backs up + LoginWpcomService.clearLoginServiceState(); + break; + case FAILURE: + onLoginFinished(false); + showError(getString(R.string.error_generic)); + break; + case SUCCESS: + onLoginFinished(true); + break; + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginGoogleFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginGoogleFragment.java new file mode 100644 index 000000000000..302701f6ed43 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginGoogleFragment.java @@ -0,0 +1,249 @@ +package org.wordpress.android.login; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import com.google.android.gms.auth.api.Auth; +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.auth.api.signin.GoogleSignInResult; +import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthenticationChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnSocialChanged; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialPayload; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import static android.app.Activity.RESULT_CANCELED; +import static android.app.Activity.RESULT_OK; +import static org.wordpress.android.fluxc.store.AccountStore.AccountSocialErrorType.UNKNOWN_USER; +import static org.wordpress.android.fluxc.store.AccountStore.AccountSocialErrorType.USER_EXISTS; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginGoogleFragment extends GoogleFragment { + private static final String ARG_SIGNUP_FROM_LOGIN_ENABLED = "ARG_SIGNUP_FROM_LOGIN_ENABLED"; + private static final int REQUEST_LOGIN = 1001; + private boolean mLoginRequested = false; + private boolean mIsSignupFromLoginEnabled; + + public static final String TAG = "login_google_fragment_tag"; + + public static LoginGoogleFragment newInstance(boolean isSignupFromLoginEnabled) { + LoginGoogleFragment fragment = new LoginGoogleFragment(); + Bundle args = new Bundle(); + args.putBoolean(ARG_SIGNUP_FROM_LOGIN_ENABLED, isSignupFromLoginEnabled); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + Bundle args = getArguments(); + if (args != null) { + mIsSignupFromLoginEnabled = args.getBoolean(ARG_SIGNUP_FROM_LOGIN_ENABLED, false); + } + } + + @Override + protected String getProgressDialogText() { + return getString(R.string.logging_in); + } + + @Override + protected void startFlow() { + if (!mLoginRequested) { + AppLog.d(T.MAIN, "GOOGLE LOGIN: startFlow"); + mLoginRequested = true; + Intent loginIntent = Auth.GoogleSignInApi.getSignInIntent(mGoogleApiClient); + mAnalyticsListener.trackSocialButtonStart(); + startActivityForResult(loginIntent, REQUEST_LOGIN); + } else { + AppLog.d(T.MAIN, "GOOGLE LOGIN: startFlow called, but is already in progress"); + } + } + + @Override + public void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + + switch (request) { + case REQUEST_LOGIN: + disconnectGoogleClient(); + mLoginRequested = false; + if (result == RESULT_OK) { + AppLog.d(T.MAIN, "GOOGLE LOGIN: Google has returned a sign in result - succcess"); + GoogleSignInResult loginResult = Auth.GoogleSignInApi.getSignInResultFromIntent(data); + + if (loginResult.isSuccess()) { + try { + GoogleSignInAccount account = loginResult.getSignInAccount(); + + if (account != null) { + mDisplayName = account.getDisplayName() != null ? account.getDisplayName() : ""; + mGoogleEmail = account.getEmail() != null ? account.getEmail() : ""; + mGoogleListener.onGoogleEmailSelected(mGoogleEmail); + mIdToken = account.getIdToken() != null ? account.getIdToken() : ""; + mPhotoUrl = removeScaleFromGooglePhotoUrl( + account.getPhotoUrl() != null ? account.getPhotoUrl().toString() : ""); + } + + AppLog.d(T.MAIN, + "GOOGLE LOGIN: Google has returned a sign in result - dispatching " + + "SocialLoginAction"); + PushSocialPayload payload = new PushSocialPayload(mIdToken, SERVICE_TYPE_GOOGLE); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialLoginAction(payload)); + } catch (NullPointerException exception) { + AppLog.d(T.MAIN, "GOOGLE LOGIN: Google has returned a sign in result - NPE"); + AppLog.e(T.NUX, "Cannot get ID token from Google login account.", exception); + showError(getString(R.string.login_error_generic)); + } + } else { + AppLog.d(T.MAIN, "GOOGLE LOGIN: Google has returned a sign in result - error"); + mAnalyticsListener.trackSocialButtonFailure(); + switch (loginResult.getStatus().getStatusCode()) { + // Internal error. + case GoogleSignInStatusCodes.INTERNAL_ERROR: + AppLog.e(T.NUX, "Google Login Failed: internal error."); + showError(getString(R.string.login_error_generic)); + break; + // Attempted to connect with an invalid account name specified. + case GoogleSignInStatusCodes.INVALID_ACCOUNT: + AppLog.e(T.NUX, "Google Login Failed: invalid account name."); + showError(getString(R.string.login_error_generic) + + getString(R.string.login_error_suffix)); + break; + // Network error. + case GoogleSignInStatusCodes.NETWORK_ERROR: + AppLog.e(T.NUX, "Google Login Failed: network error."); + showError(getString(R.string.error_generic_network)); + break; + // Cancelled by the user. + case GoogleSignInStatusCodes.SIGN_IN_CANCELLED: + AppLog.e(T.NUX, "Google Login Failed: cancelled by user."); + break; + // Attempt didn't succeed with the current account. + case GoogleSignInStatusCodes.SIGN_IN_FAILED: + AppLog.e(T.NUX, "Google Login Failed: current account failed."); + showError(getString(R.string.login_error_generic)); + break; + // Attempted to connect, but the user is not signed in. + case GoogleSignInStatusCodes.SIGN_IN_REQUIRED: + AppLog.e(T.NUX, "Google Login Failed: user is not signed in."); + showError(getString(R.string.login_error_generic)); + break; + // Timeout error. + case GoogleSignInStatusCodes.TIMEOUT: + AppLog.e(T.NUX, "Google Login Failed: timeout error."); + showError(getString(R.string.google_error_timeout)); + break; + // Unknown error. + default: + AppLog.e(T.NUX, "Google Login Failed: unknown error."); + showError(getString(R.string.login_error_generic)); + break; + } + } + } else if (result == RESULT_CANCELED) { + AppLog.d(T.MAIN, "GOOGLE LOGIN: Google has returned a sign in result - canceled"); + mAnalyticsListener.trackSocialButtonFailure(); + AppLog.e(T.NUX, "Google Login Failed: result was CANCELED."); + finishFlow(); + } else { + AppLog.d(T.MAIN, "GOOGLE LOGIN: Google has returned a sign in result - unknown"); + mAnalyticsListener.trackSocialButtonFailure(); + AppLog.e(T.NUX, "Google Login Failed: result was not OK or CANCELED."); + showError(getString(R.string.login_error_generic)); + } + + break; + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthenticationChanged(OnAuthenticationChanged event) { + if (event.isError()) { + AppLog.d(T.MAIN, "GOOGLE LOGIN: onAuthenticationChanged - error"); + AppLog.e(T.API, "LoginGoogleFragment.onAuthenticationChanged: " + event.error.type + + " - " + event.error.message); + mAnalyticsListener.trackLoginFailed(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + + mAnalyticsListener.trackSocialFailure(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + + showError(getString(R.string.login_error_generic)); + } else { + AppLog.d(T.MAIN, "GOOGLE LOGIN: onAuthenticationChanged - success"); + AppLog.i(T.NUX, "LoginGoogleFragment.onAuthenticationChanged: " + event.toString()); + mGoogleListener.onGoogleLoginFinished(); + finishFlow(); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSocialChanged(OnSocialChanged event) { + // Response returns error for non-existing account and existing account not connected. + if (event.isError()) { + AppLog.e(T.API, "LoginGoogleFragment.onSocialChanged: " + event.error.type + " - " + event.error.message); + + // We don't want to track these errors: USER_EXISTS and UNKNOWN_USER (if signup from login is enabled) + if (event.error.type != USER_EXISTS && (!mIsSignupFromLoginEnabled || event.error.type != UNKNOWN_USER)) { + mAnalyticsListener.trackLoginFailed(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + + mAnalyticsListener.trackSocialFailure(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + } + + switch (event.error.type) { + // WordPress account exists with input email address, but not connected. + case USER_EXISTS: + AppLog.d(T.MAIN, "GOOGLE LOGIN: onSocialChanged - wordpress acount exists but not connected"); + mAnalyticsListener.trackSocialAccountsNeedConnecting(); + mLoginListener.loginViaSocialAccount(mGoogleEmail, mIdToken, SERVICE_TYPE_GOOGLE, true); + break; + // WordPress account does not exist with input email address. + case UNKNOWN_USER: + AppLog.d(T.MAIN, "GOOGLE LOGIN: onSocialChanged - wordpress account doesn't exist"); + if (mIsSignupFromLoginEnabled) { + mLoginListener.gotUnregisteredSocialAccount(mGoogleEmail, mDisplayName, mIdToken, mPhotoUrl, + SERVICE_TYPE_GOOGLE); + } else { + mAnalyticsListener.trackSocialErrorUnknownUser(); + showError(getString(R.string.login_error_email_not_found_v2)); + } + break; + // Too many attempts on sending SMS verification code. The user has to wait before they try again + case SMS_CODE_THROTTLED: + AppLog.d(T.MAIN, "GOOGLE LOGIN: onSocialChanged - error - sms code throttled"); + showError(getString(R.string.login_error_sms_throttled)); + break; + // Unknown error. + case GENERIC_ERROR: + // Do nothing for now (included to show all error types) and just fall through to 'default' + default: + AppLog.d(T.MAIN, "GOOGLE LOGIN: onSocialChanged - unknown error"); + showError(getString(R.string.login_error_generic)); + break; + } + // Response does not return error when two-factor authentication is required. + } else if (event.requiresTwoStepAuth || Login2FaFragment.TWO_FACTOR_TYPE_SMS.equals(event.notificationSent)) { + AppLog.d(T.MAIN, "GOOGLE LOGIN: onSocialChanged - needs 2fa"); + mLoginListener.needs2faSocial(mGoogleEmail, event.userId, event.nonceAuthenticator, event.nonceBackup, + event.nonceSms, event.nonceWebauthn, event.twoStepTypes); + } else { + AppLog.d(T.MAIN, "GOOGLE LOGIN: onSocialChanged - success"); + mGoogleListener.onGoogleLoginFinished(); + } + finishFlow(); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginHttpAuthDialogFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginHttpAuthDialogFragment.java new file mode 100644 index 000000000000..9669ab105164 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginHttpAuthDialogFragment.java @@ -0,0 +1,135 @@ +package org.wordpress.android.login; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.wordpress.android.util.EditTextUtils; + +public class LoginHttpAuthDialogFragment extends DialogFragment { + public static final String TAG = "login_http_auth_dialog_fragment_tag"; + + public static final int DO_HTTP_AUTH = Activity.RESULT_FIRST_USER + 1; + + public static final String ARG_URL = "ARG_URL"; + public static final String ARG_MESSAGE = "ARG_MESSAGE"; + public static final String ARG_USERNAME = "ARG_USERNAME"; + public static final String ARG_PASSWORD = "ARG_PASSWORD"; + + private String mUrl; + private String mMessage; + + public static LoginHttpAuthDialogFragment newInstance(@NonNull final String url) { + return newInstance(url, ""); + } + + public static LoginHttpAuthDialogFragment newInstance(@NonNull final String url, @NonNull final String message) { + LoginHttpAuthDialogFragment fragment = new LoginHttpAuthDialogFragment(); + Bundle args = new Bundle(); + args.putString(ARG_URL, url); + args.putString(ARG_MESSAGE, message); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mUrl = getArguments().getString(ARG_URL); + mMessage = getArguments().getString(ARG_MESSAGE); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder alert = new MaterialAlertDialogBuilder(getActivity()); + alert.setTitle(R.string.http_authorization_required); + if (!TextUtils.isEmpty(mMessage)) alert.setMessage(mMessage); + + //noinspection InflateParams + View httpAuth = getActivity().getLayoutInflater().inflate(R.layout.login_alert_http_auth, null); + alert.setView(httpAuth); + + final EditText usernameEditText = (EditText) httpAuth.findViewById(R.id.login_http_username); + final EditText passwordEditText = (EditText) httpAuth.findViewById(R.id.login_http_password); + + passwordEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + String username = EditTextUtils.getText(usernameEditText); + String password = EditTextUtils.getText(passwordEditText); + sendResult(username, password); + + dismiss(); + + return true; + } + }); + + alert.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismiss(); + } + }); + alert.setPositiveButton(R.string.next, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + String username = EditTextUtils.getText(usernameEditText); + String password = EditTextUtils.getText(passwordEditText); + sendResult(username, password); + } + }); + + final AlertDialog alertDialog = alert.create(); + + // update the Next button when username edit box changes + usernameEditText.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } + + @Override + public void afterTextChanged(Editable s) { + updateButton(alertDialog, usernameEditText); + } + }); + + // update the Next button on first appearance + alertDialog.setOnShowListener(new DialogInterface.OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + updateButton(alertDialog, usernameEditText); + } + }); + + return alertDialog; + } + + private void updateButton(AlertDialog alertDialog, EditText editText) { + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled((editText.getText().length() > 0)); + } + + private void sendResult(String username, String password) { + Intent intent = new Intent(); + intent.putExtra(ARG_URL, mUrl); + intent.putExtra(ARG_USERNAME, username); + intent.putExtra(ARG_PASSWORD, password); + getTargetFragment().onActivityResult(getTargetRequestCode(), Activity.RESULT_OK, intent); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginListener.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginListener.java new file mode 100644 index 000000000000..25e85e121ec4 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginListener.java @@ -0,0 +1,95 @@ +package org.wordpress.android.login; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.fluxc.network.MemorizingTrustManager; +import org.wordpress.android.fluxc.store.SiteStore; +import org.wordpress.android.fluxc.store.SiteStore.ConnectSiteInfoPayload; + +import java.util.ArrayList; +import java.util.List; + +public interface LoginListener { + interface SelfSignedSSLCallback { + void certificateTrusted(); + } + + LoginMode getLoginMode(); + void startOver(); + + // Login Email input callbacks + void gotWpcomEmail(String email, boolean verifyEmail, @Nullable AuthOptions authOptions); + void gotUnregisteredEmail(String email); + void gotUnregisteredSocialAccount(String email, String displayName, String idToken, String photoUrl, + String service); + void loginViaSiteAddress(); + void loginViaSocialAccount(String email, String idToken, String service, boolean isPasswordRequired); + void loggedInViaSocialAccount(ArrayList oldSiteIds, boolean doLoginUpdate); + void loginViaWpcomUsernameInstead(); + void loginViaSiteCredentials(String inputSiteAddress); + void helpEmailScreen(String email); + void helpSocialEmailScreen(String email); + void addGoogleLoginFragment(boolean isSignupFromLoginEnabled); + void showHelpFindingConnectedEmail(); + void onTermsOfServiceClicked(); + + // Login Request Magic Link callbacks + void showMagicLinkSentScreen(String email, boolean allowPassword); + void usePasswordInstead(String email); + void helpMagicLinkRequest(String email); + + // Login Magic Link Sent callbacks + void openEmailClient(boolean isLogin); + void helpMagicLinkSent(String email); + + // Login email password callbacks + void forgotPassword(String url); + void useMagicLinkInstead(String email, boolean verifyEmail); + void needs2fa(String email, String password); + void needs2fa(String email, String password, String userId, String webauthnNonce, + String nonceAuthenticator, String nonceBackup, String noncePush, + List supportedAuthTypes); + void needs2faSocial(String email, String userId, String nonceAuthenticator, String nonceBackup, + String nonceSms, String nonceWebauthn, List supportedAuthTypes); + void needs2faSocialConnect(String email, String password, String idToken, String service); + void loggedInViaPassword(ArrayList oldSitesIds); + void helpEmailPasswordScreen(String email); + + // Login Site Address input callbacks + void alreadyLoggedInWpcom(ArrayList oldSitesIds); + void gotWpcomSiteInfo(String siteAddress); + void gotConnectedSiteInfo(@NonNull String siteAddress, @Nullable String redirectUrl, boolean hasJetpack); + void gotXmlRpcEndpoint(String inputSiteAddress, String endpointAddress); + void handleSslCertificateError(MemorizingTrustManager memorizingTrustManager, SelfSignedSSLCallback callback); + void helpSiteAddress(String url); + void helpFindingSiteAddress(String username, SiteStore siteStore); + void handleSiteAddressError(ConnectSiteInfoPayload siteInfo); + + // Login username password callbacks + void saveCredentialsInSmartLock(@Nullable String username, @Nullable String password, + @NonNull String displayName, @Nullable Uri profilePicture); + void loggedInViaUsernamePassword(ArrayList oldSitesIds); + void helpUsernamePassword(String url, String username, boolean isWpcom); + void helpNoJetpackScreen(String siteAddress, String endpointAddress, String username, + String password, String userAvatarUrl, Boolean checkJetpackAvailability); + void helpHandleDiscoveryError(String siteAddress, String endpointAddress, String username, + String password, String userAvatarUrl, int errorMessage); + + // Login 2FA screen callbacks + void help2FaScreen(String email); + + // General post-login callbacks + // TODO This should have a more generic name, it more or less means any kind of login was finished + void startPostLoginServices(); + + // Signup + void helpSignupEmailScreen(String email); + void helpSignupMagicLinkScreen(String email); + void helpSignupConfirmationScreen(String email); + void showSignupMagicLink(String email); + void showSignupSocial(String email, String displayName, String idToken, String photoUrl, String service); + void showSignupToLoginMessage(); +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMagicLinkRequestFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMagicLinkRequestFragment.java new file mode 100644 index 000000000000..31ff1ebc1988 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMagicLinkRequestFragment.java @@ -0,0 +1,371 @@ +package org.wordpress.android.login; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.Html; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadScheme; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadSource; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthEmailSent; +import org.wordpress.android.login.util.AvatarHelper; +import org.wordpress.android.login.util.AvatarHelper.AvatarRequestListener; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.ToastUtils; + +import java.util.HashMap; + +import javax.inject.Inject; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginMagicLinkRequestFragment extends Fragment { + public static final String TAG = "login_magic_link_request_fragment_tag"; + + private static final String KEY_IN_PROGRESS = "KEY_IN_PROGRESS"; + private static final String ARG_EMAIL_ADDRESS = "ARG_EMAIL_ADDRESS"; + private static final String ARG_MAGIC_LINK_SCHEME = "ARG_MAGIC_LINK_SCHEME"; + private static final String ARG_IS_JETPACK_CONNECT = "ARG_IS_JETPACK_CONNECT"; + private static final String ARG_JETPACK_CONNECT_SOURCE = "ARG_JETPACK_CONNECT_SOURCE"; + private static final String ARG_VERIFY_MAGIC_LINK_EMAIL = "ARG_VERIFY_MAGIC_LINK_EMAIL"; + private static final String ARG_ALLOW_PASSWORD = "ARG_ALLOW_PASSWORD"; + private static final String ARG_FORCE_REQUEST_AT_START = "ARG_FORCE_REQUEST_AT_START"; + + private static final String ERROR_KEY = "error"; + + private LoginListener mLoginListener; + + private String mEmail; + private AuthEmailPayloadScheme mMagicLinkScheme; + private String mJetpackConnectSource; + + private View mAvatarProgressBar; + private Button mRequestMagicLinkButton; + private ProgressDialog mProgressDialog; + + private boolean mInProgress; + private boolean mIsJetpackConnect; + private boolean mVerifyMagicLinkEmail; + private boolean mAllowPassword; + private boolean mForceRequestAtStart; + + @Inject protected Dispatcher mDispatcher; + + @Inject protected LoginAnalyticsListener mAnalyticsListener; + + public static LoginMagicLinkRequestFragment newInstance(String email, AuthEmailPayloadScheme scheme, + boolean isJetpackConnect, String jetpackConnectSource, + boolean verifyEmail) { + return newInstance(email, scheme, isJetpackConnect, jetpackConnectSource, verifyEmail, true, false); + } + + public static LoginMagicLinkRequestFragment newInstance(String email, AuthEmailPayloadScheme scheme, + boolean isJetpackConnect, String jetpackConnectSource, + boolean verifyEmail, boolean allowPassword, + boolean forceRequestAtStart) { + LoginMagicLinkRequestFragment fragment = new LoginMagicLinkRequestFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, email); + args.putSerializable(ARG_MAGIC_LINK_SCHEME, scheme); + args.putBoolean(ARG_IS_JETPACK_CONNECT, isJetpackConnect); + args.putString(ARG_JETPACK_CONNECT_SOURCE, jetpackConnectSource); + args.putBoolean(ARG_VERIFY_MAGIC_LINK_EMAIL, verifyEmail); + args.putBoolean(ARG_ALLOW_PASSWORD, allowPassword); + args.putBoolean(ARG_FORCE_REQUEST_AT_START, forceRequestAtStart); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + if (context instanceof LoginListener) { + mLoginListener = (LoginListener) context; + } else { + throw new RuntimeException(context.toString() + " must implement LoginListener"); + } + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null) { + mEmail = getArguments().getString(ARG_EMAIL_ADDRESS); + mMagicLinkScheme = (AuthEmailPayloadScheme) getArguments().getSerializable(ARG_MAGIC_LINK_SCHEME); + mIsJetpackConnect = getArguments().getBoolean(ARG_IS_JETPACK_CONNECT); + mJetpackConnectSource = getArguments().getString(ARG_JETPACK_CONNECT_SOURCE); + mVerifyMagicLinkEmail = getArguments().getBoolean(ARG_VERIFY_MAGIC_LINK_EMAIL); + mAllowPassword = getArguments().getBoolean(ARG_ALLOW_PASSWORD); + mForceRequestAtStart = getArguments().getBoolean(ARG_FORCE_REQUEST_AT_START); + } + + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.login_magic_link_request_screen, container, false); + mRequestMagicLinkButton = view.findViewById(R.id.login_request_magic_link); + mRequestMagicLinkButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mAnalyticsListener.trackRequestMagicLinkClick(); + dispatchMagicLinkRequest(); + } + }); + + final Button passwordButton = view.findViewById(R.id.login_enter_password); + passwordButton.setVisibility(mAllowPassword ? View.VISIBLE : View.GONE); + passwordButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mAnalyticsListener.trackLoginWithPasswordClick(); + if (mLoginListener != null) { + mLoginListener.usePasswordInstead(mEmail); + } + } + }); + + mAvatarProgressBar = view.findViewById(R.id.avatar_progress); + ImageView avatarView = view.findViewById(R.id.gravatar); + + TextView emailView = view.findViewById(R.id.email); + emailView.setText(mEmail); + + // Design changes added to the Woo Magic link sign-in + + if (mVerifyMagicLinkEmail) { + AvatarHelper.loadAvatarFromEmail(this, mEmail, avatarView, new AvatarRequestListener() { + @Override public void onRequestFinished() { + mAvatarProgressBar.setVisibility(View.GONE); + } + }); + + TextView labelTextView = view.findViewById(R.id.label); + labelTextView.setText(Html.fromHtml(String.format(getResources().getString( + R.string.login_site_credentials_magic_link_label), mEmail))); + } else { + AvatarHelper.loadAvatarFromEmail(this, mEmail, avatarView, new AvatarRequestListener() { + @Override public void onRequestFinished() { + mAvatarProgressBar.setVisibility(View.GONE); + } + }); + } + + return view; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + Toolbar toolbar = view.findViewById(R.id.toolbar); + ((AppCompatActivity) getActivity()).setSupportActionBar(toolbar); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.log_in); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + if (savedInstanceState == null) { + mAnalyticsListener.trackMagicLinkRequestFormViewed(); + } + + if (mForceRequestAtStart && !mInProgress) { + dispatchMagicLinkRequest(); + } + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + mInProgress = savedInstanceState.getBoolean(KEY_IN_PROGRESS); + if (mInProgress) { + showMagicLinkRequestProgressDialog(); + } + } + // important for accessibility - talkback + getActivity().setTitle(R.string.magic_link_login_title); + } + + @Override public void onResume() { + super.onResume(); + mAnalyticsListener.magicLinkRequestScreenResumed(); + } + + @Override + public void onDetach() { + super.onDetach(); + mLoginListener = null; + } + + @Override public void onDestroyView() { + mRequestMagicLinkButton = null; + + super.onDestroyView(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putBoolean(KEY_IN_PROGRESS, mInProgress); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_login, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.help) { + mAnalyticsListener.trackShowHelpClick(); + if (mLoginListener != null) { + mLoginListener.helpMagicLinkRequest(mEmail); + } + return true; + } + + return false; + } + + @Override + public void onStart() { + super.onStart(); + mDispatcher.register(this); + } + + @Override + public void onStop() { + super.onStop(); + mDispatcher.unregister(this); + } + + private void dispatchMagicLinkRequest() { + if (mLoginListener != null) { + if (NetworkUtils.checkConnection(getActivity())) { + showMagicLinkRequestProgressDialog(); + AuthEmailPayloadSource source = getAuthEmailPayloadSource(); + AuthEmailPayload authEmailPayload = new AuthEmailPayload(mEmail, false, + mIsJetpackConnect ? AccountStore.AuthEmailPayloadFlow.JETPACK : null, + source, mMagicLinkScheme); + mDispatcher.dispatch(AuthenticationActionBuilder.newSendAuthEmailAction(authEmailPayload)); + } + } + } + + private AuthEmailPayloadSource getAuthEmailPayloadSource() { + if (mJetpackConnectSource != null) { + if (mJetpackConnectSource.equalsIgnoreCase(AuthEmailPayloadSource.NOTIFICATIONS.toString())) { + return AuthEmailPayloadSource.NOTIFICATIONS; + } else if (mJetpackConnectSource.equalsIgnoreCase(AuthEmailPayloadSource.STATS.toString())) { + return AuthEmailPayloadSource.STATS; + } else { + return null; + } + } else { + return null; + } + } + + private void showMagicLinkRequestProgressDialog() { + startProgress(getString(R.string.login_magic_link_email_requesting)); + } + + protected void startProgress(String message) { + mRequestMagicLinkButton.setEnabled(false); + mProgressDialog = ProgressDialog.show(getActivity(), "", message, true, true, + new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + if (mInProgress) { + endProgress(); + } + } + }); + mInProgress = true; + } + + protected void endProgress() { + mInProgress = false; + + if (mProgressDialog != null) { + mProgressDialog.cancel(); + mProgressDialog = null; + } + + // nullify the reference to denote there is no operation in progress + mProgressDialog = null; + + mRequestMagicLinkButton.setEnabled(true); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthEmailSent(OnAuthEmailSent event) { + if (!mInProgress) { + // ignore the response if the magic link request is no longer pending + return; + } + + endProgress(); + + if (event.isError()) { + HashMap errorProperties = new HashMap<>(); + errorProperties.put(ERROR_KEY, event.error.message); + mAnalyticsListener.trackMagicLinkFailed(errorProperties); + mAnalyticsListener.trackFailure(event.error.message); + + AppLog.e(AppLog.T.API, "OnAuthEmailSent has error: " + event.error.type + " - " + event.error.message); + if (isAdded()) { + ToastUtils.showToast(getActivity(), R.string.magic_link_unavailable_error_message, + ToastUtils.Duration.LONG); + } + return; + } + + mAnalyticsListener.trackMagicLinkRequested(); + + if (mLoginListener != null) { + // when magic link request if forced we want to remove this fragment from backstack so user will not be + // able to navigate back to it from "Magic Link Sent" Screen + if (mForceRequestAtStart) { + FragmentManager fragmentManager = getFragmentManager(); + if (fragmentManager != null) { + fragmentManager.popBackStack(); + } + } + mLoginListener.showMagicLinkSentScreen(mEmail, mAllowPassword); + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMagicLinkSentFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMagicLinkSentFragment.java new file mode 100644 index 000000000000..54fb001e851b --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMagicLinkSentFragment.java @@ -0,0 +1,168 @@ +package org.wordpress.android.login; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; + +import org.wordpress.android.login.util.AvatarHelper; +import org.wordpress.android.login.util.AvatarHelper.AvatarRequestListener; + +import javax.inject.Inject; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginMagicLinkSentFragment extends Fragment { + public static final String TAG = "login_magic_link_sent_fragment_tag"; + + private static final String ARG_EMAIL_ADDRESS = "ARG_EMAIL_ADDRESS"; + private static final String ARG_ALLOW_PASSWORD = "ARG_ALLOW_PASSWORD"; + + private LoginListener mLoginListener; + + private String mEmail; + private boolean mAllowPassword; + + @Inject protected LoginAnalyticsListener mAnalyticsListener; + + public static LoginMagicLinkSentFragment newInstance(String email) { + return newInstance(email, true); + } + + public static LoginMagicLinkSentFragment newInstance(String email, boolean allowPassword) { + LoginMagicLinkSentFragment fragment = new LoginMagicLinkSentFragment(); + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, email); + args.putBoolean(ARG_ALLOW_PASSWORD, allowPassword); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (getArguments() != null) { + mEmail = getArguments().getString(ARG_EMAIL_ADDRESS); + mAllowPassword = getArguments().getBoolean(ARG_ALLOW_PASSWORD); + } + + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.login_magic_link_sent_screen, container, false); + + view.findViewById(R.id.login_open_email_client).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (mLoginListener != null) { + mLoginListener.openEmailClient(true); + } + } + }); + + final Button passwordButton = view.findViewById(R.id.login_enter_password); + passwordButton.setVisibility(mAllowPassword ? View.VISIBLE : View.GONE); + passwordButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mAnalyticsListener.trackLoginWithPasswordClick(); + if (mLoginListener != null) { + mLoginListener.usePasswordInstead(mEmail); + } + } + }); + + final View avatarProgressBar = view.findViewById(R.id.avatar_progress); + ImageView avatarView = view.findViewById(R.id.gravatar); + + TextView emailView = view.findViewById(R.id.email); + emailView.setText(mEmail); + + AvatarHelper.loadAvatarFromEmail(this, mEmail, avatarView, new AvatarRequestListener() { + @Override public void onRequestFinished() { + avatarProgressBar.setVisibility(View.GONE); + } + }); + + return view; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + Toolbar toolbar = view.findViewById(R.id.toolbar); + ((AppCompatActivity) getActivity()).setSupportActionBar(toolbar); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.log_in); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + if (savedInstanceState == null) { + mAnalyticsListener.trackLoginMagicLinkOpenEmailClientViewed(); + } + } + + @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // important for accessibility - talkback + getActivity().setTitle(R.string.magic_link_sent_login_title); + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + if (context instanceof LoginListener) { + mLoginListener = (LoginListener) context; + } else { + throw new RuntimeException(context.toString() + " must implement LoginListener"); + } + } + + @Override public void onResume() { + super.onResume(); + mAnalyticsListener.magicLinkSentScreenResumed(); + } + + @Override + public void onDetach() { + super.onDetach(); + mLoginListener = null; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_login, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.help) { + mAnalyticsListener.trackShowHelpClick(); + if (mLoginListener != null) { + mLoginListener.helpMagicLinkSent(mEmail); + } + return true; + } + + return false; + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMode.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMode.java new file mode 100644 index 000000000000..b715b9a6bafe --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginMode.java @@ -0,0 +1,30 @@ +package org.wordpress.android.login; + +import android.content.Intent; + +public enum LoginMode { + FULL, + SELFHOSTED_ONLY, + JETPACK_SELFHOSTED, + WPCOM_LOGIN_ONLY, + JETPACK_LOGIN_ONLY, + JETPACK_STATS, + WPCOM_LOGIN_DEEPLINK, + WPCOM_REAUTHENTICATE, + SHARE_INTENT, + WOO_LOGIN_MODE; + + private static final String ARG_LOGIN_MODE = "ARG_LOGIN_MODE"; + + public static LoginMode fromIntent(Intent intent) { + if (intent.hasExtra(ARG_LOGIN_MODE)) { + return LoginMode.valueOf(intent.getStringExtra(ARG_LOGIN_MODE)); + } else { + return FULL; + } + } + + public void putInto(Intent intent) { + intent.putExtra(ARG_LOGIN_MODE, this.name()); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressFragment.java new file mode 100644 index 000000000000..8d99ea6b8373 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressFragment.java @@ -0,0 +1,526 @@ +package org.wordpress.android.login; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.Observer; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.SiteActionBuilder; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.MemorizingTrustManager; +import org.wordpress.android.fluxc.network.discovery.DiscoveryUtils; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryError; +import org.wordpress.android.fluxc.store.AccountStore; +import org.wordpress.android.fluxc.store.SiteStore.ConnectSiteInfoPayload; +import org.wordpress.android.fluxc.store.SiteStore.OnConnectSiteInfoChecked; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.login.widgets.WPLoginInputRow; +import org.wordpress.android.login.widgets.WPLoginInputRow.OnEditorCommitListener; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.EditTextUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.UrlUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginSiteAddressFragment extends LoginBaseDiscoveryFragment implements TextWatcher, + OnEditorCommitListener, LoginBaseDiscoveryFragment.LoginBaseDiscoveryListener { + private static final String KEY_REQUESTED_SITE_ADDRESS = "KEY_REQUESTED_SITE_ADDRESS"; + + private static final String KEY_SITE_INFO_URL = "url"; + private static final String KEY_SITE_INFO_URL_AFTER_REDIRECTS = "url_after_redirects"; + private static final String KEY_SITE_INFO_EXISTS = "exists"; + private static final String KEY_SITE_INFO_HAS_JETPACK = "has_jetpack"; + private static final String KEY_SITE_INFO_IS_JETPACK_ACTIVE = "is_jetpack_active"; + private static final String KEY_SITE_INFO_IS_JETPACK_CONNECTED = "is_jetpack_connected"; + private static final String KEY_SITE_INFO_IS_WORDPRESS = "is_wordpress"; + private static final String KEY_SITE_INFO_IS_WPCOM = "is_wp_com"; + private static final String KEY_SITE_INFO_CALCULATED_HAS_JETPACK = "login_calculated_has_jetpack"; + + public static final String TAG = "login_site_address_fragment_tag"; + + private WPLoginInputRow mSiteAddressInput; + + private String mRequestedSiteAddress; + + private String mConnectSiteInfoUrl; + private String mConnectSiteInfoUrlRedirect; + private boolean mConnectSiteInfoCalculatedHasJetpack; + + private LoginSiteAddressValidator mLoginSiteAddressValidator; + + @Inject AccountStore mAccountStore; + @Inject Dispatcher mDispatcher; + @Inject HTTPAuthManager mHTTPAuthManager; + @Inject MemorizingTrustManager mMemorizingTrustManager; + + @Override + protected @LayoutRes int getContentLayout() { + return R.layout.login_site_address_screen; + } + + @Override + protected @LayoutRes int getProgressBarText() { + return R.string.login_checking_site_address; + } + + @Override + protected void setupLabel(@NonNull TextView label) { + if (mLoginListener.getLoginMode() == LoginMode.SHARE_INTENT) { + label.setText(R.string.enter_site_address_share_intent); + } else { + label.setText(R.string.enter_site_address); + } + } + + @Override + protected void setupContent(ViewGroup rootView) { + // important for accessibility - talkback + getActivity().setTitle(R.string.site_address_login_title); + mSiteAddressInput = rootView.findViewById(R.id.login_site_address_row); + if (BuildConfig.DEBUG) { + mSiteAddressInput.getEditText().setText(BuildConfig.DEBUG_WPCOM_WEBSITE_URL); + } + mSiteAddressInput.addTextChangedListener(this); + mSiteAddressInput.setOnEditorCommitListener(this); + + rootView.findViewById(R.id.login_site_address_help_button).setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mAnalyticsListener.trackShowHelpClick(); + showSiteAddressHelp(); + } + }); + } + + @Override + protected void setupBottomButton(Button button) { + button.setOnClickListener(new OnClickListener() { + public void onClick(View v) { + discover(); + } + }); + } + + @Override + protected void buildToolbar(Toolbar toolbar, ActionBar actionBar) { + actionBar.setTitle(R.string.log_in); + } + + @Override + protected EditText getEditTextToFocusOnStart() { + return mSiteAddressInput.getEditText(); + } + + @Override + protected void onHelp() { + if (mLoginListener != null) { + mLoginListener.helpSiteAddress(mRequestedSiteAddress); + } + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + mRequestedSiteAddress = savedInstanceState.getString(KEY_REQUESTED_SITE_ADDRESS); + mConnectSiteInfoUrl = savedInstanceState.getString(KEY_SITE_INFO_URL); + mConnectSiteInfoUrlRedirect = + savedInstanceState.getString(KEY_SITE_INFO_URL_AFTER_REDIRECTS); + mConnectSiteInfoCalculatedHasJetpack = + savedInstanceState.getBoolean(KEY_SITE_INFO_CALCULATED_HAS_JETPACK); + } else { + mAnalyticsListener.trackUrlFormViewed(); + } + + mLoginSiteAddressValidator = new LoginSiteAddressValidator(); + + mLoginSiteAddressValidator.getIsValid().observe(getViewLifecycleOwner(), new Observer() { + @Override public void onChanged(Boolean enabled) { + getBottomButton().setEnabled(enabled); + } + }); + mLoginSiteAddressValidator.getErrorMessageResId().observe(getViewLifecycleOwner(), new Observer() { + @Override public void onChanged(Integer resId) { + if (resId != null) { + showError(resId); + } else { + mSiteAddressInput.setError(null); + } + } + }); + } + + @Override public void onResume() { + super.onResume(); + + mAnalyticsListener.siteAddressFormScreenResumed(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putString(KEY_REQUESTED_SITE_ADDRESS, mRequestedSiteAddress); + outState.putString(KEY_SITE_INFO_URL, mConnectSiteInfoUrl); + outState.putString(KEY_SITE_INFO_URL_AFTER_REDIRECTS, mConnectSiteInfoUrlRedirect); + outState.putBoolean(KEY_SITE_INFO_CALCULATED_HAS_JETPACK, + mConnectSiteInfoCalculatedHasJetpack); + } + + @Override public void onDestroyView() { + mLoginSiteAddressValidator.dispose(); + mSiteAddressInput = null; + + super.onDestroyView(); + } + + protected void discover() { + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + mAnalyticsListener.trackSubmitClicked(); + + mLoginBaseDiscoveryListener = this; + + mRequestedSiteAddress = mLoginSiteAddressValidator.getCleanedSiteAddress(); + + String cleanedUrl = stripKnownPaths(mRequestedSiteAddress); + + mAnalyticsListener.trackConnectedSiteInfoRequested(cleanedUrl); + mDispatcher.dispatch(SiteActionBuilder.newFetchConnectSiteInfoAction(cleanedUrl)); + + startProgress(); + } + + @Override + public void onEditorCommit() { + if (getBottomButton().isEnabled()) { + discover(); + } + } + + @Override + public void afterTextChanged(Editable s) { + if (mSiteAddressInput != null) { + mLoginSiteAddressValidator + .setAddress(EditTextUtils.getText(mSiteAddressInput.getEditText())); + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + mConnectSiteInfoUrl = null; + mConnectSiteInfoUrlRedirect = null; + mConnectSiteInfoCalculatedHasJetpack = false; + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (mSiteAddressInput != null) { + mSiteAddressInput.setError(null); + } + } + + private void showError(int messageId) { + String message = getString(messageId); + mAnalyticsListener.trackFailure(message); + mSiteAddressInput.setError(message); + } + + @Override + protected void endProgress() { + super.endProgress(); + mRequestedSiteAddress = null; + } + + @Override + @NonNull public String getRequestedSiteAddress() { + return mRequestedSiteAddress; + } + + @Override + public void handleDiscoveryError(DiscoveryError error, final String failedEndpoint) { + switch (error) { + case ERRONEOUS_SSL_CERTIFICATE: + mLoginListener.handleSslCertificateError(mMemorizingTrustManager, + new LoginListener.SelfSignedSSLCallback() { + @Override + public void certificateTrusted() { + if (failedEndpoint == null) { + return; + } + // retry site lookup + discover(); + } + }); + break; + case HTTP_AUTH_REQUIRED: + askForHttpAuthCredentials(failedEndpoint, R.string.login_error_xml_rpc_cannot_read_site_auth_required); + break; + case NO_SITE_ERROR: + showError(R.string.no_site_error); + break; + case INVALID_URL: + showError(R.string.invalid_site_url_message); + mAnalyticsListener.trackInsertedInvalidUrl(); + break; + case MISSING_XMLRPC_METHOD: + showError(R.string.xmlrpc_missing_method_error); + break; + case WORDPRESS_COM_SITE: + // This is handled by handleWpComDiscoveryError + break; + case XMLRPC_BLOCKED: + showError(R.string.xmlrpc_post_blocked_error); + break; + case XMLRPC_FORBIDDEN: + showError(R.string.xmlrpc_endpoint_forbidden_error); + break; + case GENERIC_ERROR: + showError(R.string.error_generic); + break; + } + } + + @Override + public void handleWpComDiscoveryError(String failedEndpoint) { + AppLog.e(T.API, "Inputted a wpcom address in site address screen."); + + // If the user is already logged in a wordpress.com account, bail out + if (mAccountStore.hasAccessToken()) { + String currentUsername = mAccountStore.getAccount().getUserName(); + AppLog.e(T.NUX, "User is already logged in WordPress.com: " + currentUsername); + + ArrayList oldSitesIDs = SiteUtils.getCurrentSiteIds(mSiteStore, true); + mLoginListener.alreadyLoggedInWpcom(oldSitesIDs); + } else { + mLoginListener.gotWpcomSiteInfo(failedEndpoint); + } + } + + @Override + public void handleDiscoverySuccess(@NonNull String endpointAddress) { + AppLog.i(T.NUX, "Discovery succeeded, endpoint: " + endpointAddress); + + // hold the URL in a variable to use below otherwise it gets cleared up by endProgress + String inputSiteAddress = mRequestedSiteAddress; + endProgress(); + if (mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE) { + mLoginListener.gotConnectedSiteInfo( + mConnectSiteInfoUrl, + mConnectSiteInfoUrlRedirect, + mConnectSiteInfoCalculatedHasJetpack + ); + } else { + mLoginListener.gotXmlRpcEndpoint(inputSiteAddress, endpointAddress); + } + } + + private void askForHttpAuthCredentials(@NonNull final String url, int messageId) { + LoginHttpAuthDialogFragment loginHttpAuthDialogFragment = LoginHttpAuthDialogFragment.newInstance( + url, + getString(messageId) + ); + loginHttpAuthDialogFragment.setTargetFragment(this, LoginHttpAuthDialogFragment.DO_HTTP_AUTH); + loginHttpAuthDialogFragment.show(getFragmentManager(), LoginHttpAuthDialogFragment.TAG); + } + + private void showSiteAddressHelp() { + new LoginSiteAddressHelpDialogFragment().show(getFragmentManager(), LoginSiteAddressHelpDialogFragment.TAG); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == LoginHttpAuthDialogFragment.DO_HTTP_AUTH && resultCode == Activity.RESULT_OK) { + String url = data.getStringExtra(LoginHttpAuthDialogFragment.ARG_URL); + String httpUsername = data.getStringExtra(LoginHttpAuthDialogFragment.ARG_USERNAME); + String httpPassword = data.getStringExtra(LoginHttpAuthDialogFragment.ARG_PASSWORD); + mHTTPAuthManager.addHTTPAuthCredentials(httpUsername, httpPassword, url, null); + discover(); + } + } + + // OnChanged events + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onFetchedConnectSiteInfo(OnConnectSiteInfoChecked event) { + if (mRequestedSiteAddress == null) { + // bail if user canceled + return; + } + + if (!isAdded()) { + return; + } + + if (event.isError()) { + mAnalyticsListener.trackConnectedSiteInfoFailed( + mRequestedSiteAddress, + event.getClass().getSimpleName(), + event.error.type.name(), + event.error.message); + + AppLog.e(T.API, "onFetchedConnectSiteInfo has error: " + event.error.message); + if (NetworkUtils.isNetworkAvailable(requireContext())) { + showError(R.string.invalid_site_url_message); + } else { + showError(R.string.error_generic_network); + } + + endProgressIfNeeded(); + } else { + boolean hasJetpack = calculateHasJetpack(event.info); + + mConnectSiteInfoUrl = event.info.url; + mConnectSiteInfoUrlRedirect = event.info.urlAfterRedirects; + mConnectSiteInfoCalculatedHasJetpack = hasJetpack; + + mAnalyticsListener.trackConnectedSiteInfoSucceeded(createConnectSiteInfoProperties(event.info, hasJetpack)); + + if (mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE) { + handleConnectSiteInfoForWoo(event.info); + } else { + handleConnectSiteInfoForWordPress(event.info); + } + } + } + + private void handleConnectSiteInfoForWoo(ConnectSiteInfoPayload siteInfo) { + if (!siteInfo.exists) { + endProgressIfNeeded(); + // Site does not exist + showError(R.string.invalid_site_url_message); + } else if (!siteInfo.isWordPress) { + endProgressIfNeeded(); + // Not a WordPress site + mLoginListener.handleSiteAddressError(siteInfo); + } else { + endProgressIfNeeded(); + mLoginListener.gotConnectedSiteInfo( + mConnectSiteInfoUrl, + mConnectSiteInfoUrlRedirect, + mConnectSiteInfoCalculatedHasJetpack + ); + } + } + + private void handleConnectSiteInfoForWordPress(ConnectSiteInfoPayload siteInfo) { + if (siteInfo.isWPCom) { + // It's a Simple or Atomic site + LoginMode mode = mLoginListener.getLoginMode(); + if (mode == LoginMode.SELFHOSTED_ONLY || mode == LoginMode.JETPACK_SELFHOSTED) { + // We're only interested in self-hosted sites + if (siteInfo.hasJetpack) { + // This is an Atomic site, so treat it as self-hosted and start the discovery process + initiateDiscovery(); + return; + } + } + endProgressIfNeeded(); + mLoginListener.gotWpcomSiteInfo(UrlUtils.removeScheme(siteInfo.url)); + } else { + // It's a Jetpack or self-hosted site + if (mLoginListener.getLoginMode() == LoginMode.WPCOM_LOGIN_ONLY) { + // We're only interested in WordPress.com accounts + showError(R.string.enter_wpcom_or_jetpack_site); + endProgressIfNeeded(); + } else { + // Start the discovery process + initiateDiscovery(); + } + } + } + + private void handleConnectSiteInfoForJetpack(ConnectSiteInfoPayload siteInfo) { + endProgressIfNeeded(); + + if (siteInfo.hasJetpack && siteInfo.isJetpackConnected && siteInfo.isJetpackActive) { + mLoginListener.gotWpcomSiteInfo(UrlUtils.removeScheme(siteInfo.url)); + } else { + mLoginListener.handleSiteAddressError(siteInfo); + } + } + + private boolean calculateHasJetpack(ConnectSiteInfoPayload siteInfo) { + // Determining if jetpack is actually installed takes additional logic. This final + // calculated event property will make querying this event more straight-forward. + // Internal reference: p99K0U-1vO-p2#comment-3574 + boolean hasJetpack = false; + if (siteInfo.isWPCom && siteInfo.hasJetpack) { + // This is likely an atomic site. + hasJetpack = true; + } else if (siteInfo.isJetpackConnected) { + hasJetpack = true; + } + return hasJetpack; + } + + private Map createConnectSiteInfoProperties(ConnectSiteInfoPayload siteInfo, boolean hasJetpack) { + HashMap properties = new HashMap<>(); + properties.put(KEY_SITE_INFO_URL, siteInfo.url); + properties.put(KEY_SITE_INFO_URL_AFTER_REDIRECTS, siteInfo.urlAfterRedirects); + properties.put(KEY_SITE_INFO_EXISTS, Boolean.toString(siteInfo.exists)); + properties.put(KEY_SITE_INFO_HAS_JETPACK, Boolean.toString(siteInfo.hasJetpack)); + properties.put(KEY_SITE_INFO_IS_JETPACK_ACTIVE, Boolean.toString(siteInfo.isJetpackActive)); + properties.put(KEY_SITE_INFO_IS_JETPACK_CONNECTED, Boolean.toString(siteInfo.isJetpackConnected)); + properties.put(KEY_SITE_INFO_IS_WORDPRESS, Boolean.toString(siteInfo.isWordPress)); + properties.put(KEY_SITE_INFO_IS_WPCOM, Boolean.toString(siteInfo.isWPCom)); + properties.put(KEY_SITE_INFO_CALCULATED_HAS_JETPACK, Boolean.toString(hasJetpack)); + return properties; + } + + private String stripKnownPaths(String url) { + String cleanedXmlrpcSuffix = UrlUtils.removeXmlrpcSuffix(url); + + // Make sure to use a valid URL so that DiscoveryUtils#stripKnownPaths is able to strip paths + String scheme = Uri.parse(cleanedXmlrpcSuffix).getScheme(); + String urlWithScheme; + if (scheme == null) { + urlWithScheme = UrlUtils.addUrlSchemeIfNeeded(cleanedXmlrpcSuffix, false); + } else { + urlWithScheme = cleanedXmlrpcSuffix; + } + + String cleanedUrl = DiscoveryUtils.stripKnownPaths(urlWithScheme); + + // Revert the scheme changes + return scheme == null ? UrlUtils.removeScheme(cleanedUrl) : cleanedUrl; + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressHelpDialogFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressHelpDialogFragment.java new file mode 100644 index 000000000000..f36350bea392 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressHelpDialogFragment.java @@ -0,0 +1,78 @@ +package org.wordpress.android.login; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.wordpress.android.fluxc.store.AccountStore; +import org.wordpress.android.fluxc.store.SiteStore; + +import javax.inject.Inject; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginSiteAddressHelpDialogFragment extends DialogFragment { + public static final String TAG = "login_site_address_help_dialog_fragment_tag"; + + private LoginListener mLoginListener; + + @Inject SiteStore mSiteStore; + @Inject AccountStore mAccountStore; + + @Inject LoginAnalyticsListener mAnalyticsListener; + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + if (context instanceof LoginListener) { + mLoginListener = (LoginListener) context; + } else { + throw new RuntimeException(context.toString() + " must implement LoginListener"); + } + } + + @Override public void onDetach() { + super.onDetach(); + mLoginListener = null; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder alert = new MaterialAlertDialogBuilder(getActivity()); + if (mLoginListener.getLoginMode() != LoginMode.WOO_LOGIN_MODE) { + // Only set the title if not the woo app, since the woo app specifies an override + // layout that includes the title. + alert.setTitle(R.string.login_site_address_help_title); + } + + //noinspection InflateParams + alert.setView(getActivity().getLayoutInflater().inflate(R.layout.login_alert_site_address_help, null)); + alert.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + mAnalyticsListener.trackDismissDialog(); + dialog.dismiss(); + } + }); + alert.setNeutralButton(R.string.login_site_address_more_help, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mLoginListener.helpFindingSiteAddress(mAccountStore.getAccount().getUserName(), mSiteStore); + } + }); + + if (savedInstanceState == null) { + mAnalyticsListener.trackUrlHelpScreenViewed(); + } + + return alert.create(); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressValidator.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressValidator.java new file mode 100644 index 000000000000..fd8a430bdca3 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginSiteAddressValidator.java @@ -0,0 +1,74 @@ +package org.wordpress.android.login; + +import android.util.Patterns; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.wordpress.android.util.helpers.Debouncer; + +import java.util.concurrent.TimeUnit; + +/** + * Encapsulates the site address validation, cleaning, and error reporting of {@link LoginSiteAddressFragment}. + */ +class LoginSiteAddressValidator { + private static final int SECONDS_DELAY_BEFORE_SHOWING_ERROR_MESSAGE = 2; + + private MutableLiveData mIsValid = new MutableLiveData<>(); + private MutableLiveData mErrorMessageResId = new MutableLiveData<>(); + + private String mCleanedSiteAddress = ""; + private final Debouncer mDebouncer; + + @NonNull LiveData getIsValid() { + return mIsValid; + } + + @NonNull LiveData getErrorMessageResId() { + return mErrorMessageResId; + } + + @NonNull String getCleanedSiteAddress() { + return mCleanedSiteAddress; + } + + LoginSiteAddressValidator() { + this(new Debouncer()); + } + + LoginSiteAddressValidator(@NonNull Debouncer debouncer) { + mIsValid.setValue(false); + mDebouncer = debouncer; + } + + void dispose() { + mDebouncer.shutdown(); + } + + void setAddress(@NonNull String siteAddress) { + mCleanedSiteAddress = cleanSiteAddress(siteAddress); + final boolean isValid = siteAddressIsValid(mCleanedSiteAddress); + + mIsValid.setValue(isValid); + mErrorMessageResId.setValue(null); + + // Call debounce regardless if there was an error so that the previous Runnable will be cancelled. + mDebouncer.debounce(Void.class, new Runnable() { + @Override public void run() { + if (!isValid && !mCleanedSiteAddress.isEmpty()) { + mErrorMessageResId.postValue(R.string.login_invalid_site_url); + } + } + }, SECONDS_DELAY_BEFORE_SHOWING_ERROR_MESSAGE, TimeUnit.SECONDS); + } + + private static String cleanSiteAddress(@NonNull String siteAddress) { + return siteAddress.trim().replaceAll("[\r\n]", ""); + } + + private static boolean siteAddressIsValid(@NonNull String cleanedSiteAddress) { + return Patterns.WEB_URL.matcher(cleanedSiteAddress).matches(); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginUsernamePasswordFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginUsernamePasswordFragment.java new file mode 100644 index 000000000000..32ba26c091fb --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginUsernamePasswordFragment.java @@ -0,0 +1,723 @@ +package org.wordpress.android.login; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.Toolbar; +import androidx.core.widget.NestedScrollView; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.generated.SiteActionBuilder; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryError; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticatePayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthenticationChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnTwoFactorAuthStarted; +import org.wordpress.android.fluxc.store.SiteStore.OnProfileFetched; +import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged; +import org.wordpress.android.fluxc.store.SiteStore.RefreshSitesXMLRPCPayload; +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.login.widgets.WPLoginInputRow; +import org.wordpress.android.login.widgets.WPLoginInputRow.OnEditorCommitListener; +import org.wordpress.android.util.ActivityUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.EditTextUtils; +import org.wordpress.android.util.NetworkUtils; +import org.wordpress.android.util.StringUtils; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.UrlUtils; + +import java.util.ArrayList; +import java.util.List; + +import dagger.android.support.AndroidSupportInjection; + +public class LoginUsernamePasswordFragment extends LoginBaseDiscoveryFragment implements TextWatcher, + OnEditorCommitListener, LoginBaseDiscoveryFragment.LoginBaseDiscoveryListener { + private static final String KEY_LOGIN_FINISHED = "KEY_LOGIN_FINISHED"; + private static final String KEY_LOGIN_STARTED = "KEY_LOGIN_STARTED"; + private static final String KEY_REQUESTED_USERNAME = "KEY_REQUESTED_USERNAME"; + private static final String KEY_REQUESTED_PASSWORD = "KEY_REQUESTED_PASSWORD"; + private static final String KEY_OLD_SITES_IDS = "KEY_OLD_SITES_IDS"; + private static final String KEY_GET_SITE_OPTIONS_INITIATED = "KEY_GET_SITE_OPTIONS_INITIATED"; + + private static final String ARG_INPUT_SITE_ADDRESS = "ARG_INPUT_SITE_ADDRESS"; + private static final String ARG_ENDPOINT_ADDRESS = "ARG_ENDPOINT_ADDRESS"; + private static final String ARG_INPUT_USERNAME = "ARG_INPUT_USERNAME"; + private static final String ARG_INPUT_PASSWORD = "ARG_INPUT_PASSWORD"; + private static final String ARG_IS_WPCOM = "ARG_IS_WPCOM"; + + private static final String FORGOT_PASSWORD_URL_WPCOM = "https://wordpress.com/"; + + public static final String TAG = "login_username_password_fragment_tag"; + + private NestedScrollView mScrollView; + private WPLoginInputRow mUsernameInput; + private WPLoginInputRow mPasswordInput; + + private boolean mAuthFailed; + private boolean mLoginFinished; + private boolean mLoginStarted; + + private String mRequestedUsername; + private String mRequestedPassword; + ArrayList mOldSitesIDs; + private boolean mGetSiteOptionsInitiated; + + private String mInputSiteAddress; + private String mInputSiteAddressWithoutSuffix; + private String mEndpointAddress; + private String mInputUsername; + private String mInputPassword; + private boolean mIsWpcom; + + public static LoginUsernamePasswordFragment newInstance(String inputSiteAddress, String endpointAddress, + String inputUsername, String inputPassword, + boolean isWpcom) { + LoginUsernamePasswordFragment fragment = new LoginUsernamePasswordFragment(); + Bundle args = new Bundle(); + args.putString(ARG_INPUT_SITE_ADDRESS, inputSiteAddress); + args.putString(ARG_ENDPOINT_ADDRESS, endpointAddress); + args.putString(ARG_INPUT_USERNAME, inputUsername); + args.putString(ARG_INPUT_PASSWORD, inputPassword); + args.putBoolean(ARG_IS_WPCOM, isWpcom); + fragment.setArguments(args); + return fragment; + } + + @Override + protected @LayoutRes int getContentLayout() { + return R.layout.login_username_password_screen; + } + + @Override + protected @LayoutRes int getProgressBarText() { + return R.string.logging_in; + } + + @Override + protected void setupLabel(@NonNull TextView label) { + final boolean isWoo = mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE; + final int labelResId = isWoo ? R.string.enter_credentials_for_site : R.string.enter_account_info_for_site; + final String siteAddress = + (mEndpointAddress == null || mEndpointAddress.isEmpty()) ? mInputSiteAddress : mEndpointAddress; + final String formattedSiteAddress = + UrlUtils.removeScheme(UrlUtils.removeXmlrpcSuffix(StringUtils.notNullStr(siteAddress))); + label.setText(getString(labelResId, formattedSiteAddress)); + } + + @Override + protected void setupContent(ViewGroup rootView) { + // important for accessibility - talkback + getActivity().setTitle(R.string.selfhosted_site_login_title); + mScrollView = rootView.findViewById(R.id.scroll_view); + + mInputSiteAddressWithoutSuffix = (mEndpointAddress == null || mEndpointAddress.isEmpty()) + ? mInputSiteAddress : UrlUtils.removeXmlrpcSuffix(mEndpointAddress); + + mUsernameInput = rootView.findViewById(R.id.login_username_row); + mUsernameInput.setText(mInputUsername); + if (BuildConfig.DEBUG && mInputUsername == null) { + mUsernameInput.getEditText().setText(BuildConfig.DEBUG_WPCOM_LOGIN_USERNAME); + } + mUsernameInput.addTextChangedListener(this); + mUsernameInput.setOnEditorCommitListener(new OnEditorCommitListener() { + @Override + public void onEditorCommit() { + showError(null); + mPasswordInput.getEditText().requestFocus(); + } + }); + + mPasswordInput = rootView.findViewById(R.id.login_password_row); + mPasswordInput.setText(mInputPassword); + if (BuildConfig.DEBUG && mInputPassword == null) { + mPasswordInput.getEditText().setText(BuildConfig.DEBUG_WPCOM_LOGIN_PASSWORD); + } + mPasswordInput.addTextChangedListener(this); + + mPasswordInput.setOnEditorCommitListener(this); + + rootView.findViewById(R.id.login_reset_password).setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (mLoginListener != null) { + if (mIsWpcom) { + mLoginListener.forgotPassword(FORGOT_PASSWORD_URL_WPCOM); + } else { + if (!mInputSiteAddressWithoutSuffix.endsWith("/")) { + mInputSiteAddressWithoutSuffix += "/"; + } + mLoginListener.forgotPassword(mInputSiteAddressWithoutSuffix); + } + } + } + }); + } + + @Override + protected void setupBottomButton(Button button) { + button.setOnClickListener(new OnClickListener() { + public void onClick(View v) { + next(); + } + }); + } + + @Override + protected void buildToolbar(Toolbar toolbar, ActionBar actionBar) { + actionBar.setTitle(R.string.log_in); + } + + @Override + protected EditText getEditTextToFocusOnStart() { + return mUsernameInput.getEditText(); + } + + @Override + protected void onHelp() { + if (mLoginListener != null) { + mLoginListener.helpUsernamePassword(mInputSiteAddress, mRequestedUsername, mIsWpcom); + } + } + + @Override public void onDestroyView() { + if (mPasswordInput != null) { + mPasswordInput.setOnEditorCommitListener(null); + mPasswordInput = null; + } + if (mUsernameInput != null) { + mUsernameInput.setOnEditorCommitListener(null); + mUsernameInput = null; + } + mScrollView = null; + + super.onDestroyView(); + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mInputSiteAddress = getArguments().getString(ARG_INPUT_SITE_ADDRESS); + mEndpointAddress = getArguments().getString(ARG_ENDPOINT_ADDRESS, null); + mInputUsername = getArguments().getString(ARG_INPUT_USERNAME); + mInputPassword = getArguments().getString(ARG_INPUT_PASSWORD); + mIsWpcom = getArguments().getBoolean(ARG_IS_WPCOM); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (savedInstanceState != null) { + mLoginFinished = savedInstanceState.getBoolean(KEY_LOGIN_FINISHED); + mLoginStarted = savedInstanceState.getBoolean(KEY_LOGIN_STARTED); + + mRequestedUsername = savedInstanceState.getString(KEY_REQUESTED_USERNAME); + mRequestedPassword = savedInstanceState.getString(KEY_REQUESTED_PASSWORD); + mOldSitesIDs = savedInstanceState.getIntegerArrayList(KEY_OLD_SITES_IDS); + mGetSiteOptionsInitiated = savedInstanceState.getBoolean(KEY_GET_SITE_OPTIONS_INITIATED); + } else { + mAnalyticsListener.trackUsernamePasswordFormViewed(); + + // auto-login if username and password are set for wpcom login + if (mIsWpcom && !TextUtils.isEmpty(mInputUsername) && !TextUtils.isEmpty(mInputPassword)) { + getBottomButton().post(new Runnable() { + @Override + public void run() { + getBottomButton().performClick(); + } + }); + } + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + outState.putBoolean(KEY_LOGIN_FINISHED, mLoginFinished); + outState.putBoolean(KEY_LOGIN_STARTED, mLoginStarted); + outState.putString(KEY_REQUESTED_USERNAME, mRequestedUsername); + outState.putString(KEY_REQUESTED_PASSWORD, mRequestedPassword); + outState.putIntegerArrayList(KEY_OLD_SITES_IDS, mOldSitesIDs); + outState.putBoolean(KEY_GET_SITE_OPTIONS_INITIATED, mGetSiteOptionsInitiated); + } + + @Override public void onResume() { + super.onResume(); + mAnalyticsListener.usernamePasswordScreenResumed(); + updatePrimaryButtonEnabledStatus(); + } + + private void updatePrimaryButtonEnabledStatus() { + String currentUsername = mUsernameInput.getEditText().getText().toString(); + String currentPassword = mPasswordInput.getEditText().getText().toString(); + getBottomButton().setEnabled(!currentPassword.trim().isEmpty() && !currentUsername.trim().isEmpty()); + } + + protected void next() { + mAnalyticsListener.trackSubmitClicked(); + if (!NetworkUtils.checkConnection(getActivity())) { + return; + } + + if (TextUtils.isEmpty(getCleanedUsername())) { + showUsernameError(getString(R.string.login_empty_username)); + EditTextUtils.showSoftInput(mUsernameInput.getEditText()); + return; + } + + final String password = mPasswordInput.getEditText().getText().toString(); + + if (TextUtils.isEmpty(password)) { + showPasswordError(getString(R.string.login_empty_password)); + EditTextUtils.showSoftInput(mPasswordInput.getEditText()); + return; + } + + mLoginStarted = true; + startProgress(); + + mRequestedUsername = getCleanedUsername(); + mRequestedPassword = password; + + // clear up the authentication-failed flag before + mAuthFailed = false; + + mOldSitesIDs = SiteUtils.getCurrentSiteIds(mSiteStore, false); + + if (mIsWpcom) { + AuthenticatePayload payload = new AuthenticatePayload(mRequestedUsername, mRequestedPassword); + mDispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateAction(payload)); + } else if (mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE + && (mEndpointAddress == null || mEndpointAddress.isEmpty())) { + // mEndpointAddress will only be null/empty when redirecting from the Woo login flow + // initiate the discovery process before fetching the xmlrpc site + mLoginBaseDiscoveryListener = this; + initiateDiscovery(); + } else { + refreshXmlRpcSites(); + } + } + + private void refreshXmlRpcSites() { + RefreshSitesXMLRPCPayload selfHostedPayload = new RefreshSitesXMLRPCPayload( + mRequestedUsername, + mRequestedPassword, + mEndpointAddress + ); + mDispatcher.dispatch(SiteActionBuilder.newFetchSitesXmlRpcAction(selfHostedPayload)); + } + + private String getCleanedUsername() { + return EditTextUtils.getText(mUsernameInput.getEditText()).trim(); + } + + @Override + public void onEditorCommit() { + showError(null); + next(); + } + + @Override + public void afterTextChanged(Editable s) { + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + showError(null); + updatePrimaryButtonEnabledStatus(); + } + + @Override + @NonNull public String getRequestedSiteAddress() { + return mInputSiteAddressWithoutSuffix; + } + + /** + * Woo users: + * [HTTP_AUTH_REQUIRED] is not supported by Jetpack and can only occur if jetpack is not + * available. Redirect to Jetpack required screen. + * + * The other discovery errors can take place even if Jetpack is available. + * Furthermore, for errors such as [MISSING_XMLRPC_METHOD], [XMLRPC_BLOCKED], [XMLRPC_FORBIDDEN] + * [NO_SITE_ERROR] and [GENERIC_ERROR], the jetpack available flag from the CONNECT_SITE_INFO + * API returns false even if Jetpack is available for the site. + * So we redirect to discovery error screen without checking for Jetpack availability. + * */ + @Override + public void handleDiscoveryError(DiscoveryError error, String failedEndpoint) { + ActivityUtils.hideKeyboard(getActivity()); + mAnalyticsListener.trackFailure(error.name() + " - " + failedEndpoint); + if (error == DiscoveryError.HTTP_AUTH_REQUIRED) { + mLoginListener.helpNoJetpackScreen(mInputSiteAddress, mEndpointAddress, + getCleanedUsername(), mPasswordInput.getEditText().getText().toString(), + mAccountStore.getAccount().getAvatarUrl(), true); + } else { + mLoginListener.helpHandleDiscoveryError(mInputSiteAddress, mEndpointAddress, + getCleanedUsername(), mPasswordInput.getEditText().getText().toString(), + mAccountStore.getAccount().getAvatarUrl(), getDiscoveryErrorMessage(error)); + } + } + + private int getDiscoveryErrorMessage(DiscoveryError error) { + int errorMessageId = 0; + switch (error) { + case HTTP_AUTH_REQUIRED: + errorMessageId = R.string.login_discovery_error_http_auth; + break; + case ERRONEOUS_SSL_CERTIFICATE: + errorMessageId = R.string.login_discovery_error_ssl; + break; + case INVALID_URL: + case NO_SITE_ERROR: + case WORDPRESS_COM_SITE: + case GENERIC_ERROR: + errorMessageId = R.string.login_discovery_error_generic; + break; + + case MISSING_XMLRPC_METHOD: + case XMLRPC_BLOCKED: + case XMLRPC_FORBIDDEN: + errorMessageId = R.string.login_discovery_error_xmlrpc; + break; + } + return errorMessageId; + } + + @Override + public void handleWpComDiscoveryError(String failedEndpoint) { + AppLog.e(T.API, "Inputted a wpcom address in site address screen. Redirecting to Email screen"); + mLoginListener.gotWpcomSiteInfo(UrlUtils.removeScheme(failedEndpoint)); + } + + @Override + public void handleDiscoverySuccess(@NonNull String endpointAddress) { + mEndpointAddress = endpointAddress; + refreshXmlRpcSites(); + } + + private void showUsernameError(String errorMessage) { + mAnalyticsListener.trackFailure(errorMessage); + mUsernameInput.setError(errorMessage); + mPasswordInput.setError(null); + + if (errorMessage != null) { + requestScrollToView(mUsernameInput); + } + } + + private void showPasswordError(String errorMessage) { + mUsernameInput.setError(null); + mAnalyticsListener.trackFailure(errorMessage); + mPasswordInput.setError(errorMessage); + + if (errorMessage != null) { + requestScrollToView(mPasswordInput); + } + } + + private void showError(String errorMessage) { + mUsernameInput.setError(errorMessage != null ? " " : null); + mPasswordInput.setError(errorMessage); + mAnalyticsListener.trackFailure(errorMessage); + + if (errorMessage != null) { + requestScrollToView(mPasswordInput); + } + } + + private void requestScrollToView(final View view) { + view.post(new Runnable() { + @Override + public void run() { + Rect rect = new Rect(); // Coordinates to scroll to + view.getHitRect(rect); + mScrollView.requestChildRectangleOnScreen(view, rect, false); + } + }); + } + + private @Nullable SiteModel detectNewlyAddedXMLRPCSite() { + List selfhostedSites = mSiteStore.getSitesAccessedViaXMLRPC(); + for (SiteModel site : selfhostedSites) { + if (!mOldSitesIDs.contains(site.getId())) { + return site; + } + } + + return null; + } + + @Override + protected void endProgress() { + super.endProgress(); + mRequestedUsername = null; + mRequestedPassword = null; + } + + private void handleAuthError(AuthenticationErrorType error, XmlRpcErrorType xmlRpcErrorType, String errorMessage) { + switch (error) { + case INCORRECT_USERNAME_OR_PASSWORD: + case NOT_AUTHENTICATED: // NOT_AUTHENTICATED is the generic error from XMLRPC response on first call. + case HTTP_AUTH_ERROR: + if (error == AuthenticationErrorType.HTTP_AUTH_ERROR + && xmlRpcErrorType == XmlRpcErrorType.AUTH_REQUIRED) { + showError(getString(R.string.login_error_xml_rpc_auth_error_communicating)); + } else { + showError(getString(R.string.username_or_password_incorrect)); + } + + break; + case INVALID_OTP: + case INVALID_TOKEN: + case AUTHORIZATION_REQUIRED: + case NEEDS_2FA: + handle2fa(); + break; + default: + AppLog.e(T.NUX, "Server response: " + errorMessage); + + ToastUtils.showToast(getActivity(), + TextUtils.isEmpty(errorMessage) ? getString(R.string.error_generic) : errorMessage); + break; + } + } + + private void handle2fa() { + if (mIsWpcom) { + if (mLoginListener != null) { + mLoginListener.needs2fa(mRequestedUsername, mRequestedPassword); + } + } else { + showError("2FA not supported for self-hosted sites. Please use an app-password."); + } + } + + // OnChanged events + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onTwoFactorAuthStarted(OnTwoFactorAuthStarted event) { + mLoginStarted = false; + handle2fa(); + endProgress(); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthenticationChanged(OnAuthenticationChanged event) { + // emitted when wpcom site or when the selfhosted login failed (but not when succeeded) + + if (!isAdded() || mLoginFinished) { + // just bail + return; + } + + if (event.isError()) { + mLoginStarted = false; + if (mRequestedUsername == null) { + // just bail since the operation was cancelled + return; + } + + mAuthFailed = true; + AppLog.e(T.API, "Login with username/pass onAuthenticationChanged has error: " + event.error.type + + " - " + event.error.message); + mAnalyticsListener.trackLoginFailed(event.getClass().getSimpleName(), + event.error.type.toString(), event.error.message); + + handleAuthError(event.error.type, event.error.xmlRpcErrorType, event.error.message); + + // end the progress last since it cleans up the requested username/password and those might be needed + // in handleAuthError() + endProgress(); + + return; + } + + AppLog.i(T.NUX, "onAuthenticationChanged: " + event.toString()); + + doFinishLogin(); + } + + @Override + protected void onLoginFinished() { + mAnalyticsListener.trackAnalyticsSignIn(mIsWpcom); + + mLoginListener.startPostLoginServices(); + + mLoginListener.loggedInViaPassword(mOldSitesIDs); + } + + private void finishLogin() { + mAnalyticsListener.trackAnalyticsSignIn(mIsWpcom); + + // mark as finished so any subsequent onSiteChanged (e.g. triggered by WPMainActivity) won't be intercepted + mLoginFinished = true; + + if (mLoginListener != null) { + if (mIsWpcom) { + saveCredentialsInSmartLock(mLoginListener, mRequestedUsername, mRequestedPassword); + } + + mLoginListener.loggedInViaUsernamePassword(mOldSitesIDs); + } + + endProgress(); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSiteChanged(OnSiteChanged event) { + if (!isAdded() || mLoginFinished || !mLoginStarted) { + return; + } + + if (event.isError()) { + mLoginStarted = false; + if (mRequestedUsername == null) { + // just bail since the operation was cancelled + return; + } + + endProgress(); + + if (mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE) { + // Woo users: One of the errors that can happen here is the XML-RPC endpoint could + // be blocked by plugins such as `Disable XML-RPC`. Redirect the user to discovery + // error screen in such cases. + handleDiscoveryError(DiscoveryError.XMLRPC_BLOCKED, mInputSiteAddress); + return; + } + + String errorMessage; + if (event.error.type == SiteErrorType.DUPLICATE_SITE) { + if (event.rowsAffected == 0) { + // If there is a duplicate site and not any site has been added, show an error and + // stop the sign in process + errorMessage = getString(R.string.cannot_add_duplicate_site); + } else { + // If there is a duplicate site, notify the user something could be wrong, + // but continue the sign in process + errorMessage = getString(R.string.duplicate_site_detected); + } + } else { + switch (event.error.selfHostedErrorType) { + case XML_RPC_SERVICES_DISABLED: + errorMessage = getString(R.string.login_error_xml_rpc_services_disabled); + break; + case UNABLE_TO_READ_SITE: + errorMessage = getString(R.string.login_error_xml_rpc_cannot_read_site); + break; + case NOT_SET: + default: + errorMessage = getString(R.string.login_error_while_adding_site, event.error.type.toString()); + } + } + + AppLog.e(T.API, "Login with username/pass onSiteChanged has error: " + event.error.type + + " - " + errorMessage); + + if (!mAuthFailed) { + // show the error if not already displayed in onAuthenticationChanged (like in username/pass error) + showError(errorMessage); + } + + return; + } + + if (!mIsWpcom && mLoginListener.getLoginMode() == LoginMode.WOO_LOGIN_MODE) { + SiteModel lastAddedXMLRPCSite = SiteUtils.getXMLRPCSiteByUrl(mSiteStore, mInputSiteAddress); + if (lastAddedXMLRPCSite != null) { + // the wp.getOptions endpoint is already called + // verify if jetpack user email is available. + // If not, redirect to jetpack required screen. Otherwise, initiate magic sign in + if (mGetSiteOptionsInitiated) { + endProgress(); + mGetSiteOptionsInitiated = false; + String userEmail = lastAddedXMLRPCSite.getJetpackUserEmail(); + ActivityUtils.hideKeyboard(getActivity()); + if (userEmail == null || userEmail.isEmpty()) { + mLoginListener.helpNoJetpackScreen(lastAddedXMLRPCSite.getUrl(), + lastAddedXMLRPCSite.getXmlRpcUrl(), lastAddedXMLRPCSite.getUsername(), + lastAddedXMLRPCSite.getPassword(), mAccountStore.getAccount().getAvatarUrl(), + false); + } else { + mLoginListener.gotWpcomEmail(userEmail, true, null); + } + } else { + // Initiate the wp.getOptions endpoint to fetch the jetpack user email + mGetSiteOptionsInitiated = true; + mDispatcher.dispatch(SiteActionBuilder.newFetchSiteAction(lastAddedXMLRPCSite)); + } + } + return; + } + + SiteModel newlyAddedXMLRPCSite = detectNewlyAddedXMLRPCSite(); + // newlyAddedSite will be null if the user sign in with wpcom credentials + if (newlyAddedXMLRPCSite != null && !newlyAddedXMLRPCSite.isUsingWpComRestApi()) { + mDispatcher.dispatch(SiteActionBuilder.newFetchProfileXmlRpcAction(newlyAddedXMLRPCSite)); + } else { + finishLogin(); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onProfileFetched(OnProfileFetched event) { + if (!isAdded() || mLoginFinished) { + return; + } + + if (event.isError()) { + if (mRequestedUsername == null) { + // just bail since the operation was cancelled + return; + } + + endProgress(); + + AppLog.e(T.API, "Fetching selfhosted site profile has error: " + event.error.type + " - " + + event.error.message); + + // continue with success, even if the operation was cancelled since the user got logged in regardless. + // So, go on with finishing the login process + } + finishLogin(); + } +} + diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginWpcomService.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginWpcomService.java new file mode 100644 index 000000000000..445e10f3ba74 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/LoginWpcomService.java @@ -0,0 +1,462 @@ +package org.wordpress.android.login; + +import android.app.Notification; +import android.content.Context; +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.action.AccountAction; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.generated.SiteActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticatePayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; +import org.wordpress.android.fluxc.store.AccountStore.OnAccountChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthenticationChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnSocialChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnTwoFactorAuthStarted; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchSitesPayload; +import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged; +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType; +import org.wordpress.android.login.LoginWpcomService.LoginState; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.AutoForeground; +import org.wordpress.android.util.AutoForegroundNotification; +import org.wordpress.android.util.ToastUtils; + +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import dagger.android.AndroidInjection; + +public class LoginWpcomService extends AutoForeground { + private static final String ARG_EMAIL = "ARG_EMAIL"; + private static final String ARG_PASSWORD = "ARG_PASSWORD"; + private static final String ARG_SOCIAL_ID_TOKEN = "ARG_SOCIAL_ID_TOKEN"; + private static final String ARG_SOCIAL_LOGIN = "ARG_SOCIAL_LOGIN"; + private static final String ARG_JETPACK_APP_LOGIN = "ARG_JETPACK_APP_LOGIN"; + private static final String ARG_WOO_APP_LOGIN = "ARG_WOO_APP_LOGIN"; + private static final String ARG_SOCIAL_SERVICE = "ARG_SOCIAL_SERVICE"; + + public enum LoginStep { + IDLE, + AUTHENTICATING(25), + SOCIAL_LOGIN(25), + FETCHING_ACCOUNT(50), + FETCHING_SETTINGS(75), + FETCHING_SITES(100), + SUCCESS, + FAILURE_EMAIL_WRONG_PASSWORD, + FAILURE_2FA, + FAILURE_SOCIAL_2FA, + SECURITY_KEY_NEEDED, + FAILURE_FETCHING_ACCOUNT, + FAILURE_CANNOT_ADD_DUPLICATE_SITE, + FAILURE_USE_WPCOM_USERNAME_INSTEAD_OF_EMAIL, + FAILURE; + + public final int progressPercent; + + LoginStep() { + this.progressPercent = 0; + } + + LoginStep(int progressPercent) { + this.progressPercent = progressPercent; + } + } + + public static class LoginState implements AutoForeground.ServiceState { + private final LoginStep mStep; + + LoginState(@NonNull LoginStep step) { + this.mStep = step; + } + + public LoginStep getStep() { + return mStep; + } + + @Override + public boolean isIdle() { + return mStep == LoginStep.IDLE; + } + + @Override + public boolean isInProgress() { + return mStep != LoginStep.IDLE && !isTerminal(); + } + + @Override + public boolean isError() { + return mStep == LoginStep.FAILURE + || mStep == LoginStep.FAILURE_EMAIL_WRONG_PASSWORD + || mStep == LoginStep.FAILURE_2FA + || mStep == LoginStep.FAILURE_SOCIAL_2FA + || mStep == LoginStep.SECURITY_KEY_NEEDED + || mStep == LoginStep.FAILURE_FETCHING_ACCOUNT + || mStep == LoginStep.FAILURE_CANNOT_ADD_DUPLICATE_SITE + || mStep == LoginStep.FAILURE_USE_WPCOM_USERNAME_INSTEAD_OF_EMAIL; + } + + @Override + public boolean isTerminal() { + return mStep == LoginStep.SUCCESS || isError(); + } + + @Override + public String getStepName() { + return mStep.name(); + } + } + + private static class LoginNotification { + static Notification progress(Context context, int progress) { + return AutoForegroundNotification.progress(context, + context.getString(R.string.login_notification_channel_id), + progress, + R.string.notification_login_title_in_progress, + R.string.notification_logging_in, + R.drawable.login_notification_icon, + R.color.login_notification_accent_color); + } + + static Notification success(Context context) { + return AutoForegroundNotification.success(context, + context.getString(R.string.login_notification_channel_id), + R.string.notification_login_title_success, + R.string.notification_logged_in, + R.drawable.login_notification_icon, + R.color.login_notification_accent_color); + } + + static Notification failure(Context context, @StringRes int content) { + return AutoForegroundNotification.failure(context, + context.getString(R.string.login_notification_channel_id), + R.string.notification_login_title_stopped, + content, + R.drawable.login_notification_icon, + R.color.login_notification_accent_color); + } + } + + static class OnCredentialsOK { + OnCredentialsOK() {} + } + + static class TwoFactorRequested { + public final String userId; + public final String webauthnNonce; + public final String backupNonce; + public final String authenticatorNonce; + public final String pushNonce; + public final List supportedAuthTypes; + + TwoFactorRequested(String userId, String webauthnNonce, String backupNonce, + String authenticatorNonce, String pushNonce, + List supportedAuthTypes) { + this.userId = userId; + this.webauthnNonce = webauthnNonce; + this.backupNonce = backupNonce; + this.authenticatorNonce = authenticatorNonce; + this.pushNonce = pushNonce; + this.supportedAuthTypes = supportedAuthTypes; + } + } + + @Inject Dispatcher mDispatcher; + + @Inject LoginAnalyticsListener mAnalyticsListener; + + private String mIdToken; + private String mService; + private boolean mIsSocialLogin; + private boolean mIsJetpackAppLogin; + private boolean mIsWooAppLogin; + + public static void loginWithEmailAndPassword( + Context context, + String email, + String password, + String idToken, String service, + boolean isSocialLogin, + boolean isJetpackAppLogin, + boolean isWooAppLogin) { + Intent intent = new Intent(context, LoginWpcomService.class); + intent.putExtra(ARG_EMAIL, email); + intent.putExtra(ARG_PASSWORD, password); + intent.putExtra(ARG_SOCIAL_ID_TOKEN, idToken); + intent.putExtra(ARG_SOCIAL_SERVICE, service); + intent.putExtra(ARG_SOCIAL_LOGIN, isSocialLogin); + intent.putExtra(ARG_JETPACK_APP_LOGIN, isJetpackAppLogin); + intent.putExtra(ARG_WOO_APP_LOGIN, isWooAppLogin); + context.startService(intent); + } + + public static void clearLoginServiceState() { + clearServiceState(LoginState.class); + } + + public LoginWpcomService() { + super(new LoginState(LoginStep.IDLE)); + } + + @Override + protected void onProgressStart() { + mDispatcher.register(this); + } + + @Override + protected void onProgressEnd() { + mDispatcher.unregister(this); + } + + @Override + public Notification getNotification(LoginState state) { + switch (state.getStep()) { + case AUTHENTICATING: + case SOCIAL_LOGIN: + case FETCHING_ACCOUNT: + case FETCHING_SETTINGS: + case FETCHING_SITES: + return LoginNotification.progress(this, state.getStep().progressPercent); + case SUCCESS: + return LoginNotification.success(this); + case FAILURE_EMAIL_WRONG_PASSWORD: + return LoginNotification.failure(this, R.string.notification_error_wrong_password); + case FAILURE_2FA: + return LoginNotification.failure(this, R.string.notification_2fa_needed); + case FAILURE_SOCIAL_2FA: + return LoginNotification.failure(this, R.string.notification_2fa_needed); + case FAILURE_USE_WPCOM_USERNAME_INSTEAD_OF_EMAIL: + return LoginNotification.failure(this, R.string.notification_wpcom_username_needed); + case SECURITY_KEY_NEEDED: + return LoginNotification.failure(this, R.string.notification_security_key_needed); + case FAILURE_FETCHING_ACCOUNT: + case FAILURE_CANNOT_ADD_DUPLICATE_SITE: + case FAILURE: + return LoginNotification.failure(this, R.string.notification_login_failed); + } + + return null; + } + + @Override + protected void trackStateUpdate(Map props) { + mAnalyticsListener.trackWpComBackgroundServiceUpdate(props); + } + + private void setState(LoginStep phase) { + setState(new LoginState(phase)); + } + + @Override + public void onCreate() { + AndroidInjection.inject(this); + super.onCreate(); + + AppLog.i(T.MAIN, "LoginWpcomService > Created"); + + // TODO: Recover any login attempts that were interrupted by the service being stopped? + } + + @Override + public void onDestroy() { + AppLog.i(T.MAIN, "LoginWpcomService > Destroyed"); + super.onDestroy(); + } + + @Override + public void onTimeout(int startId) { + super.onTimeout(startId); + setState(LoginStep.FAILURE); // This will cal stopSelf() + } + + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + if (intent == null) { + return START_NOT_STICKY; + } + + setState(LoginStep.AUTHENTICATING); + + String email = intent.getStringExtra(ARG_EMAIL); + String password = intent.getStringExtra(ARG_PASSWORD); + + mIdToken = intent.getStringExtra(ARG_SOCIAL_ID_TOKEN); + mService = intent.getStringExtra(ARG_SOCIAL_SERVICE); + mIsSocialLogin = intent.getBooleanExtra(ARG_SOCIAL_LOGIN, false); + mIsJetpackAppLogin = intent.getBooleanExtra(ARG_JETPACK_APP_LOGIN, false); + mIsWooAppLogin = intent.getBooleanExtra(ARG_WOO_APP_LOGIN, false); + + AuthenticatePayload payload = new AuthenticatePayload(email, password); + mDispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateAction(payload)); + AppLog.i(T.NUX, "User tries to log in wpcom. Email: " + email); + + return START_REDELIVER_INTENT; + } + + private void handleAuthError(AuthenticationErrorType error, String errorMessage) { + if (error != AuthenticationErrorType.NEEDS_2FA) { + mAnalyticsListener.trackLoginFailed(error.getClass().getSimpleName(), + error.toString(), errorMessage); + + if (mIsSocialLogin) { + mAnalyticsListener.trackSocialFailure(error.getClass().getSimpleName(), + error.toString(), errorMessage); + } + } + + switch (error) { + case INCORRECT_USERNAME_OR_PASSWORD: + case NOT_AUTHENTICATED: // NOT_AUTHENTICATED is the generic error from XMLRPC response on first call. + setState(LoginStep.FAILURE_EMAIL_WRONG_PASSWORD); + break; + case NEEDS_2FA: + // login credentials were correct anyway so, offer to save to SmartLock + signalCredentialsOK(); + + if (mIsSocialLogin) { + setState(LoginStep.FAILURE_SOCIAL_2FA); + } else { + setState(LoginStep.FAILURE_2FA); + } + break; + case EMAIL_LOGIN_NOT_ALLOWED: + setState(LoginStep.FAILURE_USE_WPCOM_USERNAME_INSTEAD_OF_EMAIL); + break; + case INVALID_REQUEST: + // TODO: FluxC: could be specific? + default: + setState(LoginStep.FAILURE); + AppLog.e(T.NUX, "Server response: " + errorMessage); + + ToastUtils.showToast(this, errorMessage == null ? getString(R.string.error_generic) : errorMessage); + break; + } + } + + private void fetchAccount() { + setState(LoginStep.FETCHING_ACCOUNT); + mDispatcher.dispatch(AccountActionBuilder.newFetchAccountAction()); + } + + private void signalCredentialsOK() { + EventBus.getDefault().post(new OnCredentialsOK()); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onTwoFactorAuthStarted(OnTwoFactorAuthStarted event) { + signalCredentialsOK(); + setState(LoginStep.SECURITY_KEY_NEEDED); + TwoFactorRequested twoFactorRequest = new TwoFactorRequested(event.userId, + event.webauthnNonce, event.mBackupNonce, event.authenticatorNonce, + event.pushNonce, event.mSupportedAuthTypes); + EventBus.getDefault().post(twoFactorRequest); + } + + // OnChanged events + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthenticationChanged(OnAuthenticationChanged event) { + if (event.isError()) { + AppLog.e(T.API, "onAuthenticationChanged has error: " + event.error.type + " - " + event.error.message); + handleAuthError(event.error.type, event.error.message); + return; + } + + AppLog.i(T.NUX, "onAuthenticationChanged: " + event.toString()); + + if (mIsSocialLogin) { + setState(LoginStep.SOCIAL_LOGIN); + PushSocialPayload payload = new PushSocialPayload(mIdToken, mService); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialConnectAction(payload)); + } else { + signalCredentialsOK(); + fetchAccount(); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSocialChanged(OnSocialChanged event) { + if (event.isError()) { + mAnalyticsListener.trackSocialConnectFailure(); + switch (event.error.type) { + case UNABLE_CONNECT: + AppLog.e(T.API, "Unable to connect WordPress.com account to social account."); + break; + case USER_ALREADY_ASSOCIATED: + AppLog.e(T.API, "This social account is already associated with a WordPress.com account."); + break; + // Ignore other error cases. The above are the only two we have chosen to log. + } + + fetchAccount(); + } else if (!event.requiresTwoStepAuth) { + mAnalyticsListener.trackSocialConnectSuccess(); + fetchAccount(); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAccountChanged(OnAccountChanged event) { + if (event.isError()) { + AppLog.e(T.API, "onAccountChanged has error: " + event.error.type + " - " + event.error.message); + setState(LoginStep.FAILURE_FETCHING_ACCOUNT); + return; + } + + if (event.causeOfChange == AccountAction.FETCH_ACCOUNT) { + setState(LoginStep.FETCHING_SETTINGS); + // The user's account info has been fetched and stored - next, fetch the user's settings + mDispatcher.dispatch(AccountActionBuilder.newFetchSettingsAction()); + } else if (event.causeOfChange == AccountAction.FETCH_SETTINGS) { + setState(LoginStep.FETCHING_SITES); + // The user's account settings have also been fetched and stored - now we can fetch the user's sites + FetchSitesPayload payload = + SiteUtils.getFetchSitesPayload(mIsJetpackAppLogin, mIsWooAppLogin); + mDispatcher.dispatch(SiteActionBuilder.newFetchSitesAction(payload)); + } + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSiteChanged(OnSiteChanged event) { + if (event.isError()) { + AppLog.e(T.API, "onSiteChanged has error: " + event.error.type + " - " + event.error.toString()); + if (event.error.type != SiteErrorType.DUPLICATE_SITE) { + setState(LoginStep.FAILURE); + return; + } + + if (event.rowsAffected == 0) { + // If there is a duplicate site and not any site has been added, show an error and + // stop the sign in process + setState(LoginStep.FAILURE_CANNOT_ADD_DUPLICATE_SITE); + return; + } else { + // If there is a duplicate site, notify the user something could be wrong, + // but continue the sign in process + ToastUtils.showToast(this, R.string.duplicate_site_detected); + } + } + + setState(LoginStep.SUCCESS); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupConfirmationFragment.kt b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupConfirmationFragment.kt new file mode 100644 index 000000000000..6d07973b1c42 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupConfirmationFragment.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.login + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.Toolbar +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import dagger.android.support.AndroidSupportInjection +import org.wordpress.android.login.util.AvatarHelper.AvatarRequestListener +import org.wordpress.android.login.util.AvatarHelper.loadAvatarFromUrl +import javax.inject.Inject + +class SignupConfirmationFragment : Fragment(), MenuProvider { + private var mLoginListener: LoginListener? = null + + private var mEmail: String? = null + private var mDisplayName: String? = null + private var mIdToken: String? = null + private var mPhotoUrl: String? = null + private var mService: String? = null + + @Inject lateinit var mAnalyticsListener: LoginAnalyticsListener + + companion object { + const val TAG = "signup_confirmation_fragment_tag" + + private const val ARG_EMAIL = "ARG_EMAIL" + private const val ARG_SOCIAL_DISPLAY_NAME = "ARG_SOCIAL_DISPLAY_NAME" + private const val ARG_SOCIAL_ID_TOKEN = "ARG_SOCIAL_ID_TOKEN" + private const val ARG_SOCIAL_PHOTO_URL = "ARG_SOCIAL_PHOTO_URL" + private const val ARG_SOCIAL_SERVICE = "ARG_SOCIAL_SERVICE" + + @JvmStatic fun newInstance( + email: String?, + displayName: String?, + idToken: String?, + photoUrl: String?, + service: String? + ): SignupConfirmationFragment { + return SignupConfirmationFragment().apply { + arguments = Bundle().apply { + putString(ARG_EMAIL, email) + putString(ARG_SOCIAL_DISPLAY_NAME, displayName) + putString(ARG_SOCIAL_ID_TOKEN, idToken) + putString(ARG_SOCIAL_PHOTO_URL, photoUrl) + putString(ARG_SOCIAL_SERVICE, service) + } + } + } + } + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + if (context !is LoginListener) { + throw RuntimeException("$context must implement LoginListener") + } + mLoginListener = context + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + mEmail = it.getString(ARG_EMAIL) + mDisplayName = it.getString(ARG_SOCIAL_DISPLAY_NAME) + mIdToken = it.getString(ARG_SOCIAL_ID_TOKEN) + mPhotoUrl = it.getString(ARG_SOCIAL_PHOTO_URL) + mService = it.getString(ARG_SOCIAL_SERVICE) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.signup_confirmation_screen, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + (activity as AppCompatActivity?)?.apply { + val toolbar = view.findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.apply { + setTitle(R.string.sign_up_label) + setDisplayHomeAsUpEnabled(true) + } + } + + view.findViewById(R.id.email).text = mEmail + + val avatarProgressBar = view.findViewById(R.id.avatar_progress) + val avatarRequestListener = object : AvatarRequestListener { + override fun onRequestFinished() { + avatarProgressBar.visibility = View.GONE + } + } + + loadAvatarFromUrl(this, mPhotoUrl, view.findViewById(R.id.gravatar), avatarRequestListener) + view.findViewById(R.id.signup_confirmation_button).setOnClickListener { + mAnalyticsListener.trackCreateAccountClick() + mLoginListener?.showSignupSocial(mEmail, mDisplayName, mIdToken, mPhotoUrl, mService) + } + + if (savedInstanceState == null) { + mAnalyticsListener.trackSocialSignupConfirmationViewed() + } + + requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + // important for accessibility - talkback + activity?.setTitle(R.string.signup_confirmation_title) + } + + override fun onDetach() { + super.onDetach() + mLoginListener = null + } + + override fun onCreateMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.menu_login, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.help) { + mAnalyticsListener.trackShowHelpClick() + if (mLoginListener != null) { + mLoginListener?.helpSignupConfirmationScreen(mEmail) + } + return true + } + return false + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupGoogleFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupGoogleFragment.java new file mode 100644 index 000000000000..f37551cda781 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupGoogleFragment.java @@ -0,0 +1,287 @@ +package org.wordpress.android.login; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; + +import com.google.android.gms.auth.api.Auth; +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.auth.api.signin.GoogleSignInResult; +import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthenticationChanged; +import org.wordpress.android.fluxc.store.AccountStore.OnSocialChanged; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialPayload; +import org.wordpress.android.login.util.SiteUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +import static android.app.Activity.RESULT_CANCELED; +import static android.app.Activity.RESULT_OK; +import static org.wordpress.android.login.LoginAnalyticsListener.CreatedAccountSource.GOOGLE; + +import dagger.android.support.AndroidSupportInjection; + +public class SignupGoogleFragment extends GoogleFragment { + private static final String OLD_SITES_IDS = "old_sites_ids"; + private static final String SIGN_UP_REQUESTED = "sign_up_requested"; + + private static final String ARG_GOOGLE_EMAIL = "ARG_GOOGLE_EMAIL"; + private static final String ARG_DISPLAY_NAME = "ARG_DISPLAY_NAME"; + private static final String ARG_ID_TOKEN = "ARG_ID_TOKEN"; + private static final String ARG_PHOTO_URL = "ARG_PHOTO_URL"; + private static final String ARG_FORCE_SIGNUP_AT_START = "ARG_FORCE_SIGNUP_AT_START"; + + private ArrayList mOldSitesIds; + private boolean mSignupRequested; + private boolean mForceSignupAtStart; + + private static final int REQUEST_SIGNUP = 1002; + + public static final String TAG = "signup_google_fragment_tag"; + + public static SignupGoogleFragment newInstance(String email, String displayName, String idToken, String photoUrl) { + SignupGoogleFragment fragment = new SignupGoogleFragment(); + Bundle args = new Bundle(); + args.putString(ARG_GOOGLE_EMAIL, email); + args.putString(ARG_DISPLAY_NAME, displayName); + args.putString(ARG_ID_TOKEN, idToken); + args.putString(ARG_PHOTO_URL, photoUrl); + args.putBoolean(ARG_FORCE_SIGNUP_AT_START, true); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + Bundle args = getArguments(); + if (args != null) { + mDisplayName = args.getString(ARG_DISPLAY_NAME); + mGoogleEmail = args.getString(ARG_GOOGLE_EMAIL); + mIdToken = args.getString(ARG_ID_TOKEN); + mPhotoUrl = args.getString(ARG_PHOTO_URL); + mForceSignupAtStart = args.getBoolean(ARG_FORCE_SIGNUP_AT_START); + } + } + + @Override + protected String getProgressDialogText() { + return getString(R.string.signup_with_google_progress); + } + + @Override public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + mOldSitesIds = savedInstanceState.getIntegerArrayList(OLD_SITES_IDS); + mSignupRequested = savedInstanceState.getBoolean(SIGN_UP_REQUESTED); + } + } + + @Override public void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putIntegerArrayList(OLD_SITES_IDS, mOldSitesIds); + outState.putBoolean(SIGN_UP_REQUESTED, mSignupRequested); + } + + @Override + protected void startFlow() { + if (mForceSignupAtStart) { + dispatchSocialSignup(mIdToken); + } else { + if (!mSignupRequested) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: startFlow"); + mSignupRequested = true; + Intent signInIntent = Auth.GoogleSignInApi.getSignInIntent(mGoogleApiClient); + startActivityForResult(signInIntent, REQUEST_SIGNUP); + } else { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: startFlow called, but is already in progress"); + } + } + } + + @Override + public void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + + switch (request) { + case REQUEST_SIGNUP: + disconnectGoogleClient(); + mSignupRequested = false; + if (result == RESULT_OK) { + GoogleSignInResult signInResult = Auth.GoogleSignInApi.getSignInResultFromIntent(data); + + if (signInResult.isSuccess()) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: sign up result returned - succcess"); + try { + GoogleSignInAccount account = signInResult.getSignInAccount(); + + if (account != null) { + mDisplayName = account.getDisplayName() != null ? account.getDisplayName() : ""; + mGoogleEmail = account.getEmail() != null ? account.getEmail() : ""; + mIdToken = account.getIdToken() != null ? account.getIdToken() : ""; + mPhotoUrl = removeScaleFromGooglePhotoUrl( + account.getPhotoUrl() != null ? account.getPhotoUrl().toString() : ""); + } + + AppLog.d(T.MAIN, "GOOGLE SIGNUP: sign up result returned - dispatching SocialSignupAction"); + + dispatchSocialSignup(mIdToken); + } catch (NullPointerException exception) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: sign up result returned - NPE"); + AppLog.e(T.NUX, "Cannot get ID token from Google signup account.", exception); + showError(getString(R.string.login_error_generic)); + } + } else { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: sign up result returned - error"); + mAnalyticsListener.trackSignupSocialButtonFailure(); + switch (signInResult.getStatus().getStatusCode()) { + // Internal error. + case GoogleSignInStatusCodes.INTERNAL_ERROR: + AppLog.e(T.NUX, "Google Signup Failed: internal error."); + showError(getString(R.string.login_error_generic)); + break; + // Attempted to connect with an invalid account name specified. + case GoogleSignInStatusCodes.INVALID_ACCOUNT: + AppLog.e(T.NUX, "Google Signup Failed: invalid account name."); + showError(getString(R.string.login_error_generic) + + getString(R.string.login_error_suffix)); + break; + // Network error. + case GoogleSignInStatusCodes.NETWORK_ERROR: + AppLog.e(T.NUX, "Google Signup Failed: network error."); + showError(getString(R.string.error_generic_network)); + break; + // Cancelled by the user. + case GoogleSignInStatusCodes.SIGN_IN_CANCELLED: + AppLog.e(T.NUX, "Google Signup Failed: cancelled by user."); + break; + // Attempt didn't succeed with the current account. + case GoogleSignInStatusCodes.SIGN_IN_FAILED: + AppLog.e(T.NUX, "Google Signup Failed: current account failed."); + showError(getString(R.string.login_error_generic)); + break; + // Attempted to connect, but the user is not signed in. + case GoogleSignInStatusCodes.SIGN_IN_REQUIRED: + AppLog.e(T.NUX, "Google Signup Failed: user is not signed in."); + showError(getString(R.string.login_error_generic)); + break; + // Timeout error. + case GoogleSignInStatusCodes.TIMEOUT: + AppLog.e(T.NUX, "Google Signup Failed: timeout error."); + showError(getString(R.string.google_error_timeout)); + break; + // Unknown error. + default: + AppLog.e(T.NUX, "Google Signup Failed: unknown error."); + showError(getString(R.string.login_error_generic)); + break; + } + } + } else if (result == RESULT_CANCELED) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: sign up result returned - canceled"); + mAnalyticsListener.trackSignupSocialButtonFailure(); + AppLog.e(T.NUX, "Google Signup Failed: result was CANCELED."); + finishFlow(); + } else { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: sign up result returned - unknown"); + mAnalyticsListener.trackSignupSocialButtonFailure(); + AppLog.e(T.NUX, "Google Signup Failed: result was not OK or CANCELED."); + showError(getString(R.string.login_error_generic)); + } + + break; + } + } + + private void dispatchSocialSignup(String idToken) { + PushSocialPayload payload = new PushSocialPayload(idToken, SERVICE_TYPE_GOOGLE); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialSignupAction(payload)); + mOldSitesIds = SiteUtils.getCurrentSiteIds(mSiteStore, false); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthenticationChanged(OnAuthenticationChanged event) { + if (event.isError()) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onAuthenticationChanged - error"); + AppLog.e(T.API, + "SignupGoogleFragment.onAuthenticationChanged: " + event.error.type + " - " + event.error.message); + } else if (event.createdAccount) { + AppLog.d(T.MAIN, + "GOOGLE SIGNUP: onAuthenticationChanged - new wordpress account created"); + mAnalyticsListener.trackCreatedAccount(event.userName, mGoogleEmail, GOOGLE); + mAnalyticsListener.trackAnalyticsSignIn(true); + mGoogleListener.onGoogleSignupFinished(mDisplayName, mGoogleEmail, mPhotoUrl, event.userName); + // Continue with login since existing account was selected. + } else { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onAuthenticationChanged - the email is already attached to an account"); + mAnalyticsListener.trackSignupSocialToLogin(); + mLoginListener.loggedInViaSocialAccount(mOldSitesIds, true); + } + finishFlow(); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onSocialChanged(OnSocialChanged event) { + if (event.isError()) { + AppLog.e(T.API, "SignupGoogleFragment.onSocialChanged: " + event.error.type + " - " + event.error.message); + + switch (event.error.type) { + // WordPress account exists with input email address, and two-factor authentication is required. + case TWO_STEP_ENABLED: + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onSocialChanged - error - two step authentication"); + mAnalyticsListener.trackSignupSocialToLogin(); + mLoginListener.showSignupToLoginMessage(); + // Dispatch social login action to retrieve data required for two-factor authentication. + PushSocialPayload payload = new PushSocialPayload(mIdToken, SERVICE_TYPE_GOOGLE); + AppLog.d(T.MAIN, + "GOOGLE SIGNUP: onSocialChanged error - two step authentication - dispatching " + + "pushSocialLoginAction"); + mDispatcher.dispatch(AccountActionBuilder.newPushSocialLoginAction(payload)); + break; + // WordPress account exists with input email address, but not connected. + case USER_EXISTS: + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onSocialChanged - error - user already exists"); + loginViaSocialAccount(); + break; + // Too many attempts on sending SMS verification code. The user has to wait before they try again + case SMS_CODE_THROTTLED: + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onSocialChanged - error - sms code throttled"); + showError(getString(R.string.login_error_sms_throttled)); + break; + default: + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onSocialChanged - error - unknown"); + showError(getString(R.string.login_error_generic)); + break; + } + // Response does not return error when two-factor authentication is required. + } else if (event.requiresTwoStepAuth || Login2FaFragment.TWO_FACTOR_TYPE_SMS.equals(event.notificationSent)) { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onSocialChanged - 2fa required"); + mAnalyticsListener.trackSignupSocialToLogin(); + mLoginListener.needs2faSocial(mGoogleEmail, event.userId, event.nonceAuthenticator, event.nonceBackup, + event.nonceSms, event.nonceWebauthn, event.twoStepTypes); + finishFlow(); + } else { + AppLog.d(T.MAIN, "GOOGLE SIGNUP: onSocialChanged - google login success"); + loginViaSocialAccount(); + } + } + + private void loginViaSocialAccount() { + mAnalyticsListener.trackSignupSocialAccountsNeedConnecting(); + mAnalyticsListener.trackSignupSocialToLogin(); + mLoginListener.showSignupToLoginMessage(); + mLoginListener.loginViaSocialAccount(mGoogleEmail, mIdToken, SERVICE_TYPE_GOOGLE, true); + finishFlow(); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupMagicLinkFragment.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupMagicLinkFragment.java new file mode 100644 index 000000000000..a07d339fef65 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/SignupMagicLinkFragment.java @@ -0,0 +1,306 @@ +package org.wordpress.android.login; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadFlow; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadScheme; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadSource; +import org.wordpress.android.fluxc.store.AccountStore.OnAuthEmailSent; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.NetworkUtils; + +import javax.inject.Inject; + +import dagger.android.support.AndroidSupportInjection; + +public class SignupMagicLinkFragment extends Fragment { + private static final String ARG_EMAIL_ADDRESS = "ARG_EMAIL_ADDRESS"; + private static final String ARG_IS_JETPACK_CONNECT = "ARG_IS_JETPACK_CONNECT"; + private static final String ARG_JETPACK_CONNECT_SOURCE = "ARG_JETPACK_CONNECT_SOURCE"; + private static final String ARG_IS_EMAIL_CLIENT_AVAILABLE = "ARG_IS_EMAIL_CLIENT_AVAILABLE"; + private static final String ARG_MAGIC_LINK_SCHEME = "ARG_MAGIC_LINK_SCHEME"; + private static final String SIGNUP_FLOW_NAME = "mobile-android"; + + public static final String TAG = "signup_magic_link_fragment_tag"; + + private Button mOpenMailButton; + private ProgressDialog mProgressDialog; + private String mJetpackConnectSource; + private boolean mIsJetpackConnect; + private AuthEmailPayloadScheme mScheme; + + @Inject protected Dispatcher mDispatcher; + @Inject protected LoginAnalyticsListener mAnalyticsListener; + protected LoginListener mLoginListener; + protected String mEmail; + protected boolean mInProgress; + + public static SignupMagicLinkFragment newInstance(String email, boolean isJetpackConnect, + String jetpackConnectSource) { + return newInstance(email, isJetpackConnect, jetpackConnectSource, null, null); + } + + public static SignupMagicLinkFragment newInstance(String email, boolean isJetpackConnect, + String jetpackConnectSource, + Boolean isEmailClientAvailable) { + return newInstance(email, isJetpackConnect, jetpackConnectSource, isEmailClientAvailable, + null); + } + + public static SignupMagicLinkFragment newInstance(String email, boolean isJetpackConnect, + String jetpackConnectSource, + Boolean isEmailClientAvailable, + AuthEmailPayloadScheme scheme) { + Bundle args = new Bundle(); + args.putString(ARG_EMAIL_ADDRESS, email); + args.putBoolean(ARG_IS_JETPACK_CONNECT, isJetpackConnect); + args.putString(ARG_JETPACK_CONNECT_SOURCE, jetpackConnectSource); + if (scheme != null) { + args.putSerializable(ARG_MAGIC_LINK_SCHEME, scheme); + } + if (isEmailClientAvailable != null) { + args.putBoolean(ARG_IS_EMAIL_CLIENT_AVAILABLE, isEmailClientAvailable); + } + SignupMagicLinkFragment fragment = new SignupMagicLinkFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (getArguments() != null) { + mEmail = getArguments().getString(ARG_EMAIL_ADDRESS); + } + + setHasOptionsMenu(true); + } + + /** Determines whether to hide the "Check email button". + * When we know that the email client is not available, rather than toasting an error, we hide the button instead. + * @return + */ + private boolean shouldHideButton() { + Bundle args = getArguments(); + // preserve default behavior + if (args == null || !args.containsKey(ARG_IS_EMAIL_CLIENT_AVAILABLE)) { + return false; + } + // hide button if we know the client is not available + return !args.getBoolean(ARG_IS_EMAIL_CLIENT_AVAILABLE); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View layout = inflater.inflate(R.layout.signup_magic_link_screen, container, false); + + mOpenMailButton = layout.findViewById(R.id.signup_magic_link_button); + + if (shouldHideButton()) { + mOpenMailButton.setVisibility(View.GONE); + } else { + mOpenMailButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mLoginListener != null) { + mLoginListener.openEmailClient(false); + } + } + }); + } + + if (getArguments() != null) { + mIsJetpackConnect = getArguments().getBoolean(ARG_IS_JETPACK_CONNECT); + mJetpackConnectSource = getArguments().getString(ARG_JETPACK_CONNECT_SOURCE); + mScheme = (AuthEmailPayloadScheme) getArguments() + .getSerializable(ARG_MAGIC_LINK_SCHEME); + } + + if (savedInstanceState == null) { + sendMagicLinkEmail(); + } + + return layout; + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + Toolbar toolbar = view.findViewById(R.id.toolbar); + ((AppCompatActivity) getActivity()).setSupportActionBar(toolbar); + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + + if (actionBar != null) { + actionBar.setTitle(R.string.sign_up_label); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + if (savedInstanceState == null) { + mAnalyticsListener.trackSignupMagicLinkOpenEmailClientViewed(); + } + } + + @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // important for accessibility - talkback + getActivity().setTitle(R.string.signup_magic_link_title); + } + + @Override + public void onAttach(Context context) { + AndroidSupportInjection.inject(this); + super.onAttach(context); + + if (context instanceof LoginListener) { + mLoginListener = (LoginListener) context; + } else { + throw new RuntimeException(context.toString() + " must implement LoginListener"); + } + + mDispatcher.register(this); + } + + @Override + public void onDetach() { + super.onDetach(); + mLoginListener = null; + mDispatcher.unregister(this); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_login, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.help) { + mAnalyticsListener.trackShowHelpClick(); + if (mLoginListener != null) { + mLoginListener.helpSignupMagicLinkScreen(mEmail); + } + + return true; + } + + return false; + } + + protected void startProgress(String message) { + mOpenMailButton.setEnabled(false); + + mProgressDialog = ProgressDialog.show(getActivity(), "", message, true, true, + new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface dialog) { + if (mInProgress) { + endProgress(); + } + } + }); + + mInProgress = true; + } + + protected void endProgress() { + mInProgress = false; + + if (mProgressDialog != null) { + mProgressDialog.cancel(); + } + + mProgressDialog = null; + mOpenMailButton.setEnabled(true); + } + + protected void sendMagicLinkEmail() { + if (NetworkUtils.checkConnection(getActivity())) { + startProgress(getString(R.string.signup_magic_link_progress)); + AuthEmailPayloadSource source = getAuthEmailPayloadSource(); + AuthEmailPayload authEmailPayload = new AuthEmailPayload(mEmail, true, + mIsJetpackConnect ? AuthEmailPayloadFlow.JETPACK : null, source); + authEmailPayload.signupFlowName = SIGNUP_FLOW_NAME; + authEmailPayload.scheme = mScheme; + mDispatcher.dispatch(AuthenticationActionBuilder.newSendAuthEmailAction(authEmailPayload)); + } + } + + private AuthEmailPayloadSource getAuthEmailPayloadSource() { + if (mJetpackConnectSource != null) { + if (mJetpackConnectSource.equalsIgnoreCase(AuthEmailPayloadSource.NOTIFICATIONS.toString())) { + return AuthEmailPayloadSource.NOTIFICATIONS; + } else if (mJetpackConnectSource.equalsIgnoreCase(AuthEmailPayloadSource.STATS.toString())) { + return AuthEmailPayloadSource.STATS; + } else { + return null; + } + } else { + return null; + } + } + + protected void showErrorDialog(String message) { + mAnalyticsListener.trackFailure(message); + DialogInterface.OnClickListener dialogListener = new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + switch (which) { + case DialogInterface.BUTTON_POSITIVE: + sendMagicLinkEmail(); + break; + // DialogInterface.BUTTON_NEGATIVE is intentionally ignored. Just dismiss dialog. + } + } + }; + + AlertDialog dialog = new MaterialAlertDialogBuilder(getActivity()) + .setMessage(message) + .setNegativeButton(R.string.signup_magic_link_error_button_negative, dialogListener) + .setPositiveButton(R.string.signup_magic_link_error_button_positive, dialogListener) + .create(); + dialog.show(); + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + public void onAuthEmailSent(OnAuthEmailSent event) { + if (mInProgress) { + endProgress(); + + if (event.isError()) { + mAnalyticsListener.trackSignupMagicLinkFailed(); + AppLog.e(T.API, "OnAuthEmailSent error: " + event.error.type + " - " + event.error.message); + showErrorDialog(getString(R.string.signup_magic_link_error)); + } else { + mAnalyticsListener.trackSignupMagicLinkSent(); + } + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/di/LoginFragmentModule.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/di/LoginFragmentModule.java new file mode 100644 index 000000000000..ab83237f48c4 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/di/LoginFragmentModule.java @@ -0,0 +1,56 @@ +package org.wordpress.android.login.di; + +import org.wordpress.android.login.Login2FaFragment; +import org.wordpress.android.login.LoginEmailFragment; +import org.wordpress.android.login.LoginEmailPasswordFragment; +import org.wordpress.android.login.LoginGoogleFragment; +import org.wordpress.android.login.LoginMagicLinkRequestFragment; +import org.wordpress.android.login.LoginMagicLinkSentFragment; +import org.wordpress.android.login.LoginSiteAddressFragment; +import org.wordpress.android.login.LoginSiteAddressHelpDialogFragment; +import org.wordpress.android.login.LoginUsernamePasswordFragment; +import org.wordpress.android.login.SignupConfirmationFragment; +import org.wordpress.android.login.SignupGoogleFragment; +import org.wordpress.android.login.SignupMagicLinkFragment; + +import dagger.Module; +import dagger.android.ContributesAndroidInjector; + +@Module +public abstract class LoginFragmentModule { + @ContributesAndroidInjector + abstract Login2FaFragment login2FaFragment(); + + @ContributesAndroidInjector + abstract LoginEmailFragment loginEmailFragment(); + + @ContributesAndroidInjector + abstract LoginEmailPasswordFragment loginEmailPasswordFragment(); + + @ContributesAndroidInjector + abstract LoginGoogleFragment loginGoogleFragment(); + + @ContributesAndroidInjector + abstract LoginMagicLinkRequestFragment loginMagicLinkRequestFragment(); + + @ContributesAndroidInjector + abstract LoginMagicLinkSentFragment loginMagicLinkSentFragment(); + + @ContributesAndroidInjector + abstract LoginSiteAddressFragment loginSiteAddressFragment(); + + @ContributesAndroidInjector + abstract LoginSiteAddressHelpDialogFragment loginSiteAddressHelpDialogFragment(); + + @ContributesAndroidInjector + abstract LoginUsernamePasswordFragment loginUsernamePasswordFragment(); + + @ContributesAndroidInjector + abstract SignupGoogleFragment signupGoogleFragment(); + + @ContributesAndroidInjector + abstract SignupMagicLinkFragment signupMagicLinkFragment(); + + @ContributesAndroidInjector + abstract SignupConfirmationFragment signupConfirmationScreen(); +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/di/LoginServiceModule.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/di/LoginServiceModule.java new file mode 100644 index 000000000000..77bf000091b2 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/di/LoginServiceModule.java @@ -0,0 +1,12 @@ +package org.wordpress.android.login.di; + +import org.wordpress.android.login.LoginWpcomService; + +import dagger.Module; +import dagger.android.ContributesAndroidInjector; + +@Module +public abstract class LoginServiceModule { + @ContributesAndroidInjector + abstract LoginWpcomService loginWpcomService(); +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/AvatarHelper.kt b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/AvatarHelper.kt new file mode 100644 index 000000000000..2f9b8e28132c --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/AvatarHelper.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.login.util + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.fragment.app.Fragment +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.request.target.Target +import com.gravatar.AvatarQueryOptions +import com.gravatar.AvatarUrl +import com.gravatar.DefaultAvatarOption.Status404 +import com.gravatar.types.Email +import org.wordpress.android.login.R + +object AvatarHelper { + @JvmStatic fun loadAvatarFromEmail( + fragment: Fragment, + email: String?, + avatarView: ImageView, + listener: AvatarRequestListener + ) { + val avatarSize = fragment.resources.getDimensionPixelSize(R.dimen.avatar_sz_login) + val avatarUrl = email?.let { AvatarUrl(Email(email), + AvatarQueryOptions(preferredSize = avatarSize, defaultAvatarOption = Status404)).url().toString() } + loadAvatarFromUrl(fragment, avatarUrl, avatarView, listener) + } + + @JvmStatic fun loadAvatarFromUrl( + fragment: Fragment, + avatarUrl: String?, + avatarView: ImageView, + listener: AvatarRequestListener + ) { + Glide.with(fragment) + .load(avatarUrl) + .apply(RequestOptions.circleCropTransform()) + .apply(RequestOptions.placeholderOf(R.drawable.ic_user_circle_no_padding_grey_24dp)) + .apply(RequestOptions.errorOf(R.drawable.ic_user_circle_no_padding_grey_24dp)) + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + listener.onRequestFinished() + return false + } + + override fun onResourceReady( + drawable: Drawable?, + model: Any?, + target: Target, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + listener.onRequestFinished() + return false + } + }) + .into(avatarView) + } + + interface AvatarRequestListener { + fun onRequestFinished() + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/ContextExtensions.kt b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/ContextExtensions.kt new file mode 100644 index 000000000000..8772eb1a4692 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/ContextExtensions.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.login.util + +import android.content.Context +import android.content.res.ColorStateList +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat + +@ColorRes +fun Context.getColorResIdFromAttribute(@AttrRes attribute: Int) = + TypedValue().let { + theme.resolveAttribute(attribute, it, true) + it.resourceId + } + +@ColorInt +fun Context.getColorFromAttribute(@AttrRes attribute: Int) = + TypedValue().let { + theme.resolveAttribute(attribute, it, true) + ContextCompat.getColor(this, it.resourceId) + } + +fun Context.getColorStateListFromAttribute(@AttrRes attribute: Int): ColorStateList = + getColorResIdFromAttribute(attribute).let { + AppCompatResources.getColorStateList(this, it) + } diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/SiteUtils.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/SiteUtils.java new file mode 100644 index 000000000000..5eff40a34ba7 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/util/SiteUtils.java @@ -0,0 +1,58 @@ +package org.wordpress.android.login.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.store.SiteStore; +import org.wordpress.android.fluxc.store.SiteStore.FetchSitesPayload; +import org.wordpress.android.fluxc.store.SiteStore.SiteFilter; +import org.wordpress.android.util.UrlUtils; + +import java.util.ArrayList; +import java.util.List; + +public class SiteUtils { + public static ArrayList getCurrentSiteIds(SiteStore siteStore, boolean selfhostedOnly) { + ArrayList siteIDs = new ArrayList<>(); + List sites = selfhostedOnly ? siteStore.getSitesAccessedViaXMLRPC() : siteStore.getSites(); + for (SiteModel site : sites) { + siteIDs.add(site.getId()); + } + + return siteIDs; + } + + @Nullable + public static SiteModel getXMLRPCSiteByUrl(SiteStore siteStore, String url) { + return getSiteByMatchingUrl(siteStore.getSitesAccessedViaXMLRPC(), url); + } + + @Nullable + public static SiteModel getSiteByMatchingUrl(SiteStore siteStore, String url) { + return getSiteByMatchingUrl(siteStore.getSites(), url); + } + + @Nullable + private static SiteModel getSiteByMatchingUrl(List siteModelList, String url) { + if (siteModelList != null && !siteModelList.isEmpty()) { + for (SiteModel siteModel : siteModelList) { + String storedSiteUrl = UrlUtils.removeScheme(siteModel.getUrl()).replace("/", ""); + String incomingSiteUrl = UrlUtils.removeScheme(url).replace("/", ""); + if (storedSiteUrl.equalsIgnoreCase(incomingSiteUrl)) { + return siteModel; + } + } + } + return null; + } + + @NonNull + public static FetchSitesPayload getFetchSitesPayload(boolean isJetpackAppLogin, + boolean isWooAppLogin) { + ArrayList siteFilters = new ArrayList<>(); + return new FetchSitesPayload( + siteFilters, !isJetpackAppLogin && !isWooAppLogin + ); + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/webauthn/PasskeyRequest.kt b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/webauthn/PasskeyRequest.kt new file mode 100644 index 000000000000..096a006112ca --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/webauthn/PasskeyRequest.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.login.webauthn + +import android.content.Context +import android.os.CancellationSignal +import android.util.Log +import androidx.credentials.CredentialManager +import androidx.credentials.CredentialManagerCallback +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.GetCredentialException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder +import org.wordpress.android.fluxc.store.AccountStore.FinishWebauthnChallengePayload +import java.util.concurrent.Executors + +class PasskeyRequest private constructor( + context: Context, + requestData: PasskeyRequestData, + onSuccess: (Action) -> Unit, + onFailure: (Throwable) -> Unit +) { + init { + val executor = Executors.newSingleThreadExecutor() + val signal = CancellationSignal() + val getCredRequest = GetCredentialRequest( + listOf(GetPublicKeyCredentialOption(requestData.requestJson)) + ) + + val passkeyRequestCallback = object : CredentialManagerCallback { + override fun onError(e: GetCredentialException) { + CoroutineScope(Dispatchers.Main).launch { onFailure(e) } + Log.e(TAG, e.stackTraceToString()) + } + + override fun onResult(result: GetCredentialResponse) { + FinishWebauthnChallengePayload().apply { + mUserId = requestData.userId + mTwoStepNonce = requestData.twoStepNonce + mClientData = result.toJson().orEmpty() + }.let { + AuthenticationActionBuilder.newFinishSecurityKeyChallengeAction(it) + }.let(onSuccess) + } + } + + try { + CredentialManager.create(context).getCredentialAsync( + request = getCredRequest, + context = context, + cancellationSignal = signal, + executor = executor, + callback = passkeyRequestCallback + ) + } catch (e: GetCredentialException) { + Log.e(TAG, e.stackTraceToString()) + onFailure(e) + } + } + + private fun GetCredentialResponse.toJson(): String? { + return when (val credential = this.credential) { + is PublicKeyCredential -> credential.authenticationResponseJson + else -> { + Log.e(TAG, "Unexpected type of credential") + null + } + } + } + + data class PasskeyRequestData( + val userId: String, + val twoStepNonce: String, + val requestJson: String + ) + + companion object { + private const val TAG = "PasskeyRequest" + + @JvmStatic + fun create( + context: Context, + requestData: PasskeyRequestData, + onSuccess: (Action) -> Unit, + onFailure: (Throwable) -> Unit + ) { + PasskeyRequest(context, requestData, onSuccess, onFailure) + } + } +} diff --git a/WordPressLoginFlow/src/main/java/org/wordpress/android/login/widgets/WPLoginInputRow.java b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/widgets/WPLoginInputRow.java new file mode 100644 index 000000000000..7d07a455f874 --- /dev/null +++ b/WordPressLoginFlow/src/main/java/org/wordpress/android/login/widgets/WPLoginInputRow.java @@ -0,0 +1,207 @@ +package org.wordpress.android.login.widgets; + +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.google.android.material.textfield.TextInputLayout; + +import org.wordpress.android.login.R; + +/** + * Compound view composed of an icon and an EditText + */ +public class WPLoginInputRow extends RelativeLayout { + private static final String KEY_SUPER_STATE = "wplogin_input_row_super_state"; + + public interface OnEditorCommitListener { + void onEditorCommit(); + } + + private TextInputLayout mTextInputLayout; + private EditText mEditText; + + public EditText getEditText() { + return mEditText; + } + + public WPLoginInputRow(Context context) { + super(context); + init(context, null); + } + + public WPLoginInputRow(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public WPLoginInputRow(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(context, attrs); + } + + private void init(Context context, AttributeSet attrs) { + inflate(context, R.layout.login_input_row, this); + + mTextInputLayout = findViewById(R.id.input_layout); + mEditText = findViewById(R.id.input); + + if (attrs != null) { + TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.wpLoginInputRow, 0, 0); + + try { + if (a.hasValue(R.styleable.wpLoginInputRow_android_inputType)) { + mEditText.setInputType(a.getInteger(R.styleable.wpLoginInputRow_android_inputType, 0)); + } + + if (a.hasValue(R.styleable.wpLoginInputRow_android_imeOptions)) { + mEditText.setImeOptions(a.getInteger(R.styleable.wpLoginInputRow_android_imeOptions, 0)); + } + + if (a.hasValue(R.styleable.wpLoginInputRow_android_hint)) { + String hint = a.getString(R.styleable.wpLoginInputRow_android_hint); + mTextInputLayout.setHint(hint); + mEditText.setHint(hint); + // Makes the hint transparent, so the TalkBack can read it, when the field is prefilled + mEditText.setHintTextColor(getResources().getColor(android.R.color.transparent)); + + // Passes autofill hints values forward to child views + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isImportantForAutofill()) { + mEditText.setAutofillHints(getAutofillHints()); + } + } + } + if (a.hasValue(R.styleable.wpLoginInputRow_passwordToggleEnabled)) { + mTextInputLayout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE); + mTextInputLayout.setEndIconDrawable(R.drawable.selector_password_visibility); + } + + if (a.hasValue(R.styleable.wpLoginInputRow_android_textAlignment)) { + mEditText.setTextAlignment( + a.getInt(R.styleable.wpLoginInputRow_android_textAlignment, TEXT_ALIGNMENT_GRAVITY)); + } + } finally { + a.recycle(); + } + } + } + + + /** + * Save the Views state manually so multiple instances of the compound View can exist in the same layout. + */ + @Override + public Parcelable onSaveInstanceState() { + Bundle bundle = new Bundle(); + Parcelable editTextState = mEditText.onSaveInstanceState(); + bundle.putParcelable(KEY_SUPER_STATE, new SavedState(super.onSaveInstanceState(), editTextState)); + return bundle; + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + if (state instanceof Bundle) { + Bundle bundle = (Bundle) state; + state = restoreViewsState(bundle.getParcelable(KEY_SUPER_STATE)); + } + + super.onRestoreInstanceState(state); + } + + private Parcelable restoreViewsState(SavedState state) { + mEditText.onRestoreInstanceState(state.mEditTextState); + return state.getSuperState(); + } + + /** + * Disable the auto-save feature, since the Views state is saved manually. + */ + @Override + protected void dispatchSaveInstanceState(SparseArray container) { + super.dispatchFreezeSelfOnly(container); + } + + @Override + protected void dispatchRestoreInstanceState(SparseArray container) { + super.dispatchThawSelfOnly(container); + } + + + public void addTextChangedListener(TextWatcher watcher) { + mEditText.addTextChangedListener(watcher); + } + + public void setOnEditorCommitListener(final OnEditorCommitListener listener) { + mEditText.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE + || actionId == EditorInfo.IME_ACTION_NEXT + || (event != null + && event.getAction() == KeyEvent.ACTION_UP + && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) { + listener.onEditorCommit(); + } + + // always consume the event so the focus stays in the EditText + return true; + }); + } + + public void setOnEditorActionListener(TextView.OnEditorActionListener l) { + mEditText.setOnEditorActionListener(l); + } + + public final void setText(CharSequence text) { + mEditText.setText(text); + } + + public void setError(@Nullable final CharSequence error) { + mTextInputLayout.setError(error); + if (error == null) { + mTextInputLayout.setErrorEnabled(false); + } + } + + private static class SavedState extends BaseSavedState { + private Parcelable mEditTextState; + + SavedState(Parcelable superState, Parcelable editTextState) { + super(superState); + this.mEditTextState = editTextState; + } + + SavedState(Parcel in) { + super(in); + mEditTextState = in.readParcelable(Parcelable.class.getClassLoader()); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeParcelable(mEditTextState, 0); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/WordPressLoginFlow/src/main/res/color/login_on_background_medium_selector.xml b/WordPressLoginFlow/src/main/res/color/login_on_background_medium_selector.xml new file mode 100644 index 000000000000..bfd820bcff0f --- /dev/null +++ b/WordPressLoginFlow/src/main/res/color/login_on_background_medium_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/WordPressLoginFlow/src/main/res/color/login_on_surface_high_selector.xml b/WordPressLoginFlow/src/main/res/color/login_on_surface_high_selector.xml new file mode 100644 index 000000000000..32c80592ed5a --- /dev/null +++ b/WordPressLoginFlow/src/main/res/color/login_on_surface_high_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/WordPressLoginFlow/src/main/res/color/login_on_surface_medium_selector.xml b/WordPressLoginFlow/src/main/res/color/login_on_surface_medium_selector.xml new file mode 100644 index 000000000000..298e14de51c0 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/color/login_on_surface_medium_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/WordPressLoginFlow/src/main/res/color/material_on_surface_emphasis_low.xml b/WordPressLoginFlow/src/main/res/color/material_on_surface_emphasis_low.xml new file mode 100644 index 000000000000..85e239776013 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/color/material_on_surface_emphasis_low.xml @@ -0,0 +1,4 @@ + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable-hdpi/ic_password_visibility.png b/WordPressLoginFlow/src/main/res/drawable-hdpi/ic_password_visibility.png new file mode 100644 index 000000000000..329e617e9c7e Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-hdpi/ic_password_visibility.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-hdpi/ic_password_visibility_off.png b/WordPressLoginFlow/src/main/res/drawable-hdpi/ic_password_visibility_off.png new file mode 100644 index 000000000000..b21a6862977b Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-hdpi/ic_password_visibility_off.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-hdpi/login_notification_icon.png b/WordPressLoginFlow/src/main/res/drawable-hdpi/login_notification_icon.png new file mode 100644 index 000000000000..4e77b5c192f1 Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-hdpi/login_notification_icon.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-mdpi/ic_password_visibility.png b/WordPressLoginFlow/src/main/res/drawable-mdpi/ic_password_visibility.png new file mode 100644 index 000000000000..58597e91b97d Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-mdpi/ic_password_visibility.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-mdpi/ic_password_visibility_off.png b/WordPressLoginFlow/src/main/res/drawable-mdpi/ic_password_visibility_off.png new file mode 100644 index 000000000000..3efdf49225d2 Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-mdpi/ic_password_visibility_off.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xhdpi/ic_password_visibility.png b/WordPressLoginFlow/src/main/res/drawable-xhdpi/ic_password_visibility.png new file mode 100644 index 000000000000..1f7b4cc8f24b Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xhdpi/ic_password_visibility.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xhdpi/ic_password_visibility_off.png b/WordPressLoginFlow/src/main/res/drawable-xhdpi/ic_password_visibility_off.png new file mode 100644 index 000000000000..46bf0c931a0a Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xhdpi/ic_password_visibility_off.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xxhdpi/ic_password_visibility.png b/WordPressLoginFlow/src/main/res/drawable-xxhdpi/ic_password_visibility.png new file mode 100644 index 000000000000..c816ab49dca8 Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xxhdpi/ic_password_visibility.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xxhdpi/ic_password_visibility_off.png b/WordPressLoginFlow/src/main/res/drawable-xxhdpi/ic_password_visibility_off.png new file mode 100644 index 000000000000..13eb65df37f3 Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xxhdpi/ic_password_visibility_off.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xxhdpi/login_notification_icon.png b/WordPressLoginFlow/src/main/res/drawable-xxhdpi/login_notification_icon.png new file mode 100644 index 000000000000..17f3101f8e5e Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xxhdpi/login_notification_icon.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xxxhdpi/ic_password_visibility.png b/WordPressLoginFlow/src/main/res/drawable-xxxhdpi/ic_password_visibility.png new file mode 100644 index 000000000000..e005b976c420 Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xxxhdpi/ic_password_visibility.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable-xxxhdpi/ic_password_visibility_off.png b/WordPressLoginFlow/src/main/res/drawable-xxxhdpi/ic_password_visibility_off.png new file mode 100644 index 000000000000..ce3c9d84dc6f Binary files /dev/null and b/WordPressLoginFlow/src/main/res/drawable-xxxhdpi/ic_password_visibility_off.png differ diff --git a/WordPressLoginFlow/src/main/res/drawable/ic_globe_grey_24dp.xml b/WordPressLoginFlow/src/main/res/drawable/ic_globe_grey_24dp.xml new file mode 100644 index 000000000000..c2de2509951c --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/ic_globe_grey_24dp.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/ic_google_60dp.xml b/WordPressLoginFlow/src/main/res/drawable/ic_google_60dp.xml new file mode 100644 index 000000000000..7108238bd4e6 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/ic_google_60dp.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/ic_help_outline_white_24dp.xml b/WordPressLoginFlow/src/main/res/drawable/ic_help_outline_white_24dp.xml new file mode 100644 index 000000000000..5fdc7e34bf16 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/ic_help_outline_white_24dp.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/ic_user_circle_no_padding_grey_24dp.xml b/WordPressLoginFlow/src/main/res/drawable/ic_user_circle_no_padding_grey_24dp.xml new file mode 100644 index 000000000000..fe272c2b53b1 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/ic_user_circle_no_padding_grey_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/img_envelope.xml b/WordPressLoginFlow/src/main/res/drawable/img_envelope.xml new file mode 100644 index 000000000000..8cca51b7bda1 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/img_envelope.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/img_success_tablet.xml b/WordPressLoginFlow/src/main/res/drawable/img_success_tablet.xml new file mode 100644 index 000000000000..cf31a44f3960 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/img_success_tablet.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/login_site_address_help.xml b/WordPressLoginFlow/src/main/res/drawable/login_site_address_help.xml new file mode 100644 index 000000000000..b6344100c4d6 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/login_site_address_help.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/login_toolbar_icon.xml b/WordPressLoginFlow/src/main/res/drawable/login_toolbar_icon.xml new file mode 100644 index 000000000000..9b1b75473784 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/login_toolbar_icon.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/WordPressLoginFlow/src/main/res/drawable/selector_password_visibility.xml b/WordPressLoginFlow/src/main/res/drawable/selector_password_visibility.xml new file mode 100644 index 000000000000..49d87bc2ebb4 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/drawable/selector_password_visibility.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_2fa_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_2fa_screen.xml new file mode 100644 index 000000000000..cae797c60230 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_2fa_screen.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_alert_http_auth.xml b/WordPressLoginFlow/src/main/res/layout/login_alert_http_auth.xml new file mode 100644 index 000000000000..b9ba3cfa2994 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_alert_http_auth.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_alert_site_address_help.xml b/WordPressLoginFlow/src/main/res/layout/login_alert_site_address_help.xml new file mode 100644 index 000000000000..9f980db5a596 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_alert_site_address_help.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_email_optional_site_creds_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_email_optional_site_creds_screen.xml new file mode 100644 index 000000000000..9baf3b28ff43 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_email_optional_site_creds_screen.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_email_password_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_email_password_screen.xml new file mode 100644 index 000000000000..fee59cd210b4 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_email_password_screen.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_email_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_email_screen.xml new file mode 100644 index 000000000000..d107a1486c9f --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_email_screen.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_form_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_form_screen.xml new file mode 100644 index 000000000000..de7631d24d6a --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_form_screen.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_include_email_header.xml b/WordPressLoginFlow/src/main/res/layout/login_include_email_header.xml new file mode 100644 index 000000000000..544cc2ea24f0 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_include_email_header.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_input_row.xml b/WordPressLoginFlow/src/main/res/layout/login_input_row.xml new file mode 100644 index 000000000000..23f4ced20d43 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_input_row.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_magic_link_request_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_magic_link_request_screen.xml new file mode 100644 index 000000000000..8b25988d2a9e --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_magic_link_request_screen.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_magic_link_sent_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_magic_link_sent_screen.xml new file mode 100644 index 000000000000..f9192b18a9eb --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_magic_link_sent_screen.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_or_layout.xml b/WordPressLoginFlow/src/main/res/layout/login_or_layout.xml new file mode 100644 index 000000000000..c0652dd8fdec --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_or_layout.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_site_address_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_site_address_screen.xml new file mode 100644 index 000000000000..fc5c3dd0ac28 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_site_address_screen.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/login_username_password_screen.xml b/WordPressLoginFlow/src/main/res/layout/login_username_password_screen.xml new file mode 100644 index 000000000000..2dd5b824a902 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/login_username_password_screen.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/signup_confirmation_screen.xml b/WordPressLoginFlow/src/main/res/layout/signup_confirmation_screen.xml new file mode 100644 index 000000000000..0ed00631d658 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/signup_confirmation_screen.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/signup_magic_link_screen.xml b/WordPressLoginFlow/src/main/res/layout/signup_magic_link_screen.xml new file mode 100644 index 000000000000..5ec0d7ed3e77 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/signup_magic_link_screen.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/layout/toolbar_login.xml b/WordPressLoginFlow/src/main/res/layout/toolbar_login.xml new file mode 100644 index 000000000000..368f7caa1478 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/layout/toolbar_login.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/menu/menu_login.xml b/WordPressLoginFlow/src/main/res/menu/menu_login.xml new file mode 100644 index 000000000000..4c34dcd5fb98 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/menu/menu_login.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/WordPressLoginFlow/src/main/res/values-night/colors.xml b/WordPressLoginFlow/src/main/res/values-night/colors.xml new file mode 100644 index 000000000000..3a8511ee4e51 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values-night/colors.xml @@ -0,0 +1,6 @@ + + + + #61FFFFFF + diff --git a/WordPressLoginFlow/src/main/res/values-night/themes.xml b/WordPressLoginFlow/src/main/res/values-night/themes.xml new file mode 100644 index 000000000000..2a117d9d6107 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values-night/themes.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/values-v27/themes.xml b/WordPressLoginFlow/src/main/res/values-v27/themes.xml new file mode 100644 index 000000000000..fc3d18102ebf --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values-v27/themes.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/WordPressLoginFlow/src/main/res/values/attrs.xml b/WordPressLoginFlow/src/main/res/values/attrs.xml new file mode 100644 index 000000000000..3f5b74b856cd --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values/attrs.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/values/colors.xml b/WordPressLoginFlow/src/main/res/values/colors.xml new file mode 100644 index 000000000000..b083926356f7 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values/colors.xml @@ -0,0 +1,27 @@ + + + + #399ce3 + #0675c4 + #044b7a + + #c9356e + #8c1749 + + #d63638 + + #ffffff + #121212 + #000000 + + + #61121212 + + + #87a6bc + + + #c8d7e1 + #0087be + diff --git a/WordPressLoginFlow/src/main/res/values/dimens.xml b/WordPressLoginFlow/src/main/res/values/dimens.xml new file mode 100644 index 000000000000..1dd0f0273f50 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values/dimens.xml @@ -0,0 +1,33 @@ + + + + 0.12 + + 0dp + 24dp + 1dp + 2dp + 4dp + 6dp + 8dp + 10dp + 12dp + 16dp + 24dp + 32dp + 48dp + + 18dp + 24dp + 32dp + 38dp + 64dp + 40dp + 32dp + 40dp + + + 92dp + 4dp + + diff --git a/WordPressLoginFlow/src/main/res/values/shapes.xml b/WordPressLoginFlow/src/main/res/values/shapes.xml new file mode 100644 index 000000000000..db11611bf767 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values/shapes.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/values/themes.xml b/WordPressLoginFlow/src/main/res/values/themes.xml new file mode 100644 index 000000000000..b1e85c40f188 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values/themes.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/main/res/values/types.xml b/WordPressLoginFlow/src/main/res/values/types.xml new file mode 100644 index 000000000000..cff70fb995c7 --- /dev/null +++ b/WordPressLoginFlow/src/main/res/values/types.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressLoginFlow/src/test/java/org/wordpress/android/login/LoginSiteAddressValidatorTest.java b/WordPressLoginFlow/src/test/java/org/wordpress/android/login/LoginSiteAddressValidatorTest.java new file mode 100644 index 000000000000..c31183a770e2 --- /dev/null +++ b/WordPressLoginFlow/src/test/java/org/wordpress/android/login/LoginSiteAddressValidatorTest.java @@ -0,0 +1,129 @@ +package org.wordpress.android.login; + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule; +import androidx.lifecycle.Observer; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.wordpress.android.util.helpers.Debouncer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +@RunWith(RobolectricTestRunner.class) +public class LoginSiteAddressValidatorTest { + @Rule + public InstantTaskExecutorRule instantTaskExecutorRule = new InstantTaskExecutorRule(); + + private Debouncer mDebouncer; + private LoginSiteAddressValidator mValidator; + + @Before + public void setUp() { + mDebouncer = mock(Debouncer.class); + doAnswer(new Answer() { + @Override public Void answer(InvocationOnMock invocation) { + final Runnable runnable = invocation.getArgument(1); + runnable.run(); + return null; + } + }).when(mDebouncer).debounce(any(), any(Runnable.class), anyLong(), any(TimeUnit.class)); + + mValidator = new LoginSiteAddressValidator(mDebouncer); + } + + @After + public void tearDown() { + mValidator = null; + mDebouncer = null; + } + + @Test + public void testAnErrorIsReturnedWhenGivenAnInvalidAddress() { + // Arrange + assertThat(mValidator.getErrorMessageResId().getValue()).isNull(); + + // Act + mValidator.setAddress("invalid"); + + // Assert + assertThat(mValidator.getErrorMessageResId().getValue()).isNotNull(); + assertThat(mValidator.getCleanedSiteAddress()).isEqualTo("invalid"); + assertThat(mValidator.getIsValid().getValue()).isFalse(); + } + + @Test + public void testNoErrorIsReturnedButIsInvalidWhenGivenAnEmptyAddress() { + // Act + mValidator.setAddress(""); + + // Assert + assertThat(mValidator.getErrorMessageResId().getValue()).isNull(); + assertThat(mValidator.getIsValid().getValue()).isFalse(); + assertThat(mValidator.getCleanedSiteAddress()).isEqualTo(""); + } + + @Test + public void testTheErrorIsImmediatelyClearedWhenANewAddressIsGiven() { + // Arrange + final ArrayList> resIdValues = new ArrayList<>(); + mValidator.getErrorMessageResId().observeForever(new Observer() { + @Override public void onChanged(Integer resId) { + resIdValues.add(Optional.ofNullable(resId)); + } + }); + + // Act + mValidator.setAddress("invalid"); + mValidator.setAddress("another-invalid"); + + // Assert + assertThat(resIdValues).hasSize(4); + assertThat(resIdValues.get(0)).isEmpty(); + assertThat(resIdValues.get(1)).isNotEmpty(); + assertThat(resIdValues.get(2)).isEmpty(); + assertThat(resIdValues.get(3)).isNotEmpty(); + } + + @Test + public void testItReturnsValidWhenGivenValidURLs() { + // Arrange + final List validUrls = Arrays.asList( + "http://subdomain.example.com", + "http://example.ca", + "example.ca", + "subdomain.example.com", + " space-with-subdomain.example.net", + "https://subdomain.example.com/folder", + "http://subdomain.example.com/folder/over/there ", + "7.7.7.7", + "http://7.7.13.45", + "http://47.147.43.45/folder "); + + // Act and Assert + assertThat(validUrls).allSatisfy(new Consumer() { + @Override public void accept(String url) { + mValidator.setAddress(url); + + assertThat(mValidator.getErrorMessageResId().getValue()).isNull(); + assertThat(mValidator.getIsValid().getValue()).isTrue(); + } + }); + } +}