diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index 01981ada12..00c6f6c865 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -3,7 +3,9 @@ backstack bytearray + checkables ciphertext + coroutine decryptor emoji emojis @@ -12,8 +14,11 @@ linkified linkify megolm + msisdn pbkdf pkcs + signin + signup \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md index 27619f7d28..64de4ac68d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,8 @@ Changes in RiotX 0.9.0 (2019-XX-XX) =================================================== Features ✨: - - + - Account creation. It's now possible to create account on any homeserver with RiotX (#34) + - Iteration of the login flow (#613) Improvements 🙌: - Send mention Pills from composer diff --git a/docs/signin.md b/docs/signin.md new file mode 100644 index 0000000000..245ea444f6 --- /dev/null +++ b/docs/signin.md @@ -0,0 +1,260 @@ +# Sign in to a homeserver + +This document describes the flow of signin to a homeserver, and also the flow when user want to reset his password. Examples come from the `matrix.org` homeserver. + +## Sign up flows + +### Get the flow + +Client request the sign-in flows, once the homeserver is chosen by the user and its url is known (in the example it's `https://matrix.org`) + +> curl -X GET 'https://matrix.org/_matrix/client/r0/login' + +200 + +```json +{ + "flows": [ + { + "type": "m.login.password" + } + ] +} +``` + +### Login with username + +The user is able to connect using `m.login.password` + +> curl -X POST --data $'{"identifier":{"type":"m.id.user","user":"alice"},"password":"weak_password","type":"m.login.password","initial_device_display_name":"Portable"}' 'https://matrix.org/_matrix/client/r0/login' + +```json +{ + "identifier": { + "type": "m.id.user", + "user": "alice" + }, + "password": "weak_password", + "type": "m.login.password", + "initial_device_display_name": "Portable" +} +``` + +#### Incorrect password + +403 + +```json +{ + "errcode": "M_FORBIDDEN", + "error": "Invalid password" +} +``` + +#### Correct password: + +We get credential (200) + +```json +{ + "user_id": "@benoit0816:matrix.org", + "access_token": "MDAxOGxvY2F0aW9uIG1hdHREDACTEDb2l0MDgxNjptYXRyaXgub3JnCjAwMTZjaWQgdHlwZSA9IGFjY2VzcwowMDIxY2lkIG5vbmNlID0gfnYrSypfdTtkNXIuNWx1KgowMDJmc2lnbmF0dXJlIOsh1XqeAkXexh4qcofl_aR4kHJoSOWYGOhE7-ubX-DZCg", + "home_server": "matrix.org", + "device_id": "GTVREDALBF", + "well_known": { + "m.homeserver": { + "base_url": "https:\/\/matrix.org\/" + } + } +} +``` + +### Login with email + +If the user has associated an email with its account, he can signin using the email. + +> curl -X POST --data $'{"identifier":{"type":"m.id.thirdparty","medium":"email","address":"alice@yopmail.com"},"password":"weak_password","type":"m.login.password","initial_device_display_name":"Portable"}' 'https://matrix.org/_matrix/client/r0/login' + +```json +{ + "identifier": { + "type": "m.id.thirdparty", + "medium": "email", + "address": "alice@yopmail.com" + }, + "password": "weak_password", + "type": "m.login.password", + "initial_device_display_name": "Portable" +} +``` + +#### Unknown email + +403 + +```json +{ + "errcode": "M_FORBIDDEN", + "error": "" +} +``` + +#### Known email, wrong password + +403 + +```json +{ + "errcode": "M_FORBIDDEN", + "error": "Invalid password" +} +``` + +##### Known email, correct password + +We get the credentials (200) + +```json +{ + "user_id": "@alice:matrix.org", + "access_token": "MDAxOGxvY2F0aW9uIG1hdHJpeC5vcmREDACTEDZXJfaWQgPSBAYmVub2l0MDgxNjptYXRyaXgub3JnCjAwMTZjaWQgdHlwZSA9IGFjY2VzcwowMDIxY2lkIG5vbmNlID0gNjtDY0MwRlNPSFFoOC5wOgowMDJmc2lnbmF0dXJlIGiTRm1mYLLxQywxOh3qzQVT8HoEorSokEP2u-bAwtnYCg", + "home_server": "matrix.org", + "device_id": "WBSREDASND", + "well_known": { + "m.homeserver": { + "base_url": "https:\/\/matrix.org\/" + } + } +} +``` + +### Login with Msisdn + +Not supported yet in RiotX + +### Login with SSO + +> curl -X GET 'https://homeserver.with.sso/_matrix/client/r0/login' + +200 + +```json +{ + "flows": [ + { + "type": "m.login.sso" + } + ] +} +``` + +In this case, the user can click on "Sign in with SSO" and the web screen will be displayed on the page `https://homeserver.with.sso/_matrix/static/client/login/` and the credentials will be passed back to the native code through the JS bridge + +## Reset password + +Ref: `https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-account-password-email-requesttoken` + +When the user has forgotten his password, he can reset it by providing an email and a new password. + +Here is the flow: + +### Send email + +User is asked to enter the email linked to his account and a new password. +We display a warning regarding e2e. + +At the first step, we do not send the password, only the email and a client secret, generated by the application + +> curl -X POST --data $'{"client_secret":"6c57f284-85e2-421b-8270-fb1795a120a7","send_attempt":0,"email":"user@domain.com"}' 'https://matrix.org/_matrix/client/r0/account/password/email/requestToken' + +```json +{ + "client_secret": "6c57f284-85e2-421b-8270-fb1795a120a7", + "send_attempt": 0, + "email": "user@domain.com" +} +``` + +#### When the email is not known + +We get a 400 + +```json +{ + "errcode": "M_THREEPID_NOT_FOUND", + "error": "Email not found" +} +``` + +#### When the email is known + +We get a 200 with a `sid` + +```json +{ + "sid": "tQNbrREDACTEDldA" +} +``` + +Then the user is asked to click on the link in the email he just received, and to confirm when it's done. + +During this step, the new password is sent to the homeserver. + +If the user confirms before the link is clicked, we get an error: + +> curl -X POST --data $'{"auth":{"type":"m.login.email.identity","threepid_creds":{"client_secret":"6c57f284-85e2-421b-8270-fb1795a120a7","sid":"tQNbrREDACTEDldA"}},"new_password":"weak_password"}' 'https://matrix.org/_matrix/client/r0/account/password' + +```json +{ + "auth": { + "type": "m.login.email.identity", + "threepid_creds": { + "client_secret": "6c57f284-85e2-421b-8270-fb1795a120a7", + "sid": "tQNbrREDACTEDldA" + } + }, + "new_password": "weak_password" +} +``` + +401 + +```json +{ + "errcode": "M_UNAUTHORIZED", + "error": "" +} +``` + +### User clicks on the link + +The link has the form: + +https://matrix.org/_matrix/client/unstable/password_reset/email/submit_token?token=fzZLBlcqhTKeaFQFSRbsQnQCkzbwtGAD&client_secret=6c57f284-85e2-421b-8270-fb1795a120a7&sid=tQNbrREDACTEDldA + +It contains the client secret, a token and the sid + +When the user click the link, if validate his ownership and the new password can now be ent by the application (on user demand): + +> curl -X POST --data $'{"auth":{"type":"m.login.email.identity","threepid_creds":{"client_secret":"6c57f284-85e2-421b-8270-fb1795a120a7","sid":"tQNbrREDACTEDldA"}},"new_password":"weak_password"}' 'https://matrix.org/_matrix/client/r0/account/password' + +```json +{ + "auth": { + "type": "m.login.email.identity", + "threepid_creds": { + "client_secret": "6c57f284-85e2-421b-8270-fb1795a120a7", + "sid": "tQNbrREDACTEDldA" + } + }, + "new_password": "weak_password" +} +``` + +200 + +```json +{} +``` + +The password has been changed, and all the existing token are invalidated. User can now login with the new password. \ No newline at end of file diff --git a/docs/signup.md b/docs/signup.md new file mode 100644 index 0000000000..7372ad2204 --- /dev/null +++ b/docs/signup.md @@ -0,0 +1,579 @@ +# Sign up to a homeserver + +This document describes the flow of registration to a homeserver. Examples come from the `matrix.org` homeserver. + +*Ref*: https://matrix.org/docs/spec/client_server/latest#account-registration-and-management + +## Sign up flows + +### First step + +Client request the sign-up flows, once the homeserver is chosen by the user and its url is known (in the example it's `https://matrix.org`) + +> curl -X POST --data $'{}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ +} +``` + +We get the flows with a 401, which also means the the registration is possible on this homeserver. + +```json +{ + "session": "vwehdKMtkRedactedAMwgCACZ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAAoREDACTEDoDdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + } +} +``` + +If the registration is not possible, we get a 403 + +```json +{ + "errcode": "M_FORBIDDEN", + "error": "Registration is disabled" +} +``` + +### Step 1: entering user name and password + +The app is displaying a form to enter username and password. + +> curl -X POST --data $'{"initial_device_display_name":"Mobile device","username":"alice","password": "weak_password"}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "initial_device_display_name": "Mobile device", + "username": "alice", + "password": "weak_password" +} +``` + +401. Note that the `session` value has changed (because we did not provide the previous value in the request body), but it's ok, we will use the new value for the next steps. + +```json +{ + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAAoREDACTEDoDdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + } +} +``` + +#### If username already exists + +We get a 400: + +```json +{ + "errcode": "M_USER_IN_USE", + "error": "User ID already taken." +} +``` + +### Step 2: entering email + +User is proposed to enter an email. We skip this step. + +> curl -X POST --data $'{"auth":{"session":"xptUYoREDACTEDogOWAGVnbJQ","type":"m.login.dummy"}}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "auth": { + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "type": "m.login.dummy" + } +} +``` + +401 + +```json +{ + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAAoREDACTEDoDdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + }, + "completed": [ + "m.login.dummy" + ] +} +``` + +### Step 2 bis: we enter an email + +We request a token to the homeserver. The `client_secret` is generated by the application + +> curl -X POST --data $'{"client_secret":"53e679ea-oRED-ACTED-92b8-3012c49c6cfa","email":"alice@yopmail.com","send_attempt":0}' 'https://matrix.org/_matrix/client/r0/register/email/requestToken' + +```json +{ + "client_secret": "53e679ea-oRED-ACTED-92b8-3012c49c6cfa", + "email": "alice@yopmail.com", + "send_attempt": 0 +} +``` + +200 + +```json +{ + "sid": "qlBCREDACTEDEtgxD" +} +``` + +And + +> curl -X POST --data $'{"auth":{"threepid_creds":{"client_secret":"53e679ea-oRED-ACTED-92b8-3012c49c6cfa","sid":"qlBCREDACTEDEtgxD"},"session":"xptUYoREDACTEDogOWAGVnbJQ","type":"m.login.email.identity"}}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "auth": { + "threepid_creds": { + "client_secret": "53e679ea-oRED-ACTED-92b8-3012c49c6cfa", + "sid": "qlBCREDACTEDEtgxD" + }, + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "type": "m.login.email.identity" + } +} +``` + +We get 401 since the email is not validated yet: + +```json +{ + "errcode": "M_UNAUTHORIZED", + "error": "" +} +``` + +The app is now polling on + +> curl -X POST --data $'{"auth":{"threepid_creds":{"client_secret":"53e679ea-oRED-ACTED-92b8-3012c49c6cfa","sid":"qlBCREDACTEDEtgxD"},"session":"xptUYoREDACTEDogOWAGVnbJQ","type":"m.login.email.identity"}}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "auth": { + "threepid_creds": { + "client_secret": "53e679ea-oRED-ACTED-92b8-3012c49c6cfa", + "sid": "qlBCREDACTEDEtgxD" + }, + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "type": "m.login.email.identity" + } +} +``` + +We click on the link received by email `https://matrix.org/_matrix/client/unstable/registration/email/submit_token?token=vtQjQIZfwdoREDACTEDozrmKYSWlCXsJ&client_secret=53e679ea-oRED-ACTED-92b8-3012c49c6cfa&sid=qlBCREDACTEDEtgxD` which contains: +- A `token` vtQjQIZfwdoREDACTEDozrmKYSWlCXsJ +- The `client_secret`: 53e679ea-oRED-ACTED-92b8-3012c49c6cfa +- A `sid`: qlBCREDACTEDEtgxD + +Once the link is clicked, the registration request (polling) returns a 401 with the following content: + +```json +{ + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAAoREDACTEDoDdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + }, + "completed": [ + "m.login.email.identity" + ] +} +``` + +### Step 3: Accepting T&C + +User is proposed to accept T&C and he accepts them + +> curl -X POST --data $'{"auth":{"session":"xptUYoREDACTEDogOWAGVnbJQ","type":"m.login.terms"}}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "auth": { + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "type": "m.login.terms" + } +} +``` + +401 + +```json +{ + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAAoREDACTEDoDdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + }, + "completed": [ + "m.login.dummy", + "m.login.terms" + ] +} +``` + +### Step 4: Captcha + +User is proposed to prove he is not a robot and he does it: + +> curl -X POST --data $'{"auth":{"response":"03AOLTBLSiGS9GhFDpAMblJ2nlXOmHXqAYJ5OvHCPUjiVLBef3k9snOYI_BDC32-t4D2jv-tpvkaiEI_uloobFd9RUTPpJ7con2hMddbKjSCYqXqcUQFhzhbcX6kw8uBnh2sbwBe80_ihrHGXEoACXQkL0ki1Q0uEtOeW20YBRjbNABsZPpLNZhGIWC0QVXnQ4FouAtZrl3gOAiyM-oG3cgP6M9pcANIAC_7T2P2amAHbtsTlSR9CsazNyS-rtDR9b5MywdtnWN9Aw8fTJb8cXQk_j7nvugMxzofPjSOrPKcr8h5OqPlpUCyxxnFtag6cuaPSUwh43D2L0E-ZX7djzaY2Yh_U2n6HegFNPOQ22CJmfrKwDlodmAfMPvAXyq77n3HpoREDACTEDo3830RHF4BfkGXUaZjctgg-A1mvC17hmQmQpkG7IhDqyw0onU-0vF_-ehCjq_CcQEDpS_O3uiHJaG5xGf-0rhLm57v_wA3deugbsZuO4uTuxZZycN_mKxZ97jlDVBetl9hc_5REPbhcT1w3uzTCSx7Q","session":"xptUYoREDACTEDogOWAGVnbJQ","type":"m.login.recaptcha"}}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "auth": { + "response": "03AOLTBLSiGS9GhFDpAMblJ2nlXOmHXqAYJ5OvHCPUjiVLBef3k9snOYI_BDC32-t4D2jv-tpvkaiEI_uloobFd9RUTPpJ7con2hMddbKjSCYqXqcUQFhzhbcX6kw8uBnh2sbwBe80_ihrHGXEoACXQkL0ki1Q0uEtOeW20YBRjbNABsZPpLNZhGIWC0QVXnQ4FouAtZrl3gOAiyM-oG3cgP6M9pcANIAC_7T2P2amAHbtsTlSR9CsazNyS-rtDR9b5MywdtnWN9Aw8fTJb8cXQk_j7nvugMxzofPjSOrPKcr8h5OqPlpUCyxxnFtag6cuaPSUwh43D2L0E-ZX7djzaY2Yh_U2n6HegFNPOQ22CJmfrKwDlodmAfMPvAXyq77n3HpoREDACTEDo3830RHF4BfkGXUaZjctgg-A1mvC17hmQmQpkG7IhDqyw0onU-0vF_-ehCjq_CcQEDpS_O3uiHJaG5xGf-0rhLm57v_wA3deugbsZuO4uTuxZZycN_mKxZ97jlDVBetl9hc_5REPbhcT1w3uzTCSx7Q", + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "type": "m.login.recaptcha" + } +} +``` + +200 + +```json +{ + "user_id": "@alice:matrix.org", + "home_server": "matrix.org", + "access_token": "MDAxOGxvY2F0aW9uIG1hdHJpeC5vcmcKMoREDACTEDo50aWZpZXIga2V5CjAwMTBjaWQgZ2VuID0gMQowMDI5Y2lkIHVzZXJfaWQgPSBAYmVub2l0eHh4eDptYXRoREDACTEDoCjAwMTZjaWQgdHlwZSA9IGFjY2VzcwowMDIxY2lkIG5vbmNlID0gNHVSVm00aVFDaWlKdoREDACTEDoJmc2lnbmF0dXJlIOmHnTLRfxiPjhrWhS-dThUX-qAzZktfRThzH1YyAsxaCg", + "device_id": "FLBAREDAJZ" +} +``` + +The account is created! + +### Step 5: MSISDN + +Some homeservers may require the user to enter MSISDN. + +On matrix.org, it's not required, and not even optional, but it's still possible for the app to add a MSISDN during the registration. + +The user enter a phone number and select a country, the `client_secret` is generated by the application + +> curl -X POST --data $'{"client_secret":"d3e285f6-972a-496c-9a22-7915a2db57c7","send_attempt":1,"country":"FR","phone_number":"+33611223344"}' 'https://matrix.org/_matrix/client/r0/register/msisdn/requestToken' + +```json +{ + "client_secret": "d3e285f6-972a-496c-9a22-7915a2db57c7", + "send_attempt": 1, + "country": "FR", + "phone_number": "+33611223344" +} +``` + +If the msisdn is already associated to another account, you will received an error: + +```json +{ + "errcode": "M_THREEPID_IN_USE", + "error": "Phone number is already in use" +} +``` + +If it is not the case, the homeserver send the SMS and returns some data, especially a `sid` and a `submit_url`: + +```json +{ + "msisdn": "33611223344", + "intl_fmt": "+336 11 22 33 44", + "success": true, + "sid": "1678881798", + "submit_url": "https:\/\/matrix.org\/_matrix\/client\/unstable\/add_threepid\/msisdn\/submit_token" +} +``` + +When you execute the register request, with the received `sid`, you get an error since the MSISDN is not validated yet: + +> curl -X POST --data $'{"auth":{"type":"m.login.msisdn","session":"xptUYoREDACTEDogOWAGVnbJQ","threepid_creds":{"client_secret":"d3e285f6-972a-496c-9a22-7915a2db57c7","sid":"1678881798"}}}' 'https://matrix.org/_matrix/client/r0/register' + + +```json + "auth": { + "type": "m.login.msisdn", + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "threepid_creds": { + "client_secret": "d3e285f6-972a-496c-9a22-7915a2db57c7", + "sid": "1678881798" + } + } +} +``` + +There is an issue on Synapse, which return a 401, it sends too much data along with the classical MatrixError fields: + +```json +{ + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAABGdGmruw6DdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + }, + "completed": [], + "error": "", + "errcode": "M_UNAUTHORIZED" +} +``` + +The user receive the SMS, he can enter the SMS code in the app, which is sent using the "submit_url" received ie the response of the `requestToken` request: + +> curl -X POST --data $'{"client_secret":"d3e285f6-972a-496c-9a22-7915a2db57c7","sid":"1678881798","token":"123456"}' 'https://matrix.org/_matrix/client/unstable/add_threepid/msisdn/submit_token' + +```json +{ + "client_secret": "d3e285f6-972a-496c-9a22-7915a2db57c7", + "sid": "1678881798", + "token": "123456" +} +``` + +If the code is not correct, we get a 200 with: + +```json +{ + "success": false +} +``` + +And if the code is correct we get a 200 with: + +```json +{ + "success": true +} +``` + +We can now execute the registration request, to the homeserver + +> curl -X POST --data $'{"auth":{"type":"m.login.msisdn","session":"xptUYoREDACTEDogOWAGVnbJQ","threepid_creds":{"client_secret":"d3e285f6-972a-496c-9a22-7915a2db57c7","sid":"1678881798"}}}' 'https://matrix.org/_matrix/client/r0/register' + +```json +{ + "auth": { + "type": "m.login.msisdn", + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "threepid_creds": { + "client_secret": "d3e285f6-972a-496c-9a22-7915a2db57c7", + "sid": "1678881798" + } + } +} +``` + +Now the homeserver consider that the `m.login.msisdn` step is completed (401): + +```json +{ + "session": "xptUYoREDACTEDogOWAGVnbJQ", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.dummy" + ] + }, + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "6LcgI54UAAAAABGdGmruw6DdOocFpYVdjYBRe4zb" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https:\/\/matrix.org\/_matrix\/consent?v=1.0" + } + } + } + } + }, + "completed": [ + "m.login.msisdn" + ] +} +``` diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/auth/AuthenticatorTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/auth/AuthenticationServiceTest.kt similarity index 90% rename from matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/auth/AuthenticatorTest.kt rename to matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/auth/AuthenticationServiceTest.kt index 5c86f5ad22..c3babd7e5a 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/auth/AuthenticatorTest.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/auth/AuthenticationServiceTest.kt @@ -21,7 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.rule.GrantPermissionRule import im.vector.matrix.android.InstrumentedTest import im.vector.matrix.android.OkReplayRuleChainNoActivity -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import okreplay.* import org.junit.ClassRule import org.junit.Rule @@ -29,9 +29,9 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -internal class AuthenticatorTest : InstrumentedTest { +internal class AuthenticationServiceTest : InstrumentedTest { - lateinit var authenticator: Authenticator + lateinit var authenticationService: AuthenticationService lateinit var okReplayInterceptor: OkReplayInterceptor private val okReplayConfig = OkReplayConfig.Builder() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt index 1bfa871a42..34e284fd94 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/Matrix.kt @@ -22,7 +22,7 @@ import androidx.work.Configuration import androidx.work.WorkManager import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.BuildConfig -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import im.vector.matrix.android.internal.SessionManager import im.vector.matrix.android.internal.di.DaggerMatrixComponent import im.vector.matrix.android.internal.network.UserAgentHolder @@ -46,7 +46,7 @@ data class MatrixConfiguration( */ class Matrix private constructor(context: Context, matrixConfiguration: MatrixConfiguration) { - @Inject internal lateinit var authenticator: Authenticator + @Inject internal lateinit var authenticationService: AuthenticationService @Inject internal lateinit var userAgentHolder: UserAgentHolder @Inject internal lateinit var backgroundDetectionObserver: BackgroundDetectionObserver @Inject internal lateinit var olmManager: OlmManager @@ -64,8 +64,8 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo fun getUserAgent() = userAgentHolder.userAgent - fun authenticator(): Authenticator { - return authenticator + fun authenticationService(): AuthenticationService { + return authenticationService } companion object { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/Authenticator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/AuthenticationService.kt similarity index 56% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/Authenticator.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/AuthenticationService.kt index c1dfa465fb..140d1c259f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/Authenticator.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/AuthenticationService.kt @@ -19,29 +19,48 @@ package im.vector.matrix.android.api.auth import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig +import im.vector.matrix.android.api.auth.data.LoginFlowResult import im.vector.matrix.android.api.auth.data.SessionParams +import im.vector.matrix.android.api.auth.login.LoginWizard +import im.vector.matrix.android.api.auth.registration.RegistrationWizard import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.util.Cancelable -import im.vector.matrix.android.internal.auth.data.LoginFlowResponse /** - * This interface defines methods to authenticate to a matrix server. + * This interface defines methods to authenticate or to create an account to a matrix server. */ -interface Authenticator { +interface AuthenticationService { /** - * Request the supported login flows for this homeserver + * Request the supported login flows for this homeserver. + * This is the first method to call to be able to get a wizard to login or the create an account */ - fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable + fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable /** - * @param homeServerConnectionConfig this param is used to configure the Homeserver - * @param login the login field - * @param password the password field - * @param callback the matrix callback on which you'll receive the result of authentication. - * @return return a [Cancelable] + * Return a LoginWizard, to login to the homeserver. The login flow has to be retrieved first. */ - fun authenticate(homeServerConnectionConfig: HomeServerConnectionConfig, login: String, password: String, callback: MatrixCallback): Cancelable + fun getLoginWizard(): LoginWizard + + /** + * Return a RegistrationWizard, to create an matrix account on the homeserver. The login flow has to be retrieved first. + */ + fun getRegistrationWizard(): RegistrationWizard + + /** + * True when login and password has been sent with success to the homeserver + */ + val isRegistrationStarted: Boolean + + /** + * Cancel pending login or pending registration + */ + fun cancelPendingLoginOrRegistration() + + /** + * Reset all pending settings, including current HomeServerConnectionConfig + */ + fun reset() /** * Check if there is an authenticated [Session]. @@ -67,5 +86,7 @@ interface Authenticator { /** * Create a session after a SSO successful login */ - fun createSessionFromSso(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable + fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig, + credentials: Credentials, + callback: MatrixCallback): Cancelable } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Credentials.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Credentials.kt index d5962e261b..cf0302166f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Credentials.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Credentials.kt @@ -30,4 +30,7 @@ data class Credentials( @Json(name = "home_server") val homeServer: String, @Json(name = "access_token") val accessToken: String, @Json(name = "refresh_token") val refreshToken: String?, - @Json(name = "device_id") val deviceId: String?) + @Json(name = "device_id") val deviceId: String?, + // Optional data that may contain info to override home server and/or identity server + @Json(name = "well_known") val wellKnown: WellKnown? = null +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt index e85b05092f..853ea93544 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/HomeServerConnectionConfig.kt @@ -25,7 +25,7 @@ import okhttp3.TlsVersion /** * This data class holds how to connect to a specific Homeserver. - * It's used with [im.vector.matrix.android.api.auth.Authenticator] class. + * It's used with [im.vector.matrix.android.api.auth.AuthenticationService] class. * You should use the [Builder] to create one. */ @JsonClass(generateAdapter = true) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/LoginFlowResult.kt new file mode 100644 index 0000000000..f0d0c61d58 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/LoginFlowResult.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.data + +import im.vector.matrix.android.internal.auth.data.LoginFlowResponse + +// Either a LoginFlowResponse, or an error if the homeserver is outdated +sealed class LoginFlowResult { + data class Success( + val loginFlowResponse: LoginFlowResponse, + val isLoginAndRegistrationSupported: Boolean + ) : LoginFlowResult() + + object OutdatedHomeserver : LoginFlowResult() +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Versions.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Versions.kt new file mode 100644 index 0000000000..c4186c6ec5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/Versions.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.data + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Model for https://matrix.org/docs/spec/client_server/latest#get-matrix-client-versions + * + * Ex: + *
+ *   {
+ *     "unstable_features": {
+ *       "m.lazy_load_members": true
+ *     },
+ *     "versions": [
+ *       "r0.0.1",
+ *       "r0.1.0",
+ *       "r0.2.0",
+ *       "r0.3.0"
+ *     ]
+ *   }
+ * 
+ */ +@JsonClass(generateAdapter = true) +data class Versions( + @Json(name = "versions") + val supportedVersions: List? = null, + + @Json(name = "unstable_features") + val unstableFeatures: Map? = null +) + +// MatrixClientServerAPIVersion +private const val r0_0_1 = "r0.0.1" +private const val r0_1_0 = "r0.1.0" +private const val r0_2_0 = "r0.2.0" +private const val r0_3_0 = "r0.3.0" +private const val r0_4_0 = "r0.4.0" +private const val r0_5_0 = "r0.5.0" +private const val r0_6_0 = "r0.6.0" + +// MatrixVersionsFeature +private const val FEATURE_LAZY_LOAD_MEMBERS = "m.lazy_load_members" +private const val FEATURE_REQUIRE_IDENTITY_SERVER = "m.require_identity_server" +private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token" +private const val FEATURE_SEPARATE_ADD_AND_BIND = "m.separate_add_and_bind" + +/** + * Return true if the SDK supports this homeserver version + */ +fun Versions.isSupportedBySdk(): Boolean { + return supportLazyLoadMembers() +} + +/** + * Return true if the SDK supports this homeserver version for login and registration + */ +fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean { + return !doesServerRequireIdentityServerParam() + && doesServerAcceptIdentityAccessToken() + && doesServerSeparatesAddAndBind() +} + +/** + * Return true if the server support the lazy loading of room members + * + * @return true if the server support the lazy loading of room members + */ +private fun Versions.supportLazyLoadMembers(): Boolean { + return supportedVersions?.contains(r0_5_0) == true + || unstableFeatures?.get(FEATURE_LAZY_LOAD_MEMBERS) == true +} + +/** + * Indicate if the `id_server` parameter is required when registering with an 3pid, + * adding a 3pid or resetting password. + */ +private fun Versions.doesServerRequireIdentityServerParam(): Boolean { + if (supportedVersions?.contains(r0_6_0) == true) return false + return unstableFeatures?.get(FEATURE_REQUIRE_IDENTITY_SERVER) ?: true +} + +/** + * Indicate if the `id_access_token` parameter can be safely passed to the homeserver. + * Some homeservers may trigger errors if they are not prepared for the new parameter. + */ +private fun Versions.doesServerAcceptIdentityAccessToken(): Boolean { + return supportedVersions?.contains(r0_6_0) == true + || unstableFeatures?.get(FEATURE_ID_ACCESS_TOKEN) ?: false +} + +private fun Versions.doesServerSeparatesAddAndBind(): Boolean { + return supportedVersions?.contains(r0_6_0) == true + || unstableFeatures?.get(FEATURE_SEPARATE_ADD_AND_BIND) ?: false +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnown.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnown.kt new file mode 100644 index 0000000000..6285e866cc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnown.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.data + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + *
+ * {
+ *     "m.homeserver": {
+ *         "base_url": "https://matrix.org"
+ *     },
+ *     "m.identity_server": {
+ *         "base_url": "https://vector.im"
+ *     }
+ *     "m.integrations": {
+ *          "managers": [
+ *              {
+ *                  "api_url": "https://integrations.example.org",
+ *                  "ui_url": "https://integrations.example.org/ui"
+ *              },
+ *              {
+ *                  "api_url": "https://bots.example.org"
+ *              }
+ *          ]
+ *    }
+ * }
+ * 
+ */ +@JsonClass(generateAdapter = true) +data class WellKnown( + @Json(name = "m.homeserver") + var homeServer: WellKnownBaseConfig? = null, + + @Json(name = "m.identity_server") + var identityServer: WellKnownBaseConfig? = null, + + @Json(name = "m.integrations") + var integrations: Map? = null +) { + /** + * Returns the list of integration managers proposed + */ + fun getIntegrationManagers(): List { + val managers = ArrayList() + integrations?.get("managers")?.let { + (it as? ArrayList<*>)?.let { configs -> + configs.forEach { config -> + (config as? Map<*, *>)?.let { map -> + val apiUrl = map["api_url"] as? String + val uiUrl = map["ui_url"] as? String ?: apiUrl + if (apiUrl != null + && apiUrl.startsWith("https://") + && uiUrl!!.startsWith("https://")) { + managers.add(WellKnownManagerConfig( + apiUrl = apiUrl, + uiUrl = uiUrl + )) + } + } + } + } + } + return managers + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnownBaseConfig.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnownBaseConfig.kt new file mode 100644 index 0000000000..c544ebfdf8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnownBaseConfig.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.data + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + *
+ * {
+ *     "base_url": "https://vector.im"
+ * }
+ * 
+ */ +@JsonClass(generateAdapter = true) +data class WellKnownBaseConfig( + @Json(name = "base_url") + val baseURL: String? = null +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnownManagerConfig.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnownManagerConfig.kt new file mode 100644 index 0000000000..33ed412a2a --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/WellKnownManagerConfig.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.matrix.android.api.auth.data + +data class WellKnownManagerConfig( + val apiUrl : String, + val uiUrl: String +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/login/LoginWizard.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/login/LoginWizard.kt new file mode 100644 index 0000000000..d7b2f5d960 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/login/LoginWizard.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.login + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.util.Cancelable + +interface LoginWizard { + + /** + * @param login the login field + * @param password the password field + * @param deviceName the initial device name + * @param callback the matrix callback on which you'll receive the result of authentication. + * @return return a [Cancelable] + */ + fun login(login: String, + password: String, + deviceName: String, + callback: MatrixCallback): Cancelable + + /** + * Reset user password + */ + fun resetPassword(email: String, + newPassword: String, + callback: MatrixCallback): Cancelable + + /** + * Confirm the new password, once the user has checked his email + */ + fun resetPasswordMailConfirmed(callback: MatrixCallback): Cancelable +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegisterThreePid.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegisterThreePid.kt new file mode 100644 index 0000000000..9ad72edc67 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegisterThreePid.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.registration + +sealed class RegisterThreePid { + data class Email(val email: String) : RegisterThreePid() + data class Msisdn(val msisdn: String, val countryCode: String) : RegisterThreePid() +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegistrationResult.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegistrationResult.kt new file mode 100644 index 0000000000..fd75e096d9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegistrationResult.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.registration + +import im.vector.matrix.android.api.session.Session + +// Either a session or an object containing data about registration stages +sealed class RegistrationResult { + data class Success(val session: Session) : RegistrationResult() + data class FlowResponse(val flowResult: FlowResult) : RegistrationResult() +} + +data class FlowResult( + val missingStages: List, + val completedStages: List +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegistrationWizard.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegistrationWizard.kt new file mode 100644 index 0000000000..9c1e38e31e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/RegistrationWizard.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.registration + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.util.Cancelable + +interface RegistrationWizard { + + fun getRegistrationFlow(callback: MatrixCallback): Cancelable + + fun createAccount(userName: String, password: String, initialDeviceDisplayName: String?, callback: MatrixCallback): Cancelable + + fun performReCaptcha(response: String, callback: MatrixCallback): Cancelable + + fun acceptTerms(callback: MatrixCallback): Cancelable + + fun dummy(callback: MatrixCallback): Cancelable + + fun addThreePid(threePid: RegisterThreePid, callback: MatrixCallback): Cancelable + + fun sendAgainThreePid(callback: MatrixCallback): Cancelable + + fun handleValidateThreePid(code: String, callback: MatrixCallback): Cancelable + + fun checkIfEmailHasBeenValidated(delayMillis: Long, callback: MatrixCallback): Cancelable + + val currentThreePid: String? + + // True when login and password has been sent with success to the homeserver + val isRegistrationStarted: Boolean +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/Stage.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/Stage.kt new file mode 100644 index 0000000000..c3f4864232 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/registration/Stage.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.auth.registration + +sealed class Stage(open val mandatory: Boolean) { + + // m.login.recaptcha + data class ReCaptcha(override val mandatory: Boolean, val publicKey: String) : Stage(mandatory) + + // m.login.oauth2 + // m.login.email.identity + data class Email(override val mandatory: Boolean) : Stage(mandatory) + + // m.login.msisdn + data class Msisdn(override val mandatory: Boolean) : Stage(mandatory) + + // m.login.token + + // m.login.dummy, can be mandatory if there is no other stages. In this case the account cannot be created by just sending a username + // and a password, the dummy stage has to be done + data class Dummy(override val mandatory: Boolean) : Stage(mandatory) + + // Undocumented yet: m.login.terms + data class Terms(override val mandatory: Boolean, val policies: TermPolicies) : Stage(mandatory) + + // For unknown stages + data class Other(override val mandatory: Boolean, val type: String, val params: Map<*, *>?) : Stage(mandatory) +} + +typealias TermPolicies = Map<*, *> diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/Failure.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/Failure.kt index 6c418ed831..9d42e8388c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/Failure.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/Failure.kt @@ -34,6 +34,7 @@ sealed class Failure(cause: Throwable? = null) : Throwable(cause = cause) { data class Cancelled(val throwable: Throwable? = null) : Failure(throwable) data class NetworkConnection(val ioException: IOException? = null) : Failure(ioException) data class ServerError(val error: MatrixError, val httpCode: Int) : Failure(RuntimeException(error.toString())) + object SuccessError : Failure(RuntimeException(RuntimeException("SuccessResult is false"))) // When server send an error, but it cannot be interpreted as a MatrixError data class OtherServerError(val errorBody: String, val httpCode: Int) : Failure(RuntimeException(errorBody)) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt index 70a982089c..f3f097bcc5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt @@ -31,7 +31,9 @@ data class MatrixError( @Json(name = "consent_uri") val consentUri: String? = null, // RESOURCE_LIMIT_EXCEEDED data @Json(name = "limit_type") val limitType: String? = null, - @Json(name = "admin_contact") val adminUri: String? = null) { + @Json(name = "admin_contact") val adminUri: String? = null, + // For LIMIT_EXCEEDED + @Json(name = "retry_after_ms") val retryAfterMillis: Long? = null) { companion object { const val FORBIDDEN = "M_FORBIDDEN" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Cancelable.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Cancelable.kt index 7f3543dec2..8473f50796 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Cancelable.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Cancelable.kt @@ -29,3 +29,5 @@ interface Cancelable { // no-op } } + +object NoOpCancellable : Cancelable diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthAPI.kt index bfc2b76db7..a1c746a299 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthAPI.kt @@ -17,20 +17,47 @@ package im.vector.matrix.android.internal.auth import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.auth.data.Versions import im.vector.matrix.android.internal.auth.data.LoginFlowResponse import im.vector.matrix.android.internal.auth.data.PasswordLoginParams +import im.vector.matrix.android.internal.auth.login.ResetPasswordMailConfirmed +import im.vector.matrix.android.internal.auth.registration.* import im.vector.matrix.android.internal.network.NetworkConstants import retrofit2.Call -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.Headers -import retrofit2.http.POST +import retrofit2.http.* /** * The login REST API. */ internal interface AuthAPI { + /** + * Get the version information of the homeserver + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_ + "versions") + fun versions(): Call + + /** + * Register to the homeserver + * Ref: https://matrix.org/docs/spec/client_server/latest#account-registration-and-management + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "register") + fun register(@Body registrationParams: RegistrationParams): Call + + /** + * Add 3Pid during registration + * Ref: https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928 + * https://github.com/matrix-org/matrix-doc/pull/2290 + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "register/{threePid}/requestToken") + fun add3Pid(@Path("threePid") threePid: String, @Body params: AddThreePidRegistrationParams): Call + + /** + * Validate 3pid + */ + @POST + fun validate3Pid(@Url url: String, @Body params: ValidationCodeBody): Call + /** * Get the supported login flow * Ref: https://matrix.org/docs/spec/client_server/latest#get-matrix-client-r0-login @@ -47,4 +74,16 @@ internal interface AuthAPI { @Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000") @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "login") fun login(@Body loginParams: PasswordLoginParams): Call + + /** + * Ask the homeserver to reset the password associated with the provided email. + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/password/email/requestToken") + fun resetPassword(@Body params: AddThreePidRegistrationParams): Call + + /** + * Ask the homeserver to reset the password with the provided new password once the email is validated. + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/password") + fun resetPasswordMailConfirmed(@Body params: ResetPasswordMailConfirmed): Call } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt index 31a85afbfb..22ed0b9a37 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt @@ -20,8 +20,10 @@ import android.content.Context import dagger.Binds import dagger.Module import dagger.Provides -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService +import im.vector.matrix.android.internal.auth.db.AuthRealmMigration import im.vector.matrix.android.internal.auth.db.AuthRealmModule +import im.vector.matrix.android.internal.auth.db.RealmPendingSessionStore import im.vector.matrix.android.internal.auth.db.RealmSessionParamsStore import im.vector.matrix.android.internal.database.RealmKeysUtils import im.vector.matrix.android.internal.di.AuthDatabase @@ -50,7 +52,8 @@ internal abstract class AuthModule { } .name("matrix-sdk-auth.realm") .modules(AuthRealmModule()) - .deleteRealmIfMigrationNeeded() + .schemaVersion(AuthRealmMigration.SCHEMA_VERSION) + .migration(AuthRealmMigration()) .build() } } @@ -59,5 +62,11 @@ internal abstract class AuthModule { abstract fun bindSessionParamsStore(sessionParamsStore: RealmSessionParamsStore): SessionParamsStore @Binds - abstract fun bindAuthenticator(authenticator: DefaultAuthenticator): Authenticator + abstract fun bindPendingSessionStore(pendingSessionStore: RealmPendingSessionStore): PendingSessionStore + + @Binds + abstract fun bindAuthenticationService(authenticationService: DefaultAuthenticationService): AuthenticationService + + @Binds + abstract fun bindSessionCreator(sessionCreator: DefaultSessionCreator): SessionCreator } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/DefaultAuthenticationService.kt new file mode 100644 index 0000000000..e7cf999820 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/DefaultAuthenticationService.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth + +import dagger.Lazy +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.AuthenticationService +import im.vector.matrix.android.api.auth.data.* +import im.vector.matrix.android.api.auth.login.LoginWizard +import im.vector.matrix.android.api.auth.registration.RegistrationWizard +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.SessionManager +import im.vector.matrix.android.internal.auth.data.LoginFlowResponse +import im.vector.matrix.android.internal.auth.db.PendingSessionData +import im.vector.matrix.android.internal.auth.login.DefaultLoginWizard +import im.vector.matrix.android.internal.auth.registration.DefaultRegistrationWizard +import im.vector.matrix.android.internal.di.Unauthenticated +import im.vector.matrix.android.internal.network.RetrofitFactory +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.task.launchToCallback +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import im.vector.matrix.android.internal.util.toCancelable +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import javax.inject.Inject + +internal class DefaultAuthenticationService @Inject constructor(@Unauthenticated + private val okHttpClient: Lazy, + private val retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val sessionParamsStore: SessionParamsStore, + private val sessionManager: SessionManager, + private val sessionCreator: SessionCreator, + private val pendingSessionStore: PendingSessionStore +) : AuthenticationService { + + private var pendingSessionData: PendingSessionData? = pendingSessionStore.getPendingSessionData() + + private var currentLoginWizard: LoginWizard? = null + private var currentRegistrationWizard: RegistrationWizard? = null + + override fun hasAuthenticatedSessions(): Boolean { + return sessionParamsStore.getLast() != null + } + + override fun getLastAuthenticatedSession(): Session? { + val sessionParams = sessionParamsStore.getLast() + return sessionParams?.let { + sessionManager.getOrCreateSession(it) + } + } + + override fun getSession(sessionParams: SessionParams): Session? { + return sessionManager.getOrCreateSession(sessionParams) + } + + override fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable { + pendingSessionData = null + + return GlobalScope.launch(coroutineDispatchers.main) { + pendingSessionStore.delete() + + val result = runCatching { + getLoginFlowInternal(homeServerConnectionConfig) + } + result.fold( + { + if (it is LoginFlowResult.Success) { + // The homeserver exists and up to date, keep the config + pendingSessionData = PendingSessionData(homeServerConnectionConfig) + .also { data -> pendingSessionStore.savePendingSessionData(data) } + } + callback.onSuccess(it) + }, + { + callback.onFailure(it) + } + ) + } + .toCancelable() + } + + private suspend fun getLoginFlowInternal(homeServerConnectionConfig: HomeServerConnectionConfig) = withContext(coroutineDispatchers.io) { + val authAPI = buildAuthAPI(homeServerConnectionConfig) + + // First check the homeserver version + val versions = executeRequest { + apiCall = authAPI.versions() + } + + if (versions.isSupportedBySdk()) { + // Get the login flow + val loginFlowResponse = executeRequest { + apiCall = authAPI.getLoginFlows() + } + LoginFlowResult.Success(loginFlowResponse, versions.isLoginAndRegistrationSupportedBySdk()) + } else { + // Not supported + LoginFlowResult.OutdatedHomeserver + } + } + + override fun getRegistrationWizard(): RegistrationWizard { + return currentRegistrationWizard + ?: let { + pendingSessionData?.homeServerConnectionConfig?.let { + DefaultRegistrationWizard( + okHttpClient, + retrofitFactory, + coroutineDispatchers, + sessionCreator, + pendingSessionStore + ).also { + currentRegistrationWizard = it + } + } ?: error("Please call getLoginFlow() with success first") + } + } + + override val isRegistrationStarted: Boolean + get() = currentRegistrationWizard?.isRegistrationStarted == true + + override fun getLoginWizard(): LoginWizard { + return currentLoginWizard + ?: let { + pendingSessionData?.homeServerConnectionConfig?.let { + DefaultLoginWizard( + okHttpClient, + retrofitFactory, + coroutineDispatchers, + sessionCreator, + pendingSessionStore + ).also { + currentLoginWizard = it + } + } ?: error("Please call getLoginFlow() with success first") + } + } + + override fun cancelPendingLoginOrRegistration() { + currentLoginWizard = null + currentRegistrationWizard = null + + // Keep only the home sever config + // Update the local pendingSessionData synchronously + pendingSessionData = pendingSessionData?.homeServerConnectionConfig + ?.let { PendingSessionData(it) } + .also { + GlobalScope.launch(coroutineDispatchers.main) { + if (it == null) { + // Should not happen + pendingSessionStore.delete() + } else { + pendingSessionStore.savePendingSessionData(it) + } + } + } + } + + override fun reset() { + currentLoginWizard = null + currentRegistrationWizard = null + + pendingSessionData = null + + GlobalScope.launch(coroutineDispatchers.main) { + pendingSessionStore.delete() + } + } + + override fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig, + credentials: Credentials, + callback: MatrixCallback): Cancelable { + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + createSessionFromSso(credentials, homeServerConnectionConfig) + } + } + + private suspend fun createSessionFromSso(credentials: Credentials, + homeServerConnectionConfig: HomeServerConnectionConfig): Session = withContext(coroutineDispatchers.computation) { + sessionCreator.createSession(credentials, homeServerConnectionConfig) + } + + private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI { + val retrofit = retrofitFactory.create(okHttpClient, homeServerConnectionConfig.homeServerUri.toString()) + return retrofit.create(AuthAPI::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/DefaultAuthenticator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/DefaultAuthenticator.kt deleted file mode 100644 index ff49d4308b..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/DefaultAuthenticator.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.matrix.android.internal.auth - -import android.util.Patterns -import dagger.Lazy -import im.vector.matrix.android.api.MatrixCallback -import im.vector.matrix.android.api.auth.Authenticator -import im.vector.matrix.android.api.auth.data.Credentials -import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig -import im.vector.matrix.android.api.auth.data.SessionParams -import im.vector.matrix.android.api.session.Session -import im.vector.matrix.android.api.util.Cancelable -import im.vector.matrix.android.internal.SessionManager -import im.vector.matrix.android.internal.auth.data.LoginFlowResponse -import im.vector.matrix.android.internal.auth.data.PasswordLoginParams -import im.vector.matrix.android.internal.auth.data.ThreePidMedium -import im.vector.matrix.android.internal.di.Unauthenticated -import im.vector.matrix.android.internal.extensions.foldToCallback -import im.vector.matrix.android.internal.network.RetrofitFactory -import im.vector.matrix.android.internal.network.executeRequest -import im.vector.matrix.android.internal.util.CancelableCoroutine -import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import javax.inject.Inject - -internal class DefaultAuthenticator @Inject constructor(@Unauthenticated - private val okHttpClient: Lazy, - private val retrofitFactory: RetrofitFactory, - private val coroutineDispatchers: MatrixCoroutineDispatchers, - private val sessionParamsStore: SessionParamsStore, - private val sessionManager: SessionManager -) : Authenticator { - - override fun hasAuthenticatedSessions(): Boolean { - return sessionParamsStore.getLast() != null - } - - override fun getLastAuthenticatedSession(): Session? { - val sessionParams = sessionParamsStore.getLast() - return sessionParams?.let { - sessionManager.getOrCreateSession(it) - } - } - - override fun getSession(sessionParams: SessionParams): Session? { - return sessionManager.getOrCreateSession(sessionParams) - } - - override fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback): Cancelable { - val job = GlobalScope.launch(coroutineDispatchers.main) { - val result = runCatching { - getLoginFlowInternal(homeServerConnectionConfig) - } - result.foldToCallback(callback) - } - return CancelableCoroutine(job) - } - - override fun authenticate(homeServerConnectionConfig: HomeServerConnectionConfig, - login: String, - password: String, - callback: MatrixCallback): Cancelable { - val job = GlobalScope.launch(coroutineDispatchers.main) { - val sessionOrFailure = runCatching { - authenticate(homeServerConnectionConfig, login, password) - } - sessionOrFailure.foldToCallback(callback) - } - return CancelableCoroutine(job) - } - - private suspend fun getLoginFlowInternal(homeServerConnectionConfig: HomeServerConnectionConfig) = withContext(coroutineDispatchers.io) { - val authAPI = buildAuthAPI(homeServerConnectionConfig) - - executeRequest { - apiCall = authAPI.getLoginFlows() - } - } - - private suspend fun authenticate(homeServerConnectionConfig: HomeServerConnectionConfig, - login: String, - password: String) = withContext(coroutineDispatchers.io) { - val authAPI = buildAuthAPI(homeServerConnectionConfig) - val loginParams = if (Patterns.EMAIL_ADDRESS.matcher(login).matches()) { - PasswordLoginParams.thirdPartyIdentifier(ThreePidMedium.EMAIL, login, password, "Mobile") - } else { - PasswordLoginParams.userIdentifier(login, password, "Mobile") - } - val credentials = executeRequest { - apiCall = authAPI.login(loginParams) - } - val sessionParams = SessionParams(credentials, homeServerConnectionConfig) - sessionParamsStore.save(sessionParams) - sessionManager.getOrCreateSession(sessionParams) - } - - override fun createSessionFromSso(credentials: Credentials, - homeServerConnectionConfig: HomeServerConnectionConfig, - callback: MatrixCallback): Cancelable { - val job = GlobalScope.launch(coroutineDispatchers.main) { - val sessionOrFailure = runCatching { - createSessionFromSso(credentials, homeServerConnectionConfig) - } - sessionOrFailure.foldToCallback(callback) - } - return CancelableCoroutine(job) - } - - private suspend fun createSessionFromSso(credentials: Credentials, - homeServerConnectionConfig: HomeServerConnectionConfig): Session = withContext(coroutineDispatchers.computation) { - val sessionParams = SessionParams(credentials, homeServerConnectionConfig) - sessionParamsStore.save(sessionParams) - sessionManager.getOrCreateSession(sessionParams) - } - - private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI { - val retrofit = retrofitFactory.create(okHttpClient, homeServerConnectionConfig.homeServerUri.toString()) - return retrofit.create(AuthAPI::class.java) - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/PendingSessionStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/PendingSessionStore.kt new file mode 100644 index 0000000000..ed28de6ae8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/PendingSessionStore.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth + +import im.vector.matrix.android.internal.auth.db.PendingSessionData + +/** + * Store for elements when doing login or registration + */ +internal interface PendingSessionStore { + + suspend fun savePendingSessionData(pendingSessionData: PendingSessionData) + + fun getPendingSessionData(): PendingSessionData? + + suspend fun delete() +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt new file mode 100644 index 0000000000..f04f262d6e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth + +import android.net.Uri +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig +import im.vector.matrix.android.api.auth.data.SessionParams +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.internal.SessionManager +import timber.log.Timber +import javax.inject.Inject + +internal interface SessionCreator { + suspend fun createSession(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig): Session +} + +internal class DefaultSessionCreator @Inject constructor( + private val sessionParamsStore: SessionParamsStore, + private val sessionManager: SessionManager, + private val pendingSessionStore: PendingSessionStore +) : SessionCreator { + + /** + * Credentials can affect the homeServerConnectionConfig, override home server url and/or + * identity server url if provided in the credentials + */ + override suspend fun createSession(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig): Session { + // We can cleanup the pending session params + pendingSessionStore.delete() + + val sessionParams = SessionParams( + credentials = credentials, + homeServerConnectionConfig = homeServerConnectionConfig.copy( + homeServerUri = credentials.wellKnown?.homeServer?.baseURL + // remove trailing "/" + ?.trim { it == '/' } + ?.takeIf { it.isNotBlank() } + ?.also { Timber.d("Overriding homeserver url to $it") } + ?.let { Uri.parse(it) } + ?: homeServerConnectionConfig.homeServerUri, + identityServerUri = credentials.wellKnown?.identityServer?.baseURL + // remove trailing "/" + ?.trim { it == '/' } + ?.takeIf { it.isNotBlank() } + ?.also { Timber.d("Overriding identity server url to $it") } + ?.let { Uri.parse(it) } + ?: homeServerConnectionConfig.identityServerUri + )) + + sessionParamsStore.save(sessionParams) + return sessionManager.getOrCreateSession(sessionParams) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/InteractiveAuthenticationFlow.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/InteractiveAuthenticationFlow.kt index a6c027900f..a6d74a8de7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/InteractiveAuthenticationFlow.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/InteractiveAuthenticationFlow.kt @@ -30,12 +30,4 @@ data class InteractiveAuthenticationFlow( @Json(name = "stages") val stages: List? = null -) { - - companion object { - // Possible values for type - const val TYPE_LOGIN_SSO = "m.login.sso" - const val TYPE_LOGIN_TOKEN = "m.login.token" - const val TYPE_LOGIN_PASSWORD = "m.login.password" - } -} +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/LoginFlowTypes.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/LoginFlowTypes.kt index 81196c7414..4ff29d594a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/LoginFlowTypes.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/LoginFlowTypes.kt @@ -25,4 +25,7 @@ object LoginFlowTypes { const val MSISDN = "m.login.msisdn" const val RECAPTCHA = "m.login.recaptcha" const val DUMMY = "m.login.dummy" + const val TERMS = "m.login.terms" + const val TOKEN = "m.login.token" + const val SSO = "m.login.sso" } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/PasswordLoginParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/PasswordLoginParams.kt index 39b1dd8760..f467b4d3a0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/PasswordLoginParams.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/data/PasswordLoginParams.kt @@ -19,34 +19,46 @@ package im.vector.matrix.android.internal.auth.data import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +/** + * Ref: + * - https://matrix.org/docs/spec/client_server/r0.5.0#password-based + * - https://matrix.org/docs/spec/client_server/r0.5.0#identifier-types + */ @JsonClass(generateAdapter = true) -internal data class PasswordLoginParams(@Json(name = "identifier") val identifier: Map, - @Json(name = "password") val password: String, - @Json(name = "type") override val type: String, - @Json(name = "initial_device_display_name") val deviceDisplayName: String?, - @Json(name = "device_id") val deviceId: String?) : LoginParams { +internal data class PasswordLoginParams( + @Json(name = "identifier") val identifier: Map, + @Json(name = "password") val password: String, + @Json(name = "type") override val type: String, + @Json(name = "initial_device_display_name") val deviceDisplayName: String?, + @Json(name = "device_id") val deviceId: String?) : LoginParams { companion object { + private const val IDENTIFIER_KEY_TYPE = "type" - val IDENTIFIER_KEY_TYPE_USER = "m.id.user" - val IDENTIFIER_KEY_TYPE_THIRD_PARTY = "m.id.thirdparty" - val IDENTIFIER_KEY_TYPE_PHONE = "m.id.phone" + private const val IDENTIFIER_KEY_TYPE_USER = "m.id.user" + private const val IDENTIFIER_KEY_USER = "user" - val IDENTIFIER_KEY_TYPE = "type" - val IDENTIFIER_KEY_MEDIUM = "medium" - val IDENTIFIER_KEY_ADDRESS = "address" - val IDENTIFIER_KEY_USER = "user" - val IDENTIFIER_KEY_COUNTRY = "country" - val IDENTIFIER_KEY_NUMBER = "number" + private const val IDENTIFIER_KEY_TYPE_THIRD_PARTY = "m.id.thirdparty" + private const val IDENTIFIER_KEY_MEDIUM = "medium" + private const val IDENTIFIER_KEY_ADDRESS = "address" + + private const val IDENTIFIER_KEY_TYPE_PHONE = "m.id.phone" + private const val IDENTIFIER_KEY_COUNTRY = "country" + private const val IDENTIFIER_KEY_PHONE = "phone" fun userIdentifier(user: String, password: String, deviceDisplayName: String? = null, deviceId: String? = null): PasswordLoginParams { - val identifier = HashMap() - identifier[IDENTIFIER_KEY_TYPE] = IDENTIFIER_KEY_TYPE_USER - identifier[IDENTIFIER_KEY_USER] = user - return PasswordLoginParams(identifier, password, LoginFlowTypes.PASSWORD, deviceDisplayName, deviceId) + return PasswordLoginParams( + mapOf( + IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_USER, + IDENTIFIER_KEY_USER to user + ), + password, + LoginFlowTypes.PASSWORD, + deviceDisplayName, + deviceId) } fun thirdPartyIdentifier(medium: String, @@ -54,11 +66,33 @@ internal data class PasswordLoginParams(@Json(name = "identifier") val identifie password: String, deviceDisplayName: String? = null, deviceId: String? = null): PasswordLoginParams { - val identifier = HashMap() - identifier[IDENTIFIER_KEY_TYPE] = IDENTIFIER_KEY_TYPE_THIRD_PARTY - identifier[IDENTIFIER_KEY_MEDIUM] = medium - identifier[IDENTIFIER_KEY_ADDRESS] = address - return PasswordLoginParams(identifier, password, LoginFlowTypes.PASSWORD, deviceDisplayName, deviceId) + return PasswordLoginParams( + mapOf( + IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_THIRD_PARTY, + IDENTIFIER_KEY_MEDIUM to medium, + IDENTIFIER_KEY_ADDRESS to address + ), + password, + LoginFlowTypes.PASSWORD, + deviceDisplayName, + deviceId) + } + + fun phoneIdentifier(country: String, + phone: String, + password: String, + deviceDisplayName: String? = null, + deviceId: String? = null): PasswordLoginParams { + return PasswordLoginParams( + mapOf( + IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_PHONE, + IDENTIFIER_KEY_COUNTRY to country, + IDENTIFIER_KEY_PHONE to phone + ), + password, + LoginFlowTypes.PASSWORD, + deviceDisplayName, + deviceId) } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt new file mode 100644 index 0000000000..5f1efb487b --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.db + +import io.realm.DynamicRealm +import io.realm.RealmMigration +import timber.log.Timber + +internal class AuthRealmMigration : RealmMigration { + + companion object { + // Current schema version + const val SCHEMA_VERSION = 1L + } + + override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { + Timber.d("Migrating Auth Realm from $oldVersion to $newVersion") + + if (oldVersion <= 0) { + Timber.d("Step 0 -> 1") + Timber.d("Create PendingSessionEntity") + + realm.schema.create("PendingSessionEntity") + .addField(PendingSessionEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, String::class.java) + .setRequired(PendingSessionEntityFields.HOME_SERVER_CONNECTION_CONFIG_JSON, true) + .addField(PendingSessionEntityFields.CLIENT_SECRET, String::class.java) + .setRequired(PendingSessionEntityFields.CLIENT_SECRET, true) + .addField(PendingSessionEntityFields.SEND_ATTEMPT, Integer::class.java) + .setRequired(PendingSessionEntityFields.SEND_ATTEMPT, true) + .addField(PendingSessionEntityFields.RESET_PASSWORD_DATA_JSON, String::class.java) + .addField(PendingSessionEntityFields.CURRENT_SESSION, String::class.java) + .addField(PendingSessionEntityFields.IS_REGISTRATION_STARTED, Boolean::class.java) + .addField(PendingSessionEntityFields.CURRENT_THREE_PID_DATA_JSON, String::class.java) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmModule.kt index dcc0393569..ee930cd1ef 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmModule.kt @@ -23,6 +23,7 @@ import io.realm.annotations.RealmModule */ @RealmModule(library = true, classes = [ - SessionParamsEntity::class + SessionParamsEntity::class, + PendingSessionEntity::class ]) internal class AuthRealmModule diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionData.kt new file mode 100644 index 0000000000..0314491d3b --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionData.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.db + +import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig +import im.vector.matrix.android.internal.auth.login.ResetPasswordData +import im.vector.matrix.android.internal.auth.registration.ThreePidData +import java.util.* + +/** + * This class holds all pending data when creating a session, either by login or by register + */ +internal data class PendingSessionData( + val homeServerConnectionConfig: HomeServerConnectionConfig, + + /* ========================================================================================== + * Common + * ========================================================================================== */ + + val clientSecret: String = UUID.randomUUID().toString(), + val sendAttempt: Int = 0, + + /* ========================================================================================== + * For login + * ========================================================================================== */ + + val resetPasswordData: ResetPasswordData? = null, + + /* ========================================================================================== + * For register + * ========================================================================================== */ + + val currentSession: String? = null, + val isRegistrationStarted: Boolean = false, + val currentThreePidData: ThreePidData? = null +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionEntity.kt new file mode 100644 index 0000000000..d21c515849 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.db + +import io.realm.RealmObject + +internal open class PendingSessionEntity( + var homeServerConnectionConfigJson: String = "", + var clientSecret: String = "", + var sendAttempt: Int = 0, + var resetPasswordDataJson: String? = null, + var currentSession: String? = null, + var isRegistrationStarted: Boolean = false, + var currentThreePidDataJson: String? = null +) : RealmObject() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionMapper.kt new file mode 100644 index 0000000000..32e6ba963e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/PendingSessionMapper.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.db + +import com.squareup.moshi.Moshi +import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig +import im.vector.matrix.android.internal.auth.login.ResetPasswordData +import im.vector.matrix.android.internal.auth.registration.ThreePidData +import javax.inject.Inject + +internal class PendingSessionMapper @Inject constructor(moshi: Moshi) { + + private val homeServerConnectionConfigAdapter = moshi.adapter(HomeServerConnectionConfig::class.java) + private val resetPasswordDataAdapter = moshi.adapter(ResetPasswordData::class.java) + private val threePidDataAdapter = moshi.adapter(ThreePidData::class.java) + + fun map(entity: PendingSessionEntity?): PendingSessionData? { + if (entity == null) { + return null + } + + val homeServerConnectionConfig = homeServerConnectionConfigAdapter.fromJson(entity.homeServerConnectionConfigJson)!! + val resetPasswordData = entity.resetPasswordDataJson?.let { resetPasswordDataAdapter.fromJson(it) } + val threePidData = entity.currentThreePidDataJson?.let { threePidDataAdapter.fromJson(it) } + + return PendingSessionData( + homeServerConnectionConfig = homeServerConnectionConfig, + clientSecret = entity.clientSecret, + sendAttempt = entity.sendAttempt, + resetPasswordData = resetPasswordData, + currentSession = entity.currentSession, + isRegistrationStarted = entity.isRegistrationStarted, + currentThreePidData = threePidData) + } + + fun map(sessionData: PendingSessionData?): PendingSessionEntity? { + if (sessionData == null) { + return null + } + + val homeServerConnectionConfigJson = homeServerConnectionConfigAdapter.toJson(sessionData.homeServerConnectionConfig) + val resetPasswordDataJson = resetPasswordDataAdapter.toJson(sessionData.resetPasswordData) + val currentThreePidDataJson = threePidDataAdapter.toJson(sessionData.currentThreePidData) + + return PendingSessionEntity( + homeServerConnectionConfigJson = homeServerConnectionConfigJson, + clientSecret = sessionData.clientSecret, + sendAttempt = sessionData.sendAttempt, + resetPasswordDataJson = resetPasswordDataJson, + currentSession = sessionData.currentSession, + isRegistrationStarted = sessionData.isRegistrationStarted, + currentThreePidDataJson = currentThreePidDataJson + ) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmPendingSessionStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmPendingSessionStore.kt new file mode 100644 index 0000000000..6841e43ef0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmPendingSessionStore.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.db + +import im.vector.matrix.android.internal.auth.PendingSessionStore +import im.vector.matrix.android.internal.database.awaitTransaction +import im.vector.matrix.android.internal.di.AuthDatabase +import io.realm.Realm +import io.realm.RealmConfiguration +import javax.inject.Inject + +internal class RealmPendingSessionStore @Inject constructor(private val mapper: PendingSessionMapper, + @AuthDatabase + private val realmConfiguration: RealmConfiguration +) : PendingSessionStore { + + override suspend fun savePendingSessionData(pendingSessionData: PendingSessionData) { + awaitTransaction(realmConfiguration) { realm -> + val entity = mapper.map(pendingSessionData) + if (entity != null) { + realm.where(PendingSessionEntity::class.java) + .findAll() + .deleteAllFromRealm() + + realm.insert(entity) + } + } + } + + override fun getPendingSessionData(): PendingSessionData? { + return Realm.getInstance(realmConfiguration).use { realm -> + realm + .where(PendingSessionEntity::class.java) + .findAll() + .map { mapper.map(it) } + .firstOrNull() + } + } + + override suspend fun delete() { + awaitTransaction(realmConfiguration) { + it.where(PendingSessionEntity::class.java) + .findAll() + .deleteAllFromRealm() + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt index 00fde2682e..dfe35c363b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt @@ -30,36 +30,33 @@ internal class RealmSessionParamsStore @Inject constructor(private val mapper: S ) : SessionParamsStore { override fun getLast(): SessionParams? { - val realm = Realm.getInstance(realmConfiguration) - val sessionParams = realm - .where(SessionParamsEntity::class.java) - .findAll() - .map { mapper.map(it) } - .lastOrNull() - realm.close() - return sessionParams + return Realm.getInstance(realmConfiguration).use { realm -> + realm + .where(SessionParamsEntity::class.java) + .findAll() + .map { mapper.map(it) } + .lastOrNull() + } } override fun get(userId: String): SessionParams? { - val realm = Realm.getInstance(realmConfiguration) - val sessionParams = realm - .where(SessionParamsEntity::class.java) - .equalTo(SessionParamsEntityFields.USER_ID, userId) - .findAll() - .map { mapper.map(it) } - .firstOrNull() - realm.close() - return sessionParams + return Realm.getInstance(realmConfiguration).use { realm -> + realm + .where(SessionParamsEntity::class.java) + .equalTo(SessionParamsEntityFields.USER_ID, userId) + .findAll() + .map { mapper.map(it) } + .firstOrNull() + } } override fun getAll(): List { - val realm = Realm.getInstance(realmConfiguration) - val sessionParams = realm - .where(SessionParamsEntity::class.java) - .findAll() - .mapNotNull { mapper.map(it) } - realm.close() - return sessionParams + return Realm.getInstance(realmConfiguration).use { realm -> + realm + .where(SessionParamsEntity::class.java) + .findAll() + .mapNotNull { mapper.map(it) } + } } override suspend fun save(sessionParams: SessionParams) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/DefaultLoginWizard.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/DefaultLoginWizard.kt new file mode 100644 index 0000000000..b847773682 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/DefaultLoginWizard.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.login + +import android.util.Patterns +import dagger.Lazy +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.auth.login.LoginWizard +import im.vector.matrix.android.api.auth.registration.RegisterThreePid +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.api.util.NoOpCancellable +import im.vector.matrix.android.internal.auth.AuthAPI +import im.vector.matrix.android.internal.auth.PendingSessionStore +import im.vector.matrix.android.internal.auth.SessionCreator +import im.vector.matrix.android.internal.auth.data.PasswordLoginParams +import im.vector.matrix.android.internal.auth.data.ThreePidMedium +import im.vector.matrix.android.internal.auth.db.PendingSessionData +import im.vector.matrix.android.internal.auth.registration.AddThreePidRegistrationParams +import im.vector.matrix.android.internal.auth.registration.AddThreePidRegistrationResponse +import im.vector.matrix.android.internal.auth.registration.RegisterAddThreePidTask +import im.vector.matrix.android.internal.network.RetrofitFactory +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.task.launchToCallback +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient + +internal class DefaultLoginWizard( + okHttpClient: Lazy, + retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val sessionCreator: SessionCreator, + private val pendingSessionStore: PendingSessionStore +) : LoginWizard { + + private var pendingSessionData: PendingSessionData = pendingSessionStore.getPendingSessionData() ?: error("Pending session data should exist here") + + private val authAPI = retrofitFactory.create(okHttpClient, pendingSessionData.homeServerConnectionConfig.homeServerUri.toString()) + .create(AuthAPI::class.java) + + override fun login(login: String, + password: String, + deviceName: String, + callback: MatrixCallback): Cancelable { + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + loginInternal(login, password, deviceName) + } + } + + private suspend fun loginInternal(login: String, + password: String, + deviceName: String) = withContext(coroutineDispatchers.computation) { + val loginParams = if (Patterns.EMAIL_ADDRESS.matcher(login).matches()) { + PasswordLoginParams.thirdPartyIdentifier(ThreePidMedium.EMAIL, login, password, deviceName) + } else { + PasswordLoginParams.userIdentifier(login, password, deviceName) + } + val credentials = executeRequest { + apiCall = authAPI.login(loginParams) + } + + sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + } + + override fun resetPassword(email: String, newPassword: String, callback: MatrixCallback): Cancelable { + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + resetPasswordInternal(email, newPassword) + } + } + + private suspend fun resetPasswordInternal(email: String, newPassword: String) { + val param = RegisterAddThreePidTask.Params( + RegisterThreePid.Email(email), + pendingSessionData.clientSecret, + pendingSessionData.sendAttempt + ) + + pendingSessionData = pendingSessionData.copy(sendAttempt = pendingSessionData.sendAttempt + 1) + .also { pendingSessionStore.savePendingSessionData(it) } + + val result = executeRequest { + apiCall = authAPI.resetPassword(AddThreePidRegistrationParams.from(param)) + } + + pendingSessionData = pendingSessionData.copy(resetPasswordData = ResetPasswordData(newPassword, result)) + .also { pendingSessionStore.savePendingSessionData(it) } + } + + override fun resetPasswordMailConfirmed(callback: MatrixCallback): Cancelable { + val safeResetPasswordData = pendingSessionData.resetPasswordData ?: run { + callback.onFailure(IllegalStateException("developer error, no reset password in progress")) + return NoOpCancellable + } + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + resetPasswordMailConfirmedInternal(safeResetPasswordData) + } + } + + private suspend fun resetPasswordMailConfirmedInternal(resetPasswordData: ResetPasswordData) { + val param = ResetPasswordMailConfirmed.create( + pendingSessionData.clientSecret, + resetPasswordData.addThreePidRegistrationResponse.sid, + resetPasswordData.newPassword + ) + + executeRequest { + apiCall = authAPI.resetPasswordMailConfirmed(param) + } + + // Set to null? + // resetPasswordData = null + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/ResetPasswordData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/ResetPasswordData.kt new file mode 100644 index 0000000000..11a8b95443 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/ResetPasswordData.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.login + +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.internal.auth.registration.AddThreePidRegistrationResponse + +/** + * Container to store the data when a reset password is in the email validation step + */ +@JsonClass(generateAdapter = true) +internal data class ResetPasswordData( + val newPassword: String, + val addThreePidRegistrationResponse: AddThreePidRegistrationResponse +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/ResetPasswordMailConfirmed.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/ResetPasswordMailConfirmed.kt new file mode 100644 index 0000000000..9be4451628 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/login/ResetPasswordMailConfirmed.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2014 OpenMarket Ltd + * Copyright 2017 Vector Creations Ltd + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.matrix.android.internal.auth.login + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.internal.auth.registration.AuthParams + +/** + * Class to pass parameters to reset the password once a email has been validated. + */ +@JsonClass(generateAdapter = true) +internal data class ResetPasswordMailConfirmed( + // authentication parameters + @Json(name = "auth") + val auth: AuthParams? = null, + + // the new password + @Json(name = "new_password") + val newPassword: String? = null +) { + companion object { + fun create(clientSecret: String, sid: String, newPassword: String): ResetPasswordMailConfirmed { + return ResetPasswordMailConfirmed( + auth = AuthParams.createForResetPassword(clientSecret, sid), + newPassword = newPassword + ) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AddThreePidRegistrationParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AddThreePidRegistrationParams.kt new file mode 100644 index 0000000000..90e1894bac --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AddThreePidRegistrationParams.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.auth.registration.RegisterThreePid + +/** + * Add a three Pid during authentication + */ +@JsonClass(generateAdapter = true) +internal data class AddThreePidRegistrationParams( + /** + * Required. A unique string generated by the client, and used to identify the validation attempt. + * It must be a string consisting of the characters [0-9a-zA-Z.=_-]. Its length must not exceed 255 characters and it must not be empty. + */ + @Json(name = "client_secret") + val clientSecret: String, + + /** + * Required. The server will only send an email if the send_attempt is a number greater than the most recent one which it has seen, + * scoped to that email + client_secret pair. This is to avoid repeatedly sending the same email in the case of request retries between + * the POSTing user and the identity server. The client should increment this value if they desire a new email (e.g. a reminder) to be sent. + * If they do not, the server should respond with success but not resend the email. + */ + @Json(name = "send_attempt") + val sendAttempt: Int, + + /** + * Optional. When the validation is completed, the identity server will redirect the user to this URL. This option is ignored when + * submitting 3PID validation information through a POST request. + */ + @Json(name = "next_link") + val nextLink: String? = null, + + /** + * Required. The hostname of the identity server to communicate with. May optionally include a port. + * This parameter is ignored when the homeserver handles 3PID verification. + */ + @Json(name = "id_server") + val id_server: String? = null, + + /* ========================================================================================== + * For emails + * ========================================================================================== */ + + /** + * Required. The email address to validate. + */ + @Json(name = "email") + val email: String? = null, + + /* ========================================================================================== + * For Msisdn + * ========================================================================================== */ + + /** + * Required. The two-letter uppercase ISO country code that the number in phone_number should be parsed as if it were dialled from. + */ + @Json(name = "country") + val countryCode: String? = null, + + /** + * Required. The phone number to validate. + */ + @Json(name = "phone_number") + val msisdn: String? = null +) { + companion object { + fun from(params: RegisterAddThreePidTask.Params): AddThreePidRegistrationParams { + return when (params.threePid) { + is RegisterThreePid.Email -> AddThreePidRegistrationParams( + email = params.threePid.email, + clientSecret = params.clientSecret, + sendAttempt = params.sendAttempt + ) + is RegisterThreePid.Msisdn -> AddThreePidRegistrationParams( + msisdn = params.threePid.msisdn, + countryCode = params.threePid.countryCode, + clientSecret = params.clientSecret, + sendAttempt = params.sendAttempt + ) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AddThreePidRegistrationResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AddThreePidRegistrationResponse.kt new file mode 100644 index 0000000000..f07e66a7ef --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AddThreePidRegistrationResponse.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class AddThreePidRegistrationResponse( + /** + * Required. The session ID. Session IDs are opaque strings that must consist entirely of the characters [0-9a-zA-Z.=_-]. + * Their length must not exceed 255 characters and they must not be empty. + */ + @Json(name = "sid") + val sid: String, + + /** + * An optional field containing a URL where the client must submit the validation token to, with identical parameters to the Identity + * Service API's POST /validate/email/submitToken endpoint. The homeserver must send this token to the user (if applicable), + * who should then be prompted to provide it to the client. + * + * If this field is not present, the client can assume that verification will happen without the client's involvement provided + * the homeserver advertises this specification version in the /versions response (ie: r0.5.0). + */ + @Json(name = "submit_url") + val submitUrl: String? = null, + + /* ========================================================================================== + * It seems that the homeserver is sending more data, we may need it + * ========================================================================================== */ + + @Json(name = "msisdn") + val msisdn: String? = null, + + @Json(name = "intl_fmt") + val formattedMsisdn: String? = null, + + @Json(name = "success") + val success: Boolean? = null +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AuthParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AuthParams.kt new file mode 100644 index 0000000000..ad85579550 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/AuthParams.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.internal.auth.data.LoginFlowTypes + +/** + * Open class, parent to all possible authentication parameters + */ +@JsonClass(generateAdapter = true) +internal data class AuthParams( + @Json(name = "type") + val type: String, + + /** + * Note: session can be null for reset password request + */ + @Json(name = "session") + val session: String?, + + /** + * parameter for "m.login.recaptcha" type + */ + @Json(name = "response") + val captchaResponse: String? = null, + + /** + * parameter for "m.login.email.identity" type + */ + @Json(name = "threepid_creds") + val threePidCredentials: ThreePidCredentials? = null +) { + + companion object { + fun createForCaptcha(session: String, captchaResponse: String): AuthParams { + return AuthParams( + type = LoginFlowTypes.RECAPTCHA, + session = session, + captchaResponse = captchaResponse + ) + } + + fun createForEmailIdentity(session: String, threePidCredentials: ThreePidCredentials): AuthParams { + return AuthParams( + type = LoginFlowTypes.EMAIL_IDENTITY, + session = session, + threePidCredentials = threePidCredentials + ) + } + + /** + * Note that there is a bug in Synapse (I have to investigate where), but if we pass LoginFlowTypes.MSISDN, + * the homeserver answer with the login flow with MatrixError fields and not with a simple MatrixError 401. + */ + fun createForMsisdnIdentity(session: String, threePidCredentials: ThreePidCredentials): AuthParams { + return AuthParams( + type = LoginFlowTypes.MSISDN, + session = session, + threePidCredentials = threePidCredentials + ) + } + + fun createForResetPassword(clientSecret: String, sid: String): AuthParams { + return AuthParams( + type = LoginFlowTypes.EMAIL_IDENTITY, + session = null, + threePidCredentials = ThreePidCredentials( + clientSecret = clientSecret, + sid = sid + ) + ) + } + } +} + +@JsonClass(generateAdapter = true) +data class ThreePidCredentials( + @Json(name = "client_secret") + val clientSecret: String? = null, + + @Json(name = "id_server") + val idServer: String? = null, + + @Json(name = "sid") + val sid: String? = null +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/DefaultRegistrationWizard.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/DefaultRegistrationWizard.kt new file mode 100644 index 0000000000..29970b6c0c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/DefaultRegistrationWizard.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import dagger.Lazy +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.registration.RegisterThreePid +import im.vector.matrix.android.api.auth.registration.RegistrationResult +import im.vector.matrix.android.api.auth.registration.RegistrationWizard +import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.api.failure.Failure.RegistrationFlowError +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.api.util.NoOpCancellable +import im.vector.matrix.android.internal.auth.AuthAPI +import im.vector.matrix.android.internal.auth.PendingSessionStore +import im.vector.matrix.android.internal.auth.SessionCreator +import im.vector.matrix.android.internal.auth.data.LoginFlowTypes +import im.vector.matrix.android.internal.auth.db.PendingSessionData +import im.vector.matrix.android.internal.network.RetrofitFactory +import im.vector.matrix.android.internal.task.launchToCallback +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import okhttp3.OkHttpClient + +/** + * This class execute the registration request and is responsible to keep the session of interactive authentication + */ +internal class DefaultRegistrationWizard( + private val okHttpClient: Lazy, + private val retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val sessionCreator: SessionCreator, + private val pendingSessionStore: PendingSessionStore +) : RegistrationWizard { + + private var pendingSessionData: PendingSessionData = pendingSessionStore.getPendingSessionData() ?: error("Pending session data should exist here") + + private val authAPI = buildAuthAPI() + private val registerTask = DefaultRegisterTask(authAPI) + private val registerAddThreePidTask = DefaultRegisterAddThreePidTask(authAPI) + private val validateCodeTask = DefaultValidateCodeTask(authAPI) + + override val currentThreePid: String? + get() { + return when (val threePid = pendingSessionData.currentThreePidData?.threePid) { + is RegisterThreePid.Email -> threePid.email + is RegisterThreePid.Msisdn -> { + // Take formatted msisdn if provided by the server + pendingSessionData.currentThreePidData?.addThreePidRegistrationResponse?.formattedMsisdn?.takeIf { it.isNotBlank() } ?: threePid.msisdn + } + null -> null + } + } + + override val isRegistrationStarted: Boolean + get() = pendingSessionData.isRegistrationStarted + + override fun getRegistrationFlow(callback: MatrixCallback): Cancelable { + val params = RegistrationParams() + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + performRegistrationRequest(params) + } + } + + override fun createAccount(userName: String, + password: String, + initialDeviceDisplayName: String?, + callback: MatrixCallback): Cancelable { + val params = RegistrationParams( + username = userName, + password = password, + initialDeviceDisplayName = initialDeviceDisplayName + ) + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + performRegistrationRequest(params) + .also { + pendingSessionData = pendingSessionData.copy(isRegistrationStarted = true) + .also { pendingSessionStore.savePendingSessionData(it) } + } + } + } + + override fun performReCaptcha(response: String, callback: MatrixCallback): Cancelable { + val safeSession = pendingSessionData.currentSession ?: run { + callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) + return NoOpCancellable + } + val params = RegistrationParams(auth = AuthParams.createForCaptcha(safeSession, response)) + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + performRegistrationRequest(params) + } + } + + override fun acceptTerms(callback: MatrixCallback): Cancelable { + val safeSession = pendingSessionData.currentSession ?: run { + callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) + return NoOpCancellable + } + val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.TERMS, session = safeSession)) + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + performRegistrationRequest(params) + } + } + + override fun addThreePid(threePid: RegisterThreePid, callback: MatrixCallback): Cancelable { + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + pendingSessionData = pendingSessionData.copy(currentThreePidData = null) + .also { pendingSessionStore.savePendingSessionData(it) } + + sendThreePid(threePid) + } + } + + override fun sendAgainThreePid(callback: MatrixCallback): Cancelable { + val safeCurrentThreePid = pendingSessionData.currentThreePidData?.threePid ?: run { + callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) + return NoOpCancellable + } + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + sendThreePid(safeCurrentThreePid) + } + } + + private suspend fun sendThreePid(threePid: RegisterThreePid): RegistrationResult { + val safeSession = pendingSessionData.currentSession ?: throw IllegalStateException("developer error, call createAccount() method first") + val response = registerAddThreePidTask.execute( + RegisterAddThreePidTask.Params( + threePid, + pendingSessionData.clientSecret, + pendingSessionData.sendAttempt)) + + pendingSessionData = pendingSessionData.copy(sendAttempt = pendingSessionData.sendAttempt + 1) + .also { pendingSessionStore.savePendingSessionData(it) } + + val params = RegistrationParams( + auth = if (threePid is RegisterThreePid.Email) { + AuthParams.createForEmailIdentity(safeSession, + ThreePidCredentials( + clientSecret = pendingSessionData.clientSecret, + sid = response.sid + ) + ) + } else { + AuthParams.createForMsisdnIdentity(safeSession, + ThreePidCredentials( + clientSecret = pendingSessionData.clientSecret, + sid = response.sid + ) + ) + } + ) + // Store data + pendingSessionData = pendingSessionData.copy(currentThreePidData = ThreePidData.from(threePid, response, params)) + .also { pendingSessionStore.savePendingSessionData(it) } + + // and send the sid a first time + return performRegistrationRequest(params) + } + + override fun checkIfEmailHasBeenValidated(delayMillis: Long, callback: MatrixCallback): Cancelable { + val safeParam = pendingSessionData.currentThreePidData?.registrationParams ?: run { + callback.onFailure(IllegalStateException("developer error, no pending three pid")) + return NoOpCancellable + } + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + performRegistrationRequest(safeParam, delayMillis) + } + } + + override fun handleValidateThreePid(code: String, callback: MatrixCallback): Cancelable { + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + validateThreePid(code) + } + } + + private suspend fun validateThreePid(code: String): RegistrationResult { + val registrationParams = pendingSessionData.currentThreePidData?.registrationParams + ?: throw IllegalStateException("developer error, no pending three pid") + val safeCurrentData = pendingSessionData.currentThreePidData ?: throw IllegalStateException("developer error, call createAccount() method first") + val url = safeCurrentData.addThreePidRegistrationResponse.submitUrl ?: throw IllegalStateException("Missing url the send the code") + val validationBody = ValidationCodeBody( + clientSecret = pendingSessionData.clientSecret, + sid = safeCurrentData.addThreePidRegistrationResponse.sid, + code = code + ) + val validationResponse = validateCodeTask.execute(ValidateCodeTask.Params(url, validationBody)) + if (validationResponse.success == true) { + // The entered code is correct + // Same than validate email + return performRegistrationRequest(registrationParams, 3_000) + } else { + // The code is not correct + throw Failure.SuccessError + } + } + + override fun dummy(callback: MatrixCallback): Cancelable { + val safeSession = pendingSessionData.currentSession ?: run { + callback.onFailure(IllegalStateException("developer error, call createAccount() method first")) + return NoOpCancellable + } + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.DUMMY, session = safeSession)) + performRegistrationRequest(params) + } + } + + private suspend fun performRegistrationRequest(registrationParams: RegistrationParams, + delayMillis: Long = 0): RegistrationResult { + delay(delayMillis) + val credentials = try { + registerTask.execute(RegisterTask.Params(registrationParams)) + } catch (exception: Throwable) { + if (exception is RegistrationFlowError) { + pendingSessionData = pendingSessionData.copy(currentSession = exception.registrationFlowResponse.session) + .also { pendingSessionStore.savePendingSessionData(it) } + return RegistrationResult.FlowResponse(exception.registrationFlowResponse.toFlowResult()) + } else { + throw exception + } + } + + val session = sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + return RegistrationResult.Success(session) + } + + private fun buildAuthAPI(): AuthAPI { + val retrofit = retrofitFactory.create(okHttpClient, pendingSessionData.homeServerConnectionConfig.homeServerUri.toString()) + return retrofit.create(AuthAPI::class.java) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/LocalizedFlowDataLoginTerms.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/LocalizedFlowDataLoginTerms.kt new file mode 100644 index 0000000000..2cd52f702e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/LocalizedFlowDataLoginTerms.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.androidsdk.rest.model.login + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +/** + * This class represent a localized privacy policy for registration Flow. + */ +@Parcelize +data class LocalizedFlowDataLoginTerms( + var policyName: String? = null, + var version: String? = null, + var localizedUrl: String? = null, + var localizedName: String? = null +) : Parcelable diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegisterAddThreePidTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegisterAddThreePidTask.kt new file mode 100644 index 0000000000..0246075153 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegisterAddThreePidTask.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import im.vector.matrix.android.api.auth.registration.RegisterThreePid +import im.vector.matrix.android.internal.auth.AuthAPI +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.task.Task + +internal interface RegisterAddThreePidTask : Task { + data class Params( + val threePid: RegisterThreePid, + val clientSecret: String, + val sendAttempt: Int + ) +} + +internal class DefaultRegisterAddThreePidTask(private val authAPI: AuthAPI) + : RegisterAddThreePidTask { + + override suspend fun execute(params: RegisterAddThreePidTask.Params): AddThreePidRegistrationResponse { + return executeRequest { + apiCall = authAPI.add3Pid(params.threePid.toPath(), AddThreePidRegistrationParams.from(params)) + } + } + + private fun RegisterThreePid.toPath(): String { + return when (this) { + is RegisterThreePid.Email -> "email" + is RegisterThreePid.Msisdn -> "msisdn" + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegisterTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegisterTask.kt new file mode 100644 index 0000000000..f80021fff5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegisterTask.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.internal.auth.AuthAPI +import im.vector.matrix.android.internal.di.MoshiProvider +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.task.Task + +internal interface RegisterTask : Task { + data class Params( + val registrationParams: RegistrationParams + ) +} + +internal class DefaultRegisterTask(private val authAPI: AuthAPI) + : RegisterTask { + + override suspend fun execute(params: RegisterTask.Params): Credentials { + try { + return executeRequest { + apiCall = authAPI.register(params.registrationParams) + } + } catch (throwable: Throwable) { + if (throwable is Failure.OtherServerError && throwable.httpCode == 401) { + // Parse to get a RegistrationFlowResponse + val registrationFlowResponse = try { + MoshiProvider.providesMoshi() + .adapter(RegistrationFlowResponse::class.java) + .fromJson(throwable.errorBody) + } catch (e: Exception) { + null + } + // check if the server response can be cast + if (registrationFlowResponse != null) { + throw Failure.RegistrationFlowError(registrationFlowResponse) + } else { + throw throwable + } + } else { + // Other error + throw throwable + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt index 218251cfe5..2d3d25e538 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationFlowResponse.kt @@ -18,8 +18,12 @@ package im.vector.matrix.android.internal.auth.registration import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.auth.registration.FlowResult +import im.vector.matrix.android.api.auth.registration.Stage +import im.vector.matrix.android.api.auth.registration.TermPolicies import im.vector.matrix.android.api.util.JsonDict import im.vector.matrix.android.internal.auth.data.InteractiveAuthenticationFlow +import im.vector.matrix.android.internal.auth.data.LoginFlowTypes @JsonClass(generateAdapter = true) data class RegistrationFlowResponse( @@ -50,4 +54,46 @@ data class RegistrationFlowResponse( */ @Json(name = "params") var params: JsonDict? = null + + /** + * WARNING, + * The two MatrixError fields "errcode" and "error" can also be present here in case of error when validating a stage, + * But in this case Moshi will be able to parse the result as a MatrixError, see [RetrofitExtensions.toFailure] + * Ex: when polling for "m.login.msisdn" validation + */ ) + +/** + * Convert to something easier to handle on client side + */ +fun RegistrationFlowResponse.toFlowResult(): FlowResult { + // Get all the returned stages + val allFlowTypes = mutableSetOf() + + val missingStage = mutableListOf() + val completedStage = mutableListOf() + + this.flows?.forEach { it.stages?.mapTo(allFlowTypes) { type -> type } } + + allFlowTypes.forEach { type -> + val isMandatory = flows?.all { type in it.stages ?: emptyList() } == true + + val stage = when (type) { + LoginFlowTypes.RECAPTCHA -> Stage.ReCaptcha(isMandatory, ((params?.get(type) as? Map<*, *>)?.get("public_key") as? String) + ?: "") + LoginFlowTypes.DUMMY -> Stage.Dummy(isMandatory) + LoginFlowTypes.TERMS -> Stage.Terms(isMandatory, params?.get(type) as? TermPolicies ?: emptyMap()) + LoginFlowTypes.EMAIL_IDENTITY -> Stage.Email(isMandatory) + LoginFlowTypes.MSISDN -> Stage.Msisdn(isMandatory) + else -> Stage.Other(isMandatory, type, (params?.get(type) as? Map<*, *>)) + } + + if (type in completedStages ?: emptyList()) { + completedStage.add(stage) + } else { + missingStage.add(stage) + } + } + + return FlowResult(missingStage, completedStage) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationParams.kt new file mode 100644 index 0000000000..6a874c7387 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/RegistrationParams.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2014 OpenMarket Ltd + * Copyright 2017 Vector Creations Ltd + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Class to pass parameters to the different registration types for /register. + */ +@JsonClass(generateAdapter = true) +internal data class RegistrationParams( + // authentication parameters + @Json(name = "auth") + val auth: AuthParams? = null, + + // the account username + @Json(name = "username") + val username: String? = null, + + // the account password + @Json(name = "password") + val password: String? = null, + + // device name + @Json(name = "initial_device_display_name") + val initialDeviceDisplayName: String? = null, + + // Temporary flag to notify the server that we support msisdn flow. Used to prevent old app + // versions to end up in fallback because the HS returns the msisdn flow which they don't support + val x_show_msisdn: Boolean? = null +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/SuccessResult.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/SuccessResult.kt new file mode 100644 index 0000000000..8bfa3dda1d --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/SuccessResult.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SuccessResult( + @Json(name = "success") + val success: Boolean? +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ThreePidData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ThreePidData.kt new file mode 100644 index 0000000000..bb4751c438 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ThreePidData.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.auth.registration.RegisterThreePid + +/** + * Container to store the data when a three pid is in validation step + */ +@JsonClass(generateAdapter = true) +internal data class ThreePidData( + val email: String, + val msisdn: String, + val country: String, + val addThreePidRegistrationResponse: AddThreePidRegistrationResponse, + val registrationParams: RegistrationParams +) { + val threePid: RegisterThreePid + get() { + return if (email.isNotBlank()) { + RegisterThreePid.Email(email) + } else { + RegisterThreePid.Msisdn(msisdn, country) + } + } + + companion object { + fun from(threePid: RegisterThreePid, + addThreePidRegistrationResponse: AddThreePidRegistrationResponse, + registrationParams: RegistrationParams): ThreePidData { + return when (threePid) { + is RegisterThreePid.Email -> + ThreePidData(threePid.email, "", "", addThreePidRegistrationResponse, registrationParams) + is RegisterThreePid.Msisdn -> + ThreePidData("", threePid.msisdn, threePid.countryCode, addThreePidRegistrationResponse, registrationParams) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ValidateCodeTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ValidateCodeTask.kt new file mode 100644 index 0000000000..da75b839a6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ValidateCodeTask.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import im.vector.matrix.android.internal.auth.AuthAPI +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.task.Task + +internal interface ValidateCodeTask : Task { + data class Params( + val url: String, + val body: ValidationCodeBody + ) +} + +internal class DefaultValidateCodeTask(private val authAPI: AuthAPI) + : ValidateCodeTask { + + override suspend fun execute(params: ValidateCodeTask.Params): SuccessResult { + return executeRequest { + apiCall = authAPI.validate3Pid(params.url, params.body) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ValidationCodeBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ValidationCodeBody.kt new file mode 100644 index 0000000000..cb3b7e5e85 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/registration/ValidationCodeBody.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.auth.registration + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * This object is used to send a code received by SMS to validate Msisdn ownership + */ +@JsonClass(generateAdapter = true) +data class ValidationCodeBody( + @Json(name = "client_secret") + val clientSecret: String, + + @Json(name = "sid") + val sid: String, + + @Json(name = "token") + val code: String +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixComponent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixComponent.kt index f7314fe6b4..e8fa659d8d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixComponent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixComponent.kt @@ -22,7 +22,7 @@ import com.squareup.moshi.Moshi import dagger.BindsInstance import dagger.Component import im.vector.matrix.android.api.Matrix -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import im.vector.matrix.android.internal.SessionManager import im.vector.matrix.android.internal.auth.AuthModule import im.vector.matrix.android.internal.auth.SessionParamsStore @@ -44,7 +44,7 @@ internal interface MatrixComponent { @Unauthenticated fun okHttpClient(): OkHttpClient - fun authenticator(): Authenticator + fun authenticationService(): AuthenticationService fun context(): Context diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt index d0d8d134cb..c6c10d9a8f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/NetworkConstants.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.network internal object NetworkConstants { private const val URI_API_PREFIX_PATH = "_matrix/client" + const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/" const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/" const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt index 8a3bc1c046..51c02456d7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt @@ -26,6 +26,7 @@ import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressServi import im.vector.matrix.android.internal.session.filter.FilterRepository import im.vector.matrix.android.internal.session.homeserver.GetHomeServerCapabilitiesTask import im.vector.matrix.android.internal.session.sync.model.SyncResponse +import im.vector.matrix.android.internal.session.user.UserStore import im.vector.matrix.android.internal.task.Task import javax.inject.Inject @@ -41,7 +42,8 @@ internal class DefaultSyncTask @Inject constructor(private val syncAPI: SyncAPI, private val sessionParamsStore: SessionParamsStore, private val initialSyncProgressService: DefaultInitialSyncProgressService, private val syncTokenStore: SyncTokenStore, - private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask + private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask, + private val userStore: UserStore ) : SyncTask { override suspend fun execute(params: SyncTask.Params) { @@ -60,6 +62,8 @@ internal class DefaultSyncTask @Inject constructor(private val syncAPI: SyncAPI, val isInitialSync = token == null if (isInitialSync) { + // We might want to get the user information in parallel too + userStore.createOrUpdate(userId) initialSyncProgressService.endAll() initialSyncProgressService.startTask(R.string.initial_sync_start_importing_account, 100) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt index 51c296ba6e..22d012269b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserModule.kt @@ -53,4 +53,7 @@ internal abstract class UserModule { @Binds abstract fun bindUpdateIgnoredUserIdsTask(task: DefaultUpdateIgnoredUserIdsTask): UpdateIgnoredUserIdsTask + + @Binds + abstract fun bindUserStore(userStore: RealmUserStore): UserStore } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserStore.kt new file mode 100644 index 0000000000..cf5d2a7ce4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/UserStore.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.user + +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.internal.database.model.UserEntity +import im.vector.matrix.android.internal.util.awaitTransaction +import javax.inject.Inject + +internal interface UserStore { + suspend fun createOrUpdate(userId: String, displayName: String? = null, avatarUrl: String? = null) +} + +internal class RealmUserStore @Inject constructor(private val monarchy: Monarchy) : UserStore { + + override suspend fun createOrUpdate(userId: String, displayName: String?, avatarUrl: String?) { + monarchy.awaitTransaction { + val userEntity = UserEntity(userId, displayName ?: "", avatarUrl ?: "") + it.insertOrUpdate(userEntity) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/CoroutineToCallback.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/CoroutineToCallback.kt new file mode 100644 index 0000000000..54c19bd86f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/CoroutineToCallback.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.task + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.extensions.foldToCallback +import im.vector.matrix.android.internal.util.toCancelable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +internal fun CoroutineScope.launchToCallback( + context: CoroutineContext = EmptyCoroutineContext, + callback: MatrixCallback, + block: suspend () -> T +): Cancelable = launch(context, CoroutineStart.DEFAULT) { + val result = runCatching { + block() + } + result.foldToCallback(callback) +}.toCancelable() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/TaskExecutor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/TaskExecutor.kt index 14e546e0d6..d5392779d1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/TaskExecutor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/task/TaskExecutor.kt @@ -20,8 +20,8 @@ import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.internal.di.MatrixScope import im.vector.matrix.android.internal.extensions.foldToCallback import im.vector.matrix.android.internal.network.NetworkConnectivityChecker -import im.vector.matrix.android.internal.util.CancelableCoroutine import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import im.vector.matrix.android.internal.util.toCancelable import kotlinx.coroutines.* import timber.log.Timber import javax.inject.Inject @@ -34,27 +34,28 @@ internal class TaskExecutor @Inject constructor(private val coroutineDispatchers private val executorScope = CoroutineScope(SupervisorJob()) fun execute(task: ConfigurableTask): Cancelable { - val job = executorScope.launch(task.callbackThread.toDispatcher()) { - val resultOrFailure = runCatching { - withContext(task.executionThread.toDispatcher()) { - Timber.v("Enqueue task $task") - retry(task.retryCount) { - if (task.constraints.connectedToNetwork) { - Timber.v("Waiting network for $task") - networkConnectivityChecker.waitUntilConnected() + return executorScope + .launch(task.callbackThread.toDispatcher()) { + val resultOrFailure = runCatching { + withContext(task.executionThread.toDispatcher()) { + Timber.v("Enqueue task $task") + retry(task.retryCount) { + if (task.constraints.connectedToNetwork) { + Timber.v("Waiting network for $task") + networkConnectivityChecker.waitUntilConnected() + } + Timber.v("Execute task $task on ${Thread.currentThread().name}") + task.execute(task.params) + } } - Timber.v("Execute task $task on ${Thread.currentThread().name}") - task.execute(task.params) } + resultOrFailure + .onFailure { + Timber.d(it, "Task failed") + } + .foldToCallback(task.callback) } - } - resultOrFailure - .onFailure { - Timber.d(it, "Task failed") - } - .foldToCallback(task.callback) - } - return CancelableCoroutine(job) + .toCancelable() } fun cancelAll() = executorScope.coroutineContext.cancelChildren() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CancelableCoroutine.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CancelableCoroutine.kt index 71e2d3fdb2..53bec0d621 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CancelableCoroutine.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/CancelableCoroutine.kt @@ -19,7 +19,14 @@ package im.vector.matrix.android.internal.util import im.vector.matrix.android.api.util.Cancelable import kotlinx.coroutines.Job -internal class CancelableCoroutine(private val job: Job) : Cancelable { +internal fun Job.toCancelable(): Cancelable { + return CancelableCoroutine(this) +} + +/** + * Private, use the extension above + */ +private class CancelableCoroutine(private val job: Job) : Cancelable { override fun cancel() { if (!job.isCancelled) { diff --git a/vector/build.gradle b/vector/build.gradle index 1e19fd4d35..d77f669215 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -225,6 +225,7 @@ dependencies { def glide_version = '4.10.0' def moshi_version = '1.8.0' def daggerVersion = '2.24' + def autofill_version = "1.0.0-rc01" implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android-rx") @@ -256,6 +257,9 @@ dependencies { // Debug implementation 'com.facebook.stetho:stetho:1.5.1' + // Phone number https://github.com/google/libphonenumber + implementation 'com.googlecode.libphonenumber:libphonenumber:8.10.23' + // rx implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' @@ -290,6 +294,7 @@ dependencies { implementation "io.noties.markwon:html:$markwon_version" implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'com.google.android:flexbox:1.1.1' + implementation "androidx.autofill:autofill:$autofill_version" // Passphrase strength helper implementation 'com.nulab-inc:zxcvbn:1.2.7' diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 0c9bac61a1..5f1687c9c9 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -33,7 +33,9 @@ - + + + + + + +
+ + + diff --git a/vector/src/main/assets/sendObject.js b/vector/src/main/assets/sendObject.js new file mode 100644 index 0000000000..ebde72b58d --- /dev/null +++ b/vector/src/main/assets/sendObject.js @@ -0,0 +1 @@ +javascript:window.sendObjectMessage = function(parameters) { var iframe = document.createElement('iframe'); iframe.setAttribute('src', 'js:' + JSON.stringify(parameters)); document.documentElement.appendChild(iframe); iframe.parentNode.removeChild(iframe); iframe = null;}; \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/VectorApplication.kt b/vector/src/main/java/im/vector/riotx/VectorApplication.kt index 20a17e55d4..5ca888fc2e 100644 --- a/vector/src/main/java/im/vector/riotx/VectorApplication.kt +++ b/vector/src/main/java/im/vector/riotx/VectorApplication.kt @@ -36,7 +36,7 @@ import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.loader.glide.GlideImageLoader import im.vector.matrix.android.api.Matrix import im.vector.matrix.android.api.MatrixConfiguration -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.di.DaggerVectorComponent import im.vector.riotx.core.di.HasVectorInjector @@ -63,7 +63,7 @@ class VectorApplication : Application(), HasVectorInjector, MatrixConfiguration. lateinit var appContext: Context // font thread handler - @Inject lateinit var authenticator: Authenticator + @Inject lateinit var authenticationService: AuthenticationService @Inject lateinit var vectorConfiguration: VectorConfiguration @Inject lateinit var emojiCompatFontProvider: EmojiCompatFontProvider @Inject lateinit var emojiCompatWrapper: EmojiCompatWrapper @@ -115,8 +115,8 @@ class VectorApplication : Application(), HasVectorInjector, MatrixConfiguration. emojiCompatWrapper.init(fontRequest) notificationUtils.createNotificationChannels() - if (authenticator.hasAuthenticatedSessions() && !activeSessionHolder.hasActiveSession()) { - val lastAuthenticatedSession = authenticator.getLastAuthenticatedSession()!! + if (authenticationService.hasAuthenticatedSessions() && !activeSessionHolder.hasActiveSession()) { + val lastAuthenticatedSession = authenticationService.getLastAuthenticatedSession()!! activeSessionHolder.setActiveSession(lastAuthenticatedSession) lastAuthenticatedSession.configureAndStart(pushRuleTriggerListener, sessionListener) } diff --git a/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt index 3eccb668ea..12dfcbcaac 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt @@ -17,7 +17,7 @@ package im.vector.riotx.core.di import arrow.core.Option -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import im.vector.matrix.android.api.session.Session import im.vector.riotx.ActiveSessionDataSource import im.vector.riotx.features.crypto.keysrequest.KeyRequestHandler @@ -27,7 +27,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ActiveSessionHolder @Inject constructor(private val authenticator: Authenticator, +class ActiveSessionHolder @Inject constructor(private val authenticationService: AuthenticationService, private val sessionObservableStore: ActiveSessionDataSource, private val keyRequestHandler: KeyRequestHandler, private val incomingVerificationRequestHandler: IncomingVerificationRequestHandler @@ -64,7 +64,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticator: Authent // TODO: Stop sync ? // fun switchToSession(sessionParams: SessionParams) { -// val newActiveSession = authenticator.getSession(sessionParams) +// val newActiveSession = authenticationService.getSession(sessionParams) // activeSession.set(newActiveSession) // } } diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt index 6ae4619033..208246aa68 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt @@ -35,8 +35,8 @@ import im.vector.riotx.features.home.createdirect.CreateDirectRoomKnownUsersFrag import im.vector.riotx.features.home.group.GroupListFragment import im.vector.riotx.features.home.room.detail.RoomDetailFragment import im.vector.riotx.features.home.room.list.RoomListFragment -import im.vector.riotx.features.login.LoginFragment -import im.vector.riotx.features.login.LoginSsoFallbackFragment +import im.vector.riotx.features.login.* +import im.vector.riotx.features.login.terms.LoginTermsFragment import im.vector.riotx.features.reactions.EmojiSearchResultFragment import im.vector.riotx.features.roomdirectory.PublicRoomsFragment import im.vector.riotx.features.roomdirectory.createroom.CreateRoomFragment @@ -117,8 +117,63 @@ interface FragmentModule { @Binds @IntoMap - @FragmentKey(LoginSsoFallbackFragment::class) - fun bindLoginSsoFallbackFragment(fragment: LoginSsoFallbackFragment): Fragment + @FragmentKey(LoginCaptchaFragment::class) + fun bindLoginCaptchaFragment(fragment: LoginCaptchaFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginTermsFragment::class) + fun bindLoginTermsFragment(fragment: LoginTermsFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginServerUrlFormFragment::class) + fun bindLoginServerUrlFormFragment(fragment: LoginServerUrlFormFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginResetPasswordMailConfirmationFragment::class) + fun bindLoginResetPasswordMailConfirmationFragment(fragment: LoginResetPasswordMailConfirmationFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginResetPasswordFragment::class) + fun bindLoginResetPasswordFragment(fragment: LoginResetPasswordFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginResetPasswordSuccessFragment::class) + fun bindLoginResetPasswordSuccessFragment(fragment: LoginResetPasswordSuccessFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginServerSelectionFragment::class) + fun bindLoginServerSelectionFragment(fragment: LoginServerSelectionFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginSignUpSignInSelectionFragment::class) + fun bindLoginSignUpSignInSelectionFragment(fragment: LoginSignUpSignInSelectionFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginSplashFragment::class) + fun bindLoginSplashFragment(fragment: LoginSplashFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginWebFragment::class) + fun bindLoginWebFragment(fragment: LoginWebFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginGenericTextInputFormFragment::class) + fun bindLoginGenericTextInputFormFragment(fragment: LoginGenericTextInputFormFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(LoginWaitForEmailFragment::class) + fun bindLoginWaitForEmailFragment(fragment: LoginWaitForEmailFragment): Fragment @Binds @IntoMap diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt index 17622020d0..9f0f83a41f 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt @@ -32,8 +32,8 @@ import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsB import im.vector.riotx.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet import im.vector.riotx.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity -import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsBottomSheet import im.vector.riotx.features.home.room.list.RoomListModule +import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsBottomSheet import im.vector.riotx.features.invite.VectorInviteView import im.vector.riotx.features.link.LinkHandlerActivity import im.vector.riotx.features.login.LoginActivity @@ -47,7 +47,7 @@ import im.vector.riotx.features.reactions.EmojiReactionPickerActivity import im.vector.riotx.features.reactions.widget.ReactionButton import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity -import im.vector.riotx.features.settings.* +import im.vector.riotx.features.settings.VectorSettingsActivity import im.vector.riotx.features.share.IncomingShareActivity import im.vector.riotx.features.ui.UiStateRepository diff --git a/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt index d31955ce8e..c4b2c40787 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt @@ -21,13 +21,14 @@ import android.content.res.Resources import dagger.BindsInstance import dagger.Component import im.vector.matrix.android.api.Matrix -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import im.vector.matrix.android.api.session.Session import im.vector.riotx.ActiveSessionDataSource import im.vector.riotx.EmojiCompatFontProvider import im.vector.riotx.EmojiCompatWrapper import im.vector.riotx.VectorApplication import im.vector.riotx.core.pushers.PushersManager +import im.vector.riotx.core.utils.AssetReader import im.vector.riotx.core.utils.DimensionConverter import im.vector.riotx.features.configuration.VectorConfiguration import im.vector.riotx.features.crypto.keysrequest.KeyRequestHandler @@ -69,6 +70,8 @@ interface VectorComponent { fun resources(): Resources + fun assetReader(): AssetReader + fun dimensionConverter(): DimensionConverter fun vectorConfiguration(): VectorConfiguration @@ -97,7 +100,7 @@ interface VectorComponent { fun incomingKeyRequestHandler(): KeyRequestHandler - fun authenticator(): Authenticator + fun authenticationService(): AuthenticationService fun bugReporter(): BugReporter diff --git a/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt b/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt index e3df0eb635..84441d88e1 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt @@ -24,7 +24,7 @@ import dagger.Binds import dagger.Module import dagger.Provides import im.vector.matrix.android.api.Matrix -import im.vector.matrix.android.api.auth.Authenticator +import im.vector.matrix.android.api.auth.AuthenticationService import im.vector.matrix.android.api.session.Session import im.vector.riotx.features.navigation.DefaultNavigator import im.vector.riotx.features.navigation.Navigator @@ -64,8 +64,8 @@ abstract class VectorModule { @Provides @JvmStatic - fun providesAuthenticator(matrix: Matrix): Authenticator { - return matrix.authenticator() + fun providesAuthenticationService(matrix: Matrix): AuthenticationService { + return matrix.authenticationService() } } diff --git a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt index cc1e4dabc7..0876701504 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ViewModelModule.kt @@ -31,6 +31,7 @@ import im.vector.riotx.features.home.HomeSharedActionViewModel import im.vector.riotx.features.home.createdirect.CreateDirectRoomSharedActionViewModel import im.vector.riotx.features.home.room.detail.timeline.action.MessageSharedActionViewModel import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel +import im.vector.riotx.features.login.LoginSharedActionViewModel import im.vector.riotx.features.reactions.EmojiChooserViewModel import im.vector.riotx.features.roomdirectory.RoomDirectorySharedActionViewModel import im.vector.riotx.features.workers.signout.SignOutViewModel @@ -112,4 +113,9 @@ interface ViewModelModule { @IntoMap @ViewModelKey(RoomDirectorySharedActionViewModel::class) fun bindRoomDirectorySharedActionViewModel(viewModel: RoomDirectorySharedActionViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(LoginSharedActionViewModel::class) + fun bindLoginSharedActionViewModel(viewModel: LoginSharedActionViewModel): ViewModel } diff --git a/vector/src/main/java/im/vector/riotx/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/riotx/core/error/ErrorFormatter.kt index 10c4fe3354..621031f166 100644 --- a/vector/src/main/java/im/vector/riotx/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/core/error/ErrorFormatter.kt @@ -21,6 +21,7 @@ import im.vector.matrix.android.api.failure.MatrixError import im.vector.riotx.R import im.vector.riotx.core.resources.StringProvider import java.net.SocketTimeoutException +import java.net.UnknownHostException import javax.inject.Inject class ErrorFormatter @Inject constructor(private val stringProvider: StringProvider) { @@ -34,23 +35,61 @@ class ErrorFormatter @Inject constructor(private val stringProvider: StringProvi return when (throwable) { null -> null is Failure.NetworkConnection -> { - if (throwable.ioException is SocketTimeoutException) { - stringProvider.getString(R.string.error_network_timeout) - } else { - stringProvider.getString(R.string.error_no_network) + when { + throwable.ioException is SocketTimeoutException -> + stringProvider.getString(R.string.error_network_timeout) + throwable.ioException is UnknownHostException -> + // Invalid homeserver? + stringProvider.getString(R.string.login_error_unknown_host) + else -> + stringProvider.getString(R.string.error_no_network) } } is Failure.ServerError -> { - if (throwable.error.code == MatrixError.M_CONSENT_NOT_GIVEN) { - // Special case for terms and conditions - stringProvider.getString(R.string.error_terms_not_accepted) - } else { - throwable.error.message.takeIf { it.isNotEmpty() } - ?: throwable.error.code.takeIf { it.isNotEmpty() } + when { + throwable.error.code == MatrixError.M_CONSENT_NOT_GIVEN -> { + // Special case for terms and conditions + stringProvider.getString(R.string.error_terms_not_accepted) + } + throwable.error.code == MatrixError.FORBIDDEN + && throwable.error.message == "Invalid password" -> { + stringProvider.getString(R.string.auth_invalid_login_param) + } + throwable.error.code == MatrixError.USER_IN_USE -> { + stringProvider.getString(R.string.login_signup_error_user_in_use) + } + throwable.error.code == MatrixError.BAD_JSON -> { + stringProvider.getString(R.string.login_error_bad_json) + } + throwable.error.code == MatrixError.NOT_JSON -> { + stringProvider.getString(R.string.login_error_not_json) + } + throwable.error.code == MatrixError.LIMIT_EXCEEDED -> { + limitExceededError(throwable.error) + } + throwable.error.code == MatrixError.THREEPID_NOT_FOUND -> { + stringProvider.getString(R.string.login_reset_password_error_not_found) + } + else -> { + throwable.error.message.takeIf { it.isNotEmpty() } + ?: throwable.error.code.takeIf { it.isNotEmpty() } + } } } else -> throwable.localizedMessage } ?: stringProvider.getString(R.string.unknown_error) } + + private fun limitExceededError(error: MatrixError): String { + val delay = error.retryAfterMillis + + return if (delay == null) { + stringProvider.getString(R.string.login_error_limit_exceeded) + } else { + // Ensure at least 1 second + val delaySeconds = delay.toInt() / 1000 + 1 + stringProvider.getQuantityString(R.plurals.login_error_limit_exceeded_retry_after, delaySeconds, delaySeconds) + } + } } diff --git a/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt b/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt new file mode 100644 index 0000000000..dd4257fe1f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.core.error + +import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.api.failure.MatrixError +import javax.net.ssl.HttpsURLConnection + +fun Throwable.is401(): Boolean { + return (this is Failure.ServerError && this.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED /* 401 */ + && this.error.code == MatrixError.UNAUTHORIZED) +} diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt index 6d7c3d39e6..f9f5d3b3d2 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Activity.kt @@ -18,6 +18,7 @@ package im.vector.riotx.core.extensions import android.os.Parcelable import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction import im.vector.riotx.core.platform.VectorBaseActivity fun VectorBaseActivity.addFragment(frameId: Int, fragment: Fragment) { @@ -44,8 +45,13 @@ fun VectorBaseActivity.addFragmentToBackstack(frameId: Int, fragment: Fragment, supportFragmentManager.commitTransaction { replace(frameId, fragment).addToBackStack(tag) } } -fun VectorBaseActivity.addFragmentToBackstack(frameId: Int, fragmentClass: Class, params: Parcelable? = null, tag: String? = null) { +fun VectorBaseActivity.addFragmentToBackstack(frameId: Int, + fragmentClass: Class, + params: Parcelable? = null, + tag: String? = null, + option: ((FragmentTransaction) -> Unit)? = null) { supportFragmentManager.commitTransaction { + option?.invoke(this) replace(frameId, fragmentClass, params.toMvRxBundle(), tag).addToBackStack(tag) } } diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt index 1e3da7f878..5bd6852e8a 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt @@ -17,6 +17,7 @@ package im.vector.riotx.core.extensions import android.os.Bundle +import android.util.Patterns import androidx.fragment.app.Fragment fun Boolean.toOnOff() = if (this) "ON" else "OFF" @@ -27,3 +28,8 @@ inline fun T.ooi(block: (T) -> Unit): T = also(block) * Apply argument to a Fragment */ fun T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bundle().apply(block) } + +/** + * Check if a CharSequence is an email + */ +fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt index 7db27ececb..b93ab3fdce 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Fragment.kt @@ -79,3 +79,6 @@ fun VectorBaseFragment.addChildFragmentToBackstack(frameId: Int, replace(frameId, fragmentClass, params.toMvRxBundle(), tag).addToBackStack(tag) } } + +// Define a missing constant +const val POP_BACK_STACK_EXCLUSIVE = 0 diff --git a/vector/src/main/java/im/vector/riotx/core/platform/OnBackPressed.kt b/vector/src/main/java/im/vector/riotx/core/platform/OnBackPressed.kt index 17f7730f86..c8a58997a1 100644 --- a/vector/src/main/java/im/vector/riotx/core/platform/OnBackPressed.kt +++ b/vector/src/main/java/im/vector/riotx/core/platform/OnBackPressed.kt @@ -21,6 +21,7 @@ interface OnBackPressed { /** * Returns true, if the on back pressed event has been handled by this Fragment. * Otherwise return false + * @param toolbarButton true if this is the back button from the toolbar */ - fun onBackPressed(): Boolean + fun onBackPressed(toolbarButton: Boolean): Boolean } diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt index 4a3056657f..79b040cd41 100644 --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt @@ -278,7 +278,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector { override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { - onBackPressed() + onBackPressed(true) return true } @@ -286,20 +286,24 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector { } override fun onBackPressed() { - val handled = recursivelyDispatchOnBackPressed(supportFragmentManager) + onBackPressed(false) + } + + private fun onBackPressed(fromToolbar: Boolean) { + val handled = recursivelyDispatchOnBackPressed(supportFragmentManager, fromToolbar) if (!handled) { super.onBackPressed() } } - private fun recursivelyDispatchOnBackPressed(fm: FragmentManager): Boolean { - val reverseOrder = fm.fragments.filter { it is VectorBaseFragment }.reversed() + private fun recursivelyDispatchOnBackPressed(fm: FragmentManager, fromToolbar: Boolean): Boolean { + val reverseOrder = fm.fragments.filterIsInstance().reversed() for (f in reverseOrder) { - val handledByChildFragments = recursivelyDispatchOnBackPressed(f.childFragmentManager) + val handledByChildFragments = recursivelyDispatchOnBackPressed(f.childFragmentManager, fromToolbar) if (handledByChildFragments) { return true } - if (f is OnBackPressed && f.onBackPressed()) { + if (f is OnBackPressed && f.onBackPressed(fromToolbar)) { return true } } diff --git a/vector/src/main/java/im/vector/riotx/core/utils/AssetReader.kt b/vector/src/main/java/im/vector/riotx/core/utils/AssetReader.kt new file mode 100644 index 0000000000..908f0e68b6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/utils/AssetReader.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.core.utils + +import android.content.Context +import timber.log.Timber +import javax.inject.Inject + +/** + * Read asset files + */ +class AssetReader @Inject constructor(private val context: Context) { + + /* ========================================================================================== + * CACHE + * ========================================================================================== */ + private val cache = mutableMapOf() + + /** + * Read an asset from resource and return a String or null in case of error. + * + * @param assetFilename Asset filename + * @return the content of the asset file, or null in case of error + */ + fun readAssetFile(assetFilename: String): String? { + return cache.getOrPut(assetFilename, { + return try { + context.assets.open(assetFilename) + .use { asset -> + buildString { + var ch = asset.read() + while (ch != -1) { + append(ch.toChar()) + ch = asset.read() + } + } + } + } catch (e: Exception) { + Timber.e(e, "## readAssetFile() failed") + null + } + }) + } + + fun clearCache() { + cache.clear() + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/utils/ViewUtils.kt b/vector/src/main/java/im/vector/riotx/core/utils/ViewUtils.kt new file mode 100644 index 0000000000..335b9112ef --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/utils/ViewUtils.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.core.utils + +import android.text.Editable +import android.view.ViewGroup +import androidx.core.view.children +import com.google.android.material.textfield.TextInputLayout +import im.vector.riotx.core.platform.SimpleTextWatcher + +/** + * Find all TextInputLayout in a ViewGroup and in all its descendants + */ +fun ViewGroup.findAllTextInputLayout(): List { + val res = ArrayList() + + children.forEach { + if (it is TextInputLayout) { + res.add(it) + } else if (it is ViewGroup) { + // Recursive call + res.addAll(it.findAllTextInputLayout()) + } + } + + return res +} + +/** + * Add a text change listener to all TextInputEditText to reset error on its TextInputLayout when the text is changed + */ +fun autoResetTextInputLayoutErrors(textInputLayouts: List) { + textInputLayouts.forEach { + it.editText?.addTextChangedListener(object : SimpleTextWatcher() { + override fun afterTextChanged(s: Editable) { + // Reset the error + it.error = null + } + }) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/MainActivity.kt b/vector/src/main/java/im/vector/riotx/features/MainActivity.kt index 02a206fc9b..7064ad0d49 100644 --- a/vector/src/main/java/im/vector/riotx/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/MainActivity.kt @@ -21,9 +21,7 @@ import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AlertDialog import com.bumptech.glide.Glide -import im.vector.matrix.android.api.Matrix import im.vector.matrix.android.api.MatrixCallback -import im.vector.matrix.android.api.auth.Authenticator import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.di.ScreenComponent @@ -56,8 +54,6 @@ class MainActivity : VectorBaseActivity() { } } - @Inject lateinit var matrix: Matrix - @Inject lateinit var authenticator: Authenticator @Inject lateinit var sessionHolder: ActiveSessionHolder @Inject lateinit var errorFormatter: ErrorFormatter diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt index a5e9a7b4bf..04d1802264 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt @@ -329,7 +329,7 @@ class RoomListFragment @Inject constructor( stateView.state = StateView.State.Error(message) } - override fun onBackPressed(): Boolean { + override fun onBackPressed(toolbarButton: Boolean): Boolean { if (createChatFabMenu.onBackPressed()) { return true } diff --git a/vector/src/main/java/im/vector/riotx/features/homeserver/ServerUrlsRepository.kt b/vector/src/main/java/im/vector/riotx/features/homeserver/ServerUrlsRepository.kt index 9535499d70..f485226935 100644 --- a/vector/src/main/java/im/vector/riotx/features/homeserver/ServerUrlsRepository.kt +++ b/vector/src/main/java/im/vector/riotx/features/homeserver/ServerUrlsRepository.kt @@ -68,8 +68,8 @@ object ServerUrlsRepository { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getString(HOME_SERVER_URL_PREF, - prefs.getString(DEFAULT_REFERRER_HOME_SERVER_URL_PREF, - getDefaultHomeServerUrl(context))!!)!! + prefs.getString(DEFAULT_REFERRER_HOME_SERVER_URL_PREF, + getDefaultHomeServerUrl(context))!!)!! } /** @@ -80,5 +80,5 @@ object ServerUrlsRepository { /** * Return default home server url from resources */ - fun getDefaultHomeServerUrl(context: Context): String = context.getString(R.string.default_hs_server_url) + fun getDefaultHomeServerUrl(context: Context): String = context.getString(R.string.matrix_org_server_url) } diff --git a/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt new file mode 100644 index 0000000000..6cca32cf7f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.annotation.CallSuper +import androidx.appcompat.app.AlertDialog +import androidx.transition.TransitionInflater +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.api.failure.MatrixError +import im.vector.riotx.R +import im.vector.riotx.core.platform.OnBackPressed +import im.vector.riotx.core.platform.VectorBaseFragment +import javax.net.ssl.HttpsURLConnection + +/** + * Parent Fragment for all the login/registration screens + */ +abstract class AbstractLoginFragment : VectorBaseFragment(), OnBackPressed { + + protected val loginViewModel: LoginViewModel by activityViewModel() + protected lateinit var loginSharedActionViewModel: LoginSharedActionViewModel + + private var isResetPasswordStarted = false + + // Due to async, we keep a boolean to avoid displaying twice the cancellation dialog + private var displayCancelDialog = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move) + } + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + loginSharedActionViewModel = activityViewModelProvider.get(LoginSharedActionViewModel::class.java) + + loginViewModel.viewEvents + .observe() + .subscribe { + handleLoginViewEvents(it) + } + .disposeOnDestroyView() + } + + private fun handleLoginViewEvents(loginViewEvents: LoginViewEvents) { + when (loginViewEvents) { + is LoginViewEvents.Error -> showError(loginViewEvents.throwable) + else -> + // This is handled by the Activity + Unit + } + } + + private fun showError(throwable: Throwable) { + when (throwable) { + is Failure.ServerError -> { + if (throwable.error.code == MatrixError.FORBIDDEN + && throwable.httpCode == HttpsURLConnection.HTTP_FORBIDDEN /* 403 */) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(getString(R.string.login_registration_disabled)) + .setPositiveButton(R.string.ok, null) + .show() + } else { + onError(throwable) + } + } + else -> onError(throwable) + } + } + + abstract fun onError(throwable: Throwable) + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + return when { + displayCancelDialog && loginViewModel.isRegistrationStarted -> { + // Ask for confirmation before cancelling the registration + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_signup_cancel_confirmation_title) + .setMessage(R.string.login_signup_cancel_confirmation_content) + .setPositiveButton(R.string.yes) { _, _ -> + displayCancelDialog = false + vectorBaseActivity.onBackPressed() + } + .setNegativeButton(R.string.no, null) + .show() + + true + } + displayCancelDialog && isResetPasswordStarted -> { + // Ask for confirmation before cancelling the reset password + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_reset_password_cancel_confirmation_title) + .setMessage(R.string.login_reset_password_cancel_confirmation_content) + .setPositiveButton(R.string.yes) { _, _ -> + displayCancelDialog = false + vectorBaseActivity.onBackPressed() + } + .setNegativeButton(R.string.no, null) + .show() + + true + } + else -> { + resetViewModel() + // Do not consume the Back event + false + } + } + } + + final override fun invalidate() = withState(loginViewModel) { state -> + // True when email is sent with success to the homeserver + isResetPasswordStarted = state.resetPasswordEmail.isNullOrBlank().not() + + updateWithState(state) + } + + open fun updateWithState(state: LoginViewState) { + // No op by default + } + + // Reset any modification on the loginViewModel by the current fragment + abstract fun resetViewModel() +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/Config.kt b/vector/src/main/java/im/vector/riotx/features/login/Config.kt new file mode 100644 index 0000000000..964e3fa0a1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/Config.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +// TODO Check the link with Nad +const val MODULAR_LINK = "https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication" diff --git a/vector/src/main/java/im/vector/riotx/features/login/HomeServerConnectionConfigFactory.kt b/vector/src/main/java/im/vector/riotx/features/login/HomeServerConnectionConfigFactory.kt new file mode 100644 index 0000000000..9f116b99f7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/HomeServerConnectionConfigFactory.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig +import timber.log.Timber +import javax.inject.Inject + +class HomeServerConnectionConfigFactory @Inject constructor() { + + fun create(url: String?): HomeServerConnectionConfig? { + if (url == null) { + return null + } + + return try { + HomeServerConnectionConfig.Builder() + .withHomeServerUri(url) + .build() + } catch (t: Throwable) { + Timber.e(t) + null + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/JavascriptResponse.kt b/vector/src/main/java/im/vector/riotx/features/login/JavascriptResponse.kt new file mode 100644 index 0000000000..4d88cf6097 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/JavascriptResponse.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.auth.data.Credentials + +@JsonClass(generateAdapter = true) +data class JavascriptResponse( + @Json(name = "action") + val action: String? = null, + + /** + * Use for captcha result + */ + @Json(name = "response") + val response: String? = null, + + /** + * Used for login/registration result + */ + @Json(name = "credentials") + val credentials: Credentials? = null +) diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt index bb42bc8e0c..618b3ea85d 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt @@ -17,12 +17,42 @@ package im.vector.riotx.features.login import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.auth.registration.RegisterThreePid import im.vector.riotx.core.platform.VectorViewModelAction sealed class LoginAction : VectorViewModelAction { + data class UpdateServerType(val serverType: ServerType) : LoginAction() data class UpdateHomeServer(val homeServerUrl: String) : LoginAction() - data class Login(val login: String, val password: String) : LoginAction() - data class SsoLoginSuccess(val credentials: Credentials) : LoginAction() - data class NavigateTo(val target: LoginActivity.Navigation) : LoginAction() + data class UpdateSignMode(val signMode: SignMode) : LoginAction() + data class WebLoginSuccess(val credentials: Credentials) : LoginAction() data class InitWith(val loginConfig: LoginConfig) : LoginAction() + data class ResetPassword(val email: String, val newPassword: String) : LoginAction() + object ResetPasswordMailConfirmed : LoginAction() + + // Login or Register, depending on the signMode + data class LoginOrRegister(val username: String, val password: String, val initialDeviceName: String) : LoginAction() + + // Register actions + open class RegisterAction : LoginAction() + + data class AddThreePid(val threePid: RegisterThreePid) : RegisterAction() + object SendAgainThreePid : RegisterAction() + // TODO Confirm Email (from link in the email, open in the phone, intercepted by RiotX) + data class ValidateThreePid(val code: String) : RegisterAction() + + data class CheckIfEmailHasBeenValidated(val delayMillis: Long) : RegisterAction() + object StopEmailValidationCheck : RegisterAction() + + data class CaptchaDone(val captchaResponse: String) : RegisterAction() + object AcceptTerms : RegisterAction() + object RegisterDummy : RegisterAction() + + // Reset actions + open class ResetAction : LoginAction() + + object ResetHomeServerType : ResetAction() + object ResetHomeServerUrl : ResetAction() + object ResetSignMode : ResetAction() + object ResetLogin : ResetAction() + object ResetResetPassword : ResetAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt index abed22cb5e..2dec402f85 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt @@ -18,28 +18,41 @@ package im.vector.riotx.features.login import android.content.Context import android.content.Intent +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager -import com.airbnb.mvrx.Success +import androidx.fragment.app.FragmentTransaction import com.airbnb.mvrx.viewModel +import com.airbnb.mvrx.withState +import im.vector.matrix.android.api.auth.registration.FlowResult +import im.vector.matrix.android.api.auth.registration.Stage import im.vector.riotx.R import im.vector.riotx.core.di.ScreenComponent +import im.vector.riotx.core.extensions.POP_BACK_STACK_EXCLUSIVE import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragmentToBackstack -import im.vector.riotx.core.extensions.observeEvent +import im.vector.riotx.core.platform.ToolbarConfigurable import im.vector.riotx.core.platform.VectorBaseActivity -import im.vector.riotx.features.disclaimer.showDisclaimerDialog import im.vector.riotx.features.home.HomeActivity +import im.vector.riotx.features.login.terms.LoginTermsFragment +import im.vector.riotx.features.login.terms.LoginTermsFragmentArgument +import im.vector.riotx.features.login.terms.toLocalizedLoginTerms +import kotlinx.android.synthetic.main.activity_login.* import javax.inject.Inject -class LoginActivity : VectorBaseActivity() { - - // Supported navigation actions for this Activity - sealed class Navigation { - object OpenSsoLoginFallback : Navigation() - object GoBack : Navigation() - } +/** + * The LoginActivity manages the fragment navigation and also display the loading View + */ +class LoginActivity : VectorBaseActivity(), ToolbarConfigurable { private val loginViewModel: LoginViewModel by viewModel() + private lateinit var loginSharedActionViewModel: LoginSharedActionViewModel @Inject lateinit var loginViewModelFactory: LoginViewModel.Factory @@ -47,42 +60,290 @@ class LoginActivity : VectorBaseActivity() { injector.inject(this) } - override fun getLayoutRes() = R.layout.activity_simple + private val enterAnim = R.anim.enter_fade_in + private val exitAnim = R.anim.exit_fade_out + + private val popEnterAnim = R.anim.no_anim + private val popExitAnim = R.anim.exit_fade_out + + private val topFragment: Fragment? + get() = supportFragmentManager.findFragmentById(R.id.loginFragmentContainer) + + private val commonOption: (FragmentTransaction) -> Unit = { ft -> + // Find the loginLogo on the current Fragment, this should not return null + (topFragment?.view as? ViewGroup) + // Find findViewById does not work, I do not know why + // findViewById(R.id.loginLogo) + ?.children + ?.first { it.id == R.id.loginLogo } + ?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // TODO + ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + } + + override fun getLayoutRes() = R.layout.activity_login override fun initUiAndData() { if (isFirstCreation()) { - addFragment(R.id.simpleFragmentContainer, LoginFragment::class.java) + addFragment(R.id.loginFragmentContainer, LoginSplashFragment::class.java) } // Get config extra val loginConfig = intent.getParcelableExtra(EXTRA_CONFIG) if (loginConfig != null && isFirstCreation()) { + // TODO Check this loginViewModel.handle(LoginAction.InitWith(loginConfig)) } - loginViewModel.navigationLiveData.observeEvent(this) { - when (it) { - is Navigation.OpenSsoLoginFallback -> addFragmentToBackstack(R.id.simpleFragmentContainer, LoginSsoFallbackFragment::class.java) - is Navigation.GoBack -> supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + loginSharedActionViewModel = viewModelProvider.get(LoginSharedActionViewModel::class.java) + loginSharedActionViewModel.observe() + .subscribe { + handleLoginNavigation(it) + } + .disposeOnDestroy() + + loginViewModel + .subscribe(this) { + updateWithState(it) + } + .disposeOnDestroy() + + loginViewModel.viewEvents + .observe() + .subscribe { + handleLoginViewEvents(it) + } + .disposeOnDestroy() + } + + private fun handleLoginNavigation(loginNavigation: LoginNavigation) { + // Assigning to dummy make sure we do not forget a case + @Suppress("UNUSED_VARIABLE") + val dummy = when (loginNavigation) { + is LoginNavigation.OpenServerSelection -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginServerSelectionFragment::class.java, + option = { ft -> + findViewById(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + findViewById(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + findViewById(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // TODO Disabled because it provokes a flickering + // ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + }) + is LoginNavigation.OnServerSelectionDone -> onServerSelectionDone() + is LoginNavigation.OnSignModeSelected -> onSignModeSelected() + is LoginNavigation.OnLoginFlowRetrieved -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginSignUpSignInSelectionFragment::class.java, + option = commonOption) + is LoginNavigation.OnWebLoginError -> onWebLoginError(loginNavigation) + is LoginNavigation.OnForgetPasswordClicked -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordFragment::class.java, + option = commonOption) + is LoginNavigation.OnResetPasswordSendThreePidDone -> { + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordMailConfirmationFragment::class.java, + option = commonOption) } + is LoginNavigation.OnResetPasswordMailConfirmationSuccess -> { + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordSuccessFragment::class.java, + option = commonOption) + } + is LoginNavigation.OnResetPasswordMailConfirmationSuccessDone -> { + // Go back to the login fragment + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + } + is LoginNavigation.OnSendEmailSuccess -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWaitForEmailFragment::class.java, + LoginWaitForEmailFragmentArgument(loginNavigation.email), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is LoginNavigation.OnSendMsisdnSuccess -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, loginNavigation.msisdn), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + } + } + + private fun handleLoginViewEvents(loginViewEvents: LoginViewEvents) { + when (loginViewEvents) { + is LoginViewEvents.RegistrationFlowResult -> { + // Check that all flows are supported by the application + if (loginViewEvents.flowResult.missingStages.any { !it.isSupported() }) { + // Display a popup to propose use web fallback + onRegistrationStageNotSupported() + } else { + if (loginViewEvents.isRegistrationStarted) { + // Go on with registration flow + handleRegistrationNavigation(loginViewEvents.flowResult) + } else { + // First ask for login and password + // I add a tag to indicate that this fragment is a registration stage. + // This way it will be automatically popped in when starting the next registration stage + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment::class.java, + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption + ) + } + } + } + is LoginViewEvents.OutdatedHomeserver -> + AlertDialog.Builder(this) + .setTitle(R.string.login_error_outdated_homeserver_title) + .setMessage(R.string.login_error_outdated_homeserver_content) + .setPositiveButton(R.string.ok, null) + .show() + is LoginViewEvents.Error -> + // This is handled by the Fragments + Unit + } + } + + private fun updateWithState(loginViewState: LoginViewState) { + if (loginViewState.isUserLogged()) { + val intent = HomeActivity.newIntent(this) + startActivity(intent) + finish() + return } - loginViewModel.selectSubscribe(this, LoginViewState::asyncLoginAction) { - if (it is Success) { - val intent = HomeActivity.newIntent(this) - startActivity(intent) - finish() + // Loading + loginLoading.isVisible = loginViewState.isLoading() + } + + private fun onWebLoginError(onWebLoginError: LoginNavigation.OnWebLoginError) { + // Pop the backstack + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + // And inform the user + AlertDialog.Builder(this) + .setTitle(R.string.dialog_title_error) + .setMessage(getString(R.string.login_sso_error_message, onWebLoginError.description, onWebLoginError.errorCode)) + .setPositiveButton(R.string.ok, null) + .show() + } + + private fun onServerSelectionDone() = withState(loginViewModel) { state -> + when (state.serverType) { + ServerType.MatrixOrg -> Unit // In this case, we wait for the login flow + ServerType.Modular, + ServerType.Other -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginServerUrlFormFragment::class.java, + option = commonOption) + } + } + + private fun onSignModeSelected() = withState(loginViewModel) { state -> + when (state.signMode) { + SignMode.Unknown -> error("Sign mode has to be set before calling this method") + SignMode.SignUp -> { + // This is managed by the LoginViewEvents + } + SignMode.SignIn -> { + // It depends on the LoginMode + when (state.loginMode) { + LoginMode.Unknown -> error("Developer error") + LoginMode.Password -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment::class.java, + tag = FRAGMENT_LOGIN_TAG, + option = commonOption) + LoginMode.Sso -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment::class.java, + option = commonOption) + LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes) + } } } } - override fun onResume() { - super.onResume() + private fun onRegistrationStageNotSupported() { + AlertDialog.Builder(this) + .setTitle(R.string.app_name) + .setMessage(getString(R.string.login_registration_not_supported)) + .setPositiveButton(R.string.yes) { _, _ -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment::class.java, + option = commonOption) + } + .setNegativeButton(R.string.no, null) + .show() + } - showDisclaimerDialog(this) + private fun onLoginModeNotSupported(supportedTypes: List) { + AlertDialog.Builder(this) + .setTitle(R.string.app_name) + .setMessage(getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" })) + .setPositiveButton(R.string.yes) { _, _ -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment::class.java, + option = commonOption) + } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun handleRegistrationNavigation(flowResult: FlowResult) { + // Complete all mandatory stages first + val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory } + + if (mandatoryStage != null) { + doStage(mandatoryStage) + } else { + // Consider optional stages + val optionalStage = flowResult.missingStages.firstOrNull { !it.mandatory && it !is Stage.Dummy } + if (optionalStage == null) { + // Should not happen... + } else { + doStage(optionalStage) + } + } + } + + private fun doStage(stage: Stage) { + // Ensure there is no fragment for registration stage in the backstack + supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + when (stage) { + is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginCaptchaFragment::class.java, + LoginCaptchaFragmentArgument(stage.publicKey), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Email -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Msisdn -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Terms -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginTermsFragment::class.java, + LoginTermsFragmentArgument(stage.policies.toLocalizedLoginTerms(getString(R.string.resources_language))), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + else -> Unit // Should not happen + } + } + + override fun configure(toolbar: Toolbar) { + configureToolbar(toolbar) } companion object { + private const val FRAGMENT_REGISTRATION_STAGE_TAG = "FRAGMENT_REGISTRATION_STAGE_TAG" + private const val FRAGMENT_LOGIN_TAG = "FRAGMENT_LOGIN_TAG" + private const val EXTRA_CONFIG = "EXTRA_CONFIG" fun newIntent(context: Context, loginConfig: LoginConfig?): Intent { diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt new file mode 100644 index 0000000000..3ff3e902cb --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt @@ -0,0 +1,193 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Build +import android.os.Parcelable +import android.view.KeyEvent +import android.webkit.* +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import im.vector.matrix.android.internal.di.MoshiProvider +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.utils.AssetReader +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.fragment_login_captcha.* +import timber.log.Timber +import java.net.URLDecoder +import java.util.* +import javax.inject.Inject + +@Parcelize +data class LoginCaptchaFragmentArgument( + val siteKey: String +) : Parcelable + +/** + * In this screen, the user is asked to confirm he is not a robot + */ +class LoginCaptchaFragment @Inject constructor( + private val assetReader: AssetReader, + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_captcha + + private val params: LoginCaptchaFragmentArgument by args() + + private var isWebViewLoaded = false + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: LoginViewState) { + loginCaptchaWevView.settings.javaScriptEnabled = true + + val reCaptchaPage = assetReader.readAssetFile("reCaptchaPage.html") ?: error("missing asset reCaptchaPage.html") + + val html = Formatter().format(reCaptchaPage, params.siteKey).toString() + val mime = "text/html" + val encoding = "utf-8" + + val homeServerUrl = state.homeServerUrl ?: error("missing url of homeserver") + loginCaptchaWevView.loadDataWithBaseURL(homeServerUrl, html, mime, encoding, null) + loginCaptchaWevView.requestLayout() + + loginCaptchaWevView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + // Show loader + loginCaptchaProgress.isVisible = true + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + + // Hide loader + loginCaptchaProgress.isVisible = false + } + + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { + Timber.d("## onReceivedSslError() : " + error.certificate) + + if (!isAdded) { + return + } + + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.ssl_could_not_verify) + .setPositiveButton(R.string.ssl_trust) { _, _ -> + Timber.d("## onReceivedSslError() : the user trusted") + handler.proceed() + } + .setNegativeButton(R.string.ssl_do_not_trust) { _, _ -> + Timber.d("## onReceivedSslError() : the user did not trust") + handler.cancel() + } + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + handler.cancel() + Timber.d("## onReceivedSslError() : the user dismisses the trust dialog.") + dialog.dismiss() + return@OnKeyListener true + } + false + }) + .setCancelable(false) + .show() + } + + // common error message + private fun onError(errorMessage: String) { + Timber.e("## onError() : $errorMessage") + + // TODO + // Toast.makeText(this@AccountCreationCaptchaActivity, errorMessage, Toast.LENGTH_LONG).show() + + // on error case, close this activity + // runOnUiThread(Runnable { finish() }) + } + + @SuppressLint("NewApi") + override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { + super.onReceivedHttpError(view, request, errorResponse) + + if (request.url.toString().endsWith("favicon.ico")) { + // Ignore this error + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + onError(errorResponse.reasonPhrase) + } else { + onError(errorResponse.toString()) + } + } + + override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { + @Suppress("DEPRECATION") + super.onReceivedError(view, errorCode, description, failingUrl) + onError(description) + } + + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + if (url?.startsWith("js:") == true) { + var json = url.substring(3) + var javascriptResponse: JavascriptResponse? = null + + try { + // URL decode + json = URLDecoder.decode(json, "UTF-8") + javascriptResponse = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java).fromJson(json) + } catch (e: Exception) { + Timber.e(e, "## shouldOverrideUrlLoading(): failed") + } + + val response = javascriptResponse?.response + if (javascriptResponse?.action == "verifyCallback" && response != null) { + loginViewModel.handle(LoginAction.CaptchaDone(response)) + } + } + return true + } + } + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) + } + + override fun updateWithState(state: LoginViewState) { + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt index 456e4b2bb3..cc4f25141e 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt @@ -16,34 +16,38 @@ package im.vector.riotx.features.login +import android.os.Build import android.os.Bundle import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.Toast +import androidx.autofill.HintConstants import androidx.core.view.isVisible -import androidx.transition.TransitionManager -import com.airbnb.mvrx.* -import com.jakewharton.rxbinding3.view.focusChanges +import butterknife.OnClick +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.api.failure.MatrixError import im.vector.riotx.R -import im.vector.riotx.core.extensions.setTextWithColoredPart +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.showPassword -import im.vector.riotx.core.platform.VectorBaseFragment -import im.vector.riotx.core.utils.openUrlInExternalBrowser -import im.vector.riotx.features.homeserver.ServerUrlsRepository import io.reactivex.Observable -import io.reactivex.functions.Function3 +import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy import kotlinx.android.synthetic.main.fragment_login.* import javax.inject.Inject /** - * What can be improved: - * - When filtering more (when entering new chars), we could filter on result we already have, during the new server request, to avoid empty screen effect + * In this screen, in signin mode: + * - the user is asked for login and password to sign in to a homeserver. + * - He also can reset his password + * In signup mode: + * - the user is asked for login and password */ -class LoginFragment @Inject constructor() : VectorBaseFragment() { - - private val viewModel: LoginViewModel by activityViewModel() +class LoginFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { private var passwordShown = false @@ -52,69 +56,101 @@ class LoginFragment @Inject constructor() : VectorBaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupNotice() - setupAuthButton() + setupSubmitButton() setupPasswordReveal() + } - homeServerField.focusChanges() - .subscribe { - if (!it) { - viewModel.handle(LoginAction.UpdateHomeServer(homeServerField.text.toString())) - } + private fun setupAutoFill(state: LoginViewState) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> { + loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) + passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + SignMode.SignIn -> { + loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) + passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) } - .disposeOnDestroyView() - - homeServerField.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - viewModel.handle(LoginAction.UpdateHomeServer(homeServerField.text.toString())) - return@setOnEditorActionListener true } - return@setOnEditorActionListener false - } - - val initHsUrl = viewModel.getInitialHomeServerUrl() - if (initHsUrl != null) { - homeServerField.setText(initHsUrl) - } else { - homeServerField.setText(ServerUrlsRepository.getDefaultHomeServerUrl(requireContext())) - } - viewModel.handle(LoginAction.UpdateHomeServer(homeServerField.text.toString())) - } - - private fun setupNotice() { - riotx_no_registration_notice.setTextWithColoredPart(R.string.riotx_no_registration_notice, R.string.riotx_no_registration_notice_colored_part) - - riotx_no_registration_notice.setOnClickListener { - openUrlInExternalBrowser(requireActivity(), "https://about.riot.im/downloads") } } - private fun authenticate() { - val login = loginField.text?.trim().toString() - val password = passwordField.text?.trim().toString() + @OnClick(R.id.loginSubmit) + fun submit() { + cleanupUi() - viewModel.handle(LoginAction.Login(login, password)) + val login = loginField.text.toString() + val password = passwordField.text.toString() + + loginViewModel.handle(LoginAction.LoginOrRegister(login, password, getString(R.string.login_mobile_device))) } - private fun setupAuthButton() { + private fun cleanupUi() { + loginSubmit.hideKeyboard() + loginFieldTil.error = null + passwordFieldTil.error = null + } + + private fun setupUi(state: LoginViewState) { + val resId = when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> R.string.login_signup_to + SignMode.SignIn -> R.string.login_connect_to + } + + when (state.serverType) { + ServerType.MatrixOrg -> { + loginServerIcon.isVisible = true + loginServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) + loginTitle.text = getString(resId, state.homeServerUrlSimple) + loginNotice.text = getString(R.string.login_server_matrix_org_text) + } + ServerType.Modular -> { + loginServerIcon.isVisible = true + loginServerIcon.setImageResource(R.drawable.ic_logo_modular) + // TODO + loginTitle.text = getString(resId, "TODO") + loginNotice.text = getString(R.string.login_server_modular_text) + } + ServerType.Other -> { + loginServerIcon.isVisible = false + loginTitle.text = getString(resId, state.homeServerUrlSimple) + loginNotice.text = getString(R.string.login_server_other_text) + } + } + } + + private fun setupButtons(state: LoginViewState) { + forgetPasswordButton.isVisible = state.signMode == SignMode.SignIn + + loginSubmit.text = getString(when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> R.string.login_signup_submit + SignMode.SignIn -> R.string.login_signin + }) + } + + private fun setupSubmitButton() { Observable .combineLatest( loginField.textChanges().map { it.trim().isNotEmpty() }, passwordField.textChanges().map { it.trim().isNotEmpty() }, - homeServerField.textChanges().map { it.trim().isNotEmpty() }, - Function3 { isLoginNotEmpty, isPasswordNotEmpty, isHomeServerNotEmpty -> - isLoginNotEmpty && isPasswordNotEmpty && isHomeServerNotEmpty + BiFunction { isLoginNotEmpty, isPasswordNotEmpty -> + isLoginNotEmpty && isPasswordNotEmpty } ) - .subscribeBy { authenticateButton.isEnabled = it } + .subscribeBy { + loginFieldTil.error = null + passwordFieldTil.error = null + loginSubmit.isEnabled = it + } .disposeOnDestroyView() - authenticateButton.setOnClickListener { authenticate() } - - authenticateButtonSso.setOnClickListener { openSso() } } - private fun openSso() { - viewModel.handle(LoginAction.NavigateTo(LoginActivity.Navigation.OpenSsoLoginFallback)) + @OnClick(R.id.forgetPasswordButton) + fun forgetPasswordClicked() { + loginSharedActionViewModel.post(LoginNavigation.OnForgetPasswordClicked) } private fun setupPasswordReveal() { @@ -141,73 +177,47 @@ class LoginFragment @Inject constructor() : VectorBaseFragment() { } } - override fun invalidate() = withState(viewModel) { state -> - TransitionManager.beginDelayedTransition(login_fragment) + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) + } - when (state.asyncHomeServerLoginFlowRequest) { - is Incomplete -> { - progressBar.isVisible = true - touchArea.isVisible = true - loginField.isVisible = false - passwordContainer.isVisible = false - authenticateButton.isVisible = false - authenticateButtonSso.isVisible = false - passwordShown = false - renderPasswordField() - } - is Fail -> { - progressBar.isVisible = false - touchArea.isVisible = false - loginField.isVisible = false - passwordContainer.isVisible = false - authenticateButton.isVisible = false - authenticateButtonSso.isVisible = false - Toast.makeText(requireActivity(), "Authenticate failure: ${state.asyncHomeServerLoginFlowRequest.error}", Toast.LENGTH_LONG).show() - } - is Success -> { - progressBar.isVisible = false - touchArea.isVisible = false + override fun onError(throwable: Throwable) { + loginFieldTil.error = errorFormatter.toHumanReadable(throwable) + } - when (state.asyncHomeServerLoginFlowRequest()) { - LoginMode.Password -> { - loginField.isVisible = true - passwordContainer.isVisible = true - authenticateButton.isVisible = true - authenticateButtonSso.isVisible = false - if (loginField.text.isNullOrBlank() && passwordField.text.isNullOrBlank()) { - // Jump focus to login - loginField.requestFocus() - } - } - LoginMode.Sso -> { - loginField.isVisible = false - passwordContainer.isVisible = false - authenticateButton.isVisible = false - authenticateButtonSso.isVisible = true - } - LoginMode.Unsupported -> { - loginField.isVisible = false - passwordContainer.isVisible = false - authenticateButton.isVisible = false - authenticateButtonSso.isVisible = false - Toast.makeText(requireActivity(), "None of the homeserver login mode is supported by RiotX", Toast.LENGTH_LONG).show() - } - } - } - } + override fun updateWithState(state: LoginViewState) { + setupUi(state) + setupAutoFill(state) + setupButtons(state) when (state.asyncLoginAction) { is Loading -> { - progressBar.isVisible = true - touchArea.isVisible = true - + // Ensure password is hidden passwordShown = false renderPasswordField() } is Fail -> { - progressBar.isVisible = false - touchArea.isVisible = false - Toast.makeText(requireActivity(), "Authenticate failure: ${state.asyncLoginAction.error}", Toast.LENGTH_LONG).show() + val error = state.asyncLoginAction.error + if (error is Failure.ServerError + && error.error.code == MatrixError.FORBIDDEN + && error.error.message.isEmpty()) { + // Login with email, but email unknown + loginFieldTil.error = getString(R.string.login_login_with_email_error) + } else { + // Trick to display the error without text. + loginFieldTil.error = " " + passwordFieldTil.error = errorFormatter.toHumanReadable(state.asyncLoginAction.error) + } + } + // Success is handled by the LoginActivity + is Success -> Unit + } + + when (state.asyncRegistration) { + is Loading -> { + // Ensure password is hidden + passwordShown = false + renderPasswordField() } // Success is handled by the LoginActivity is Success -> Unit diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt new file mode 100644 index 0000000000..527b0c6802 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt @@ -0,0 +1,252 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.text.InputType +import android.view.View +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import butterknife.OnClick +import com.airbnb.mvrx.args +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.matrix.android.api.auth.registration.RegisterThreePid +import im.vector.matrix.android.api.failure.Failure +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.error.is401 +import im.vector.riotx.core.extensions.hideKeyboard +import im.vector.riotx.core.extensions.isEmail +import im.vector.riotx.core.extensions.setTextOrHide +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.fragment_login_generic_text_input_form.* +import javax.inject.Inject + +enum class TextInputFormFragmentMode { + SetEmail, + SetMsisdn, + ConfirmMsisdn +} + +@Parcelize +data class LoginGenericTextInputFormFragmentArgument( + val mode: TextInputFormFragmentMode, + val mandatory: Boolean, + val extra: String = "" +) : Parcelable + +/** + * In this screen, the user is asked for a text input + */ +class LoginGenericTextInputFormFragment @Inject constructor(private val errorFormatter: ErrorFormatter) : AbstractLoginFragment() { + + private val params: LoginGenericTextInputFormFragmentArgument by args() + + override fun getLayoutResId() = R.layout.fragment_login_generic_text_input_form + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUi() + setupSubmitButton() + setupTil() + setupAutoFill() + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + loginGenericTextInputFormTextInput.setAutofillHints( + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS + TextInputFormFragmentMode.SetMsisdn -> HintConstants.AUTOFILL_HINT_PHONE_NUMBER + TextInputFormFragmentMode.ConfirmMsisdn -> HintConstants.AUTOFILL_HINT_SMS_OTP + } + ) + } + } + + private fun setupTil() { + loginGenericTextInputFormTextInput.textChanges() + .subscribe { + loginGenericTextInputFormTil.error = null + } + .disposeOnDestroyView() + } + + private fun setupUi() { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + loginGenericTextInputFormTitle.text = getString(R.string.login_set_email_title) + loginGenericTextInputFormNotice.text = getString(R.string.login_set_email_notice) + loginGenericTextInputFormNotice2.setTextOrHide(null) + loginGenericTextInputFormTil.hint = + getString(if (params.mandatory) R.string.login_set_email_mandatory_hint else R.string.login_set_email_optional_hint) + loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + loginGenericTextInputFormOtherButton.isVisible = false + loginGenericTextInputFormSubmit.text = getString(R.string.login_set_email_submit) + } + TextInputFormFragmentMode.SetMsisdn -> { + loginGenericTextInputFormTitle.text = getString(R.string.login_set_msisdn_title) + loginGenericTextInputFormNotice.text = getString(R.string.login_set_msisdn_notice) + loginGenericTextInputFormNotice2.setTextOrHide(getString(R.string.login_set_msisdn_notice2)) + loginGenericTextInputFormTil.hint = + getString(if (params.mandatory) R.string.login_set_msisdn_mandatory_hint else R.string.login_set_msisdn_optional_hint) + loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_PHONE + loginGenericTextInputFormOtherButton.isVisible = false + loginGenericTextInputFormSubmit.text = getString(R.string.login_set_msisdn_submit) + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginGenericTextInputFormTitle.text = getString(R.string.login_msisdn_confirm_title) + loginGenericTextInputFormNotice.text = getString(R.string.login_msisdn_confirm_notice, params.extra) + loginGenericTextInputFormNotice2.setTextOrHide(null) + loginGenericTextInputFormTil.hint = + getString(R.string.login_msisdn_confirm_hint) + loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_NUMBER + loginGenericTextInputFormOtherButton.isVisible = true + loginGenericTextInputFormOtherButton.text = getString(R.string.login_msisdn_confirm_send_again) + loginGenericTextInputFormSubmit.text = getString(R.string.login_msisdn_confirm_submit) + } + } + } + + @OnClick(R.id.loginGenericTextInputFormOtherButton) + fun onOtherButtonClicked() { + when (params.mode) { + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginViewModel.handle(LoginAction.SendAgainThreePid) + } + else -> { + // Should not happen, button is not displayed + } + } + } + + @OnClick(R.id.loginGenericTextInputFormSubmit) + fun submit() { + cleanupUi() + val text = loginGenericTextInputFormTextInput.text.toString() + + if (text.isEmpty()) { + // Perform dummy action + loginViewModel.handle(LoginAction.RegisterDummy) + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + loginViewModel.handle(LoginAction.AddThreePid(RegisterThreePid.Email(text))) + } + TextInputFormFragmentMode.SetMsisdn -> { + getCountryCodeOrShowError(text)?.let { countryCode -> + loginViewModel.handle(LoginAction.AddThreePid(RegisterThreePid.Msisdn(text, countryCode))) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginViewModel.handle(LoginAction.ValidateThreePid(text)) + } + } + } + } + + private fun cleanupUi() { + loginGenericTextInputFormSubmit.hideKeyboard() + loginGenericTextInputFormSubmit.error = null + } + + private fun getCountryCodeOrShowError(text: String): String? { + // We expect an international format for the moment (see https://github.com/vector-im/riotX-android/issues/693) + if (text.startsWith("+")) { + try { + val phoneNumber = PhoneNumberUtil.getInstance().parse(text, null) + return PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(phoneNumber.countryCode) + } catch (e: NumberParseException) { + loginGenericTextInputFormTil.error = getString(R.string.login_msisdn_error_other) + } + } else { + loginGenericTextInputFormTil.error = getString(R.string.login_msisdn_error_not_international) + } + + // Error + return null + } + + private fun setupSubmitButton() { + loginGenericTextInputFormSubmit.isEnabled = false + loginGenericTextInputFormTextInput.textChanges() + .subscribe { + loginGenericTextInputFormSubmit.isEnabled = isInputValid(it) + } + .disposeOnDestroyView() + } + + private fun isInputValid(input: CharSequence): Boolean { + return if (input.isEmpty() && !params.mandatory) { + true + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + input.isEmail() + } + TextInputFormFragmentMode.SetMsisdn -> { + input.isNotBlank() + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + input.isNotBlank() + } + } + } + } + + override fun onError(throwable: Throwable) { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + if (throwable.is401()) { + // This is normal use case, we go to the mail waiting screen + loginSharedActionViewModel.post(LoginNavigation.OnSendEmailSuccess(loginViewModel.currentThreePid ?: "")) + } else { + loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + TextInputFormFragmentMode.SetMsisdn -> { + if (throwable.is401()) { + // This is normal use case, we go to the enter code screen + loginSharedActionViewModel.post(LoginNavigation.OnSendMsisdnSuccess(loginViewModel.currentThreePid ?: "")) + } else { + loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + when { + throwable is Failure.SuccessError -> + // The entered code is not correct + loginGenericTextInputFormTil.error = getString(R.string.login_validation_code_is_not_correct) + throwable.is401() -> + // It can happen if user request again the 3pid + Unit + else -> + loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginMode.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginMode.kt new file mode 100644 index 0000000000..ee39ac564b --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginMode.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +enum class LoginMode { + Unknown, + Password, + Sso, + Unsupported +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginNavigation.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginNavigation.kt new file mode 100644 index 0000000000..79c6409a3f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginNavigation.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import im.vector.riotx.core.platform.VectorSharedAction + +// Supported navigation actions for LoginActivity +sealed class LoginNavigation : VectorSharedAction { + object OpenServerSelection : LoginNavigation() + object OnServerSelectionDone : LoginNavigation() + object OnLoginFlowRetrieved : LoginNavigation() + object OnSignModeSelected : LoginNavigation() + object OnForgetPasswordClicked : LoginNavigation() + object OnResetPasswordSendThreePidDone : LoginNavigation() + object OnResetPasswordMailConfirmationSuccess : LoginNavigation() + object OnResetPasswordMailConfirmationSuccessDone : LoginNavigation() + + data class OnSendEmailSuccess(val email: String) : LoginNavigation() + data class OnSendMsisdnSuccess(val msisdn: String) : LoginNavigation() + + data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : LoginNavigation() +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt new file mode 100644 index 0000000000..18fcd8938b --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import butterknife.OnClick +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.extensions.hideKeyboard +import im.vector.riotx.core.extensions.isEmail +import im.vector.riotx.core.extensions.showPassword +import io.reactivex.Observable +import io.reactivex.functions.BiFunction +import io.reactivex.rxkotlin.subscribeBy +import kotlinx.android.synthetic.main.fragment_login_reset_password.* +import javax.inject.Inject + +/** + * In this screen, the user is asked for email and new password to reset his password + */ +class LoginResetPasswordFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + private var passwordShown = false + + // Show warning only once + private var showWarning = true + + override fun getLayoutResId() = R.layout.fragment_login_reset_password + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupPasswordReveal() + } + + private fun setupUi(state: LoginViewState) { + resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrlSimple) + } + + private fun setupSubmitButton() { + Observable + .combineLatest( + resetPasswordEmail.textChanges().map { it.isEmail() }, + passwordField.textChanges().map { it.isNotEmpty() }, + BiFunction { isEmail, isPasswordNotEmpty -> + isEmail && isPasswordNotEmpty + } + ) + .subscribeBy { + resetPasswordEmailTil.error = null + passwordFieldTil.error = null + resetPasswordSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + @OnClick(R.id.resetPasswordSubmit) + fun submit() { + cleanupUi() + + if (showWarning) { + showWarning = false + // Display a warning as Riot-Web does first + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_reset_password_warning_title) + .setMessage(R.string.login_reset_password_warning_content) + .setPositiveButton(R.string.login_reset_password_warning_submit) { _, _ -> + doSubmit() + } + .setNegativeButton(R.string.cancel, null) + .show() + } else { + doSubmit() + } + } + + private fun doSubmit() { + val email = resetPasswordEmail.text.toString() + val password = passwordField.text.toString() + + loginViewModel.handle(LoginAction.ResetPassword(email, password)) + } + + private fun cleanupUi() { + resetPasswordSubmit.hideKeyboard() + resetPasswordEmailTil.error = null + passwordFieldTil.error = null + } + + private fun setupPasswordReveal() { + passwordShown = false + + passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + passwordField.showPassword(passwordShown) + + if (passwordShown) { + passwordReveal.setImageResource(R.drawable.ic_eye_closed_black) + passwordReveal.contentDescription = getString(R.string.a11y_hide_password) + } else { + passwordReveal.setImageResource(R.drawable.ic_eye_black) + passwordReveal.contentDescription = getString(R.string.a11y_show_password) + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetResetPassword) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun updateWithState(state: LoginViewState) { + setupUi(state) + + when (state.asyncResetPassword) { + is Loading -> { + // Ensure new password is hidden + passwordShown = false + renderPasswordField() + } + is Fail -> { + resetPasswordEmailTil.error = errorFormatter.toHumanReadable(state.asyncResetPassword.error) + } + is Success -> { + loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordSendThreePidDone) + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt new file mode 100644 index 0000000000..03053a9718 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import androidx.appcompat.app.AlertDialog +import butterknife.OnClick +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Success +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.error.is401 +import kotlinx.android.synthetic.main.fragment_login_reset_password_mail_confirmation.* +import javax.inject.Inject + +/** + * In this screen, the user is asked to check his email and to click on a button once it's done + */ +class LoginResetPasswordMailConfirmationFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_reset_password_mail_confirmation + + private fun setupUi(state: LoginViewState) { + resetPasswordMailConfirmationNotice.text = getString(R.string.login_reset_password_mail_confirmation_notice, state.resetPasswordEmail) + } + + @OnClick(R.id.resetPasswordMailConfirmationSubmit) + fun submit() { + loginViewModel.handle(LoginAction.ResetPasswordMailConfirmed) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetResetPassword) + } + + override fun updateWithState(state: LoginViewState) { + setupUi(state) + + when (state.asyncResetMailConfirmed) { + is Fail -> { + // Link in email not yet clicked ? + val message = if (state.asyncResetMailConfirmed.error.is401()) { + getString(R.string.auth_reset_password_error_unauthorized) + } else { + errorFormatter.toHumanReadable(state.asyncResetMailConfirmed.error) + } + + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + is Success -> { + loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordMailConfirmationSuccess) + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt new file mode 100644 index 0000000000..92d75b3998 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import androidx.appcompat.app.AlertDialog +import butterknife.OnClick +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import javax.inject.Inject + +/** + * In this screen, the user is asked for email and new password to reset his password + */ +class LoginResetPasswordSuccessFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_reset_password_success + + @OnClick(R.id.resetPasswordSuccessSubmit) + fun submit() { + loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordMailConfirmationSuccessDone) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetResetPassword) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt new file mode 100644 index 0000000000..6e427d0bdb --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import butterknife.OnClick +import com.airbnb.mvrx.withState +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.utils.openUrlInExternalBrowser +import kotlinx.android.synthetic.main.fragment_login_server_selection.* +import me.gujun.android.span.span +import javax.inject.Inject + +/** + * In this screen, the user will choose between matrix.org, modular or other type of homeserver + */ +class LoginServerSelectionFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_server_selection + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initTextViews() + } + + private fun updateSelectedChoice(state: LoginViewState) { + state.serverType.let { + loginServerChoiceMatrixOrg.isChecked = it == ServerType.MatrixOrg + loginServerChoiceModular.isChecked = it == ServerType.Modular + loginServerChoiceOther.isChecked = it == ServerType.Other + } + } + + private fun initTextViews() { + loginServerChoiceModularLearnMore.text = span { + text = getString(R.string.login_server_modular_learn_more) + textDecorationLine = "underline" + } + } + + @OnClick(R.id.loginServerChoiceModularLearnMore) + fun learMore() { + openUrlInExternalBrowser(requireActivity(), MODULAR_LINK) + } + + @OnClick(R.id.loginServerChoiceMatrixOrg) + fun selectMatrixOrg() { + if (loginServerChoiceMatrixOrg.isChecked) { + // Consider this is a submit + submit() + } else { + loginViewModel.handle(LoginAction.UpdateServerType(ServerType.MatrixOrg)) + } + } + + @OnClick(R.id.loginServerChoiceModular) + fun selectModular() { + if (loginServerChoiceModular.isChecked) { + // Consider this is a submit + submit() + } else { + loginViewModel.handle(LoginAction.UpdateServerType(ServerType.Modular)) + } + } + + @OnClick(R.id.loginServerChoiceOther) + fun selectOther() { + if (loginServerChoiceOther.isChecked) { + // Consider this is a submit + submit() + } else { + loginViewModel.handle(LoginAction.UpdateServerType(ServerType.Other)) + } + } + + @OnClick(R.id.loginServerSubmit) + fun submit() = withState(loginViewModel) { state -> + if (state.serverType == ServerType.MatrixOrg) { + // Request login flow here + loginViewModel.handle(LoginAction.UpdateHomeServer(getString(R.string.matrix_org_server_url))) + } else { + loginSharedActionViewModel.post(LoginNavigation.OnServerSelectionDone) + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetHomeServerType) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun updateWithState(state: LoginViewState) { + updateSelectedChoice(state) + + if (state.loginMode != LoginMode.Unknown) { + // LoginFlow for matrix.org has been retrieved + loginSharedActionViewModel.post(LoginNavigation.OnLoginFlowRetrieved) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt new file mode 100644 index 0000000000..d632ffe100 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.core.view.isVisible +import butterknife.OnClick +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.extensions.hideKeyboard +import im.vector.riotx.core.utils.openUrlInExternalBrowser +import kotlinx.android.synthetic.main.fragment_login_server_url_form.* +import javax.inject.Inject + +/** + * In this screen, the user is prompted to enter a homeserver url + */ +class LoginServerUrlFormFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_server_url_form + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupHomeServerField() + } + + private fun setupHomeServerField() { + loginServerUrlFormHomeServerUrl.textChanges() + .subscribe { + loginServerUrlFormHomeServerUrlTil.error = null + loginServerUrlFormSubmit.isEnabled = it.isNotBlank() + } + .disposeOnDestroyView() + + loginServerUrlFormHomeServerUrl.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupUi(state: LoginViewState) { + when (state.serverType) { + ServerType.Modular -> { + loginServerUrlFormIcon.isVisible = true + loginServerUrlFormTitle.text = getString(R.string.login_connect_to_modular) + loginServerUrlFormText.text = getString(R.string.login_server_url_form_modular_text) + loginServerUrlFormLearnMore.isVisible = true + loginServerUrlFormHomeServerUrlTil.hint = getText(R.string.login_server_url_form_modular_hint) + loginServerUrlFormNotice.text = getString(R.string.login_server_url_form_modular_notice) + } + ServerType.Other -> { + loginServerUrlFormIcon.isVisible = false + loginServerUrlFormTitle.text = getString(R.string.login_server_other_title) + loginServerUrlFormText.text = getString(R.string.login_connect_to_a_custom_server) + loginServerUrlFormLearnMore.isVisible = false + loginServerUrlFormHomeServerUrlTil.hint = getText(R.string.login_server_url_form_other_hint) + loginServerUrlFormNotice.text = getString(R.string.login_server_url_form_other_notice) + } + else -> error("This fragment should not be displayed in matrix.org mode") + } + } + + @OnClick(R.id.loginServerUrlFormLearnMore) + fun learnMore() { + openUrlInExternalBrowser(requireActivity(), MODULAR_LINK) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetHomeServerUrl) + } + + @SuppressLint("SetTextI18n") + @OnClick(R.id.loginServerUrlFormSubmit) + fun submit() { + cleanupUi() + + // Static check of homeserver url, empty, malformed, etc. + var serverUrl = loginServerUrlFormHomeServerUrl.text.toString().trim() + + when { + serverUrl.isBlank() -> { + loginServerUrlFormHomeServerUrlTil.error = getString(R.string.login_error_invalid_home_server) + } + else -> { + if (serverUrl.startsWith("http").not()) { + serverUrl = "https://$serverUrl" + } + loginServerUrlFormHomeServerUrl.setText(serverUrl) + loginViewModel.handle(LoginAction.UpdateHomeServer(serverUrl)) + } + } + } + + private fun cleanupUi() { + loginServerUrlFormSubmit.hideKeyboard() + loginServerUrlFormHomeServerUrlTil.error = null + } + + override fun onError(throwable: Throwable) { + loginServerUrlFormHomeServerUrlTil.error = errorFormatter.toHumanReadable(throwable) + } + + override fun updateWithState(state: LoginViewState) { + setupUi(state) + + if (state.loginMode != LoginMode.Unknown) { + // The home server url is valid + loginSharedActionViewModel.post(LoginNavigation.OnLoginFlowRetrieved) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginSharedActionViewModel.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginSharedActionViewModel.kt new file mode 100644 index 0000000000..625208b682 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginSharedActionViewModel.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import im.vector.riotx.core.platform.VectorSharedActionViewModel +import javax.inject.Inject + +class LoginSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel() diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt new file mode 100644 index 0000000000..0473f3d91c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import butterknife.OnClick +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.* +import javax.inject.Inject + +/** + * In this screen, the user is asked to sign up or to sign in to the homeserver + */ +class LoginSignUpSignInSelectionFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_signup_signin_selection + + private var isSsoSignIn: Boolean = false + + private fun setupUi(state: LoginViewState) { + when (state.serverType) { + ServerType.MatrixOrg -> { + loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) + loginSignupSigninServerIcon.isVisible = true + loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrlSimple) + loginSignupSigninText.text = getString(R.string.login_server_matrix_org_text) + } + ServerType.Modular -> { + loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_modular) + loginSignupSigninServerIcon.isVisible = true + // TODO + loginSignupSigninTitle.text = getString(R.string.login_connect_to, "TODO MODULAR NAME") + loginSignupSigninText.text = state.homeServerUrlSimple + } + ServerType.Other -> { + loginSignupSigninServerIcon.isVisible = false + loginSignupSigninTitle.text = getString(R.string.login_server_other_title) + loginSignupSigninText.text = getString(R.string.login_connect_to, state.homeServerUrlSimple) + } + } + } + + private fun setupButtons(state: LoginViewState) { + isSsoSignIn = state.loginMode == LoginMode.Sso + + if (isSsoSignIn) { + loginSignupSigninSubmit.text = getString(R.string.login_signin_sso) + loginSignupSigninSignIn.isVisible = false + } else { + loginSignupSigninSubmit.text = getString(R.string.login_signup) + loginSignupSigninSignIn.isVisible = true + } + } + + @OnClick(R.id.loginSignupSigninSubmit) + fun signUp() { + if (isSsoSignIn) { + signIn() + } else { + loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp)) + } + } + + @OnClick(R.id.loginSignupSigninSignIn) + fun signIn() { + loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignIn)) + loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetSignMode) + } + + override fun updateWithState(state: LoginViewState) { + setupUi(state) + setupButtons(state) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt new file mode 100644 index 0000000000..ef17bea920 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import androidx.appcompat.app.AlertDialog +import butterknife.OnClick +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import javax.inject.Inject + +/** + * In this screen, the user is viewing an introduction to what he can do with this application + */ +class LoginSplashFragment @Inject constructor( + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { + + override fun getLayoutResId() = R.layout.fragment_login_splash + + @OnClick(R.id.loginSplashSubmit) + fun getStarted() { + loginSharedActionViewModel.post(LoginNavigation.OpenServerSelection) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun resetViewModel() { + // Nothing to do + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewEvents.kt new file mode 100644 index 0000000000..4c089174f4 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewEvents.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package im.vector.riotx.features.login + +import im.vector.matrix.android.api.auth.registration.FlowResult + +/** + * Transient events for Login + */ +sealed class LoginViewEvents { + data class RegistrationFlowResult(val flowResult: FlowResult, val isRegistrationStarted: Boolean) : LoginViewEvents() + data class Error(val throwable: Throwable) : LoginViewEvents() + object OutdatedHomeserver : LoginViewEvents() +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt index a0a7258e2a..de76f6b416 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt @@ -16,31 +16,38 @@ package im.vector.riotx.features.login -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import arrow.core.Try import com.airbnb.mvrx.* import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.MatrixCallback -import im.vector.matrix.android.api.auth.Authenticator -import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig +import im.vector.matrix.android.api.auth.AuthenticationService +import im.vector.matrix.android.api.auth.data.LoginFlowResult +import im.vector.matrix.android.api.auth.login.LoginWizard +import im.vector.matrix.android.api.auth.registration.FlowResult +import im.vector.matrix.android.api.auth.registration.RegistrationResult +import im.vector.matrix.android.api.auth.registration.RegistrationWizard +import im.vector.matrix.android.api.auth.registration.Stage import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.util.Cancelable -import im.vector.matrix.android.internal.auth.data.InteractiveAuthenticationFlow -import im.vector.matrix.android.internal.auth.data.LoginFlowResponse +import im.vector.matrix.android.internal.auth.data.LoginFlowTypes import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.extensions.configureAndStart import im.vector.riotx.core.platform.VectorViewModel -import im.vector.riotx.core.utils.LiveEvent +import im.vector.riotx.core.utils.DataSource +import im.vector.riotx.core.utils.PublishDataSource import im.vector.riotx.features.notifications.PushRuleTriggerListener import im.vector.riotx.features.session.SessionListener import timber.log.Timber +import java.util.concurrent.CancellationException +/** + * + */ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginViewState, - private val authenticator: Authenticator, + private val authenticationService: AuthenticationService, private val activeSessionHolder: ActiveSessionHolder, private val pushRuleTriggerListener: PushRuleTriggerListener, + private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory, private val sessionListener: SessionListener) : VectorViewModel(initialState) { @@ -58,22 +65,250 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi } } + val currentThreePid: String? + get() = registrationWizard?.currentThreePid + + // True when login and password has been sent with success to the homeserver + val isRegistrationStarted: Boolean + get() = authenticationService.isRegistrationStarted + + private val registrationWizard: RegistrationWizard? + get() = authenticationService.getRegistrationWizard() + + private val loginWizard: LoginWizard? + get() = authenticationService.getLoginWizard() + private var loginConfig: LoginConfig? = null - private val _navigationLiveData = MutableLiveData>() - val navigationLiveData: LiveData> - get() = _navigationLiveData - - private var homeServerConnectionConfig: HomeServerConnectionConfig? = null private var currentTask: Cancelable? = null + private val _viewEvents = PublishDataSource() + val viewEvents: DataSource = _viewEvents + override fun handle(action: LoginAction) { when (action) { - is LoginAction.InitWith -> handleInitWith(action) - is LoginAction.UpdateHomeServer -> handleUpdateHomeserver(action) - is LoginAction.Login -> handleLogin(action) - is LoginAction.SsoLoginSuccess -> handleSsoLoginSuccess(action) - is LoginAction.NavigateTo -> handleNavigation(action) + is LoginAction.UpdateServerType -> handleUpdateServerType(action) + is LoginAction.UpdateSignMode -> handleUpdateSignMode(action) + is LoginAction.InitWith -> handleInitWith(action) + is LoginAction.UpdateHomeServer -> handleUpdateHomeserver(action) + is LoginAction.LoginOrRegister -> handleLoginOrRegister(action) + is LoginAction.WebLoginSuccess -> handleWebLoginSuccess(action) + is LoginAction.ResetPassword -> handleResetPassword(action) + is LoginAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() + is LoginAction.RegisterAction -> handleRegisterAction(action) + is LoginAction.ResetAction -> handleResetAction(action) + } + } + + private fun handleRegisterAction(action: LoginAction.RegisterAction) { + when (action) { + is LoginAction.CaptchaDone -> handleCaptchaDone(action) + is LoginAction.AcceptTerms -> handleAcceptTerms() + is LoginAction.RegisterDummy -> handleRegisterDummy() + is LoginAction.AddThreePid -> handleAddThreePid(action) + is LoginAction.SendAgainThreePid -> handleSendAgainThreePid() + is LoginAction.ValidateThreePid -> handleValidateThreePid(action) + is LoginAction.CheckIfEmailHasBeenValidated -> handleCheckIfEmailHasBeenValidated(action) + is LoginAction.StopEmailValidationCheck -> handleStopEmailValidationCheck() + } + } + + private fun handleCheckIfEmailHasBeenValidated(action: LoginAction.CheckIfEmailHasBeenValidated) { + // We do not want the common progress bar to be displayed, so we do not change asyncRegistration value in the state + currentTask?.cancel() + currentTask = null + currentTask = registrationWizard?.checkIfEmailHasBeenValidated(action.delayMillis, registrationCallback) + } + + private fun handleStopEmailValidationCheck() { + currentTask?.cancel() + currentTask = null + } + + private fun handleValidateThreePid(action: LoginAction.ValidateThreePid) { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.handleValidateThreePid(action.code, registrationCallback) + } + + private val registrationCallback = object : MatrixCallback { + override fun onSuccess(data: RegistrationResult) { + /* + // Simulate registration disabled + onFailure(Failure.ServerError(MatrixError( + code = MatrixError.FORBIDDEN, + message = "Registration is disabled" + ), 403)) + */ + + setState { + copy( + asyncRegistration = Uninitialized + ) + } + + when (data) { + is RegistrationResult.Success -> onSessionCreated(data.session) + is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult) + } + } + + override fun onFailure(failure: Throwable) { + if (failure !is CancellationException) { + _viewEvents.post(LoginViewEvents.Error(failure)) + } + setState { + copy( + asyncRegistration = Uninitialized + ) + } + } + } + + private fun handleAddThreePid(action: LoginAction.AddThreePid) { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.addThreePid(action.threePid, object : MatrixCallback { + override fun onSuccess(data: RegistrationResult) { + setState { + copy( + asyncRegistration = Uninitialized + ) + } + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(LoginViewEvents.Error(failure)) + setState { + copy( + asyncRegistration = Uninitialized + ) + } + } + }) + } + + private fun handleSendAgainThreePid() { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.sendAgainThreePid(object : MatrixCallback { + override fun onSuccess(data: RegistrationResult) { + setState { + copy( + asyncRegistration = Uninitialized + ) + } + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(LoginViewEvents.Error(failure)) + setState { + copy( + asyncRegistration = Uninitialized + ) + } + } + }) + } + + private fun handleAcceptTerms() { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.acceptTerms(registrationCallback) + } + + private fun handleRegisterDummy() { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.dummy(registrationCallback) + } + + private fun handleRegisterWith(action: LoginAction.LoginOrRegister) { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.createAccount( + action.username, + action.password, + action.initialDeviceName, + registrationCallback + ) + } + + private fun handleCaptchaDone(action: LoginAction.CaptchaDone) { + setState { copy(asyncRegistration = Loading()) } + currentTask = registrationWizard?.performReCaptcha(action.captchaResponse, registrationCallback) + } + + private fun handleResetAction(action: LoginAction.ResetAction) { + // Cancel any request + currentTask?.cancel() + currentTask = null + + when (action) { + LoginAction.ResetHomeServerType -> { + setState { + copy( + serverType = ServerType.MatrixOrg + ) + } + } + LoginAction.ResetHomeServerUrl -> { + authenticationService.reset() + + setState { + copy( + asyncHomeServerLoginFlowRequest = Uninitialized, + homeServerUrl = null, + loginMode = LoginMode.Unknown, + loginModeSupportedTypes = emptyList() + ) + } + } + LoginAction.ResetSignMode -> { + setState { + copy( + asyncHomeServerLoginFlowRequest = Uninitialized, + signMode = SignMode.Unknown, + loginMode = LoginMode.Unknown, + loginModeSupportedTypes = emptyList() + ) + } + } + LoginAction.ResetLogin -> { + authenticationService.cancelPendingLoginOrRegistration() + + setState { + copy( + asyncLoginAction = Uninitialized, + asyncRegistration = Uninitialized + ) + } + } + LoginAction.ResetResetPassword -> { + setState { + copy( + asyncResetPassword = Uninitialized, + asyncResetMailConfirmed = Uninitialized, + resetPasswordEmail = null + ) + } + } + } + } + + private fun handleUpdateSignMode(action: LoginAction.UpdateSignMode) { + setState { + copy( + signMode = action.signMode + ) + } + + if (action.signMode == SignMode.SignUp) { + startRegistrationFlow() + } else if (action.signMode == SignMode.SignIn) { + startAuthenticationFlow() + } + } + + private fun handleUpdateServerType(action: LoginAction.UpdateServerType) { + setState { + copy( + serverType = action.serverType + ) } } @@ -81,10 +316,98 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi loginConfig = action.loginConfig } - private fun handleLogin(action: LoginAction.Login) { - val homeServerConnectionConfigFinal = homeServerConnectionConfig + private fun handleResetPassword(action: LoginAction.ResetPassword) { + val safeLoginWizard = loginWizard - if (homeServerConnectionConfigFinal == null) { + if (safeLoginWizard == null) { + setState { + copy( + asyncResetPassword = Fail(Throwable("Bad configuration")), + asyncResetMailConfirmed = Uninitialized + ) + } + } else { + setState { + copy( + asyncResetPassword = Loading(), + asyncResetMailConfirmed = Uninitialized + ) + } + + currentTask = safeLoginWizard.resetPassword(action.email, action.newPassword, object : MatrixCallback { + override fun onSuccess(data: Unit) { + setState { + copy( + asyncResetPassword = Success(data), + resetPasswordEmail = action.email + ) + } + } + + override fun onFailure(failure: Throwable) { + // TODO Handled JobCancellationException + setState { + copy( + asyncResetPassword = Fail(failure) + ) + } + } + }) + } + } + + private fun handleResetPasswordMailConfirmed() { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + setState { + copy( + asyncResetPassword = Uninitialized, + asyncResetMailConfirmed = Fail(Throwable("Bad configuration")) + ) + } + } else { + setState { + copy( + asyncResetPassword = Uninitialized, + asyncResetMailConfirmed = Loading() + ) + } + + currentTask = safeLoginWizard.resetPasswordMailConfirmed(object : MatrixCallback { + override fun onSuccess(data: Unit) { + setState { + copy( + asyncResetMailConfirmed = Success(data), + resetPasswordEmail = null + ) + } + } + + override fun onFailure(failure: Throwable) { + // TODO Handled JobCancellationException + setState { + copy( + asyncResetMailConfirmed = Fail(failure) + ) + } + } + }) + } + } + + private fun handleLoginOrRegister(action: LoginAction.LoginOrRegister) = withState { state -> + when (state.signMode) { + SignMode.SignIn -> handleLogin(action) + SignMode.SignUp -> handleRegisterWith(action) + else -> error("Developer error, invalid sign mode") + } + } + + private fun handleLogin(action: LoginAction.LoginOrRegister) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { setState { copy( asyncLoginAction = Fail(Throwable("Bad configuration")) @@ -97,19 +420,50 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi ) } - authenticator.authenticate(homeServerConnectionConfigFinal, action.login, action.password, object : MatrixCallback { - override fun onSuccess(data: Session) { - onSessionCreated(data) - } + currentTask = safeLoginWizard.login( + action.username, + action.password, + action.initialDeviceName, + object : MatrixCallback { + override fun onSuccess(data: Session) { + onSessionCreated(data) + } - override fun onFailure(failure: Throwable) { - setState { - copy( - asyncLoginAction = Fail(failure) - ) - } - } - }) + override fun onFailure(failure: Throwable) { + // TODO Handled JobCancellationException + setState { + copy( + asyncLoginAction = Fail(failure) + ) + } + } + }) + } + } + + private fun startRegistrationFlow() { + setState { + copy( + asyncRegistration = Loading() + ) + } + + currentTask = registrationWizard?.getRegistrationFlow(registrationCallback) + } + + private fun startAuthenticationFlow() { + // No op + loginWizard + } + + private fun onFlowResponse(flowResult: FlowResult) { + // If dummy stage is mandatory, and password is already sent, do the dummy stage now + if (isRegistrationStarted + && flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) { + handleRegisterDummy() + } else { + // Notify the user + _viewEvents.post(LoginViewEvents.RegistrationFlowResult(flowResult, isRegistrationStarted)) } } @@ -123,14 +477,14 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi } } - private fun handleSsoLoginSuccess(action: LoginAction.SsoLoginSuccess) { - val homeServerConnectionConfigFinal = homeServerConnectionConfig + private fun handleWebLoginSuccess(action: LoginAction.WebLoginSuccess) = withState { state -> + val homeServerConnectionConfigFinal = homeServerConnectionConfigFactory.create(state.homeServerUrl) if (homeServerConnectionConfigFinal == null) { // Should not happen Timber.w("homeServerConnectionConfig is null") } else { - authenticator.createSessionFromSso(action.credentials, homeServerConnectionConfigFinal, object : MatrixCallback { + authenticationService.createSessionFromSso(homeServerConnectionConfigFinal, action.credentials, object : MatrixCallback { override fun onSuccess(data: Session) { onSessionCreated(data) } @@ -142,59 +496,69 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi } } - private fun handleUpdateHomeserver(action: LoginAction.UpdateHomeServer) = withState { state -> + private fun handleUpdateHomeserver(action: LoginAction.UpdateHomeServer) { + val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) - var newConfig: HomeServerConnectionConfig? = null - Try { - val homeServerUri = action.homeServerUrl - newConfig = HomeServerConnectionConfig.Builder() - .withHomeServerUri(homeServerUri) - .build() - } - - // Do not retry if we already have flows for this config -> causes infinite focus loop - if (newConfig?.homeServerUri?.toString() == homeServerConnectionConfig?.homeServerUri?.toString() - && state.asyncHomeServerLoginFlowRequest is Success) return@withState - - currentTask?.cancel() - homeServerConnectionConfig = newConfig - - val homeServerConnectionConfigFinal = homeServerConnectionConfig - - if (homeServerConnectionConfigFinal == null) { + if (homeServerConnectionConfig == null) { // This is invalid - setState { - copy( - asyncHomeServerLoginFlowRequest = Fail(Throwable("Bad format")) - ) - } + _viewEvents.post(LoginViewEvents.Error(Throwable("Unable to create a HomeServerConnectionConfig"))) } else { + currentTask?.cancel() + currentTask = null + authenticationService.cancelPendingLoginOrRegistration() + setState { copy( asyncHomeServerLoginFlowRequest = Loading() ) } - currentTask = authenticator.getLoginFlow(homeServerConnectionConfigFinal, object : MatrixCallback { + currentTask = authenticationService.getLoginFlow(homeServerConnectionConfig, object : MatrixCallback { override fun onFailure(failure: Throwable) { + _viewEvents.post(LoginViewEvents.Error(failure)) setState { copy( - asyncHomeServerLoginFlowRequest = Fail(failure) + asyncHomeServerLoginFlowRequest = Uninitialized ) } } - override fun onSuccess(data: LoginFlowResponse) { - val loginMode = when { - // SSO login is taken first - data.flows.any { it.type == InteractiveAuthenticationFlow.TYPE_LOGIN_SSO } -> LoginMode.Sso - data.flows.any { it.type == InteractiveAuthenticationFlow.TYPE_LOGIN_PASSWORD } -> LoginMode.Password - else -> LoginMode.Unsupported + override fun onSuccess(data: LoginFlowResult) { + when (data) { + is LoginFlowResult.Success -> { + val loginMode = when { + // SSO login is taken first + data.loginFlowResponse.flows.any { it.type == LoginFlowTypes.SSO } -> LoginMode.Sso + data.loginFlowResponse.flows.any { it.type == LoginFlowTypes.PASSWORD } -> LoginMode.Password + else -> LoginMode.Unsupported + } + + if (loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) { + notSupported() + } else { + setState { + copy( + asyncHomeServerLoginFlowRequest = Uninitialized, + homeServerUrl = action.homeServerUrl, + loginMode = loginMode, + loginModeSupportedTypes = data.loginFlowResponse.flows.mapNotNull { it.type }.toList() + ) + } + } + } + is LoginFlowResult.OutdatedHomeserver -> { + notSupported() + } } + } + + private fun notSupported() { + // Notify the UI + _viewEvents.post(LoginViewEvents.OutdatedHomeserver) setState { copy( - asyncHomeServerLoginFlowRequest = Success(loginMode) + asyncHomeServerLoginFlowRequest = Uninitialized ) } } @@ -202,10 +566,6 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi } } - private fun handleNavigation(action: LoginAction.NavigateTo) { - _navigationLiveData.postValue(LiveEvent(action.target)) - } - override fun onCleared() { super.onCleared() @@ -215,8 +575,4 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi fun getInitialHomeServerUrl(): String? { return loginConfig?.homeServerUrl } - - fun getHomeServerUrl(): String { - return homeServerConnectionConfig?.homeServerUri?.toString() ?: "" - } } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt index 0cc0476254..e4b3fe214a 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt @@ -16,17 +16,50 @@ package im.vector.riotx.features.login -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MvRxState -import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.* data class LoginViewState( val asyncLoginAction: Async = Uninitialized, - val asyncHomeServerLoginFlowRequest: Async = Uninitialized -) : MvRxState + val asyncHomeServerLoginFlowRequest: Async = Uninitialized, + val asyncResetPassword: Async = Uninitialized, + val asyncResetMailConfirmed: Async = Uninitialized, + val asyncRegistration: Async = Uninitialized, -enum class LoginMode { - Password, - Sso, - Unsupported + // User choices + @PersistState + val serverType: ServerType = ServerType.MatrixOrg, + @PersistState + val signMode: SignMode = SignMode.Unknown, + @PersistState + val resetPasswordEmail: String? = null, + @PersistState + val homeServerUrl: String? = null, + + // Network result + @PersistState + val loginMode: LoginMode = LoginMode.Unknown, + @PersistState + // Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable + val loginModeSupportedTypes: List = emptyList() +) : MvRxState { + + fun isLoading(): Boolean { + return asyncLoginAction is Loading + || asyncHomeServerLoginFlowRequest is Loading + || asyncResetPassword is Loading + || asyncResetMailConfirmed is Loading + || asyncRegistration is Loading + } + + fun isUserLogged(): Boolean { + return asyncLoginAction is Success + } + + /** + * Ex: "https://matrix.org/" -> "matrix.org" + */ + val homeServerUrlSimple: String + get() = (homeServerUrl ?: "") + .substringAfter("://") + .trim { it == '/' } } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt new file mode 100644 index 0000000000..2436b1d2d1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import androidx.appcompat.app.AlertDialog +import com.airbnb.mvrx.args +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.error.is401 +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.fragment_login_wait_for_email.* +import javax.inject.Inject + +@Parcelize +data class LoginWaitForEmailFragmentArgument( + val email: String +) : Parcelable + +/** + * In this screen, the user is asked to check his emails + */ +class LoginWaitForEmailFragment @Inject constructor(private val errorFormatter: ErrorFormatter) : AbstractLoginFragment() { + + private val params: LoginWaitForEmailFragmentArgument by args() + + override fun getLayoutResId() = R.layout.fragment_login_wait_for_email + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUi() + } + + override fun onResume() { + super.onResume() + + loginViewModel.handle(LoginAction.CheckIfEmailHasBeenValidated(0)) + } + + override fun onPause() { + super.onPause() + + loginViewModel.handle(LoginAction.StopEmailValidationCheck) + } + + private fun setupUi() { + loginWaitForEmailNotice.text = getString(R.string.login_wait_for_email_notice, params.email) + } + + override fun onError(throwable: Throwable) { + if (throwable.is401()) { + // Try again, with a delay + loginViewModel.handle(LoginAction.CheckIfEmailHasBeenValidated(10_000)) + } else { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginSsoFallbackFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt similarity index 51% rename from vector/src/main/java/im/vector/riotx/features/login/LoginSsoFallbackFragment.kt rename to vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt index 38deccccaf..eac4511b57 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginSsoFallbackFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt @@ -30,70 +30,66 @@ import android.webkit.SslErrorHandler import android.webkit.WebView import android.webkit.WebViewClient import androidx.appcompat.app.AlertDialog -import com.airbnb.mvrx.activityViewModel -import im.vector.matrix.android.api.auth.data.Credentials -import im.vector.matrix.android.api.util.JsonDict import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.riotx.R -import im.vector.riotx.core.platform.OnBackPressed -import im.vector.riotx.core.platform.VectorBaseFragment -import kotlinx.android.synthetic.main.fragment_login_sso_fallback.* +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.utils.AssetReader +import kotlinx.android.synthetic.main.fragment_login_web.* import timber.log.Timber import java.net.URLDecoder import javax.inject.Inject /** - * Only login is supported for the moment + * This screen is displayed for SSO login and also when the application does not support login flow or registration flow + * of the homeserver, as a fallback to login or to create an account */ -class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnBackPressed { +class LoginWebFragment @Inject constructor( + private val assetReader: AssetReader, + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment() { - private val viewModel: LoginViewModel by activityViewModel() + override fun getLayoutResId() = R.layout.fragment_login_web - var homeServerUrl: String = "" - - enum class Mode { - MODE_LOGIN, - // Not supported in RiotX for the moment - MODE_REGISTER - } - - // Mode (MODE_LOGIN or MODE_REGISTER) - private var mMode = Mode.MODE_LOGIN - - override fun getLayoutResId() = R.layout.fragment_login_sso_fallback + private var isWebViewLoaded = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar(login_sso_fallback_toolbar) - login_sso_fallback_toolbar.title = getString(R.string.login) + setupToolbar(loginWebToolbar) + } - setupWebview() + override fun updateWithState(state: LoginViewState) { + setupTitle(state) + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } + + private fun setupTitle(state: LoginViewState) { + loginWebToolbar.title = when (state.signMode) { + SignMode.SignIn -> getString(R.string.login_signin) + else -> getString(R.string.login_signup) + } } @SuppressLint("SetJavaScriptEnabled") - private fun setupWebview() { - login_sso_fallback_webview.settings.javaScriptEnabled = true + private fun setupWebView(state: LoginViewState) { + loginWebWebView.settings.javaScriptEnabled = true // Due to https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html, we hack // the user agent to bypass the limitation of Google, as a quick fix (a proper solution will be to use the SSO SDK) - login_sso_fallback_webview.settings.userAgentString = "Mozilla/5.0 Google" - - homeServerUrl = viewModel.getHomeServerUrl() - - if (!homeServerUrl.endsWith("/")) { - homeServerUrl += "/" - } + loginWebWebView.settings.userAgentString = "Mozilla/5.0 Google" // AppRTC requires third party cookies to work val cookieManager = android.webkit.CookieManager.getInstance() // clear the cookies must be cleared if (cookieManager == null) { - launchWebView() + launchWebView(state) } else { if (!cookieManager.hasCookies()) { - launchWebView() + launchWebView(state) } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { try { cookieManager.removeAllCookie() @@ -101,27 +97,27 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB Timber.e(e, " cookieManager.removeAllCookie() fails") } - launchWebView() + launchWebView(state) } else { try { - cookieManager.removeAllCookies { launchWebView() } + cookieManager.removeAllCookies { launchWebView(state) } } catch (e: Exception) { Timber.e(e, " cookieManager.removeAllCookie() fails") - launchWebView() + launchWebView(state) } } } } - private fun launchWebView() { - if (mMode == Mode.MODE_LOGIN) { - login_sso_fallback_webview.loadUrl(homeServerUrl + "_matrix/static/client/login/") + private fun launchWebView(state: LoginViewState) { + if (state.signMode == SignMode.SignIn) { + loginWebWebView.loadUrl(state.homeServerUrl?.trim { it == '/' } + "/_matrix/static/client/login/") } else { // MODE_REGISTER - login_sso_fallback_webview.loadUrl(homeServerUrl + "_matrix/static/client/register/") + loginWebWebView.loadUrl(state.homeServerUrl?.trim { it == '/' } + "/_matrix/static/client/register/") } - login_sso_fallback_webview.webViewClient = object : WebViewClient() { + loginWebWebView.webViewClient = object : WebViewClient() { override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { AlertDialog.Builder(requireActivity()) @@ -136,53 +132,37 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB } false }) + .setCancelable(false) .show() } override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { super.onReceivedError(view, errorCode, description, failingUrl) - // on error case, close this fragment - viewModel.handle(LoginAction.NavigateTo(LoginActivity.Navigation.GoBack)) + loginSharedActionViewModel.post(LoginNavigation.OnWebLoginError(errorCode, description, failingUrl)) } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) - login_sso_fallback_toolbar.subtitle = url + loginWebToolbar.subtitle = url } override fun onPageFinished(view: WebView, url: String) { // avoid infinite onPageFinished call if (url.startsWith("http")) { // Generic method to make a bridge between JS and the UIWebView - val mxcJavascriptSendObjectMessage = "javascript:window.sendObjectMessage = function(parameters) {" + - " var iframe = document.createElement('iframe');" + - " iframe.setAttribute('src', 'js:' + JSON.stringify(parameters));" + - " document.documentElement.appendChild(iframe);" + - " iframe.parentNode.removeChild(iframe); iframe = null;" + - " };" - + val mxcJavascriptSendObjectMessage = assetReader.readAssetFile("sendObject.js") view.loadUrl(mxcJavascriptSendObjectMessage) - if (mMode == Mode.MODE_LOGIN) { + if (state.signMode == SignMode.SignIn) { // The function the fallback page calls when the login is complete - val mxcJavascriptOnRegistered = "javascript:window.matrixLogin.onLogin = function(response) {" + - " sendObjectMessage({ 'action': 'onLogin', 'credentials': response });" + - " };" - - view.loadUrl(mxcJavascriptOnRegistered) + val mxcJavascriptOnLogin = assetReader.readAssetFile("onLogin.js") + view.loadUrl(mxcJavascriptOnLogin) } else { // MODE_REGISTER // The function the fallback page calls when the registration is complete - val mxcJavascriptOnRegistered = "javascript:window.matrixRegistration.onRegistered" + - " = function(homeserverUrl, userId, accessToken) {" + - " sendObjectMessage({ 'action': 'onRegistered'," + - " 'homeServer': homeserverUrl," + - " 'userId': userId," + - " 'accessToken': accessToken });" + - " };" - + val mxcJavascriptOnRegistered = assetReader.readAssetFile("onRegistered.js") view.loadUrl(mxcJavascriptOnRegistered) } } @@ -214,46 +194,27 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB override fun shouldOverrideUrlLoading(view: WebView, url: String?): Boolean { if (null != url && url.startsWith("js:")) { var json = url.substring(3) - var parameters: Map? = null + var javascriptResponse: JavascriptResponse? = null try { // URL decode json = URLDecoder.decode(json, "UTF-8") - - val adapter = MoshiProvider.providesMoshi().adapter(Map::class.java) - - @Suppress("UNCHECKED_CAST") - parameters = adapter.fromJson(json) as JsonDict? + val adapter = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java) + javascriptResponse = adapter.fromJson(json) } catch (e: Exception) { Timber.e(e, "## shouldOverrideUrlLoading() : fromJson failed") } // succeeds to parse parameters - if (parameters != null) { - val action = parameters["action"] as String + if (javascriptResponse != null) { + val action = javascriptResponse.action - if (mMode == Mode.MODE_LOGIN) { + if (state.signMode == SignMode.SignIn) { try { if (action == "onLogin") { - @Suppress("UNCHECKED_CAST") - val credentials = parameters["credentials"] as Map - - val userId = credentials["user_id"] - val accessToken = credentials["access_token"] - val homeServer = credentials["home_server"] - val deviceId = credentials["device_id"] - - // check if the parameters are defined - if (null != homeServer && null != userId && null != accessToken) { - val safeCredentials = Credentials( - userId = userId, - accessToken = accessToken, - homeServer = homeServer, - deviceId = deviceId, - refreshToken = null - ) - - viewModel.handle(LoginAction.SsoLoginSuccess(safeCredentials)) + val credentials = javascriptResponse.credentials + if (credentials != null) { + loginViewModel.handle(LoginAction.WebLoginSuccess(credentials)) } } } catch (e: Exception) { @@ -263,22 +224,9 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB // MODE_REGISTER // check the required parameters if (action == "onRegistered") { - // TODO The keys are very strange, this code comes from Riot-Android... - if (parameters.containsKey("homeServer") - && parameters.containsKey("userId") - && parameters.containsKey("accessToken")) { - // We cannot parse Credentials here because of https://github.com/matrix-org/synapse/issues/4756 - // Build on object manually - val credentials = Credentials( - userId = parameters["userId"] as String, - accessToken = parameters["accessToken"] as String, - homeServer = parameters["homeServer"] as String, - // TODO We need deviceId on RiotX... - deviceId = "TODO", - refreshToken = null - ) - - viewModel.handle(LoginAction.SsoLoginSuccess(credentials)) + val credentials = javascriptResponse.credentials + if (credentials != null) { + loginViewModel.handle(LoginAction.WebLoginSuccess(credentials)) } } } @@ -291,12 +239,23 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB } } - override fun onBackPressed(): Boolean { - return if (login_sso_fallback_webview.canGoBack()) { - login_sso_fallback_webview.goBack() - true - } else { - false + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + return when { + toolbarButton -> super.onBackPressed(toolbarButton) + loginWebWebView.canGoBack() -> loginWebWebView.goBack().run { true } + else -> super.onBackPressed(toolbarButton) } } } diff --git a/vector/src/main/java/im/vector/riotx/features/login/ServerType.kt b/vector/src/main/java/im/vector/riotx/features/login/ServerType.kt new file mode 100644 index 0000000000..4c7007c137 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/ServerType.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +enum class ServerType { + MatrixOrg, + Modular, + Other +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/SignMode.kt b/vector/src/main/java/im/vector/riotx/features/login/SignMode.kt new file mode 100644 index 0000000000..b793a0fe1d --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/SignMode.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +enum class SignMode { + Unknown, + // Account creation + SignUp, + // Login + SignIn +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/SupportedStage.kt b/vector/src/main/java/im/vector/riotx/features/login/SupportedStage.kt new file mode 100644 index 0000000000..ce234caeb0 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/SupportedStage.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login + +import im.vector.matrix.android.api.auth.registration.Stage + +/** + * Stage.Other is not supported, as well as any other new stages added to the SDK before it is added to the list below + */ +fun Stage.isSupported(): Boolean { + return this is Stage.ReCaptcha + || this is Stage.Dummy + || this is Stage.Msisdn + || this is Stage.Terms + || this is Stage.Email +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/LocalizedFlowDataLoginTermsChecked.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/LocalizedFlowDataLoginTermsChecked.kt new file mode 100644 index 0000000000..52aaa9d4a4 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/LocalizedFlowDataLoginTermsChecked.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +import org.matrix.androidsdk.rest.model.login.LocalizedFlowDataLoginTerms + +data class LocalizedFlowDataLoginTermsChecked(val localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms, + var checked: Boolean = false) diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt new file mode 100755 index 0000000000..08110f3b33 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import androidx.appcompat.app.AlertDialog +import butterknife.OnClick +import com.airbnb.mvrx.args +import im.vector.riotx.R +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.utils.openUrlInExternalBrowser +import im.vector.riotx.features.login.AbstractLoginFragment +import im.vector.riotx.features.login.LoginAction +import im.vector.riotx.features.login.LoginViewState +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.fragment_login_terms.* +import org.matrix.androidsdk.rest.model.login.LocalizedFlowDataLoginTerms +import javax.inject.Inject + +@Parcelize +data class LoginTermsFragmentArgument( + val localizedFlowDataLoginTerms: List +) : Parcelable + +/** + * LoginTermsFragment displays the list of policies the user has to accept + */ +class LoginTermsFragment @Inject constructor( + private val policyController: PolicyController, + private val errorFormatter: ErrorFormatter +) : AbstractLoginFragment(), + PolicyController.PolicyControllerListener { + + private val params: LoginTermsFragmentArgument by args() + + override fun getLayoutResId() = R.layout.fragment_login_terms + + private var loginTermsViewState: LoginTermsViewState = LoginTermsViewState(emptyList()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + loginTermsPolicyList.setController(policyController) + policyController.listener = this + + val list = ArrayList() + + params.localizedFlowDataLoginTerms + .forEach { + list.add(LocalizedFlowDataLoginTermsChecked(it)) + } + + loginTermsViewState = LoginTermsViewState(list) + } + + private fun renderState() { + policyController.setData(loginTermsViewState.localizedFlowDataLoginTermsChecked) + + // Button is enabled only if all checkboxes are checked + loginTermsSubmit.isEnabled = loginTermsViewState.allChecked() + } + + override fun setChecked(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms, isChecked: Boolean) { + if (isChecked) { + loginTermsViewState.check(localizedFlowDataLoginTerms) + } else { + loginTermsViewState.uncheck(localizedFlowDataLoginTerms) + } + + renderState() + } + + override fun openPolicy(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms) { + localizedFlowDataLoginTerms.localizedUrl + ?.takeIf { it.isNotBlank() } + ?.let { + openUrlInExternalBrowser(requireContext(), it) + } + } + + @OnClick(R.id.loginTermsSubmit) + internal fun submit() { + loginViewModel.handle(LoginAction.AcceptTerms) + } + + override fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun updateWithState(state: LoginViewState) { + policyController.homeServer = state.homeServerUrlSimple + renderState() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsViewState.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsViewState.kt new file mode 100644 index 0000000000..104ea88daa --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsViewState.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +import com.airbnb.mvrx.MvRxState +import org.matrix.androidsdk.rest.model.login.LocalizedFlowDataLoginTerms + +data class LoginTermsViewState( + val localizedFlowDataLoginTermsChecked: List +) : MvRxState { + fun check(data: LocalizedFlowDataLoginTerms) { + localizedFlowDataLoginTermsChecked.find { it.localizedFlowDataLoginTerms == data }?.checked = true + } + + fun uncheck(data: LocalizedFlowDataLoginTerms) { + localizedFlowDataLoginTermsChecked.find { it.localizedFlowDataLoginTerms == data }?.checked = false + } + + fun allChecked(): Boolean { + return localizedFlowDataLoginTermsChecked.all { it.checked } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/PolicyController.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/PolicyController.kt new file mode 100644 index 0000000000..c301463c2a --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/PolicyController.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +import android.view.View +import com.airbnb.epoxy.TypedEpoxyController +import org.matrix.androidsdk.rest.model.login.LocalizedFlowDataLoginTerms +import javax.inject.Inject + +class PolicyController @Inject constructor() : TypedEpoxyController>() { + + var listener: PolicyControllerListener? = null + + var homeServer: String? = null + + override fun buildModels(data: List) { + data.forEach { entry -> + policyItem { + id(entry.localizedFlowDataLoginTerms.policyName) + checked(entry.checked) + title(entry.localizedFlowDataLoginTerms.localizedName) + subtitle(homeServer) + + clickListener(View.OnClickListener { listener?.openPolicy(entry.localizedFlowDataLoginTerms) }) + checkChangeListener { _, isChecked -> + listener?.setChecked(entry.localizedFlowDataLoginTerms, isChecked) + } + } + } + } + + interface PolicyControllerListener { + fun setChecked(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms, isChecked: Boolean) + fun openPolicy(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/PolicyItem.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/PolicyItem.kt new file mode 100644 index 0000000000..9931d33068 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/PolicyItem.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2018 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +import android.view.View +import android.widget.CheckBox +import android.widget.CompoundButton +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder + +@EpoxyModelClass(layout = R.layout.item_policy) +abstract class PolicyItem : EpoxyModelWithHolder() { + @EpoxyAttribute + var checked: Boolean = false + + @EpoxyAttribute + var title: String? = null + + @EpoxyAttribute + var subtitle: String? = null + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var checkChangeListener: CompoundButton.OnCheckedChangeListener? = null + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var clickListener: View.OnClickListener? = null + + override fun bind(holder: Holder) { + holder.let { + it.checkbox.isChecked = checked + it.checkbox.setOnCheckedChangeListener(checkChangeListener) + it.title.text = title + it.subtitle.text = subtitle + it.view.setOnClickListener(clickListener) + } + } + + // Ensure checkbox behaves as expected (remove the listener) + override fun unbind(holder: Holder) { + super.unbind(holder) + holder.checkbox.setOnCheckedChangeListener(null) + } + + class Holder : VectorEpoxyHolder() { + val checkbox by bind(R.id.adapter_item_policy_checkbox) + val title by bind(R.id.adapter_item_policy_title) + val subtitle by bind(R.id.adapter_item_policy_subtitle) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/UrlAndName.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/UrlAndName.kt new file mode 100644 index 0000000000..1ccb7cac49 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/UrlAndName.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +data class UrlAndName( + val url: String, + val name: String +) diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/converter.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/converter.kt new file mode 100644 index 0000000000..c9e6dcf3fd --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/converter.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.login.terms + +import im.vector.matrix.android.api.auth.registration.TermPolicies +import org.matrix.androidsdk.rest.model.login.LocalizedFlowDataLoginTerms + +/** + * This method extract the policies from the login terms parameter, regarding the user language. + * For each policy, if user language is not found, the default language is used and if not found, the first url and name are used (not predictable) + * + * Example of Data: + *
+ * "m.login.terms": {
+ *       "policies": {
+ *         "privacy_policy": {
+ *           "version": "1.0",
+ *           "en": {
+ *             "url": "http:\/\/matrix.org\/_matrix\/consent?v=1.0",
+ *             "name": "Terms and Conditions"
+ *           }
+ *         }
+ *       }
+ *     }
+ *
+ * + * @param userLanguage the user language + * @param defaultLanguage the default language to use if the user language is not found for a policy in registrationFlowResponse + */ +fun TermPolicies.toLocalizedLoginTerms(userLanguage: String, + defaultLanguage: String = "en"): List { + val result = ArrayList() + + val policies = get("policies") + if (policies is Map<*, *>) { + policies.keys.forEach { policyName -> + val localizedFlowDataLoginTerms = LocalizedFlowDataLoginTerms() + localizedFlowDataLoginTerms.policyName = policyName as String + + val policy = policies[policyName] + + // Enter this policy + if (policy is Map<*, *>) { + // Version + localizedFlowDataLoginTerms.version = policy["version"] as String? + + var userLanguageUrlAndName: UrlAndName? = null + var defaultLanguageUrlAndName: UrlAndName? = null + var firstUrlAndName: UrlAndName? = null + + // Search for language + policy.keys.forEach { policyKey -> + when (policyKey) { + "version" -> Unit // Ignore + userLanguage -> { + // We found the data for the user language + userLanguageUrlAndName = extractUrlAndName(policy[policyKey]) + } + defaultLanguage -> { + // We found default language + defaultLanguageUrlAndName = extractUrlAndName(policy[policyKey]) + } + else -> { + if (firstUrlAndName == null) { + // Get at least some data + firstUrlAndName = extractUrlAndName(policy[policyKey]) + } + } + } + } + + // Copy found language data by priority + when { + userLanguageUrlAndName != null -> { + localizedFlowDataLoginTerms.localizedUrl = userLanguageUrlAndName!!.url + localizedFlowDataLoginTerms.localizedName = userLanguageUrlAndName!!.name + } + defaultLanguageUrlAndName != null -> { + localizedFlowDataLoginTerms.localizedUrl = defaultLanguageUrlAndName!!.url + localizedFlowDataLoginTerms.localizedName = defaultLanguageUrlAndName!!.name + } + firstUrlAndName != null -> { + localizedFlowDataLoginTerms.localizedUrl = firstUrlAndName!!.url + localizedFlowDataLoginTerms.localizedName = firstUrlAndName!!.name + } + } + } + + result.add(localizedFlowDataLoginTerms) + } + } + + return result +} + +private fun extractUrlAndName(policyData: Any?): UrlAndName? { + if (policyData is Map<*, *>) { + val url = policyData["url"] as String? + val name = policyData["name"] as String? + + if (url != null && name != null) { + return UrlAndName(url, name) + } + } + return null +} diff --git a/vector/src/main/res/anim/enter_fade_in.xml b/vector/src/main/res/anim/enter_fade_in.xml index 292e35edde..8326050fdc 100644 --- a/vector/src/main/res/anim/enter_fade_in.xml +++ b/vector/src/main/res/anim/enter_fade_in.xml @@ -1,10 +1,10 @@ + android:startOffset="@integer/default_animation_offset"> - \ No newline at end of file + diff --git a/vector/src/main/res/anim/exit_fade_out.xml b/vector/src/main/res/anim/exit_fade_out.xml index 28934ead10..b24bb6724c 100644 --- a/vector/src/main/res/anim/exit_fade_out.xml +++ b/vector/src/main/res/anim/exit_fade_out.xml @@ -1,10 +1,9 @@ + android:duration="@integer/default_animation_half"> - \ No newline at end of file + diff --git a/vector/src/main/res/color/button_background_tint_selector.xml b/vector/src/main/res/color/button_background_tint_selector.xml new file mode 100644 index 0000000000..dd6e5f0421 --- /dev/null +++ b/vector/src/main/res/color/button_background_tint_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/vector/src/main/res/color/login_button_tint.xml b/vector/src/main/res/color/login_button_tint.xml new file mode 100644 index 0000000000..719335766c --- /dev/null +++ b/vector/src/main/res/color/login_button_tint.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/bg_login_server.xml b/vector/src/main/res/drawable/bg_login_server.xml new file mode 100644 index 0000000000..5aecd26292 --- /dev/null +++ b/vector/src/main/res/drawable/bg_login_server.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/bg_login_server_checked.xml b/vector/src/main/res/drawable/bg_login_server_checked.xml new file mode 100644 index 0000000000..1aea622462 --- /dev/null +++ b/vector/src/main/res/drawable/bg_login_server_checked.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/bg_login_server_selector.xml b/vector/src/main/res/drawable/bg_login_server_selector.xml new file mode 100644 index 0000000000..57be1e5d54 --- /dev/null +++ b/vector/src/main/res/drawable/bg_login_server_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/ic_login_splash_lock.xml b/vector/src/main/res/drawable/ic_login_splash_lock.xml new file mode 100644 index 0000000000..26470cefce --- /dev/null +++ b/vector/src/main/res/drawable/ic_login_splash_lock.xml @@ -0,0 +1,22 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_login_splash_message_circle.xml b/vector/src/main/res/drawable/ic_login_splash_message_circle.xml new file mode 100644 index 0000000000..81b5e9476a --- /dev/null +++ b/vector/src/main/res/drawable/ic_login_splash_message_circle.xml @@ -0,0 +1,14 @@ + + + diff --git a/vector/src/main/res/drawable/ic_login_splash_sliders.xml b/vector/src/main/res/drawable/ic_login_splash_sliders.xml new file mode 100644 index 0000000000..b7c850eea7 --- /dev/null +++ b/vector/src/main/res/drawable/ic_login_splash_sliders.xml @@ -0,0 +1,14 @@ + + + diff --git a/vector/src/main/res/drawable/ic_logo_matrix_org.xml b/vector/src/main/res/drawable/ic_logo_matrix_org.xml new file mode 100644 index 0000000000..13a05fba4f --- /dev/null +++ b/vector/src/main/res/drawable/ic_logo_matrix_org.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/vector/src/main/res/drawable/ic_logo_modular.xml b/vector/src/main/res/drawable/ic_logo_modular.xml new file mode 100644 index 0000000000..c95ee66b86 --- /dev/null +++ b/vector/src/main/res/drawable/ic_logo_modular.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/vector/src/main/res/layout/activity_login.xml b/vector/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000000..0add6040a7 --- /dev/null +++ b/vector/src/main/res/layout/activity_login.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_create_direct_room.xml b/vector/src/main/res/layout/fragment_create_direct_room.xml index 66a040b935..f8450d1e6e 100644 --- a/vector/src/main/res/layout/fragment_create_direct_room.xml +++ b/vector/src/main/res/layout/fragment_create_direct_room.xml @@ -107,7 +107,7 @@ diff --git a/vector/src/main/res/layout/fragment_loading.xml b/vector/src/main/res/layout/fragment_loading.xml index 96bafda319..ae605097cd 100644 --- a/vector/src/main/res/layout/fragment_loading.xml +++ b/vector/src/main/res/layout/fragment_loading.xml @@ -4,12 +4,13 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + + android:layout_height="match_parent" + android:background="?riotx_background"> - + + + + + + style="@style/LoginFormContainer" + android:orientation="vertical"> + tools:src="@drawable/ic_logo_matrix_org" /> + android:layout_marginTop="@dimen/layout_vertical_margin" + android:textAppearance="@style/TextAppearance.Vector.Login.Title" + tools:text="@string/login_signin_to" /> + + + android:layout_marginTop="32dp" + android:hint="@string/login_signup_username_hint" + app:errorEnabled="true"> + android:hint="@string/login_signup_password_hint" + app:errorEnabled="true" + app:errorIconDrawable="@null"> - + android:layout_marginTop="22dp" + android:orientation="horizontal"> - + android:layout_gravity="start" + android:text="@string/auth_forgot_password" /> - + - - - + - - - - - + diff --git a/vector/src/main/res/layout/fragment_login_captcha.xml b/vector/src/main/res/layout/fragment_login_captcha.xml new file mode 100644 index 0000000000..2f8a4f9b0d --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_captcha.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_generic_text_input_form.xml b/vector/src/main/res/layout/fragment_login_generic_text_input_form.xml new file mode 100644 index 0000000000..5421d5eac8 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_generic_text_input_form.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password.xml b/vector/src/main/res/layout/fragment_login_reset_password.xml new file mode 100644 index 0000000000..506afbe519 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_mail_confirmation.xml b/vector/src/main/res/layout/fragment_login_reset_password_mail_confirmation.xml new file mode 100644 index 0000000000..ec2ae5cda3 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_mail_confirmation.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_success.xml b/vector/src/main/res/layout/fragment_login_reset_password_success.xml new file mode 100644 index 0000000000..fc5aea3394 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_success.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_selection.xml b/vector/src/main/res/layout/fragment_login_server_selection.xml new file mode 100644 index 0000000000..c97b32bd21 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_server_selection.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_url_form.xml b/vector/src/main/res/layout/fragment_login_server_url_form.xml new file mode 100644 index 0000000000..c8c2bb9a57 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_server_url_form.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_signup_signin_selection.xml b/vector/src/main/res/layout/fragment_login_signup_signin_selection.xml new file mode 100644 index 0000000000..3de579c6d9 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_signup_signin_selection.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_splash.xml b/vector/src/main/res/layout/fragment_login_splash.xml new file mode 100644 index 0000000000..44a81df539 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_splash.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_terms.xml b/vector/src/main/res/layout/fragment_login_terms.xml new file mode 100644 index 0000000000..e7daebfce7 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_terms.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_wait_for_email.xml b/vector/src/main/res/layout/fragment_login_wait_for_email.xml new file mode 100644 index 0000000000..511e19ca43 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_wait_for_email.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_sso_fallback.xml b/vector/src/main/res/layout/fragment_login_web.xml similarity index 84% rename from vector/src/main/res/layout/fragment_login_sso_fallback.xml rename to vector/src/main/res/layout/fragment_login_web.xml index e83680d2cd..cd673d03bf 100644 --- a/vector/src/main/res/layout/fragment_login_sso_fallback.xml +++ b/vector/src/main/res/layout/fragment_login_web.xml @@ -3,10 +3,11 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="?riotx_background" android:orientation="vertical"> diff --git a/vector/src/main/res/layout/fragment_public_rooms.xml b/vector/src/main/res/layout/fragment_public_rooms.xml index ceb45b275b..acc9bb5673 100644 --- a/vector/src/main/res/layout/fragment_public_rooms.xml +++ b/vector/src/main/res/layout/fragment_public_rooms.xml @@ -45,7 +45,7 @@ @@ -68,7 +68,7 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_room_filter_footer.xml b/vector/src/main/res/layout/item_room_filter_footer.xml index 75a77d074d..00cede6f1f 100644 --- a/vector/src/main/res/layout/item_room_filter_footer.xml +++ b/vector/src/main/res/layout/item_room_filter_footer.xml @@ -16,7 +16,7 @@ + + + + + diff --git a/vector/src/main/res/values-v21/styles_login.xml b/vector/src/main/res/values-v21/styles_login.xml new file mode 100644 index 0000000000..22eeec5528 --- /dev/null +++ b/vector/src/main/res/values-v21/styles_login.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values-v21/theme_black.xml b/vector/src/main/res/values-v21/theme_black.xml index 74ec2cd9e2..6c6d78879e 100644 --- a/vector/src/main/res/values-v21/theme_black.xml +++ b/vector/src/main/res/values-v21/theme_black.xml @@ -11,7 +11,6 @@ @transition/image_preview_transition @transition/image_preview_transition - + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/values/styles_riot.xml b/vector/src/main/res/values/styles_riot.xml index c5b04de730..798c7ced87 100644 --- a/vector/src/main/res/values/styles_riot.xml +++ b/vector/src/main/res/values/styles_riot.xml @@ -122,7 +122,7 @@ using colorControlHighlight as an overlay for focused and pressed states. --> + + + + + + \ No newline at end of file