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 33ba41e778..472c56648a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,24 @@ +Changes in RiotX 0.9.0 (2019-12-05) +=================================================== + +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 + - Links in message preview in the bottom sheet are now active. + - Rework the read marker to make it more usable + +Other changes: + - Fix a small grammatical error when an empty room list is shown. + +Bugfix 🐛: + - Do not show long click help if only invitation are displayed + - Fix emoji filtering not working + - Fix issue of closing Realm in another thread (#725) + - Attempt to properly cancel the crypto module when user signs out (#724) + Changes in RiotX 0.8.0 (2019-11-19) =================================================== 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/session/file/FileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/FileService.kt index e1694199ed..4d9cff3e92 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/FileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/FileService.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.api.session.file import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import java.io.File @@ -47,5 +48,5 @@ interface FileService { fileName: String, url: String?, elementToDecrypt: ElementToDecrypt?, - callback: MatrixCallback) + callback: MatrixCallback): Cancelable } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt index 5af5183dfa..385699b4db 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt @@ -72,7 +72,7 @@ interface RelationService { */ fun editTextMessage(targetEventId: String, msgType: String, - newBodyText: String, + newBodyText: CharSequence, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String = "* $newBodyText"): Cancelable @@ -97,12 +97,14 @@ interface RelationService { /** * Reply to an event in the timeline (must be in same room) * https://matrix.org/docs/spec/client_server/r0.4.0.html#id350 + * The replyText can be a Spannable and contains special spans (UserMentionSpan) that will be translated + * by the sdk into pills. * @param eventReplied the event referenced by the reply * @param replyText the reply text * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present */ fun replyToMessage(eventReplied: TimelineEvent, - replyText: String, + replyText: CharSequence, autoMarkdown: Boolean = false): Cancelable? fun getEventSummaryLive(eventId: String): LiveData> diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt index 8c783837a2..bdae5eaaa6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt @@ -29,20 +29,23 @@ interface SendService { /** * Method to send a text message asynchronously. + * The text to send can be a Spannable and contains special spans (UserMentionSpan) that will be translated + * by the sdk into pills. * @param text the text message to send * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @return a [Cancelable] */ - fun sendTextMessage(text: String, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable + fun sendTextMessage(text: CharSequence, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable /** * Method to send a text message with a formatted body. * @param text the text message to send * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML + * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE * @return a [Cancelable] */ - fun sendFormattedTextMessage(text: String, formattedText: String): Cancelable + fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String = MessageType.MSGTYPE_TEXT): Cancelable /** * Method to send a media asynchronously. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt new file mode 100644 index 0000000000..4cd8080dc3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.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.api.session.room.send + +/** + * Tag class for spans that should mention a user. + * These Spans will be transformed into pills when detected in message to send + */ +interface UserMentionSpan { + val displayName: String + val userId: String +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index c03effd7ad..85dbdcaa19 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -30,10 +30,16 @@ package im.vector.matrix.android.api.session.room.timeline */ interface Timeline { - var listener: Listener? + val timelineID: String val isLive: Boolean + fun addListener(listener: Listener): Boolean + + fun removeListener(listener: Listener): Boolean + + fun removeAllListeners() + /** * This should be called before any other method after creating the timeline. It ensures the underlying database is open */ @@ -98,7 +104,7 @@ interface Timeline { interface Listener { /** * Call when the timeline has been updated through pagination or sync. - * @param snapshot the most uptodate snapshot + * @param snapshot the most up to date snapshot */ fun onUpdated(snapshot: List) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt index ad747efee9..ed7f49aa46 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt @@ -41,8 +41,7 @@ data class TimelineEvent( val isUniqueDisplayName: Boolean, val senderAvatar: String?, val annotations: EventAnnotationsSummary? = null, - val readReceipts: List = emptyList(), - val hasReadMarker: Boolean = false + val readReceipts: List = emptyList() ) { val metadata = HashMap() 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..1b15995ae6 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 @@ -22,6 +22,8 @@ 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 io.realm.exceptions.RealmPrimaryKeyConstraintException +import timber.log.Timber import javax.inject.Inject internal class RealmSessionParamsStore @Inject constructor(private val mapper: SessionParamsMapper, @@ -30,43 +32,45 @@ 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) { awaitTransaction(realmConfiguration) { val entity = mapper.map(sessionParams) if (entity != null) { - it.insert(entity) + try { + it.insert(entity) + } catch (e: RealmPrimaryKeyConstraintException) { + Timber.e(e, "Something wrong happened during previous session creation. Override with new credentials") + it.insertOrUpdate(entity) + } } } } 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/crypto/CryptoModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt index 5a7e28b70f..a12f6e40ce 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoModule.kt @@ -37,6 +37,8 @@ import im.vector.matrix.android.internal.session.SessionScope import im.vector.matrix.android.internal.session.cache.ClearCacheTask import im.vector.matrix.android.internal.session.cache.RealmClearCacheTask import io.realm.RealmConfiguration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import retrofit2.Retrofit import java.io.File @@ -66,6 +68,13 @@ internal abstract class CryptoModule { .build() } + @JvmStatic + @Provides + @SessionScope + fun providesCryptoCoroutineScope(): CoroutineScope { + return CoroutineScope(SupervisorJob()) + } + @JvmStatic @Provides @CryptoDatabase diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt index cf5506a443..c50b9e2e10 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/DefaultCryptoService.kt @@ -132,7 +132,8 @@ internal class DefaultCryptoService @Inject constructor( private val loadRoomMembersTask: LoadRoomMembersTask, private val monarchy: Monarchy, private val coroutineDispatchers: MatrixCoroutineDispatchers, - private val taskExecutor: TaskExecutor + private val taskExecutor: TaskExecutor, + private val cryptoCoroutineScope: CoroutineScope ) : CryptoService { private val uiHandler = Handler(Looper.getMainLooper()) @@ -243,7 +244,8 @@ internal class DefaultCryptoService @Inject constructor( return } isStarting.set(true) - GlobalScope.launch(coroutineDispatchers.crypto) { + + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { internalStart(isInitialSync) } } @@ -269,10 +271,9 @@ internal class DefaultCryptoService @Inject constructor( isStarted.set(true) }, { - Timber.e("Start failed: $it") - delay(1000) isStarting.set(false) - internalStart(isInitialSync) + isStarted.set(false) + Timber.e(it, "Start failed") } ) } @@ -281,9 +282,12 @@ internal class DefaultCryptoService @Inject constructor( * Close the crypto */ fun close() = runBlocking(coroutineDispatchers.crypto) { + cryptoCoroutineScope.coroutineContext.cancelChildren(CancellationException("Closing crypto module")) + + outgoingRoomKeyRequestManager.stop() + olmDevice.release() cryptoStore.close() - outgoingRoomKeyRequestManager.stop() } // Aways enabled on RiotX @@ -305,19 +309,21 @@ internal class DefaultCryptoService @Inject constructor( * @param syncResponse the syncResponse */ fun onSyncCompleted(syncResponse: SyncResponse) { - GlobalScope.launch(coroutineDispatchers.crypto) { - if (syncResponse.deviceLists != null) { - deviceListManager.handleDeviceListsChanges(syncResponse.deviceLists.changed, syncResponse.deviceLists.left) - } - if (syncResponse.deviceOneTimeKeysCount != null) { - val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0 - oneTimeKeysUploader.updateOneTimeKeyCount(currentCount) - } - if (isStarted()) { - // Make sure we process to-device messages before generating new one-time-keys #2782 - deviceListManager.refreshOutdatedDeviceLists() - oneTimeKeysUploader.maybeUploadOneTimeKeys() - incomingRoomKeyRequestManager.processReceivedRoomKeyRequests() + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { + runCatching { + if (syncResponse.deviceLists != null) { + deviceListManager.handleDeviceListsChanges(syncResponse.deviceLists.changed, syncResponse.deviceLists.left) + } + if (syncResponse.deviceOneTimeKeysCount != null) { + val currentCount = syncResponse.deviceOneTimeKeysCount.signedCurve25519 ?: 0 + oneTimeKeysUploader.updateOneTimeKeyCount(currentCount) + } + if (isStarted()) { + // Make sure we process to-device messages before generating new one-time-keys #2782 + deviceListManager.refreshOutdatedDeviceLists() + oneTimeKeysUploader.maybeUploadOneTimeKeys() + incomingRoomKeyRequestManager.processReceivedRoomKeyRequests() + } } } } @@ -511,7 +517,7 @@ internal class DefaultCryptoService @Inject constructor( eventType: String, roomId: String, callback: MatrixCallback) { - GlobalScope.launch(coroutineDispatchers.crypto) { + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { if (!isStarted()) { Timber.v("## encryptEventContent() : wait after e2e init") internalStart(false) @@ -571,7 +577,7 @@ internal class DefaultCryptoService @Inject constructor( * @param callback the callback to return data or null */ override fun decryptEventAsync(event: Event, timeline: String, callback: MatrixCallback) { - GlobalScope.launch { + cryptoCoroutineScope.launch { val result = runCatching { withContext(coroutineDispatchers.crypto) { internalDecryptEvent(event, timeline) @@ -621,7 +627,7 @@ internal class DefaultCryptoService @Inject constructor( * @param event the event */ fun onToDeviceEvent(event: Event) { - GlobalScope.launch(coroutineDispatchers.crypto) { + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { when (event.getClearType()) { EventType.ROOM_KEY, EventType.FORWARDED_ROOM_KEY -> { onRoomKeyEvent(event) @@ -661,7 +667,7 @@ internal class DefaultCryptoService @Inject constructor( * @param event the encryption event. */ private fun onRoomEncryptionEvent(roomId: String, event: Event) { - GlobalScope.launch(coroutineDispatchers.crypto) { + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { val params = LoadRoomMembersTask.Params(roomId) try { loadRoomMembersTask.execute(params) @@ -753,7 +759,7 @@ internal class DefaultCryptoService @Inject constructor( * @param callback the exported keys */ override fun exportRoomKeys(password: String, callback: MatrixCallback) { - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { runCatching { exportRoomKeys(password, MXMegolmExportEncryption.DEFAULT_ITERATION_COUNT) }.foldToCallback(callback) @@ -791,7 +797,7 @@ internal class DefaultCryptoService @Inject constructor( password: String, progressListener: ProgressListener?, callback: MatrixCallback) { - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { runCatching { withContext(coroutineDispatchers.crypto) { Timber.v("## importRoomKeys starts") @@ -839,7 +845,7 @@ internal class DefaultCryptoService @Inject constructor( */ fun checkUnknownDevices(userIds: List, callback: MatrixCallback) { // force the refresh to ensure that the devices list is up-to-date - GlobalScope.launch(coroutineDispatchers.crypto) { + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { runCatching { val keys = deviceListManager.downloadKeys(userIds, true) val unknownDevices = getUnknownDevices(keys) @@ -999,7 +1005,7 @@ internal class DefaultCryptoService @Inject constructor( } override fun downloadKeys(userIds: List, forceDownload: Boolean, callback: MatrixCallback>) { - GlobalScope.launch(coroutineDispatchers.crypto) { + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { runCatching { deviceListManager.downloadKeys(userIds, forceDownload) }.foldToCallback(callback) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/IncomingRoomKeyRequestManager.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/IncomingRoomKeyRequestManager.kt index 3c8d70f2f1..e8d8bf0f35 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/IncomingRoomKeyRequestManager.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/IncomingRoomKeyRequestManager.kt @@ -25,7 +25,6 @@ import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore import im.vector.matrix.android.internal.session.SessionScope import timber.log.Timber import javax.inject.Inject -import kotlin.collections.ArrayList @SessionScope internal class IncomingRoomKeyRequestManager @Inject constructor( @@ -51,7 +50,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor( * * @param event the announcement event. */ - suspend fun onRoomKeyRequestEvent(event: Event) { + fun onRoomKeyRequestEvent(event: Event) { val roomKeyShare = event.getClearContent().toModel() when (roomKeyShare?.action) { RoomKeyShare.ACTION_SHARE_REQUEST -> receivedRoomKeyRequests.add(IncomingRoomKeyRequest(event)) @@ -78,7 +77,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor( Timber.v("m.room_key_request from $userId:$deviceId for $roomId / ${body.sessionId} id ${request.requestId}") if (userId == null || credentials.userId != userId) { // TODO: determine if we sent this device the keys already: in - Timber.e("## processReceivedRoomKeyRequests() : Ignoring room key request from other user for now") + Timber.w("## processReceivedRoomKeyRequests() : Ignoring room key request from other user for now") return } // TODO: should we queue up requests we don't yet have keys for, in case they turn up later? @@ -86,11 +85,11 @@ internal class IncomingRoomKeyRequestManager @Inject constructor( // the keys for the requested events, and can drop the requests. val decryptor = roomDecryptorProvider.getRoomDecryptor(roomId, alg) if (null == decryptor) { - Timber.e("## processReceivedRoomKeyRequests() : room key request for unknown $alg in room $roomId") + Timber.w("## processReceivedRoomKeyRequests() : room key request for unknown $alg in room $roomId") continue } if (!decryptor.hasKeysForKeyRequest(request)) { - Timber.e("## processReceivedRoomKeyRequests() : room key request for unknown session ${body.sessionId!!}") + Timber.w("## processReceivedRoomKeyRequests() : room key request for unknown session ${body.sessionId!!}") cryptoStore.deleteIncomingRoomKeyRequest(request) continue } @@ -139,7 +138,7 @@ internal class IncomingRoomKeyRequestManager @Inject constructor( if (null != receivedRoomKeyRequestCancellations) { for (request in receivedRoomKeyRequestCancellations!!) { Timber.v("## ## processReceivedRoomKeyRequests() : m.room_key_request cancellation for " + request.userId - + ":" + request.deviceId + " id " + request.requestId) + + ":" + request.deviceId + " id " + request.requestId) // we should probably only notify the app of cancellations we told it // about, but we don't currently have a record of that, so we just pass diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/MXOlmDevice.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/MXOlmDevice.kt index 68aaaf3831..6171b32811 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/MXOlmDevice.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/MXOlmDevice.kt @@ -764,7 +764,7 @@ internal class MXOlmDevice @Inject constructor( return session } } else { - Timber.e("## getInboundGroupSession() : Cannot retrieve inbound group session $sessionId") + Timber.w("## getInboundGroupSession() : Cannot retrieve inbound group session $sessionId") throw MXCryptoError.Base(MXCryptoError.ErrorType.UNKNOWN_INBOUND_SESSION_ID, MXCryptoError.UNKNOWN_INBOUND_SESSION_ID_REASON) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt index e6b57d149f..a0483335e5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OneTimeKeysUploader.kt @@ -42,8 +42,6 @@ internal class OneTimeKeysUploader @Inject constructor( private var lastOneTimeKeyCheck: Long = 0 private var oneTimeKeyCount: Int? = null - private var lastPublishedOneTimeKeys: Map>? = null - /** * Stores the current one_time_key count which will be handled later (in a call of * _onSyncCompleted). The count is e.g. coming from a /sync response. @@ -59,10 +57,12 @@ internal class OneTimeKeysUploader @Inject constructor( */ suspend fun maybeUploadOneTimeKeys() { if (oneTimeKeyCheckInProgress) { + Timber.v("maybeUploadOneTimeKeys: already in progress") return } if (System.currentTimeMillis() - lastOneTimeKeyCheck < ONE_TIME_KEY_UPLOAD_PERIOD) { // we've done a key upload recently. + Timber.v("maybeUploadOneTimeKeys: executed too recently") return } @@ -79,12 +79,8 @@ internal class OneTimeKeysUploader @Inject constructor( // discard the oldest private keys first. This will eventually clean // out stale private keys that won't receive a message. val keyLimit = floor(maxOneTimeKeys / 2.0).toInt() - if (oneTimeKeyCount != null) { - uploadOTK(oneTimeKeyCount!!, keyLimit) - } else { - // ask the server how many keys we have - val uploadKeysParams = UploadKeysTask.Params(null, null, credentials.deviceId!!) - val response = uploadKeysTask.execute(uploadKeysParams) + val oneTimeKeyCountFromSync = oneTimeKeyCount + if (oneTimeKeyCountFromSync != null) { // We need to keep a pool of one time public keys on the server so that // other devices can start conversations with us. But we can only store // a finite number of private keys in the olm Account object. @@ -96,14 +92,17 @@ internal class OneTimeKeysUploader @Inject constructor( // private keys clogging up our local storage. // So we need some kind of engineering compromise to balance all of // these factors. - // TODO Why we do not set oneTimeKeyCount here? - // TODO This is not needed anymore, see https://github.com/matrix-org/matrix-js-sdk/pull/493 (TODO on iOS also) - val keyCount = response.oneTimeKeyCountsForAlgorithm(MXKey.KEY_SIGNED_CURVE_25519_TYPE) - uploadOTK(keyCount, keyLimit) + try { + val uploadedKeys = uploadOTK(oneTimeKeyCountFromSync, keyLimit) + Timber.v("## uploadKeys() : success, $uploadedKeys key(s) sent") + } finally { + oneTimeKeyCheckInProgress = false + } + } else { + Timber.w("maybeUploadOneTimeKeys: waiting to know the number of OTK from the sync") + oneTimeKeyCheckInProgress = false + lastOneTimeKeyCheck = 0 } - Timber.v("## uploadKeys() : success") - oneTimeKeyCount = null - oneTimeKeyCheckInProgress = false } /** @@ -111,53 +110,51 @@ internal class OneTimeKeysUploader @Inject constructor( * * @param keyCount the key count * @param keyLimit the limit + * @return the number of uploaded keys */ - private suspend fun uploadOTK(keyCount: Int, keyLimit: Int) { + private suspend fun uploadOTK(keyCount: Int, keyLimit: Int): Int { if (keyLimit <= keyCount) { // If we don't need to generate any more keys then we are done. - return + return 0 } val keysThisLoop = min(keyLimit - keyCount, ONE_TIME_KEY_GENERATION_MAX_NUMBER) olmDevice.generateOneTimeKeys(keysThisLoop) - val response = uploadOneTimeKeys() + val response = uploadOneTimeKeys(olmDevice.getOneTimeKeys()) + olmDevice.markKeysAsPublished() + if (response.hasOneTimeKeyCountsForAlgorithm(MXKey.KEY_SIGNED_CURVE_25519_TYPE)) { - uploadOTK(response.oneTimeKeyCountsForAlgorithm(MXKey.KEY_SIGNED_CURVE_25519_TYPE), keyLimit) + // Maybe upload other keys + return keysThisLoop + uploadOTK(response.oneTimeKeyCountsForAlgorithm(MXKey.KEY_SIGNED_CURVE_25519_TYPE), keyLimit) } else { - Timber.e("## uploadLoop() : response for uploading keys does not contain one_time_key_counts.signed_curve25519") + Timber.e("## uploadOTK() : response for uploading keys does not contain one_time_key_counts.signed_curve25519") throw Exception("response for uploading keys does not contain one_time_key_counts.signed_curve25519") } } /** - * Upload my user's one time keys. + * Upload curve25519 one time keys. */ - private suspend fun uploadOneTimeKeys(): KeysUploadResponse { - val oneTimeKeys = olmDevice.getOneTimeKeys() + private suspend fun uploadOneTimeKeys(oneTimeKeys: Map>?): KeysUploadResponse { val oneTimeJson = mutableMapOf() - val curve25519Map = oneTimeKeys?.get(OlmAccount.JSON_KEY_ONE_TIME_KEY) + val curve25519Map = oneTimeKeys?.get(OlmAccount.JSON_KEY_ONE_TIME_KEY) ?: emptyMap() - if (null != curve25519Map) { - for ((key_id, value) in curve25519Map) { - val k = mutableMapOf() - k["key"] = value + curve25519Map.forEach { (key_id, value) -> + val k = mutableMapOf() + k["key"] = value - // the key is also signed - val canonicalJson = JsonCanonicalizer.getCanonicalJson(Map::class.java, k) + // the key is also signed + val canonicalJson = JsonCanonicalizer.getCanonicalJson(Map::class.java, k) - k["signatures"] = objectSigner.signObject(canonicalJson) + k["signatures"] = objectSigner.signObject(canonicalJson) - oneTimeJson["signed_curve25519:$key_id"] = k - } + oneTimeJson["signed_curve25519:$key_id"] = k } // For now, we set the device id explicitly, as we may not be using the // same one as used in login. val uploadParams = UploadKeysTask.Params(null, oneTimeJson, credentials.deviceId!!) - val response = uploadKeysTask.execute(uploadParams) - lastPublishedOneTimeKeys = oneTimeKeys - olmDevice.markKeysAsPublished() - return response + return uploadKeysTask.execute(uploadParams) } companion object { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingRoomKeyRequestManager.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingRoomKeyRequestManager.kt index 86e8a1825c..5320b84b0e 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingRoomKeyRequestManager.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/OutgoingRoomKeyRequestManager.kt @@ -63,6 +63,7 @@ internal class OutgoingRoomKeyRequestManager @Inject constructor( */ fun stop() { isClientRunning = false + stopTimer() } /** @@ -171,6 +172,10 @@ internal class OutgoingRoomKeyRequestManager @Inject constructor( }, SEND_KEY_REQUESTS_DELAY_MS.toLong()) } + private fun stopTimer() { + BACKGROUND_HANDLER.removeCallbacksAndMessages(null) + } + // look for and send any queued requests. Runs itself recursively until // there are no more requests, or there is an error (in which case, the // timer will be restarted before the promise resolves). @@ -187,7 +192,7 @@ internal class OutgoingRoomKeyRequestManager @Inject constructor( OutgoingRoomKeyRequest.RequestState.CANCELLATION_PENDING_AND_WILL_RESEND)) if (null == outgoingRoomKeyRequest) { - Timber.e("## sendOutgoingRoomKeyRequests() : No more outgoing room key requests") + Timber.v("## sendOutgoingRoomKeyRequests() : No more outgoing room key requests") sendOutgoingRoomKeyRequestsRunning.set(false) return } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index 0230141e1b..81ac1403df 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -34,7 +34,7 @@ import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore import im.vector.matrix.android.internal.crypto.tasks.SendToDeviceTask import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -46,8 +46,9 @@ internal class MXMegolmDecryption(private val userId: String, private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, private val cryptoStore: IMXCryptoStore, private val sendToDeviceTask: SendToDeviceTask, - private val coroutineDispatchers: MatrixCoroutineDispatchers) - : IMXDecrypting { + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val cryptoCoroutineScope: CoroutineScope +) : IMXDecrypting { var newSessionListener: NewSessionListener? = null @@ -61,7 +62,7 @@ internal class MXMegolmDecryption(private val userId: String, return decryptEvent(event, timeline, true) } - private suspend fun decryptEvent(event: Event, timeline: String, requestKeysOnFail: Boolean): MXEventDecryptionResult { + private fun decryptEvent(event: Event, timeline: String, requestKeysOnFail: Boolean): MXEventDecryptionResult { if (event.roomId.isNullOrBlank()) { throw MXCryptoError.Base(MXCryptoError.ErrorType.MISSING_FIELDS, MXCryptoError.MISSING_FIELDS_REASON) } @@ -292,7 +293,7 @@ internal class MXMegolmDecryption(private val userId: String, return } val userId = request.userId ?: return - GlobalScope.launch(coroutineDispatchers.crypto) { + cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { runCatching { deviceListManager.downloadKeys(listOf(userId), false) } .mapCatching { val deviceId = request.deviceId diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt index b7329221ab..7cddd27779 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt @@ -25,17 +25,21 @@ import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore import im.vector.matrix.android.internal.crypto.tasks.SendToDeviceTask import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope import javax.inject.Inject -internal class MXMegolmDecryptionFactory @Inject constructor(@UserId private val userId: String, - private val olmDevice: MXOlmDevice, - private val deviceListManager: DeviceListManager, - private val outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager, - private val messageEncrypter: MessageEncrypter, - private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, - private val cryptoStore: IMXCryptoStore, - private val sendToDeviceTask: SendToDeviceTask, - private val coroutineDispatchers: MatrixCoroutineDispatchers) { +internal class MXMegolmDecryptionFactory @Inject constructor( + @UserId private val userId: String, + private val olmDevice: MXOlmDevice, + private val deviceListManager: DeviceListManager, + private val outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager, + private val messageEncrypter: MessageEncrypter, + private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, + private val cryptoStore: IMXCryptoStore, + private val sendToDeviceTask: SendToDeviceTask, + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val cryptoCoroutineScope: CoroutineScope +) { fun create(): MXMegolmDecryption { return MXMegolmDecryption( @@ -47,6 +51,7 @@ internal class MXMegolmDecryptionFactory @Inject constructor(@UserId private val ensureOlmSessionsForDevicesAction, cryptoStore, sendToDeviceTask, - coroutineDispatchers) + coroutineDispatchers, + cryptoCoroutineScope) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt index b3ee138591..1cc1a8a05a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt @@ -59,7 +59,7 @@ import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.JsonCanonicalizer import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers import im.vector.matrix.android.internal.util.awaitCallback -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.olm.OlmException @@ -102,7 +102,8 @@ internal class KeysBackup @Inject constructor( private val updateKeysBackupVersionTask: UpdateKeysBackupVersionTask, // Task executor private val taskExecutor: TaskExecutor, - private val coroutineDispatchers: MatrixCoroutineDispatchers + private val coroutineDispatchers: MatrixCoroutineDispatchers, + private val cryptoCoroutineScope: CoroutineScope ) : KeysBackupService { private val uiHandler = Handler(Looper.getMainLooper()) @@ -143,7 +144,7 @@ internal class KeysBackup @Inject constructor( override fun prepareKeysBackupVersion(password: String?, progressListener: ProgressListener?, callback: MatrixCallback) { - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { runCatching { withContext(coroutineDispatchers.crypto) { val olmPkDecryption = OlmPkDecryption() @@ -233,7 +234,7 @@ internal class KeysBackup @Inject constructor( } override fun deleteBackup(version: String, callback: MatrixCallback?) { - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { withContext(coroutineDispatchers.crypto) { // If we're currently backing up to this backup... stop. // (We start using it automatically in createKeysBackupVersion so this is symmetrical). @@ -344,9 +345,7 @@ internal class KeysBackup @Inject constructor( } }) } - } - - keysBackupStateManager.addListener(keysBackupStateListener!!) + }.also { keysBackupStateManager.addListener(it) } backupKeys() } @@ -448,7 +447,7 @@ internal class KeysBackup @Inject constructor( callback.onFailure(IllegalArgumentException("Missing element")) } else { - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { val updateKeysBackupVersionBody = withContext(coroutineDispatchers.crypto) { // Get current signatures, or create an empty set val myUserSignatures = authData.signatures?.get(userId)?.toMutableMap() @@ -523,7 +522,7 @@ internal class KeysBackup @Inject constructor( callback: MatrixCallback) { Timber.v("trustKeysBackupVersionWithRecoveryKey: version ${keysBackupVersion.version}") - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { val isValid = withContext(coroutineDispatchers.crypto) { isValidRecoveryKeyForKeysBackupVersion(recoveryKey, keysBackupVersion) } @@ -543,7 +542,7 @@ internal class KeysBackup @Inject constructor( callback: MatrixCallback) { Timber.v("trustKeysBackupVersionWithPassphrase: version ${keysBackupVersion.version}") - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { val recoveryKey = withContext(coroutineDispatchers.crypto) { recoveryKeyFromPassword(password, keysBackupVersion, null) } @@ -614,7 +613,7 @@ internal class KeysBackup @Inject constructor( callback: MatrixCallback) { Timber.v("restoreKeysWithRecoveryKey: From backup version: ${keysVersionResult.version}") - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { runCatching { val decryption = withContext(coroutineDispatchers.crypto) { // Check if the recovery is valid before going any further @@ -695,7 +694,7 @@ internal class KeysBackup @Inject constructor( callback: MatrixCallback) { Timber.v("[MXKeyBackup] restoreKeyBackup with password: From backup version: ${keysBackupVersion.version}") - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { runCatching { val progressListener = if (stepProgressListener != null) { object : ProgressListener { @@ -729,8 +728,8 @@ internal class KeysBackup @Inject constructor( * parameters and always returns a KeysBackupData object through the Callback */ private suspend fun getKeys(sessionId: String?, - roomId: String?, - version: String): KeysBackupData { + roomId: String?, + version: String): KeysBackupData { return if (roomId != null && sessionId != null) { // Get key for the room and for the session val data = getRoomSessionDataTask.execute(GetRoomSessionDataTask.Params(roomId, sessionId, version)) @@ -1154,7 +1153,7 @@ internal class KeysBackup @Inject constructor( keysBackupStateManager.state = KeysBackupState.BackingUp - GlobalScope.launch(coroutineDispatchers.main) { + cryptoCoroutineScope.launch(coroutineDispatchers.main) { withContext(coroutineDispatchers.crypto) { Timber.v("backupKeys: 2 - Encrypting keys") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/UploadKeysTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/UploadKeysTask.kt index fd7a5e8e7a..db05f473b1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/UploadKeysTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/UploadKeysTask.kt @@ -53,10 +53,10 @@ internal class DefaultUploadKeysTask @Inject constructor(private val cryptoApi: } return executeRequest { - if (encodedDeviceId.isNullOrBlank()) { - apiCall = cryptoApi.uploadKeys(body) + apiCall = if (encodedDeviceId.isBlank()) { + cryptoApi.uploadKeys(body) } else { - apiCall = cryptoApi.uploadKeys(encodedDeviceId, body) + cryptoApi.uploadKeys(encodedDeviceId, body) } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt index 881d7ce2c5..bc806a56a4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/SessionRealmConfigurationFactory.kt @@ -83,7 +83,7 @@ internal class SessionRealmConfigurationFactory @Inject constructor(private val try { File(directory, file).deleteRecursively() } catch (e: Exception) { - Timber.e(e, "Unable to move files") + Timber.e(e, "Unable to delete files") } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt index e9ffa140c9..826b35254e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt @@ -23,7 +23,6 @@ import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity -import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity @@ -140,7 +139,7 @@ internal fun ChunkEntity.add(roomId: String, val senderId = event.senderId ?: "" val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventId).findFirst() - ?: ReadReceiptsSummaryEntity(eventId, roomId) + ?: ReadReceiptsSummaryEntity(eventId, roomId) // Update RR for the sender of a new message with a dummy one @@ -168,7 +167,6 @@ internal fun ChunkEntity.add(roomId: String, it.roomId = roomId it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() it.readReceipts = readReceiptsSummaryEntity - it.readMarker = ReadMarkerEntity.where(realm, roomId = roomId, eventId = eventId).findFirst() } val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size timelineEvents.add(position, eventEntity) @@ -176,14 +174,14 @@ internal fun ChunkEntity.add(roomId: String, internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { return when (direction) { - PaginationDirection.FORWARDS -> forwardsDisplayIndex - PaginationDirection.BACKWARDS -> backwardsDisplayIndex - } ?: defaultValue + PaginationDirection.FORWARDS -> forwardsDisplayIndex + PaginationDirection.BACKWARDS -> backwardsDisplayIndex + } ?: defaultValue } internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { return when (direction) { - PaginationDirection.FORWARDS -> forwardsStateIndex - PaginationDirection.BACKWARDS -> backwardsStateIndex - } ?: defaultValue + PaginationDirection.FORWARDS -> forwardsStateIndex + PaginationDirection.BACKWARDS -> backwardsStateIndex + } ?: defaultValue } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt index 8046ecbff0..9959f940b6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt @@ -36,7 +36,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS } return TimelineEvent( root = timelineEventEntity.root?.asDomain() - ?: Event("", timelineEventEntity.eventId), + ?: Event("", timelineEventEntity.eventId), annotations = timelineEventEntity.annotations?.asDomain(), localId = timelineEventEntity.localId, displayIndex = timelineEventEntity.root?.displayIndex ?: 0, @@ -45,8 +45,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS senderAvatar = timelineEventEntity.senderAvatar, readReceipts = readReceipts?.sortedByDescending { it.originServerTs - } ?: emptyList(), - hasReadMarker = timelineEventEntity.readMarker?.eventId?.isNotEmpty() == true + } ?: emptyList() ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt index 9e78c94f88..4d16d120d8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt @@ -17,8 +17,6 @@ package im.vector.matrix.android.internal.database.model import io.realm.RealmObject -import io.realm.RealmResults -import io.realm.annotations.LinkingObjects import io.realm.annotations.PrimaryKey internal open class ReadMarkerEntity( @@ -27,8 +25,5 @@ internal open class ReadMarkerEntity( var eventId: String = "" ) : RealmObject() { - @LinkingObjects("readMarker") - val timelineEvent: RealmResults? = null - companion object } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt index fd3a427781..235910b1ea 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt @@ -30,8 +30,7 @@ internal open class TimelineEventEntity(var localId: Long = 0, var isUniqueDisplayName: Boolean = false, var senderAvatar: String? = null, var senderMembershipEvent: EventEntity? = null, - var readReceipts: ReadReceiptsSummaryEntity? = null, - var readMarker: ReadMarkerEntity? = null + var readReceipts: ReadReceiptsSummaryEntity? = null ) : RealmObject() { @LinkingObjects("timelineEvents") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt index 4f64f2896f..6902d39a82 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterEntityQueries.kt @@ -16,26 +16,27 @@ package im.vector.matrix.android.internal.database.query -import im.vector.matrix.android.internal.database.awaitTransaction import im.vector.matrix.android.internal.database.model.FilterEntity import im.vector.matrix.android.internal.session.filter.FilterFactory import io.realm.Realm +import io.realm.kotlin.createObject import io.realm.kotlin.where +/** + * Get the current filter + */ +internal fun FilterEntity.Companion.get(realm: Realm): FilterEntity? { + return realm.where().findFirst() +} + /** * Get the current filter, create one if it does not exist */ -internal suspend fun FilterEntity.Companion.getFilter(realm: Realm): FilterEntity { - var filter = realm.where().findFirst() - if (filter == null) { - filter = FilterEntity().apply { - filterBodyJson = FilterFactory.createDefaultFilterBody().toJSONString() - roomEventFilterJson = FilterFactory.createDefaultRoomFilter().toJSONString() - filterId = "" - } - awaitTransaction(realm.configuration) { - it.insert(filter) - } - } - return filter +internal fun FilterEntity.Companion.getOrCreate(realm: Realm): FilterEntity { + return get(realm) ?: realm.createObject() + .apply { + filterBodyJson = FilterFactory.createDefaultFilterBody().toJSONString() + roomEventFilterJson = FilterFactory.createDefaultRoomFilter().toJSONString() + filterId = "" + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt index 061634a9da..d95dc58574 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt @@ -22,13 +22,9 @@ import io.realm.Realm import io.realm.RealmQuery import io.realm.kotlin.where -internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String, eventId: String? = null): RealmQuery { - val query = realm.where() +internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String): RealmQuery { + return realm.where() .equalTo(ReadMarkerEntityFields.ROOM_ID, roomId) - if (eventId != null) { - query.equalTo(ReadMarkerEntityFields.EVENT_ID, eventId) - } - return query } internal fun ReadMarkerEntity.Companion.getOrCreate(realm: Realm, roomId: String): ReadMarkerEntity { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt index 0a925ac1ab..c214886ec8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadQueries.kt @@ -18,7 +18,9 @@ package im.vector.matrix.android.internal.database.query import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.events.model.LocalEcho import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity +import io.realm.Realm internal fun isEventRead(monarchy: Monarchy, userId: String?, @@ -39,8 +41,10 @@ internal fun isEventRead(monarchy: Monarchy, isEventRead = if (eventToCheck?.sender == userId) { true } else { - val readReceipt = ReadReceiptEntity.where(realm, roomId, userId).findFirst() ?: return@doWithRealm - val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex ?: Int.MIN_VALUE + val readReceipt = ReadReceiptEntity.where(realm, roomId, userId).findFirst() + ?: return@doWithRealm + val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex + ?: Int.MIN_VALUE val eventToCheckIndex = eventToCheck?.displayIndex ?: Int.MAX_VALUE eventToCheckIndex <= readReceiptIndex @@ -49,3 +53,21 @@ internal fun isEventRead(monarchy: Monarchy, return isEventRead } + +internal fun isReadMarkerMoreRecent(monarchy: Monarchy, + roomId: String?, + eventId: String?): Boolean { + if (roomId.isNullOrBlank() || eventId.isNullOrBlank()) { + return false + } + return Realm.getInstance(monarchy.realmConfiguration).use { realm -> + val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) ?: return false + val eventToCheck = liveChunk.timelineEvents.find(eventId)?.root + + val readMarker = ReadMarkerEntity.where(realm, roomId).findFirst() ?: return false + val readMarkerIndex = liveChunk.timelineEvents.find(readMarker.eventId)?.root?.displayIndex + ?: Int.MIN_VALUE + val eventToCheckIndex = eventToCheck?.displayIndex ?: Int.MAX_VALUE + eventToCheckIndex <= readMarkerIndex + } +} 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/DefaultFileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt index e530bafb18..868d63665a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt @@ -22,12 +22,14 @@ import arrow.core.Try import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.file.FileService +import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments import im.vector.matrix.android.internal.di.UserMd5 import im.vector.matrix.android.internal.extensions.foldToCallback import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers import im.vector.matrix.android.internal.util.md5 +import im.vector.matrix.android.internal.util.toCancelable import im.vector.matrix.android.internal.util.writeToFile import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -55,8 +57,8 @@ internal class DefaultFileService @Inject constructor(private val context: Conte fileName: String, url: String?, elementToDecrypt: ElementToDecrypt?, - callback: MatrixCallback) { - GlobalScope.launch(coroutineDispatchers.main) { + callback: MatrixCallback): Cancelable { + return GlobalScope.launch(coroutineDispatchers.main) { withContext(coroutineDispatchers.io) { Try { val folder = getFolder(downloadMode, id) @@ -96,7 +98,7 @@ internal class DefaultFileService @Inject constructor(private val context: Conte } } .foldToCallback(callback) - } + }.toCancelable() } private fun getFolder(downloadMode: FileService.DownloadMode, id: String): File { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultInitialSyncProgressService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultInitialSyncProgressService.kt index c8bd5154a2..3f653571b7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultInitialSyncProgressService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultInitialSyncProgressService.kt @@ -101,7 +101,7 @@ class DefaultInitialSyncProgressService @Inject constructor() : InitialSyncProgr val parentProgress = (currentProgress * parentWeight).toInt() it.setProgress(offset + parentProgress) } ?: run { - Timber.e("--- ${leaf().nameRes}: $currentProgress") + Timber.v("--- ${leaf().nameRes}: $currentProgress") status.postValue( InitialSyncProgressService.Status(leaf().nameRes, currentProgress) ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt index 53967784a1..ae8e8ce891 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/filter/DefaultFilterRepository.kt @@ -19,7 +19,8 @@ package im.vector.matrix.android.internal.session.filter import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.internal.database.model.FilterEntity import im.vector.matrix.android.internal.database.model.FilterEntityFields -import im.vector.matrix.android.internal.database.query.getFilter +import im.vector.matrix.android.internal.database.query.get +import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.util.awaitTransaction import io.realm.Realm import io.realm.kotlin.where @@ -29,26 +30,28 @@ internal class DefaultFilterRepository @Inject constructor(private val monarchy: override suspend fun storeFilter(filterBody: FilterBody, roomEventFilter: RoomEventFilter): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> - val filter = FilterEntity.getFilter(realm) - val result = if (filter.filterBodyJson != filterBody.toJSONString()) { - // Filter has changed, store it and reset the filter Id - monarchy.awaitTransaction { + val filter = FilterEntity.get(realm) + // Filter has changed, or no filter Id yet + filter == null + || filter.filterBodyJson != filterBody.toJSONString() + || filter.filterId.isBlank() + }.also { hasChanged -> + if (hasChanged) { + // Filter is new or has changed, store it and reset the filter Id. + // This has to be done outside of the Realm.use(), because awaitTransaction change the current thread + monarchy.awaitTransaction { realm -> // We manage only one filter for now val filterBodyJson = filterBody.toJSONString() val roomEventFilterJson = roomEventFilter.toJSONString() - val filterEntity = FilterEntity.getFilter(it) + val filterEntity = FilterEntity.getOrCreate(realm) filterEntity.filterBodyJson = filterBodyJson filterEntity.roomEventFilterJson = roomEventFilterJson // Reset filterId filterEntity.filterId = "" } - true - } else { - filter.filterId.isBlank() } - result } } @@ -67,7 +70,7 @@ internal class DefaultFilterRepository @Inject constructor(private val monarchy: override suspend fun getFilter(): String { return Realm.getInstance(monarchy.realmConfiguration).use { - val filter = FilterEntity.getFilter(it) + val filter = FilterEntity.getOrCreate(it) if (filter.filterId.isBlank()) { // Use the Json format filter.filterBodyJson @@ -80,7 +83,7 @@ internal class DefaultFilterRepository @Inject constructor(private val monarchy: override suspend fun getRoomFilter(): String { return Realm.getInstance(monarchy.realmConfiguration).use { - FilterEntity.getFilter(it).roomEventFilterJson + FilterEntity.getOrCreate(it).roomEventFilterJson } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt index 224b3bcfeb..3d7c5df5fc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt @@ -298,7 +298,7 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( } } } else { - Timber.e("Unknwon relation type ${content.relatesTo?.type} for event ${event.eventId}") + Timber.e("Unknown relation type ${content.relatesTo?.type} for event ${event.eventId}") } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index 7e5de176bb..b9dca748cb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -18,7 +18,6 @@ package im.vector.matrix.android.internal.session.room.read import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.events.model.LocalEcho -import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.* @@ -57,22 +56,18 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI override suspend fun execute(params: SetReadMarkersTask.Params) { val markers = HashMap() - val fullyReadEventId: String? - val readReceiptEventId: String? Timber.v("Execute set read marker with params: $params") - if (params.markAllAsRead) { + val (fullyReadEventId, readReceiptEventId) = if (params.markAllAsRead) { val latestSyncedEventId = Realm.getInstance(monarchy.realmConfiguration).use { realm -> TimelineEventEntity.latestEvent(realm, roomId = params.roomId, includesSending = false)?.eventId } - fullyReadEventId = latestSyncedEventId - readReceiptEventId = latestSyncedEventId + Pair(latestSyncedEventId, latestSyncedEventId) } else { - fullyReadEventId = params.fullyReadEventId - readReceiptEventId = params.readReceiptEventId + Pair(params.fullyReadEventId, params.readReceiptEventId) } - if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) { + if (fullyReadEventId != null && !isReadMarkerMoreRecent(monarchy, params.roomId, fullyReadEventId)) { if (LocalEcho.isLocalEchoId(fullyReadEventId)) { Timber.w("Can't set read marker for local event $fullyReadEventId") } else { @@ -118,16 +113,4 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } } - - private fun isReadMarkerMoreRecent(roomId: String, newReadMarkerId: String): Boolean { - return Realm.getInstance(monarchy.realmConfiguration).use { realm -> - val currentReadMarkerId = ReadMarkerEntity.where(realm, roomId = roomId).findFirst()?.eventId - ?: return true - val readMarkerEvent = TimelineEventEntity.where(realm, roomId = roomId, eventId = currentReadMarkerId).findFirst() - val newReadMarkerEvent = TimelineEventEntity.where(realm, roomId = roomId, eventId = newReadMarkerId).findFirst() - val currentReadMarkerIndex = readMarkerEvent?.root?.displayIndex ?: Int.MAX_VALUE - val newReadMarkerIndex = newReadMarkerEvent?.root?.displayIndex ?: Int.MIN_VALUE - newReadMarkerIndex > currentReadMarkerIndex - } - } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt index 11be821d7e..db3b6100a0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt @@ -115,7 +115,7 @@ internal class DefaultRelationService @AssistedInject constructor(@Assisted priv override fun editTextMessage(targetEventId: String, msgType: String, - newBodyText: String, + newBodyText: CharSequence, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String): Cancelable { val event = eventFactory @@ -164,7 +164,7 @@ internal class DefaultRelationService @AssistedInject constructor(@Assisted priv .executeBy(taskExecutor) } - override fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Cancelable? { + override fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Cancelable? { val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown) ?.also { saveLocalEcho(it) } ?: return null diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index 7c720e56a7..8fad03b588 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -68,7 +68,7 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor() - override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable { + override fun sendTextMessage(text: CharSequence, msgType: String, autoMarkdown: Boolean): Cancelable { val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also { saveLocalEcho(it) } @@ -76,8 +76,8 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private return sendEvent(event) } - override fun sendFormattedTextMessage(text: String, formattedText: String): Cancelable { - val event = localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText)).also { + override fun sendFormattedTextMessage(text: String, formattedText: String, msgType: String): Cancelable { + val event = localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText), msgType).also { saveLocalEcho(it) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index 3fa0dcdca1..b773d1f892 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -36,6 +36,7 @@ import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.session.content.ThumbnailExtractor import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater +import im.vector.matrix.android.internal.session.room.send.pills.TextPillsUtils import im.vector.matrix.android.internal.util.StringProvider import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer @@ -50,45 +51,55 @@ import javax.inject.Inject * * The transactionID is used as loc */ -internal class LocalEchoEventFactory @Inject constructor(@UserId private val userId: String, - private val stringProvider: StringProvider, - private val roomSummaryUpdater: RoomSummaryUpdater) { +internal class LocalEchoEventFactory @Inject constructor( + @UserId private val userId: String, + private val stringProvider: StringProvider, + private val roomSummaryUpdater: RoomSummaryUpdater, + private val textPillsUtils: TextPillsUtils +) { // TODO Inject private val parser = Parser.builder().build() // TODO Inject private val renderer = HtmlRenderer.builder().build() - fun createTextEvent(roomId: String, msgType: String, text: String, autoMarkdown: Boolean): Event { - if (msgType == MessageType.MSGTYPE_TEXT) { - return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown)) + fun createTextEvent(roomId: String, msgType: String, text: CharSequence, autoMarkdown: Boolean): Event { + if (msgType == MessageType.MSGTYPE_TEXT || msgType == MessageType.MSGTYPE_EMOTE) { + return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown), msgType) } - val content = MessageTextContent(type = msgType, body = text) + val content = MessageTextContent(type = msgType, body = text.toString()) return createEvent(roomId, content) } - private fun createTextContent(text: String, autoMarkdown: Boolean): TextContent { + private fun createTextContent(text: CharSequence, autoMarkdown: Boolean): TextContent { if (autoMarkdown) { - val document = parser.parse(text) + val source = textPillsUtils.processSpecialSpansToMarkdown(text) + ?: text.toString() + val document = parser.parse(source) val htmlText = renderer.render(document) - if (isFormattedTextPertinent(text, htmlText)) { - return TextContent(text, htmlText) + if (isFormattedTextPertinent(source, htmlText)) { + return TextContent(source, htmlText) + } + } else { + // Try to detect pills + textPillsUtils.processSpecialSpansToHtml(text)?.let { + return TextContent(text.toString(), it) } } - return TextContent(text) + return TextContent(text.toString()) } private fun isFormattedTextPertinent(text: String, htmlText: String?) = text != htmlText && htmlText != "

${text.trim()}

\n" - fun createFormattedTextEvent(roomId: String, textContent: TextContent): Event { - return createEvent(roomId, textContent.toMessageTextContent()) + fun createFormattedTextEvent(roomId: String, textContent: TextContent, msgType: String): Event { + return createEvent(roomId, textContent.toMessageTextContent(msgType)) } fun createReplaceTextEvent(roomId: String, targetEventId: String, - newBodyText: String, + newBodyText: CharSequence, newBodyAutoMarkdown: Boolean, msgType: String, compatibilityText: String): Event { @@ -279,7 +290,7 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use return System.currentTimeMillis() } - fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Event? { + fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Event? { // Fallbacks and event representation // TODO Add error/warning logs when any of this is null val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null @@ -298,7 +309,7 @@ internal class LocalEchoEventFactory @Inject constructor(@UserId private val use // // > <@alice:example.org> This is the original body // - val replyFallback = buildReplyFallback(body, userId, replyText) + val replyFallback = buildReplyFallback(body, userId, replyText.toString()) val eventId = eventReplied.root.eventId ?: return null val content = MessageTextContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpec.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpec.kt new file mode 100644 index 0000000000..5ad61b5441 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpec.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.matrix.android.internal.session.room.send.pills + +import im.vector.matrix.android.api.session.room.send.UserMentionSpan + +internal data class MentionLinkSpec( + val span: UserMentionSpan, + val start: Int, + val end: Int +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpecComparator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpecComparator.kt new file mode 100644 index 0000000000..76fd8336cc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/MentionLinkSpecComparator.kt @@ -0,0 +1,32 @@ +/* + * 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.room.send.pills + +import javax.inject.Inject + +internal class MentionLinkSpecComparator @Inject constructor() : Comparator { + + override fun compare(o1: MentionLinkSpec, o2: MentionLinkSpec): Int { + return when { + o1.start < o2.start -> -1 + o1.start > o2.start -> 1 + o1.end < o2.end -> 1 + o1.end > o2.end -> -1 + else -> 0 + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt new file mode 100644 index 0000000000..580e49b2ce --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt @@ -0,0 +1,114 @@ +/* + * 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.room.send.pills + +import android.text.SpannableString +import im.vector.matrix.android.api.session.room.send.UserMentionSpan +import java.util.* +import javax.inject.Inject + +/** + * Utility class to detect special span in CharSequence and turn them into + * formatted text to send them as a Matrix messages. + * + * For now only support UserMentionSpans (TODO rooms, room aliases, etc...) + */ +internal class TextPillsUtils @Inject constructor( + private val mentionLinkSpecComparator: MentionLinkSpecComparator +) { + + /** + * Detects if transformable spans are present in the text. + * @return the transformed String or null if no Span found + */ + fun processSpecialSpansToHtml(text: CharSequence): String? { + return transformPills(text, MENTION_SPAN_TO_HTML_TEMPLATE) + } + + /** + * Detects if transformable spans are present in the text. + * @return the transformed String or null if no Span found + */ + fun processSpecialSpansToMarkdown(text: CharSequence): String? { + return transformPills(text, MENTION_SPAN_TO_MD_TEMPLATE) + } + + private fun transformPills(text: CharSequence, template: String): String? { + val spannableString = SpannableString.valueOf(text) + val pills = spannableString + ?.getSpans(0, text.length, UserMentionSpan::class.java) + ?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) } + ?.toMutableList() + ?.takeIf { it.isNotEmpty() } + ?: return null + + // we need to prune overlaps! + pruneOverlaps(pills) + + return buildString { + var currIndex = 0 + pills.forEachIndexed { _, (urlSpan, start, end) -> + // We want to replace with the pill with a html link + // append text before pill + append(text, currIndex, start) + // append the pill + append(String.format(template, urlSpan.userId, urlSpan.displayName)) + currIndex = end + } + // append text after the last pill + append(text, currIndex, text.length) + } + } + + private fun pruneOverlaps(links: MutableList) { + Collections.sort(links, mentionLinkSpecComparator) + var len = links.size + var i = 0 + while (i < len - 1) { + val a = links[i] + val b = links[i + 1] + var remove = -1 + + // test if there is an overlap + if (b.start in a.start until a.end) { + when { + b.end <= a.end -> + // b is inside a -> b should be removed + remove = i + 1 + a.end - a.start > b.end - b.start -> + // overlap and a is bigger -> b should be removed + remove = i + 1 + a.end - a.start < b.end - b.start -> + // overlap and a is smaller -> a should be removed + remove = i + } + + if (remove != -1) { + links.removeAt(remove) + len-- + continue + } + } + i++ + } + } + + companion object { + private const val MENTION_SPAN_TO_HTML_TEMPLATE = "%2\$s" + + private const val MENTION_SPAN_TO_MD_TEMPLATE = "[%2\$s](https://matrix.to/#/%1\$s)" + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultGetContextOfEventTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultGetContextOfEventTask.kt index 96e1caf71b..08d34d3056 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultGetContextOfEventTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultGetContextOfEventTask.kt @@ -26,7 +26,8 @@ internal interface GetContextOfEventTask : Task { - apiCall = roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter) + apiCall = roomAPI.getContextOfEvent(params.roomId, params.eventId, params.limit, filter) } return tokenChunkEventPersistor.insertInDb(response, params.roomId, PaginationDirection.BACKWARDS) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 4127e43540..b83240a681 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -74,22 +74,14 @@ internal class DefaultTimeline( private val cryptoService: CryptoService, private val timelineEventMapper: TimelineEventMapper, private val settings: TimelineSettings, - private val hiddenReadReceipts: TimelineHiddenReadReceipts, - private val hiddenReadMarker: TimelineHiddenReadMarker -) : Timeline, TimelineHiddenReadReceipts.Delegate, TimelineHiddenReadMarker.Delegate { + private val hiddenReadReceipts: TimelineHiddenReadReceipts +) : Timeline, TimelineHiddenReadReceipts.Delegate { private companion object { val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD") } - override var listener: Timeline.Listener? = null - set(value) { - field = value - BACKGROUND_HANDLER.post { - postSnapshot() - } - } - + private val listeners = ArrayList() private val isStarted = AtomicBoolean(false) private val isReady = AtomicBoolean(false) private val mainHandler = createUIHandler() @@ -110,7 +102,7 @@ internal class DefaultTimeline( private val backwardsState = AtomicReference(State()) private val forwardsState = AtomicReference(State()) - private val timelineID = UUID.randomUUID().toString() + override val timelineID = UUID.randomUUID().toString() override val isLive get() = !hasMoreToLoad(Timeline.Direction.FORWARDS) @@ -197,7 +189,6 @@ internal class DefaultTimeline( if (settings.buildReadReceipts) { hiddenReadReceipts.start(realm, filteredEvents, nonFilteredEvents, this) } - hiddenReadMarker.start(realm, filteredEvents, nonFilteredEvents, this) isReady.set(true) } } @@ -217,7 +208,6 @@ internal class DefaultTimeline( if (this::filteredEvents.isInitialized) { filteredEvents.removeAllChangeListeners() } - hiddenReadMarker.dispose() if (settings.buildReadReceipts) { hiddenReadReceipts.dispose() } @@ -298,7 +288,21 @@ internal class DefaultTimeline( return hasMoreInCache(direction) || !hasReachedEnd(direction) } -// TimelineHiddenReadReceipts.Delegate + override fun addListener(listener: Timeline.Listener) = synchronized(listeners) { + listeners.add(listener).also { + postSnapshot() + } + } + + override fun removeListener(listener: Timeline.Listener) = synchronized(listeners) { + listeners.remove(listener) + } + + override fun removeAllListeners() = synchronized(listeners) { + listeners.clear() + } + + // TimelineHiddenReadReceipts.Delegate override fun rebuildEvent(eventId: String, readReceipts: List): Boolean { return rebuildEvent(eventId) { te -> @@ -310,19 +314,7 @@ internal class DefaultTimeline( postSnapshot() } -// TimelineHiddenReadMarker.Delegate - - override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean { - return rebuildEvent(eventId) { te -> - te.copy(hasReadMarker = hasReadMarker) - } - } - - override fun onReadMarkerUpdated() { - postSnapshot() - } - -// Private methods ***************************************************************************** + // Private methods ***************************************************************************** private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean { return builtEventsIdMap[eventId]?.let { builtIndex -> @@ -502,9 +494,9 @@ internal class DefaultTimeline( return } val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) + from = token, + direction = direction.toPaginationDirection(), + limit = limit) Timber.v("Should fetch $limit items $direction") cancelableBag += paginationTask @@ -579,7 +571,7 @@ internal class DefaultTimeline( val timelineEvent = buildTimelineEvent(eventEntity) if (timelineEvent.isEncrypted() - && timelineEvent.root.mxDecryptionResult == null) { + && timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.let { eventDecryptor.requestDecryption(it) } } @@ -641,7 +633,7 @@ internal class DefaultTimeline( } private fun fetchEvent(eventId: String) { - val params = GetContextOfEventTask.Params(roomId, eventId) + val params = GetContextOfEventTask.Params(roomId, eventId, settings.initialSize) cancelableBag += contextOfEventTask.configureWith(params).executeBy(taskExecutor) } @@ -652,7 +644,13 @@ internal class DefaultTimeline( } updateLoadingStates(filteredEvents) val snapshot = createSnapshot() - val runnable = Runnable { listener?.onUpdated(snapshot) } + val runnable = Runnable { + synchronized(listeners) { + listeners.forEach { + it.onUpdated(snapshot) + } + } + } debouncer.debounce("post_snapshot", runnable, 50) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index 3bd67d38c3..d92dbd66be 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -53,17 +53,16 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv override fun createTimeline(eventId: String?, settings: TimelineSettings): Timeline { return DefaultTimeline(roomId, - eventId, - monarchy.realmConfiguration, - taskExecutor, - contextOfEventTask, - clearUnlinkedEventsTask, - paginationTask, - cryptoService, - timelineEventMapper, - settings, - TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings), - TimelineHiddenReadMarker(roomId, settings) + eventId, + monarchy.realmConfiguration, + taskExecutor, + contextOfEventTask, + clearUnlinkedEventsTask, + paginationTask, + cryptoService, + timelineEventMapper, + settings, + TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings) ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/EventContextResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/EventContextResponse.kt index 4dfe3e5c45..f06697351e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/EventContextResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/EventContextResponse.kt @@ -30,6 +30,7 @@ data class EventContextResponse( @Json(name = "state") override val stateEvents: List = emptyList() ) : TokenChunkEvent { - override val events: List - get() = listOf(event) + override val events: List by lazy { + eventsAfter.reversed() + listOf(event) + eventsBefore + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt deleted file mode 100644 index 4f80883bf9..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt +++ /dev/null @@ -1,133 +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.session.room.timeline - -import im.vector.matrix.android.api.session.room.timeline.TimelineSettings -import im.vector.matrix.android.internal.database.model.ReadMarkerEntity -import im.vector.matrix.android.internal.database.model.ReadMarkerEntityFields -import im.vector.matrix.android.internal.database.model.TimelineEventEntity -import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields -import im.vector.matrix.android.internal.database.query.FilterContent -import im.vector.matrix.android.internal.database.query.where -import io.realm.OrderedRealmCollectionChangeListener -import io.realm.Realm -import io.realm.RealmQuery -import io.realm.RealmResults - -/** - * This class is responsible for handling the read marker for hidden events. - * When an hidden event has read marker, we want to transfer it on the first older displayed event. - * It has to be used in [DefaultTimeline] and we should call the [start] and [dispose] methods to properly handle realm subscription. - */ -internal class TimelineHiddenReadMarker constructor(private val roomId: String, - private val settings: TimelineSettings) { - - interface Delegate { - fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean - fun onReadMarkerUpdated() - } - - private var previousDisplayedEventId: String? = null - private var hiddenReadMarker: RealmResults? = null - - private lateinit var filteredEvents: RealmResults - private lateinit var nonFilteredEvents: RealmResults - private lateinit var delegate: Delegate - - private val readMarkerListener = OrderedRealmCollectionChangeListener> { readMarkers, changeSet -> - if (!readMarkers.isLoaded || !readMarkers.isValid) { - return@OrderedRealmCollectionChangeListener - } - var hasChange = false - if (changeSet.deletions.isNotEmpty()) { - previousDisplayedEventId?.also { - hasChange = delegate.rebuildEvent(it, false) - previousDisplayedEventId = null - } - } - val readMarker = readMarkers.firstOrNull() ?: return@OrderedRealmCollectionChangeListener - val hiddenEvent = readMarker.timelineEvent?.firstOrNull() - ?: return@OrderedRealmCollectionChangeListener - - val isLoaded = nonFilteredEvents.where() - .equalTo(TimelineEventEntityFields.EVENT_ID, hiddenEvent.eventId) - .findFirst() != null - - val displayIndex = hiddenEvent.root?.displayIndex - if (isLoaded && displayIndex != null) { - // Then we are looking for the first displayable event after the hidden one - val firstDisplayedEvent = filteredEvents.where() - .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) - .findFirst() - - // If we find one, we should rebuild this one with marker - if (firstDisplayedEvent != null) { - previousDisplayedEventId = firstDisplayedEvent.eventId - hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true) - } - } - if (hasChange) { - delegate.onReadMarkerUpdated() - } - } - - /** - * Start the realm query subscription. Has to be called on an HandlerThread - */ - fun start(realm: Realm, - filteredEvents: RealmResults, - nonFilteredEvents: RealmResults, - delegate: Delegate) { - this.filteredEvents = filteredEvents - this.nonFilteredEvents = nonFilteredEvents - this.delegate = delegate - // We are looking for read receipts set on hidden events. - // We only accept those with a timelineEvent (so coming from pagination/sync). - hiddenReadMarker = ReadMarkerEntity.where(realm, roomId = roomId) - .isNotEmpty(ReadMarkerEntityFields.TIMELINE_EVENT) - .filterReceiptsWithSettings() - .findAllAsync() - .also { it.addChangeListener(readMarkerListener) } - } - - /** - * Dispose the realm query subscription. Has to be called on an HandlerThread - */ - fun dispose() { - this.hiddenReadMarker?.removeAllChangeListeners() - } - - /** - * We are looking for readMarker related to filtered events. So, it's the opposite of [DefaultTimeline.filterEventsWithSettings] method. - */ - private fun RealmQuery.filterReceiptsWithSettings(): RealmQuery { - beginGroup() - if (settings.filterTypes) { - not().`in`("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.TYPE}", settings.allowedTypes.toTypedArray()) - } - if (settings.filterTypes && settings.filterEdits) { - or() - } - if (settings.filterEdits) { - like("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.CONTENT}", FilterContent.EDIT_TYPE) - } - endGroup() - return this - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt index fbeabff0b5..7bff2936fd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.session.signout import android.content.Context +import im.vector.matrix.android.BuildConfig import im.vector.matrix.android.internal.SessionManager import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.crypto.CryptoModule @@ -27,6 +28,8 @@ import im.vector.matrix.android.internal.session.SessionModule import im.vector.matrix.android.internal.session.cache.ClearCacheTask import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.worker.WorkManagerUtil +import io.realm.Realm +import io.realm.RealmConfiguration import timber.log.Timber import java.io.File import javax.inject.Inject @@ -42,6 +45,8 @@ internal class DefaultSignOutTask @Inject constructor(private val context: Conte @CryptoDatabase private val clearCryptoDataTask: ClearCacheTask, @UserCacheDirectory private val userFile: File, private val realmKeysUtils: RealmKeysUtils, + @SessionDatabase private val realmSessionConfiguration: RealmConfiguration, + @CryptoDatabase private val realmCryptoConfiguration: RealmConfiguration, @UserMd5 private val userMd5: String) : SignOutTask { override suspend fun execute(params: Unit) { @@ -71,5 +76,15 @@ internal class DefaultSignOutTask @Inject constructor(private val context: Conte Timber.d("SignOut: clear the database keys") realmKeysUtils.clear(SessionModule.DB_ALIAS_PREFIX + userMd5) realmKeysUtils.clear(CryptoModule.DB_ALIAS_PREFIX + userMd5) + + // Sanity check + if (BuildConfig.DEBUG) { + Realm.getGlobalInstanceCount(realmSessionConfiguration) + .takeIf { it > 0 } + ?.let { Timber.e("All realm instance for session has not been closed ($it)") } + Realm.getGlobalInstanceCount(realmCryptoConfiguration) + .takeIf { it > 0 } + ?.let { Timber.e("All realm instance for crypto has not been closed ($it)") } + } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt index 853774460f..61ae8b9925 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -16,14 +16,10 @@ package im.vector.matrix.android.internal.session.sync -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity -import im.vector.matrix.android.internal.database.model.TimelineEventEntity -import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.query.getOrCreate -import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.session.room.read.FullyReadContent import io.realm.Realm import timber.log.Timber import javax.inject.Inject @@ -39,18 +35,8 @@ internal class RoomFullyReadHandler @Inject constructor() { RoomSummaryEntity.getOrCreate(realm, roomId).apply { readMarkerId = content.eventId } - // Remove the old markers if any - val oldReadMarkerEvents = TimelineEventEntity - .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH) - .isNotNull(TimelineEventEntityFields.READ_MARKER.`$`) - .findAll() - - oldReadMarkerEvents.forEach { it.readMarker = null } - val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { + ReadMarkerEntity.getOrCreate(realm, roomId).apply { this.eventId = content.eventId } - // Attach to timelineEvent if known - val timelineEventEntities = TimelineEventEntity.where(realm, roomId = roomId, eventId = content.eventId).findAll() - timelineEventEntities.forEach { it.readMarker = readMarkerEntity } } } 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 e425d53a62..d77f669215 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -15,7 +15,7 @@ androidExtensions { } ext.versionMajor = 0 -ext.versionMinor = 8 +ext.versionMinor = 9 ext.versionPatch = 0 static def getGitTimestamp() { @@ -221,10 +221,11 @@ dependencies { def arrow_version = "0.8.2" def coroutines_version = "1.3.2" def markwon_version = '4.1.2' - def big_image_viewer_version = '1.5.6' + def big_image_viewer_version = '1.6.2' 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/debug/java/im/vector/riotx/features/debug/sas/SasEmojiController.kt b/vector/src/debug/java/im/vector/riotx/features/debug/sas/SasEmojiController.kt index daf432fb45..6804828b20 100644 --- a/vector/src/debug/java/im/vector/riotx/features/debug/sas/SasEmojiController.kt +++ b/vector/src/debug/java/im/vector/riotx/features/debug/sas/SasEmojiController.kt @@ -29,7 +29,7 @@ class SasEmojiController : TypedEpoxyController() { if (data == null) return data.emojiList.forEachIndexed { idx, emojiRepresentation -> - itemSasEmoji { + sasEmojiItem { id(idx) index(idx) emojiRepresentation(emojiRepresentation) diff --git a/vector/src/debug/java/im/vector/riotx/features/debug/sas/ItemSasEmoji.kt b/vector/src/debug/java/im/vector/riotx/features/debug/sas/SasEmojiItem.kt similarity index 96% rename from vector/src/debug/java/im/vector/riotx/features/debug/sas/ItemSasEmoji.kt rename to vector/src/debug/java/im/vector/riotx/features/debug/sas/SasEmojiItem.kt index 92d9bc0b11..cf35873f6b 100644 --- a/vector/src/debug/java/im/vector/riotx/features/debug/sas/ItemSasEmoji.kt +++ b/vector/src/debug/java/im/vector/riotx/features/debug/sas/SasEmojiItem.kt @@ -25,7 +25,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel @EpoxyModelClass(layout = im.vector.riotx.R.layout.item_sas_emoji) -abstract class ItemSasEmoji : VectorEpoxyModel() { +abstract class SasEmojiItem : VectorEpoxyModel() { @EpoxyAttribute var index: Int = 0 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/epoxy/bottomsheet/BottomSheetItemAction.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetActionItem.kt similarity index 97% rename from vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemAction.kt rename to vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetActionItem.kt index 483650a434..c55dbdde8a 100644 --- a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemAction.kt +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetActionItem.kt @@ -37,7 +37,7 @@ import im.vector.riotx.features.themes.ThemeUtils * A action for bottom sheet. */ @EpoxyModelClass(layout = R.layout.item_bottom_sheet_action) -abstract class BottomSheetItemAction : VectorEpoxyModel() { +abstract class BottomSheetActionItem : VectorEpoxyModel() { @EpoxyAttribute @DrawableRes diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemMessagePreview.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt similarity index 83% rename from vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemMessagePreview.kt rename to vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt index 999068b289..8105d7a7c0 100644 --- a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemMessagePreview.kt +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt @@ -16,6 +16,7 @@ */ package im.vector.riotx.core.epoxy.bottomsheet +import android.text.method.MovementMethod import android.widget.ImageView import android.widget.TextView import com.airbnb.epoxy.EpoxyAttribute @@ -25,12 +26,13 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess /** * A message preview for bottom sheet. */ @EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_preview) -abstract class BottomSheetItemMessagePreview : VectorEpoxyModel() { +abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @@ -44,11 +46,15 @@ abstract class BottomSheetItemMessagePreview : VectorEpoxyModel() { +abstract class BottomSheetQuickReactionsItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var fontProvider: EmojiCompatFontProvider diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemRoomPreview.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetRoomPreviewItem.kt similarity index 95% rename from vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemRoomPreview.kt rename to vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetRoomPreviewItem.kt index 9b9d0fc380..1a5b4e2f66 100644 --- a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemRoomPreview.kt +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetRoomPreviewItem.kt @@ -31,7 +31,7 @@ import im.vector.riotx.features.home.AvatarRenderer * A room preview for bottom sheet. */ @EpoxyModelClass(layout = R.layout.item_bottom_sheet_room_preview) -abstract class BottomSheetItemRoomPreview : VectorEpoxyModel() { +abstract class BottomSheetRoomPreviewItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemSendState.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetSendStateItem.kt similarity index 94% rename from vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemSendState.kt rename to vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetSendStateItem.kt index 08d727cfa9..8f830ba706 100644 --- a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemSendState.kt +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetSendStateItem.kt @@ -30,7 +30,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel * A send state for bottom sheet. */ @EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_status) -abstract class BottomSheetItemSendState : VectorEpoxyModel() { +abstract class BottomSheetSendStateItem : VectorEpoxyModel() { @EpoxyAttribute var showProgress: Boolean = false diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemSeparator.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetSeparatorItem.kt similarity index 90% rename from vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemSeparator.kt rename to vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetSeparatorItem.kt index fddf507bf9..dd41d5dd66 100644 --- a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetItemSeparator.kt +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetSeparatorItem.kt @@ -22,7 +22,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel @EpoxyModelClass(layout = R.layout.item_bottom_sheet_divider) -abstract class BottomSheetItemSeparator : VectorEpoxyModel() { +abstract class BottomSheetSeparatorItem : VectorEpoxyModel() { class Holder : VectorEpoxyHolder() } 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 2dc75c5fa2..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,11 +17,19 @@ 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" +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/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt index 387105c480..388ec9bebe 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt @@ -24,7 +24,3 @@ fun TimelineEvent.canReact(): Boolean { // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment return root.getClearType() == EventType.MESSAGE && root.sendState == SendState.SYNCED && !root.isRedacted() } - -fun TimelineEvent.displayReadMarker(myUserId: String): Boolean { - return hasReadMarker && readReceipts.find { it.user.userId == myUserId } == null -} diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/View.kt b/vector/src/main/java/im/vector/riotx/core/extensions/View.kt index bcbab97360..41f98ed264 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/View.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/View.kt @@ -21,6 +21,14 @@ import android.view.View import android.view.inputmethod.InputMethodManager fun View.hideKeyboard() { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(windowToken, 0) + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(windowToken, 0) +} + +fun View.showKeyboard(andRequestFocus: Boolean = false) { + if (andRequestFocus) { + requestFocus() + } + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } 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/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt index abc2dd98f8..b2adde449a 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -23,7 +23,6 @@ import android.util.AttributeSet import android.view.View import android.widget.RelativeLayout import androidx.core.content.ContextCompat -import androidx.core.view.isInvisible import im.vector.riotx.R import kotlinx.android.synthetic.main.view_jump_to_read_marker.view.* @@ -34,7 +33,7 @@ class JumpToReadMarkerView @JvmOverloads constructor( ) : RelativeLayout(context, attrs, defStyleAttr) { interface Callback { - fun onJumpToReadMarkerClicked(readMarkerId: String) + fun onJumpToReadMarkerClicked() fun onClearReadMarkerClicked() } @@ -44,24 +43,15 @@ class JumpToReadMarkerView @JvmOverloads constructor( setupView() } - private var readMarkerId: String? = null - private fun setupView() { inflate(context, R.layout.view_jump_to_read_marker, this) setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color)) jumpToReadMarkerLabelView.setOnClickListener { - readMarkerId?.also { - callback?.onJumpToReadMarkerClicked(it) - } + callback?.onJumpToReadMarkerClicked() } closeJumpToReadMarkerView.setOnClickListener { visibility = View.INVISIBLE callback?.onClearReadMarkerClicked() } } - - fun render(show: Boolean, readMarkerId: String?) { - this.readMarkerId = readMarkerId - isInvisible = !show - } } diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt deleted file mode 100644 index 0fb8b55250..0000000000 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt +++ /dev/null @@ -1,89 +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.riotx.core.ui.views - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import im.vector.riotx.R -import kotlinx.coroutines.* - -private const val DELAY_IN_MS = 1_000L - -class ReadMarkerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : View(context, attrs, defStyleAttr) { - - interface Callback { - fun onReadMarkerLongBound(isDisplayed: Boolean) - } - - private var eventId: String? = null - private var callback: Callback? = null - private var callbackDispatcherJob: Job? = null - - fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) { - this.eventId = eventId - this.callback = readMarkerCallback - if (displayReadMarker) { - startAnimation() - } else { - this.animation?.cancel() - this.visibility = INVISIBLE - } - if (hasReadMarker) { - callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { - delay(DELAY_IN_MS) - callback?.onReadMarkerLongBound(displayReadMarker) - } - } - } - - fun unbind() { - this.callbackDispatcherJob?.cancel() - this.callback = null - this.eventId = null - this.animation?.cancel() - this.visibility = INVISIBLE - } - - private fun startAnimation() { - if (animation == null) { - animation = AnimationUtils.loadAnimation(context, R.anim.unread_marker_anim) - animation.startOffset = DELAY_IN_MS / 2 - animation.duration = DELAY_IN_MS / 2 - animation.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - } - - override fun onAnimationEnd(animation: Animation) { - visibility = INVISIBLE - } - - override fun onAnimationRepeat(animation: Animation) {} - }) - } - visibility = VISIBLE - animation.start() - } -} 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/command/CommandParser.kt b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt index 3f5808949b..bc451f8e84 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt @@ -27,7 +27,7 @@ object CommandParser { * @param textMessage the text message * @return a parsed slash command (ok or error) */ - fun parseSplashCommand(textMessage: String): ParsedCommand { + fun parseSplashCommand(textMessage: CharSequence): ParsedCommand { // check if it has the Slash marker if (!textMessage.startsWith("/")) { return ParsedCommand.ErrorNotACommand @@ -76,7 +76,7 @@ object CommandParser { } } Command.EMOTE.command -> { - val message = textMessage.substring(Command.EMOTE.command.length).trim() + val message = textMessage.subSequence(Command.EMOTE.command.length, textMessage.length).trim() ParsedCommand.SendEmote(message) } diff --git a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt index 02f5abe540..89438c8a9d 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt @@ -33,7 +33,7 @@ sealed class ParsedCommand { // Valid commands: - class SendEmote(val message: String) : ParsedCommand() + class SendEmote(val message: CharSequence) : ParsedCommand() class BanUser(val userId: String, val reason: String) : ParsedCommand() class UnbanUser(val userId: String) : ParsedCommand() class SetUserPowerLevel(val userId: String, val powerLevel: Int) : ParsedCommand() diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt index 104aa301cb..1102c67e16 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeActivity.kt @@ -97,7 +97,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable { if (status == null) { waiting_view.isVisible = false } else { - Timber.e("${getString(status.statusText)} ${status.percentProgress}") + Timber.v("${getString(status.statusText)} ${status.percentProgress}") waiting_view.setOnClickListener { // block interactions } diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt index cf6abf12e9..59f31ec2ee 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomDirectoryUsersFragment.kt @@ -16,10 +16,8 @@ package im.vector.riotx.features.home.createdirect -import android.content.Context import android.os.Bundle import android.view.View -import android.view.inputmethod.InputMethodManager import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.withState import com.jakewharton.rxbinding3.widget.textChanges @@ -27,6 +25,7 @@ import im.vector.matrix.android.api.session.user.model.User import im.vector.riotx.R import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.setupAsSearch +import im.vector.riotx.core.extensions.showKeyboard import im.vector.riotx.core.platform.VectorBaseFragment import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.* import javax.inject.Inject @@ -63,9 +62,7 @@ class CreateDirectRoomDirectoryUsersFragment @Inject constructor( viewModel.handle(CreateDirectRoomAction.SearchDirectoryUsers(it.toString())) } .disposeOnDestroyView() - createDirectRoomSearchById.requestFocus() - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(createDirectRoomSearchById, InputMethodManager.SHOW_IMPLICIT) + createDirectRoomSearchById.showKeyboard(andRequestFocus = true) } private fun setupCloseView() { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt deleted file mode 100644 index 7b3ebeb71c..0000000000 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt +++ /dev/null @@ -1,99 +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.riotx.features.home.room.detail - -import androidx.recyclerview.widget.LinearLayoutManager -import im.vector.riotx.core.di.ScreenScope -import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import javax.inject.Inject - -@ScreenScope -class ReadMarkerHelper @Inject constructor() { - - lateinit var timelineEventController: TimelineEventController - lateinit var layoutManager: LinearLayoutManager - var callback: Callback? = null - - private var onReadMarkerLongDisplayed = false - private var jumpToReadMarkerVisible = false - private var readMarkerVisible: Boolean = true - private var state: RoomDetailViewState? = null - - fun readMarkerVisible(): Boolean { - return readMarkerVisible - } - - fun onResume() { - onReadMarkerLongDisplayed = false - } - - fun onReadMarkerLongDisplayed() { - onReadMarkerLongDisplayed = true - } - - fun updateWith(newState: RoomDetailViewState) { - state = newState - checkReadMarkerVisibility() - checkJumpToReadMarkerVisibility() - } - - fun onTimelineScrolled() { - checkJumpToReadMarkerVisibility() - } - - private fun checkReadMarkerVisibility() { - val nonNullState = this.state ?: return - val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - readMarkerVisible = if (!onReadMarkerLongDisplayed) { - true - } else { - if (nonNullState.timeline?.isLive == false) { - true - } else { - !(firstVisibleItem == 0 && lastVisibleItem > 0) - } - } - } - - private fun checkJumpToReadMarkerVisibility() { - val nonNullState = this.state ?: return - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId - val newJumpToReadMarkerVisible = if (readMarkerId == null) { - false - } else { - val correctedReadMarkerId = nonNullState.timeline?.getFirstDisplayableEventId(readMarkerId) - ?: readMarkerId - val positionOfReadMarker = timelineEventController.searchPositionOfEvent(correctedReadMarkerId) - if (positionOfReadMarker == null) { - nonNullState.timeline?.isLive == true && lastVisibleItem > 0 - } else { - positionOfReadMarker > lastVisibleItem - } - } - if (newJumpToReadMarkerVisible != jumpToReadMarkerVisible) { - jumpToReadMarkerVisible = newJumpToReadMarkerVisible - callback?.onJumpToReadMarkerVisibilityUpdate(jumpToReadMarkerVisible, readMarkerId) - } - } - - interface Callback { - fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) - } -} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt index 2e59e70d08..c1743ae3fc 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt @@ -25,7 +25,7 @@ import im.vector.riotx.core.platform.VectorViewModelAction sealed class RoomDetailAction : VectorViewModelAction { data class SaveDraft(val draft: String) : RoomDetailAction() - data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailAction() + data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction() data class SendMedia(val attachments: List) : RoomDetailAction() data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailAction() data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailAction() @@ -35,13 +35,15 @@ sealed class RoomDetailAction : VectorViewModelAction { data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailAction() data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction() data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction() - data class SetReadMarkerAction(val eventId: String) : RoomDetailAction() object MarkAllAsRead : RoomDetailAction() data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailAction() data class HandleTombstoneEvent(val event: Event) : RoomDetailAction() object AcceptInvite : RoomDetailAction() object RejectInvite : RoomDetailAction() + object EnterTrackingUnreadMessagesState : RoomDetailAction() + object ExitTrackingUnreadMessagesState : RoomDetailAction() + data class EnterEditMode(val eventId: String, val text: String) : RoomDetailAction() data class EnterQuoteMode(val eventId: String, val text: String) : RoomDetailAction() data class EnterReplyMode(val eventId: String, val text: String) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 6fdbf94590..d50b0c9f68 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail import android.annotation.SuppressLint import android.app.Activity.RESULT_OK -import android.content.Context import android.content.DialogInterface import android.content.Intent import android.graphics.drawable.ColorDrawable @@ -29,7 +28,6 @@ import android.os.Parcelable import android.text.Editable import android.text.Spannable import android.view.* -import android.view.inputmethod.InputMethodManager import android.widget.TextView import android.widget.Toast import androidx.annotation.DrawableRes @@ -37,9 +35,11 @@ import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat +import androidx.core.text.buildSpannedString import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.core.view.forEach +import androidx.core.view.isVisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -58,7 +58,6 @@ import im.vector.matrix.android.api.permalinks.PermalinkFactory import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.events.model.LocalEcho import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.send.SendState @@ -73,6 +72,7 @@ import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.setTextOrHide +import im.vector.riotx.core.extensions.showKeyboard import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.platform.VectorBaseFragment @@ -145,8 +145,7 @@ class RoomDetailFragment @Inject constructor( val textComposerViewModelFactory: TextComposerViewModel.Factory, private val errorFormatter: ErrorFormatter, private val eventHtmlRenderer: EventHtmlRenderer, - private val vectorPreferences: VectorPreferences, - private val readMarkerHelper: ReadMarkerHelper + private val vectorPreferences: VectorPreferences ) : VectorBaseFragment(), TimelineEventController.Callback, @@ -158,7 +157,7 @@ class RoomDetailFragment @Inject constructor( companion object { - /**x + /** * Sanitize the display name. * * @param displayName the display name to sanitize @@ -292,6 +291,7 @@ class RoomDetailFragment @Inject constructor( } override fun onDestroy() { + roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) debouncer.cancelAll() super.onDestroy() } @@ -299,6 +299,7 @@ class RoomDetailFragment @Inject constructor( private fun setupJumpToBottomView() { jumpToBottomView.visibility = View.INVISIBLE jumpToBottomView.setOnClickListener { + roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) jumpToBottomView.visibility = View.INVISIBLE withState(roomDetailViewModel) { state -> if (state.timeline?.isLive == false) { @@ -405,7 +406,12 @@ class RoomDetailFragment @Inject constructor( composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) composerLayout.sendButton.setContentDescription(getString(descriptionRes)) - avatarRenderer.render(event.senderAvatar, event.root.senderId ?: "", event.getDisambiguatedDisplayName(), composerLayout.composerRelatedMessageAvatar) + avatarRenderer.render( + event.senderAvatar, + event.root.senderId ?: "", + event.getDisambiguatedDisplayName(), + composerLayout.composerRelatedMessageAvatar + ) composerLayout.expand { // need to do it here also when not using quick reply focusComposerAndShowKeyboard() @@ -418,12 +424,12 @@ class RoomDetailFragment @Inject constructor( if (text != composerLayout.composerEditText.text.toString()) { // Ignore update to avoid saving a draft composerLayout.composerEditText.setText(text) - composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length ?: 0) + composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length + ?: 0) } } override fun onResume() { - readMarkerHelper.onResume() super.onResume() notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId) } @@ -468,24 +474,12 @@ class RoomDetailFragment @Inject constructor( it.dispatchTo(stateRestorer) it.dispatchTo(scrollOnNewMessageCallback) it.dispatchTo(scrollOnHighlightedEventCallback) - } - readMarkerHelper.timelineEventController = timelineEventController - readMarkerHelper.layoutManager = layoutManager - readMarkerHelper.callback = object : ReadMarkerHelper.Callback { - override fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) { - jumpToReadMarkerView.render(show, readMarkerId) - } + updateJumpToReadMarkerViewVisibility() + updateJumpToBottomViewVisibility() } recyclerView.adapter = timelineEventController.adapter recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { - updateJumpToBottomViewVisibility() - } - readMarkerHelper.onTimelineScrolled() - } - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { when (newState) { RecyclerView.SCROLL_STATE_IDLE -> { @@ -527,6 +521,30 @@ class RoomDetailFragment @Inject constructor( } } + private fun updateJumpToReadMarkerViewVisibility() = jumpToReadMarkerView.post { + withState(roomDetailViewModel) { + val showJumpToUnreadBanner = when (it.unreadState) { + UnreadState.Unknown, + UnreadState.HasNoUnread -> false + is UnreadState.ReadMarkerNotLoaded -> true + is UnreadState.HasUnread -> { + if (it.canShowJumpToReadMarker) { + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + val positionOfReadMarker = timelineEventController.getPositionOfReadMarker() + if (positionOfReadMarker == null) { + false + } else { + positionOfReadMarker > lastVisibleItem + } + } else { + false + } + } + } + jumpToReadMarkerView.isVisible = showJumpToUnreadBanner + } + } + private fun updateJumpToBottomViewVisibility() { debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") @@ -588,7 +606,13 @@ class RoomDetailFragment @Inject constructor( // Add the span val user = session.getUser(item.userId) - val span = PillImageSpan(glideRequests, avatarRenderer, requireContext(), item.userId, user) + val span = PillImageSpan( + glideRequests, + avatarRenderer, + requireContext(), + item.userId, + user?.displayName ?: item.userId, + user?.avatarUrl) span.bind(composerLayout.composerEditText) editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) @@ -609,7 +633,7 @@ class RoomDetailFragment @Inject constructor( attachmentTypeSelector.show(composerLayout.attachmentButton, keyboardStateUtils.isKeyboardShowing) } - override fun onSendMessage(text: String) { + override fun onSendMessage(text: CharSequence) { if (lockSendButton) { Timber.w("Send button is locked") return @@ -651,13 +675,12 @@ class RoomDetailFragment @Inject constructor( } private fun renderState(state: RoomDetailViewState) { - readMarkerHelper.updateWith(state) renderRoomSummary(state) val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { scrollOnHighlightedEventCallback.timeline = state.timeline - timelineEventController.update(state, readMarkerHelper.readMarkerVisible()) + timelineEventController.update(state) inviteView.visibility = View.GONE val uid = session.myUserId val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) @@ -975,9 +998,8 @@ class RoomDetailFragment @Inject constructor( vectorBaseActivity.notImplemented("Click on user avatar") } - @SuppressLint("SetTextI18n") override fun onMemberNameClicked(informationData: MessageInformationData) { - insertUserDisplayNameInTextEditor(informationData.memberName?.toString()) + insertUserDisplayNameInTextEditor(informationData.senderId) } override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { @@ -1014,28 +1036,9 @@ class RoomDetailFragment @Inject constructor( .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") } - override fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean) { - readMarkerHelper.onReadMarkerLongDisplayed() - val readMarkerIndex = timelineEventController.searchPositionOfEvent(readMarkerId) ?: return - val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() - if (readMarkerIndex > lastVisibleItemPosition) { - return - } - val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() - var nextReadMarkerId: String? = null - for (itemPosition in firstVisibleItemPosition until lastVisibleItemPosition) { - val timelineItem = timelineEventController.adapter.getModelAtPosition(itemPosition) - if (timelineItem is BaseEventItem) { - val eventId = timelineItem.getEventIds().firstOrNull() ?: continue - if (!LocalEcho.isLocalEchoId(eventId)) { - nextReadMarkerId = eventId - break - } - } - } - if (nextReadMarkerId != null) { - roomDetailViewModel.handle(RoomDetailAction.SetReadMarkerAction(nextReadMarkerId)) - } + override fun onReadMarkerVisible() { + updateJumpToReadMarkerViewVisibility() + roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) } // AutocompleteUserPresenter.Callback @@ -1153,61 +1156,73 @@ class RoomDetailFragment @Inject constructor( is EventSharedAction.IgnoreUser -> { roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(action.senderId)) } + is EventSharedAction.OnUrlClicked -> { + onUrlClicked(action.url) + } + is EventSharedAction.OnUrlLongClicked -> { + onUrlLongClicked(action.url) + } else -> { Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show() } } } -// utils /** - * Insert an user displayname in the message editor. + * Insert a user displayName in the message editor. * - * @param text the text to insert. + * @param userId the userId. */ -// TODO legacy, refactor - private fun insertUserDisplayNameInTextEditor(text: String?) { - // TODO move logic outside of fragment - if (null != text) { -// var vibrate = false + @SuppressLint("SetTextI18n") + private fun insertUserDisplayNameInTextEditor(userId: String) { + val startToCompose = composerLayout.composerEditText.text.isNullOrBlank() - val myDisplayName = session.getUser(session.myUserId)?.displayName - if (myDisplayName == text) { - // current user - if (composerLayout.composerEditText.text.isNullOrBlank()) { - composerLayout.composerEditText.append(Command.EMOTE.command + " ") - composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length ?: 0) -// vibrate = true - } - } else { - // another user - if (composerLayout.composerEditText.text.isNullOrBlank()) { - // Ensure displayName will not be interpreted as a Slash command - if (text.startsWith("/")) { - composerLayout.composerEditText.append("\\") + if (startToCompose + && userId == session.myUserId) { + // Empty composer, current user: start an emote + composerLayout.composerEditText.setText(Command.EMOTE.command + " ") + composerLayout.composerEditText.setSelection(Command.EMOTE.command.length + 1) + } else { + val roomMember = roomDetailViewModel.getMember(userId) + // TODO move logic outside of fragment + (roomMember?.displayName ?: userId) + .let { sanitizeDisplayName(it) } + .let { displayName -> + buildSpannedString { + append(displayName) + setSpan( + PillImageSpan( + glideRequests, + avatarRenderer, + requireContext(), + userId, + displayName, + roomMember?.avatarUrl) + .also { it.bind(composerLayout.composerEditText) }, + 0, + displayName.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + append(if (startToCompose) ": " else " ") + }.let { pill -> + if (startToCompose) { + if (displayName.startsWith("/")) { + // Ensure displayName will not be interpreted as a Slash command + composerLayout.composerEditText.append("\\") + } + composerLayout.composerEditText.append(pill) + } else { + composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, pill) + } + } } - composerLayout.composerEditText.append(sanitizeDisplayName(text) + ": ") - } else { - composerLayout.composerEditText.text?.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayName(text) + " ") - } - -// vibrate = true - } - -// if (vibrate && vectorPreferences.vibrateWhenMentioning()) { -// val v= context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator -// if (v?.hasVibrator() == true) { -// v.vibrate(100) -// } -// } - focusComposerAndShowKeyboard() } + + focusComposerAndShowKeyboard() } private fun focusComposerAndShowKeyboard() { - composerLayout.composerEditText.requestFocus() - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT) + composerLayout.composerEditText.showKeyboard(andRequestFocus = true) } private fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) { @@ -1230,8 +1245,14 @@ class RoomDetailFragment @Inject constructor( // JumpToReadMarkerView.Callback - override fun onJumpToReadMarkerClicked(readMarkerId: String) { - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(readMarkerId, false)) + override fun onJumpToReadMarkerClicked() = withState(roomDetailViewModel) { + jumpToReadMarkerView.isVisible = false + if (it.unreadState is UnreadState.HasUnread) { + roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.firstUnreadEventId, false)) + } + if (it.unreadState is UnreadState.ReadMarkerNotLoaded) { + roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.readMarkerId, false)) + } } override fun onClearReadMarkerClicked() { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index d2c2c7fdde..642bce3319 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.airbnb.mvrx.* import com.jakewharton.rxrelay2.BehaviorRelay +import com.jakewharton.rxrelay2.PublishRelay import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.MatrixCallback @@ -34,11 +35,15 @@ import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities import im.vector.matrix.android.api.session.room.model.Membership +import im.vector.matrix.android.api.session.room.model.RoomMember +import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.getFileUrl import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.send.UserDraft +import im.vector.matrix.android.api.session.room.timeline.Timeline +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt @@ -57,19 +62,23 @@ import im.vector.riotx.features.command.CommandParser import im.vector.riotx.features.command.ParsedCommand import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import im.vector.riotx.features.settings.VectorPreferences +import io.reactivex.Observable +import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy +import io.reactivex.schedulers.Schedulers import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import timber.log.Timber import java.io.File import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState, userPreferencesProvider: UserPreferencesProvider, private val vectorPreferences: VectorPreferences, private val stringProvider: StringProvider, private val session: Session -) : VectorViewModel(initialState) { +) : VectorViewModel(initialState), Timeline.Listener { private val room = session.getRoom(initialState.roomId)!! private val eventId = initialState.eventId @@ -89,6 +98,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts()) } + private var timelineEvents = PublishRelay.create>() private var timeline = room.createTimeline(eventId, timelineSettings) // Can be used for several actions, for a one shot result @@ -101,6 +111,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro // Slot to keep a pending uri during permission request var pendingUri: Uri? = null + private var trackUnreadMessages = AtomicBoolean(false) + private var mostRecentDisplayedEvent: TimelineEvent? = null + @AssistedInject.Factory interface Factory { fun create(initialState: RoomDetailViewState): RoomDetailViewModel @@ -119,52 +132,74 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } init { + getUnreadState() observeSyncState() observeRoomSummary() observeEventDisplayedActions() observeSummaryState() observeDrafts() + observeUnreadState() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() + timeline.addListener(this) timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } } override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.SaveDraft -> handleSaveDraft(action) - is RoomDetailAction.SendMessage -> handleSendMessage(action) - is RoomDetailAction.SendMedia -> handleSendMedia(action) - is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) - is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) - is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) - is RoomDetailAction.SendReaction -> handleSendReaction(action) - is RoomDetailAction.AcceptInvite -> handleAcceptInvite() - is RoomDetailAction.RejectInvite -> handleRejectInvite() - is RoomDetailAction.RedactAction -> handleRedactEvent(action) - is RoomDetailAction.UndoReaction -> handleUndoReact(action) - is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailAction.ExitSpecialMode -> handleExitSpecialMode(action) - is RoomDetailAction.EnterEditMode -> handleEditAction(action) - is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) - is RoomDetailAction.DownloadFile -> handleDownloadFile(action) - is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) - is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) - is RoomDetailAction.ResendMessage -> handleResendEvent(action) - is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) - is RoomDetailAction.ClearSendQueue -> handleClearSendQueue() - is RoomDetailAction.ResendAll -> handleResendAll() - is RoomDetailAction.SetReadMarkerAction -> handleSetReadMarkerAction(action) - is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() - is RoomDetailAction.ReportContent -> handleReportContent(action) - is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) + is RoomDetailAction.SaveDraft -> handleSaveDraft(action) + is RoomDetailAction.SendMessage -> handleSendMessage(action) + is RoomDetailAction.SendMedia -> handleSendMedia(action) + is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) + is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) + is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) + is RoomDetailAction.SendReaction -> handleSendReaction(action) + is RoomDetailAction.AcceptInvite -> handleAcceptInvite() + is RoomDetailAction.RejectInvite -> handleRejectInvite() + is RoomDetailAction.RedactAction -> handleRedactEvent(action) + is RoomDetailAction.UndoReaction -> handleUndoReact(action) + is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) + is RoomDetailAction.ExitSpecialMode -> handleExitSpecialMode(action) + is RoomDetailAction.EnterEditMode -> handleEditAction(action) + is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) + is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) + is RoomDetailAction.DownloadFile -> handleDownloadFile(action) + is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) + is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) + is RoomDetailAction.ResendMessage -> handleResendEvent(action) + is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) + is RoomDetailAction.ClearSendQueue -> handleClearSendQueue() + is RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() + is RoomDetailAction.ReportContent -> handleReportContent(action) + is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) + is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() + is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() } } + private fun startTrackingUnreadMessages() { + trackUnreadMessages.set(true) + setState { copy(canShowJumpToReadMarker = false) } + } + + private fun stopTrackingUnreadMessages() { + if (trackUnreadMessages.getAndSet(false)) { + mostRecentDisplayedEvent?.root?.eventId?.also { + room.setReadMarker(it, callback = object : MatrixCallback {}) + } + mostRecentDisplayedEvent = null + } + setState { copy(canShowJumpToReadMarker = true) } + } + private fun handleEventInvisible(action: RoomDetailAction.TimelineEventTurnsInvisible) { invisibleEventsObservable.accept(action) } + fun getMember(userId: String) : RoomMember? { + return room.getRoomMember(userId) + } /** * Convert a send mode to a draft and save the draft */ @@ -355,7 +390,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro if (inReplyTo != null) { // TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { - room.editReply(state.sendMode.timelineEvent, it, action.text) + room.editReply(state.sendMode.timelineEvent, it, action.text.toString()) } } else { val messageContent: MessageContent? = @@ -380,7 +415,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val textMsg = messageContent?.body - val finalText = legacyRiotQuoteText(textMsg, action.text) + val finalText = legacyRiotQuoteText(textMsg, action.text.toString()) + + // TODO check for pills? // TODO Refactor this, just temporary for quotes val parser = Parser.builder().build() @@ -397,7 +434,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } is SendMode.REPLY -> { state.sendMode.timelineEvent.let { - room.replyToMessage(it, action.text, action.autoMarkdown) + room.replyToMessage(it, action.text.toString(), action.autoMarkdown) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent) popDraft() } @@ -621,6 +658,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } private fun handleNavigateToEvent(action: RoomDetailAction.NavigateToEvent) { + stopTrackingUnreadMessages() val targetEventId: String = action.eventId val correctedEventId = timeline.getFirstDisplayableEventId(targetEventId) ?: targetEventId val indexOfEvent = timeline.getIndexOfEvent(correctedEventId) @@ -679,26 +717,23 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro .buffer(1, TimeUnit.SECONDS) .filter { it.isNotEmpty() } .subscribeBy(onNext = { actions -> - val mostRecentEvent = actions.maxBy { it.event.displayIndex } - mostRecentEvent?.event?.root?.eventId?.let { eventId -> + val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event + ?: return@subscribeBy + val globalMostRecentDisplayedEvent = mostRecentDisplayedEvent + if (trackUnreadMessages.get()) { + if (globalMostRecentDisplayedEvent == null) { + mostRecentDisplayedEvent = bufferedMostRecentDisplayedEvent + } else if (bufferedMostRecentDisplayedEvent.displayIndex > globalMostRecentDisplayedEvent.displayIndex) { + mostRecentDisplayedEvent = bufferedMostRecentDisplayedEvent + } + } + bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId -> room.setReadReceipt(eventId, callback = object : MatrixCallback {}) } }) .disposeOnClear() } - private fun handleSetReadMarkerAction(action: RoomDetailAction.SetReadMarkerAction) = withState { - var readMarkerId = action.eventId - val indexOfEvent = timeline.getIndexOfEvent(readMarkerId) - // force to set the read marker on the next event - if (indexOfEvent != null) { - timeline.getTimelineEventAtIndex(indexOfEvent - 1)?.root?.eventId?.also { eventIdOfNext -> - readMarkerId = eventIdOfNext - } - } - room.setReadMarker(readMarkerId, callback = object : MatrixCallback {}) - } - private fun handleMarkAllAsRead() { room.markAllAsRead(object : MatrixCallback {}) } @@ -753,6 +788,56 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } + private fun getUnreadState() { + Observable + .combineLatest, RoomSummary, UnreadState>( + timelineEvents.observeOn(Schedulers.computation()), + room.rx().liveRoomSummary().unwrap(), + BiFunction { timelineEvents, roomSummary -> + computeUnreadState(timelineEvents, roomSummary) + } + ) + // We don't want live update of unread so we skip when we already had a HasUnread or HasNoUnread + .distinctUntilChanged { previous, current -> + when { + previous is UnreadState.Unknown || previous is UnreadState.ReadMarkerNotLoaded -> false + current is UnreadState.HasUnread || current is UnreadState.HasNoUnread -> true + else -> false + } + } + .subscribe { + setState { copy(unreadState = it) } + } + .disposeOnClear() + } + + private fun computeUnreadState(events: List, roomSummary: RoomSummary): UnreadState { + if (events.isEmpty()) return UnreadState.Unknown + val readMarkerIdSnapshot = roomSummary.readMarkerId ?: return UnreadState.Unknown + val firstDisplayableEventId = timeline.getFirstDisplayableEventId(readMarkerIdSnapshot) + ?: return UnreadState.ReadMarkerNotLoaded(readMarkerIdSnapshot) + val firstDisplayableEventIndex = timeline.getIndexOfEvent(firstDisplayableEventId) + ?: return UnreadState.ReadMarkerNotLoaded(readMarkerIdSnapshot) + for (i in (firstDisplayableEventIndex - 1) downTo 0) { + val timelineEvent = events.getOrNull(i) ?: return UnreadState.Unknown + val eventId = timelineEvent.root.eventId ?: return UnreadState.Unknown + val isFromMe = timelineEvent.root.senderId == session.myUserId + if (!isFromMe) { + return UnreadState.HasUnread(eventId) + } + } + return UnreadState.HasNoUnread + } + + private fun observeUnreadState() { + selectSubscribe(RoomDetailViewState::unreadState) { + Timber.v("Unread state: $it") + if (it is UnreadState.HasNoUnread) { + startTrackingUnreadMessages() + } + } + } + private fun observeSummaryState() { asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> if (summary.membership == Membership.INVITE) { @@ -768,8 +853,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } + override fun onUpdated(snapshot: List) { + timelineEvents.accept(snapshot) + } + override fun onCleared() { timeline.dispose() + timeline.removeAllListeners() super.onCleared() } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 03110858a1..a0be8fc9dc 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -41,6 +41,13 @@ sealed class SendMode(open val text: String) { data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text) } +sealed class UnreadState { + object Unknown : UnreadState() + object HasNoUnread : UnreadState() + data class ReadMarkerNotLoaded(val readMarkerId: String): UnreadState() + data class HasUnread(val firstUnreadEventId: String) : UnreadState() +} + data class RoomDetailViewState( val roomId: String, val eventId: String?, @@ -52,7 +59,9 @@ data class RoomDetailViewState( val tombstoneEvent: Event? = null, val tombstoneEventHandling: Async = Uninitialized, val syncState: SyncState = SyncState.IDLE, - val highlightedEventId: String? = null + val highlightedEventId: String? = null, + val unreadState: UnreadState = UnreadState.Unknown, + val canShowJumpToReadMarker: Boolean = true ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt index 273aeecbfa..ab37431103 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/ComposerEditText.kt @@ -20,12 +20,17 @@ package im.vector.riotx.features.home.room.detail.composer import android.content.Context import android.net.Uri import android.os.Build +import android.text.Editable import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import androidx.appcompat.widget.AppCompatEditText import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat +import im.vector.riotx.core.extensions.ooi +import im.vector.riotx.core.platform.SimpleTextWatcher +import im.vector.riotx.features.html.PillImageSpan +import timber.log.Timber class ComposerEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = android.R.attr.editTextStyle) : AppCompatEditText(context, attrs, defStyleAttr) { @@ -55,4 +60,41 @@ class ComposerEditText @JvmOverloads constructor(context: Context, attrs: Attrib } return InputConnectionCompat.createWrapper(ic, editorInfo, callback) } + + init { + addTextChangedListener( + object : SimpleTextWatcher() { + var spanToRemove: PillImageSpan? = null + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + Timber.v("Pills: beforeTextChanged: start:$start count:$count after:$after") + + if (count > after) { + // A char has been deleted + val deleteCharPosition = start + count + Timber.v("Pills: beforeTextChanged: deleted char at $deleteCharPosition") + + // Get the first span at this position + spanToRemove = editableText.getSpans(deleteCharPosition, deleteCharPosition, PillImageSpan::class.java) + .ooi { Timber.v("Pills: beforeTextChanged: found ${it.size} span(s)") } + .firstOrNull() + } + } + + override fun afterTextChanged(s: Editable) { + if (spanToRemove != null) { + val start = editableText.getSpanStart(spanToRemove) + val end = editableText.getSpanEnd(spanToRemove) + Timber.v("Pills: afterTextChanged Removing the span start:$start end:$end") + // Must be done before text replacement + editableText.removeSpan(spanToRemove) + if (start != -1 && end != -1) { + editableText.replace(start, end, "") + } + spanToRemove = null + } + } + } + ) + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt index 32307dc3d4..593ce1a8f6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/composer/TextComposerView.kt @@ -26,6 +26,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.text.toSpannable import androidx.transition.AutoTransition import androidx.transition.Transition import androidx.transition.TransitionManager @@ -43,7 +44,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib interface Callback : ComposerEditText.Callback { fun onCloseRelatedMessage() - fun onSendMessage(text: String) + fun onSendMessage(text: CharSequence) fun onAddAttachment() } @@ -86,7 +87,7 @@ class TextComposerView @JvmOverloads constructor(context: Context, attrs: Attrib } sendButton.setOnClickListener { - val textMessage = text?.toString() ?: "" + val textMessage = text?.toSpannable() ?: "" callback?.onSendMessage(textMessage) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsBottomSheet.kt index 50ade56474..f220570e69 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsBottomSheet.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsBottomSheet.kt @@ -66,7 +66,7 @@ class DisplayReadReceiptsBottomSheet : VectorBaseBottomSheetDialogFragment() { super.onActivityCreated(savedInstanceState) recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) recyclerView.adapter = epoxyController.adapter - bottomSheetTitle.text = getString(R.string.read_at) + bottomSheetTitle.text = getString(R.string.seen_by) epoxyController.setData(displayReadReceiptArgs.readReceipts) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index be2f1dd7e4..326e19c431 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -25,21 +25,18 @@ import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.VisibilityState +import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.core.epoxy.LoadingItem_ import im.vector.riotx.core.extensions.localDateTime -import im.vector.riotx.core.utils.DimensionConverter -import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.RoomDetailViewState +import im.vector.riotx.features.home.room.detail.UnreadState import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory -import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback -import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener -import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider -import im.vector.riotx.features.home.room.detail.timeline.helper.nextOrNull +import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer @@ -47,11 +44,10 @@ import org.threeten.bp.LocalDateTime import javax.inject.Inject class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter, + private val session: Session, private val timelineItemFactory: TimelineItemFactory, private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val mergedHeaderItemFactory: MergedHeaderItemFactory, - private val avatarRenderer: AvatarRenderer, - private val dimensionConverter: DimensionConverter, @TimelineEventControllerHandler private val backgroundHandler: Handler ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { @@ -86,7 +82,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) - fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean) + fun onReadMarkerVisible() } interface UrlClickCallback { @@ -101,6 +97,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private var currentSnapshot: List = emptyList() private var inSubmitList: Boolean = false private var timeline: Timeline? = null + private var unreadState: UnreadState = UnreadState.Unknown + private var positionOfReadMarker: Int? = null + private var eventIdToHighlight: String? = null var callback: Callback? = null @@ -152,7 +151,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } // Update position when we are building new items - override fun intercept(models: MutableList>) { + override fun intercept(models: MutableList>) = synchronized(modelCache) { + positionOfReadMarker = null adapterPositionMapping.clear() models.forEachIndexed { index, epoxyModel -> if (epoxyModel is BaseEventItem) { @@ -161,18 +161,25 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } } + val currentUnreadState = this.unreadState + if (currentUnreadState is UnreadState.HasUnread) { + val position = adapterPositionMapping[currentUnreadState.firstUnreadEventId]?.plus(1) + positionOfReadMarker = position + if (position != null) { + val readMarker = TimelineReadMarkerItem_() + .also { + it.id("read_marker") + it.setOnVisibilityStateChanged(ReadMarkerVisibilityStateChangedListener(callback)) + } + models.add(position, readMarker) + } + } } - fun update(viewState: RoomDetailViewState, readMarkerVisible: Boolean) { - if (timeline != viewState.timeline) { + fun update(viewState: RoomDetailViewState) { + if (timeline?.timelineID != viewState.timeline?.timelineID) { timeline = viewState.timeline - timeline?.listener = this - // Clear cache - synchronized(modelCache) { - for (i in 0 until modelCache.size) { - modelCache[i] = null - } - } + timeline?.addListener(this) } var requestModelBuild = false if (eventIdToHighlight != viewState.highlightedEventId) { @@ -188,8 +195,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec eventIdToHighlight = viewState.highlightedEventId requestModelBuild = true } - if (this.readMarkerVisible != readMarkerVisible) { - this.readMarkerVisible = readMarkerVisible + if (this.unreadState != viewState.unreadState) { + this.unreadState = viewState.unreadState requestModelBuild = true } if (requestModelBuild) { @@ -197,9 +204,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - private var readMarkerVisible: Boolean = false - private var eventIdToHighlight: String? = null - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { super.onAttachedToRecyclerView(recyclerView) timelineMediaSizeProvider.recyclerView = recyclerView @@ -224,7 +228,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - // Timeline.LISTENER *************************************************************************** +// Timeline.LISTENER *************************************************************************** override fun onUpdated(snapshot: List) { submitSnapshot(snapshot) @@ -246,43 +250,40 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } private fun getModels(): List> { - synchronized(modelCache) { - (0 until modelCache.size).forEach { position -> - // Should be build if not cached or if cached but contains mergedHeader or formattedDay - // We then are sure we always have items up to date. - if (modelCache[position] == null - || modelCache[position]?.mergedHeaderModel != null - || modelCache[position]?.formattedDayModel != null) { - modelCache[position] = buildItemModels(position, currentSnapshot) - } - } - return modelCache - .map { - val eventModel = if (it == null || mergedHeaderItemFactory.isCollapsed(it.localId)) { - null - } else { - it.eventModel - } - listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel) + buildCacheItemsIfNeeded() + return modelCache + .map { + val eventModel = if (it == null || mergedHeaderItemFactory.isCollapsed(it.localId)) { + null + } else { + it.eventModel } - .flatten() - .filterNotNull() + listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel) + } + .flatten() + .filterNotNull() + } + + private fun buildCacheItemsIfNeeded() = synchronized(modelCache) { + if (modelCache.isEmpty()) { + return + } + (0 until modelCache.size).forEach { position -> + // Should be build if not cached or if cached but contains additional models + // We then are sure we always have items up to date. + if (modelCache[position] == null || modelCache[position]?.shouldTriggerBuild() == true) { + modelCache[position] = buildCacheItem(position, currentSnapshot) + } } } - private fun buildItemModels(currentPosition: Int, items: List): CacheItemData { + private fun buildCacheItem(currentPosition: Int, items: List): CacheItemData { val event = items[currentPosition] val nextEvent = items.nextOrNull(currentPosition) val date = event.root.localDateTime() val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() - // Don't show read marker if it's on first item - val showReadMarker = if (currentPosition == 0 && event.hasReadMarker) { - false - } else { - readMarkerVisible - } - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, showReadMarker, callback).also { + val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also { it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } @@ -290,7 +291,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec nextEvent = nextEvent, items = items, addDaySeparator = addDaySeparator, - readMarkerVisible = readMarkerVisible, currentPosition = currentPosition, eventIdToHighlight = eventIdToHighlight, callback = callback @@ -298,7 +298,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec requestModelBuild() } val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date) - return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem) } @@ -335,6 +334,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec return adapterPositionMapping[eventId] } + fun getPositionOfReadMarker(): Int? = synchronized(modelCache) { + return positionOfReadMarker + } + fun isLoadingForward() = showingForwardLoader private data class CacheItemData( @@ -343,5 +346,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val eventModel: EpoxyModel<*>? = null, val mergedHeaderModel: MergedHeaderItem? = null, val formattedDayModel: DaySeparatorItem? = null - ) + ) { + fun shouldTriggerBuild(): Boolean { + return mergedHeaderModel != null || formattedDayModel != null + } + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt index 37d96ad62c..8077786d06 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/EventSharedAction.kt @@ -88,4 +88,12 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, @DrawableRes val ic data class ViewEditHistory(val messageInformationData: MessageInformationData) : EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history) + + // An url in the event preview has been clicked + data class OnUrlClicked(val url: String) : + EventSharedAction(0, 0) + + // An url in the event preview has been long clicked + data class OnUrlLongClicked(val url: String) : + EventSharedAction(0, 0) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt index 3f4171f733..a5bf6f8558 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt @@ -68,6 +68,18 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment(), Message messageActionsEpoxyController.listener = this } + override fun onUrlClicked(url: String): Boolean { + sharedActionViewModel.post(EventSharedAction.OnUrlClicked(url)) + // Always consume + return true + } + + override fun onUrlLongClicked(url: String): Boolean { + sharedActionViewModel.post(EventSharedAction.OnUrlLongClicked(url)) + // Always consume + return true + } + override fun didSelectMenuAction(eventAction: EventSharedAction) { if (eventAction is EventSharedAction.ReportContent) { // Toggle report menu diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt index b561a6df3c..efbfd3434c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt @@ -23,6 +23,9 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.bottomsheet.* import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod +import im.vector.riotx.features.home.room.detail.timeline.tools.linkify import javax.inject.Inject /** @@ -38,26 +41,27 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid // Message preview val body = state.messageBody if (body != null) { - bottomSheetItemMessagePreview { + bottomSheetMessagePreviewItem { id("preview") avatarRenderer(avatarRenderer) avatarUrl(state.informationData.avatarUrl ?: "") senderId(state.informationData.senderId) senderName(state.senderName()) - body(body) + movementMethod(createLinkMovementMethod(listener)) + body(body.linkify(listener)) time(state.time()) } } // Send state if (state.informationData.sendState.isSending()) { - bottomSheetItemSendState { + bottomSheetSendStateItem { id("send_state") showProgress(true) text(stringProvider.getString(R.string.event_status_sending_message)) } } else if (state.informationData.sendState.hasFailed()) { - bottomSheetItemSendState { + bottomSheetSendStateItem { id("send_state") showProgress(false) text(stringProvider.getString(R.string.unable_to_send_message)) @@ -68,16 +72,16 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid // Quick reactions if (state.canReact() && state.quickStates is Success) { // Separator - bottomSheetItemSeparator { + bottomSheetSeparatorItem { id("reaction_separator") } - bottomSheetItemQuickReactions { + bottomSheetQuickReactionsItem { id("quick_reaction") fontProvider(fontProvider) texts(state.quickStates()?.map { it.reaction }.orEmpty()) selecteds(state.quickStates.invoke().map { it.isSelected }) - listener(object : BottomSheetItemQuickReactions.Listener { + listener(object : BottomSheetQuickReactionsItem.Listener { override fun didSelect(emoji: String, selected: Boolean) { listener?.didSelectMenuAction(EventSharedAction.QuickReact(state.eventId, emoji, selected)) } @@ -86,18 +90,18 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid } // Separator - bottomSheetItemSeparator { + bottomSheetSeparatorItem { id("actions_separator") } // Action state.actions()?.forEachIndexed { index, action -> if (action is EventSharedAction.Separator) { - bottomSheetItemSeparator { + bottomSheetSeparatorItem { id("separator_$index") } } else { - bottomSheetItemAction { + bottomSheetActionItem { id("action_$index") iconRes(action.iconResId) textRes(action.titleRes) @@ -114,7 +118,7 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid EventSharedAction.ReportContentInappropriate(action.eventId, action.senderId), EventSharedAction.ReportContentCustom(action.eventId, action.senderId) ).forEachIndexed { indexReport, actionReport -> - bottomSheetItemAction { + bottomSheetActionItem { id("actionReport_$indexReport") subMenuItem(true) iconRes(actionReport.iconResId) @@ -127,7 +131,7 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid } } - interface MessageActionsEpoxyControllerListener { + interface MessageActionsEpoxyControllerListener : TimelineEventController.UrlClickCallback { fun didSelectMenuAction(eventAction: EventSharedAction) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index 1ae47f9c22..94d7812512 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt @@ -46,7 +46,6 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava fun create(event: TimelineEvent, highlight: Boolean, - readMarkerVisible: Boolean, callback: TimelineEventController.Callback?, exception: Exception? = null): DefaultItem { val text = if (exception == null) { @@ -54,7 +53,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava } else { "an exception occurred when rendering the event ${event.root.eventId}" } - val informationData = informationDataFactory.create(event, null, readMarkerVisible) + val informationData = informationDataFactory.create(event, null) return create(text, informationData, highlight, callback) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index e67507d7bb..512fffa29e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -25,9 +25,10 @@ import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider -import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory +import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ +import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod import me.gujun.android.span.span import javax.inject.Inject @@ -41,7 +42,6 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, - readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { event.root.eventId ?: return null @@ -57,7 +57,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat } val message = stringProvider.getString(R.string.encrypted_message).takeIf { cryptoError == null } - ?: stringProvider.getString(R.string.notice_crypto_unable_to_decrypt, errorDescription) + ?: stringProvider.getString(R.string.notice_crypto_unable_to_decrypt, errorDescription) val spannableStr = span(message) { textStyle = "italic" textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) @@ -65,14 +65,14 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat // TODO This is not correct format for error, change it - val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible) + val informationData = messageInformationDataFactory.create(event, nextEvent) val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) .attributes(attributes) .message(spannableStr) - .urlClickCallback(callback) + .movementMethod(createLinkMovementMethod(callback)) } else -> null } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 51364e24c9..a2e979a08d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -36,7 +36,6 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act nextEvent: TimelineEvent?, items: List, addDaySeparator: Boolean, - readMarkerVisible: Boolean, currentPosition: Int, eventIdToHighlight: String?, callback: TimelineEventController.Callback?, @@ -50,20 +49,12 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act null } else { var highlighted = false - var readMarkerId: String? = null - var showReadMarker = false val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() val mergedData = ArrayList(mergedEvents.size) mergedEvents.forEach { mergedEvent -> if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { highlighted = true } - if (readMarkerId == null && mergedEvent.hasReadMarker) { - readMarkerId = mergedEvent.root.eventId - } - if (!showReadMarker && mergedEvent.hasReadMarker && readMarkerVisible) { - showReadMarker = true - } val senderAvatar = mergedEvent.senderAvatar val senderName = mergedEvent.getDisambiguatedDisplayName() val data = MergedHeaderItem.Data( @@ -96,8 +87,6 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act mergeItemCollapseStates[event.localId] = it requestModelBuild() }, - readMarkerId = readMarkerId, - showReadMarker = isCollapsed && showReadMarker, readReceiptsCallback = callback ) MergedHeaderItem_() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index ac6c563099..9c96f17022 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -24,8 +24,6 @@ import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.view.View import dagger.Lazy -import im.vector.matrix.android.api.permalinks.MatrixLinkify -import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.message.* @@ -35,7 +33,6 @@ import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyModel -import im.vector.riotx.core.linkify.VectorLinkify import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.utils.DebouncedClickListener @@ -45,8 +42,10 @@ import im.vector.riotx.core.utils.isLocalFile import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.item.* -import im.vector.riotx.features.html.EventHtmlRenderer +import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod +import im.vector.riotx.features.home.room.detail.timeline.tools.linkify import im.vector.riotx.features.html.CodeVisitor +import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer import me.gujun.android.span.span @@ -70,12 +69,11 @@ class MessageItemFactory @Inject constructor( fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, - readMarkerVisible: Boolean, callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { event.root.eventId ?: return null - val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible) + val informationData = messageInformationDataFactory.create(event, nextEvent) if (event.root.isRedacted()) { // message is redacted @@ -89,10 +87,10 @@ class MessageItemFactory @Inject constructor( return defaultItemFactory.create(malformedText, informationData, highlight, callback) } if (messageContent.relatesTo?.type == RelationType.REPLACE - || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE + || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { // This is an edit event, we should it when debugging as a notice event - return noticeItemFactory.create(event, highlight, readMarkerVisible, callback) + return noticeItemFactory.create(event, highlight, callback) } val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) @@ -195,8 +193,7 @@ class MessageItemFactory @Inject constructor( val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val thumbnailData = ImageContentRenderer.Data( filename = messageContent.body, - url = messageContent.videoInfo?.thumbnailFile?.url - ?: messageContent.videoInfo?.thumbnailUrl, + url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl, elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), height = messageContent.videoInfo?.height, maxHeight = maxHeight, @@ -258,7 +255,7 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val linkifiedBody = linkifyBody(body, callback) + val linkifiedBody = body.linkify(callback) return MessageTextItem_().apply { if (informationData.hasBeenEdited) { @@ -273,7 +270,7 @@ class MessageItemFactory @Inject constructor( .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) - .urlClickCallback(callback) + .movementMethod(createLinkMovementMethod(callback)) } private fun buildCodeBlockItem(formattedBody: CharSequence, @@ -326,9 +323,9 @@ class MessageItemFactory @Inject constructor( // nop } }, - editStart, - editEnd, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + editStart, + editEnd, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) return spannable } @@ -344,14 +341,14 @@ class MessageItemFactory @Inject constructor( textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) textStyle = "italic" } - linkifyBody(formattedBody, callback) + formattedBody.linkify(callback) } return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .message(message) .highlighted(highlight) - .urlClickCallback(callback) + .movementMethod(createLinkMovementMethod(callback)) } private fun buildEmoteMessageItem(messageContent: MessageEmoteContent, @@ -361,7 +358,7 @@ class MessageItemFactory @Inject constructor( attributes: AbsMessageItem.Attributes): MessageTextItem? { val message = messageContent.body.let { val formattedBody = "* ${informationData.memberName} $it" - linkifyBody(formattedBody, callback) + formattedBody.linkify(callback) } return MessageTextItem_() .apply { @@ -375,7 +372,7 @@ class MessageItemFactory @Inject constructor( .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) - .urlClickCallback(callback) + .movementMethod(createLinkMovementMethod(callback)) } private fun buildRedactedItem(attributes: AbsMessageItem.Attributes, @@ -386,17 +383,6 @@ class MessageItemFactory @Inject constructor( .highlighted(highlight) } - private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence { - val spannable = SpannableStringBuilder(body) - MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback { - override fun onUrlClicked(url: String) { - callback?.onUrlClicked(url) - } - }) - VectorLinkify.addLinks(spannable, true) - return spannable - } - companion object { private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5 } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index 8768da26cf..4ee90f82a9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -34,10 +34,9 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv fun create(event: TimelineEvent, highlight: Boolean, - readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): NoticeItem? { val formattedText = eventFormatter.format(event) ?: return null - val informationData = informationDataFactory.create(event, null, readMarkerVisible) + val informationData = informationDataFactory.create(event, null) val attributes = NoticeItem.Attributes( avatarRenderer = avatarRenderer, informationData = informationData, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 618ca121c2..5b6dec9900 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -33,14 +33,13 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me fun create(event: TimelineEvent, nextEvent: TimelineEvent?, eventIdToHighlight: String?, - readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { val highlight = event.root.eventId == eventIdToHighlight val computedModel = try { when (event.root.getClearType()) { EventType.STICKER, - EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) + EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback) // State and call EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -53,21 +52,21 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_ANSWER, EventType.REACTION, EventType.REDACTION, - EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, readMarkerVisible, callback) + EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, callback) // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) // Crypto EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it - messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) + messageItemFactory.create(event, nextEvent, highlight, callback) } else { - encryptedItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) + encryptedItemFactory.create(event, nextEvent, highlight, callback) } } // Unhandled event types (yet) - EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, readMarkerVisible, callback) + EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, callback) else -> { Timber.v("Type ${event.root.getClearType()} not handled") null @@ -75,7 +74,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me } } catch (e: Exception) { Timber.e(e, "failed to create message item") - defaultItemFactory.create(event, highlight, readMarkerVisible, callback, e) + defaultItemFactory.create(event, highlight, callback, e) } return (computedModel ?: EmptyItem_()) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index e44e657733..784a180d00 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -39,7 +39,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses private val dateFormatter: VectorDateFormatter, private val colorProvider: ColorProvider) { - fun create(event: TimelineEvent, nextEvent: TimelineEvent?, readMarkerVisible: Boolean): MessageInformationData { + fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData { // Non nullability has been tested before val eventId = event.root.eventId!! @@ -47,7 +47,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60)) - ?: false + ?: false val showInformation = addDaySeparator @@ -63,8 +63,6 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } - val displayReadMarker = readMarkerVisible && event.hasReadMarker - return MessageInformationData( eventId = eventId, senderId = event.root.senderId ?: "", @@ -88,9 +86,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses .map { ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) } - .toList(), - hasReadMarker = event.hasReadMarker, - displayReadMarker = displayReadMarker + .toList() ) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineVisibilityStateChangedListeners.kt similarity index 85% rename from vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt rename to vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineVisibilityStateChangedListeners.kt index c2aaf482ae..69b2b24899 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineVisibilityStateChangedListeners.kt @@ -21,6 +21,16 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +class ReadMarkerVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?) + : VectorEpoxyModel.OnVisibilityStateChangedListener { + + override fun onVisibilityStateChanged(visibilityState: Int) { + if (visibilityState == VisibilityState.VISIBLE) { + callback?.onReadMarkerVisible() + } + } +} + class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?, private val event: TimelineEvent) : VectorEpoxyModel.OnVisibilityStateChangedListener { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 2ca6bbfd37..713b60d4d8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -27,7 +27,6 @@ import com.airbnb.epoxy.EpoxyAttribute import im.vector.matrix.android.api.session.room.send.SendState import im.vector.riotx.R import im.vector.riotx.core.resources.ColorProvider -import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @@ -50,13 +49,6 @@ abstract class AbsMessageItem : BaseEventItem() { attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) }) - private val _readMarkerCallback = object : ReadMarkerView.Callback { - - override fun onReadMarkerLongBound(isDisplayed: Boolean) { - attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed) - } - } - var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener { override fun onReacted(reactionButton: ReactionButton) { attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, true) @@ -110,12 +102,6 @@ abstract class AbsMessageItem : BaseEventItem() { attributes.avatarRenderer, _readReceiptsClickListener ) - holder.readMarkerView.bindView( - attributes.informationData.eventId, - attributes.informationData.hasReadMarker, - attributes.informationData.displayReadMarker, - _readMarkerCallback - ) val reactions = attributes.informationData.orderedReactionList if (!shouldShowReactionAtBottom() || reactions.isNullOrEmpty()) { @@ -138,7 +124,6 @@ abstract class AbsMessageItem : BaseEventItem() { } override fun unbind(holder: H) { - holder.readMarkerView.unbind() holder.readReceiptsView.unbind() super.unbind(holder) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt index 8543484b00..02b7341c72 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -19,14 +19,12 @@ import android.view.View import android.view.ViewStub import android.widget.RelativeLayout import androidx.annotation.IdRes -import androidx.core.view.marginStart import androidx.core.view.updateLayoutParams import com.airbnb.epoxy.EpoxyAttribute import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.platform.CheckableView -import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.core.ui.views.ReadReceiptsView import im.vector.riotx.core.utils.DimensionConverter @@ -62,7 +60,6 @@ abstract class BaseEventItem : VectorEpoxyModel val leftGuideline by bind(R.id.messageStartGuideline) val checkableBackground by bind(R.id.messageSelectedBackground) val readReceiptsView by bind(R.id.readReceiptsView) - val readMarkerView by bind(R.id.readMarkerView) override fun bindView(itemView: View) { super.bindView(itemView) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index 01e82ddf6b..a2a3c9ad3b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -25,7 +25,6 @@ import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R -import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @@ -39,13 +38,6 @@ abstract class MergedHeaderItem : BaseEventItem() { attributes.mergeData.distinctBy { it.userId } } - private val _readMarkerCallback = object : ReadMarkerView.Callback { - - override fun onReadMarkerLongBound(isDisplayed: Boolean) { - attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.readMarkerId ?: "", isDisplayed) - } - } - override fun getViewType() = STUB_ID override fun bind(holder: Holder) { @@ -77,20 +69,14 @@ abstract class MergedHeaderItem : BaseEventItem() { } // No read receipt for this item holder.readReceiptsView.isVisible = false - holder.readMarkerView.bindView( - attributes.readMarkerId, - !attributes.readMarkerId.isNullOrEmpty(), - attributes.showReadMarker, - _readMarkerCallback) - } - - override fun unbind(holder: Holder) { - holder.readMarkerView.unbind() - super.unbind(holder) } override fun getEventIds(): List { - return attributes.mergeData.map { it.eventId } + return if (attributes.isCollapsed) { + attributes.mergeData.map { it.eventId } + } else { + emptyList() + } } data class Data( @@ -102,9 +88,7 @@ abstract class MergedHeaderItem : BaseEventItem() { ) data class Attributes( - val readMarkerId: String?, val isCollapsed: Boolean, - val showReadMarker: Boolean, val mergeData: List, val avatarRenderer: AvatarRenderer, val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, @@ -119,6 +103,6 @@ abstract class MergedHeaderItem : BaseEventItem() { } companion object { - private const val STUB_ID = R.id.messageContentMergedheaderStub + private const val STUB_ID = R.id.messageContentMergedHeaderStub } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt index 96c74ccb88..2dd581ce6f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -33,9 +33,7 @@ data class MessageInformationData( val orderedReactionList: List? = null, val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, - val readReceipts: List = emptyList(), - val hasReadMarker: Boolean = false, - val displayReadMarker: Boolean = false + val readReceipts: List = emptyList() ) : Parcelable @Parcelize diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt index 45a6e2e743..5ee0576be7 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -16,22 +16,14 @@ package im.vector.riotx.features.home.room.detail.timeline.item -import android.view.MotionEvent +import android.text.method.MovementMethod import androidx.appcompat.widget.AppCompatTextView import androidx.core.text.PrecomputedTextCompat -import androidx.core.text.toSpannable import androidx.core.widget.TextViewCompat import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R -import im.vector.riotx.core.utils.isValidUrl -import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.html.PillImageSpan -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.saket.bettermovementmethod.BetterLinkMovementMethod +import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageTextItem : AbsMessageItem() { @@ -43,30 +35,11 @@ abstract class MessageTextItem : AbsMessageItem() { @EpoxyAttribute var useBigFont: Boolean = false @EpoxyAttribute - var urlClickCallback: TimelineEventController.UrlClickCallback? = null - - // Better link movement methods fixes the issue when - // long pressing to open the context menu on a TextView also triggers an autoLink click. - private val mvmtMethod = BetterLinkMovementMethod.newInstance().also { - it.setOnLinkClickListener { _, url -> - // Return false to let android manage the click on the link, or true if the link is handled by the application - url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true - } - // We need also to fix the case when long click on link will trigger long click on cell - it.setOnLinkLongClickListener { tv, url -> - // Long clicks are handled by parent, return true to block android to do something with url - if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) { - tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)) - true - } else { - false - } - } - } + var movementMethod: MovementMethod? = null override fun bind(holder: Holder) { super.bind(holder) - holder.messageView.movementMethod = mvmtMethod + holder.messageView.movementMethod = movementMethod if (useBigFont) { holder.messageView.textSize = 44F } else { @@ -76,7 +49,7 @@ abstract class MessageTextItem : AbsMessageItem() { holder.messageView.setOnClickListener(attributes.itemClickListener) holder.messageView.setOnLongClickListener(attributes.itemLongClickListener) if (searchForPills) { - findPillsAndProcess { it.bind(holder.messageView) } + message?.findPillsAndProcess { it.bind(holder.messageView) } } val textFuture = PrecomputedTextCompat.getTextFuture( message ?: "", @@ -85,17 +58,6 @@ abstract class MessageTextItem : AbsMessageItem() { holder.messageView.setTextFuture(textFuture) } - private fun findPillsAndProcess(processBlock: (span: PillImageSpan) -> Unit) { - GlobalScope.launch(Dispatchers.Main) { - val pillImageSpans: Array? = withContext(Dispatchers.IO) { - message?.toSpannable()?.let { spannable -> - spannable.getSpans(0, spannable.length, PillImageSpan::class.java) - } - } - pillImageSpans?.forEach { processBlock(it) } - } - } - override fun getViewType() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index 1f39ae3ca4..05dedcfa22 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -22,7 +22,6 @@ import android.widget.TextView import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R -import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @@ -37,13 +36,6 @@ abstract class NoticeItem : BaseEventItem() { attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) }) - private val _readMarkerCallback = object : ReadMarkerView.Callback { - - override fun onReadMarkerLongBound(isDisplayed: Boolean) { - attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed) - } - } - override fun bind(holder: Holder) { super.bind(holder) holder.noticeTextView.text = attributes.noticeText @@ -56,17 +48,6 @@ abstract class NoticeItem : BaseEventItem() { ) holder.view.setOnLongClickListener(attributes.itemLongClickListener) holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.bindView( - attributes.informationData.eventId, - attributes.informationData.hasReadMarker, - attributes.informationData.displayReadMarker, - _readMarkerCallback - ) - } - - override fun unbind(holder: Holder) { - holder.readMarkerView.unbind() - super.unbind(holder) } override fun getEventIds(): List { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/TimelineReadMarkerItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/TimelineReadMarkerItem.kt new file mode 100644 index 0000000000..4d867156d3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/TimelineReadMarkerItem.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.riotx.features.home.room.detail.timeline.item + +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_timeline_read_marker) +abstract class TimelineReadMarkerItem : VectorEpoxyModel() { + + override fun bind(holder: Holder) { + } + + class Holder : VectorEpoxyHolder() +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/tools/EventRenderingTools.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/tools/EventRenderingTools.kt new file mode 100644 index 0000000000..492248985e --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/tools/EventRenderingTools.kt @@ -0,0 +1,76 @@ +/* + * 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.home.room.detail.timeline.tools + +import android.text.SpannableStringBuilder +import android.view.MotionEvent +import androidx.core.text.toSpannable +import im.vector.matrix.android.api.permalinks.MatrixLinkify +import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan +import im.vector.riotx.core.linkify.VectorLinkify +import im.vector.riotx.core.utils.isValidUrl +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.html.PillImageSpan +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.saket.bettermovementmethod.BetterLinkMovementMethod + +fun CharSequence.findPillsAndProcess(processBlock: (PillImageSpan) -> Unit) { + GlobalScope.launch(Dispatchers.Main) { + withContext(Dispatchers.IO) { + toSpannable().let { spannable -> + spannable.getSpans(0, spannable.length, PillImageSpan::class.java) + } + }.forEach { processBlock(it) } + } +} + +fun CharSequence.linkify(callback: TimelineEventController.UrlClickCallback?): CharSequence { + val spannable = SpannableStringBuilder(this) + MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback { + override fun onUrlClicked(url: String) { + callback?.onUrlClicked(url) + } + }) + VectorLinkify.addLinks(spannable, true) + return spannable +} + +// Better link movement methods fixes the issue when +// long pressing to open the context menu on a TextView also triggers an autoLink click. +fun createLinkMovementMethod(urlClickCallback: TimelineEventController.UrlClickCallback?): BetterLinkMovementMethod { + return BetterLinkMovementMethod.newInstance() + .apply { + setOnLinkClickListener { _, url -> + // Return false to let android manage the click on the link, or true if the link is handled by the application + url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true + } + + // We need also to fix the case when long click on link will trigger long click on cell + setOnLinkLongClickListener { tv, url -> + // Long clicks are handled by parent, return true to block android to do something with url + if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) { + tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)) + true + } else { + false + } + } + } +} 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/home/room/list/RoomSummaryController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt index 74dab6563f..4107bf01b2 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryController.kt @@ -24,8 +24,8 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.helpFooterItem import im.vector.riotx.core.epoxy.noResultItem import im.vector.riotx.core.resources.StringProvider -import im.vector.riotx.features.home.RoomListDisplayMode import im.vector.riotx.core.resources.UserPreferencesProvider +import im.vector.riotx.features.home.RoomListDisplayMode import im.vector.riotx.features.home.room.filtered.FilteredRoomFooterItem import im.vector.riotx.features.home.room.filtered.filteredRoomFooterItem import javax.inject.Inject @@ -63,7 +63,7 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri RoomListDisplayMode.SHARE -> { buildFilteredRooms(nonNullViewState) } - else -> { + else -> { var showHelp = false val roomSummaries = nonNullViewState.asyncFilteredRooms() roomSummaries?.forEach { (category, summaries) -> @@ -80,7 +80,10 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri nonNullViewState.joiningErrorRoomsIds, nonNullViewState.rejectingRoomsIds, nonNullViewState.rejectingErrorRoomsIds) - showHelp = userPreferencesProvider.shouldShowLongClickOnRoomHelp() + // Never set showHelp to true for invitation + if (category != RoomCategory.INVITE) { + showHelp = userPreferencesProvider.shouldShowLongClickOnRoomHelp() + } } } } @@ -108,7 +111,7 @@ class RoomSummaryController @Inject constructor(private val stringProvider: Stri when { viewState.displayMode == RoomListDisplayMode.FILTERED -> addFilterFooter(viewState) - filteredSummaries.isEmpty() -> addEmptyFooter() + filteredSummaries.isEmpty() -> addEmptyFooter() } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt index 2e17464cc6..84fd5bc6f2 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt @@ -18,9 +18,9 @@ package im.vector.riotx.features.home.room.list.actions import android.view.View import com.airbnb.epoxy.TypedEpoxyController import im.vector.matrix.android.api.session.room.notification.RoomNotificationState -import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetItemAction -import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetItemRoomPreview -import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetItemSeparator +import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetActionItem +import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetRoomPreviewItem +import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetSeparatorItem import im.vector.riotx.features.home.AvatarRenderer import javax.inject.Inject @@ -36,7 +36,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar val roomSummary = state.roomSummary() ?: return // Preview - bottomSheetItemRoomPreview { + bottomSheetRoomPreviewItem { id("preview") avatarRenderer(avatarRenderer) roomName(roomSummary.displayName) @@ -46,7 +46,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar } // Notifications - bottomSheetItemSeparator { + bottomSheetSeparatorItem { id("notifications_separator") } @@ -57,7 +57,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar RoomListQuickActionsSharedAction.NotificationsMute(roomSummary.roomId).toBottomSheetItem(3, selectedRoomState) // Leave - bottomSheetItemSeparator { + bottomSheetSeparatorItem { id("leave_separator") } RoomListQuickActionsSharedAction.Leave(roomSummary.roomId).toBottomSheetItem(5) @@ -72,7 +72,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar is RoomListQuickActionsSharedAction.Settings, is RoomListQuickActionsSharedAction.Leave -> false } - return bottomSheetItemAction { + return bottomSheetActionItem { id("action_$index") selected(selected) iconRes(iconResId) 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/html/MxLinkTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt index fdcbb12cd7..ecbf0da415 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt @@ -41,7 +41,8 @@ class MxLinkTagHandler(private val glideRequests: GlideRequests, when (permalinkData) { is PermalinkData.UserLink -> { val user = sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId) - val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user) + val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user?.displayName + ?: permalinkData.userId, user?.avatarUrl) SpannableBuilder.setSpans( visitor.builder(), span, diff --git a/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt b/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt index bc954204c0..a192c71961 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt @@ -28,7 +28,7 @@ import androidx.annotation.UiThread import com.bumptech.glide.request.target.SimpleTarget import com.bumptech.glide.request.transition.Transition import com.google.android.material.chip.ChipDrawable -import im.vector.matrix.android.api.session.user.model.User +import im.vector.matrix.android.api.session.room.send.UserMentionSpan import im.vector.riotx.R import im.vector.riotx.core.glide.GlideRequests import im.vector.riotx.features.home.AvatarRenderer @@ -37,16 +37,14 @@ import java.lang.ref.WeakReference /** * This span is able to replace a text by a [ChipDrawable] * It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached. + * Implements UserMentionSpan so that it could be automatically transformed in matrix links and displayed as pills. */ class PillImageSpan(private val glideRequests: GlideRequests, private val avatarRenderer: AvatarRenderer, private val context: Context, - private val userId: String, - private val user: User?) : ReplacementSpan() { - - private val displayName by lazy { - if (user?.displayName.isNullOrEmpty()) userId else user?.displayName!! - } + override val userId: String, + override val displayName: String, + private val avatarUrl: String?) : ReplacementSpan(), UserMentionSpan { private val pillDrawable = createChipDrawable() private val target = PillImageSpanTarget(this) @@ -55,7 +53,7 @@ class PillImageSpan(private val glideRequests: GlideRequests, @UiThread fun bind(textView: TextView) { tv = WeakReference(textView) - avatarRenderer.render(context, glideRequests, user?.avatarUrl, userId, displayName, target) + avatarRenderer.render(context, glideRequests, avatarUrl, userId, displayName, target) } // ReplacementSpan ***************************************************************************** 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..e35923f5b0 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/Config.kt @@ -0,0 +1,19 @@ +/* + * 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 + +const val MODULAR_LINK = "https://modular.im/?utm_source=riot-x-android&utm_medium=native&utm_campaign=riot-x-android-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..67935c1ae8 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,100 @@ 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) + loginTitle.text = getString(resId, "Modular") + 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 +176,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..0484357ae2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt @@ -0,0 +1,103 @@ +/* + * 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 + loginSignupSigninTitle.text = getString(R.string.login_connect_to_modular) + 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/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt index 029f468b70..e5b46c2176 100644 --- a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt @@ -20,12 +20,12 @@ import android.view.View import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.airbnb.epoxy.EpoxyRecyclerView import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.withState import im.vector.riotx.R import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.utils.LiveEvent +import kotlinx.android.synthetic.main.fragment_generic_recycler_epoxy.* import javax.inject.Inject class EmojiSearchResultFragment @Inject constructor( @@ -50,7 +50,6 @@ class EmojiSearchResultFragment @Inject constructor( } val lmgr = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - val epoxyRecyclerView = view as? EpoxyRecyclerView ?: return epoxyRecyclerView.layoutManager = lmgr val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context, lmgr.orientation) epoxyRecyclerView.addItemDecoration(dividerItemDecoration) diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt index ff76c61754..ca994db62c 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt @@ -19,13 +19,11 @@ package im.vector.riotx.features.settings import android.app.Activity -import android.content.Context import android.content.Intent import android.text.Editable import android.util.Patterns import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager import android.widget.ImageView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat @@ -38,6 +36,7 @@ import com.bumptech.glide.load.engine.cache.DiskCache import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import im.vector.riotx.R +import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.showPassword import im.vector.riotx.core.platform.SimpleTextWatcher import im.vector.riotx.core.preference.UserAvatarPreference @@ -696,8 +695,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { .setPositiveButton(R.string.settings_change_password_submit, null) .setNegativeButton(R.string.cancel, null) .setOnDismissListener { - val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.applicationWindowToken, 0) + view.hideKeyboard() } .create() @@ -762,8 +760,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { showPassword.performClick() } - val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.applicationWindowToken, 0) + view.hideKeyboard() val oldPwd = oldPasswordText.text.toString().trim() val newPwd = newPasswordText.text.toString().trim() 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_emoji_reaction_picker.xml b/vector/src/main/res/layout/activity_emoji_reaction_picker.xml index 9d680e6221..16c0ae3c8b 100644 --- a/vector/src/main/res/layout/activity_emoji_reaction_picker.xml +++ b/vector/src/main/res/layout/activity_emoji_reaction_picker.xml @@ -19,8 +19,9 @@ android:name="im.vector.riotx.features.reactions.EmojiSearchResultFragment" android:layout_width="match_parent" android:layout_height="match_parent" + android:visibility="gone" app:layout_behavior="@string/appbar_scrolling_view_behavior" - android:visibility="gone" /> + tools:visibility="visible" /> + + + + + + + + + + + + + + + \ 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 @@ @@ -145,15 +145,4 @@ - - \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml b/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml index 583997577a..c1987dccb2 100644 --- a/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml +++ b/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml @@ -10,7 +10,7 @@ android:id="@+id/messageSelectedBackground" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_alignBottom="@+id/informationBottom" + android:layout_alignBottom="@+id/readReceiptsView" android:layout_alignParentTop="true" android:background="?riotx_highlighted_message_background" /> @@ -47,37 +47,19 @@ android:layout="@layout/item_timeline_event_blank_stub" /> - - - - - - - + android:layout_alignParentEnd="true" + android:layout_marginEnd="8dp" + android:layout_marginBottom="4dp" /> \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_day_separator.xml b/vector/src/main/res/layout/item_timeline_event_day_separator.xml index 81e94fd68e..13b70c4243 100644 --- a/vector/src/main/res/layout/item_timeline_event_day_separator.xml +++ b/vector/src/main/res/layout/item_timeline_event_day_separator.xml @@ -1,6 +1,5 @@ - - + android:layout_marginEnd="8dp" + android:background="?riotx_header_panel_background" /> - - - - \ No newline at end of file + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_read_marker.xml b/vector/src/main/res/layout/item_timeline_read_marker.xml new file mode 100644 index 0000000000..e76ffa3d5c --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_read_marker.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/vector/src/main/res/layout/vector_invite_view.xml b/vector/src/main/res/layout/vector_invite_view.xml index 4f881f2532..5e557895c2 100644 --- a/vector/src/main/res/layout/vector_invite_view.xml +++ b/vector/src/main/res/layout/vector_invite_view.xml @@ -59,7 +59,7 @@ Информация за устройството
ID - Име - Име на устройството + Публично име + Обнови публичното име Последно видян %1$s @ %2$s Тази операция изискра допълнителна автентикация. @@ -582,9 +582,9 @@ Грешка при разшифроване Информация за устройството на подателя - Име на устройство - Име - ID на устройство + Публично име + Публично име + ID Ключ на устройство Потвърждение Ed25519 отпечатък @@ -745,7 +745,7 @@ Фонова синхронизация Включване на фонова синхронизация Времето за синхронизация изтече - Време за чакане между всяка заявка + Време за чакане между синхронизации Разрешение за достъп до контакти Начало Вибрация при споменаване на потребител @@ -1622,4 +1622,141 @@ Прочетете на + Cyrl + + Нищо + Оттегли + Прекъсни + Не е настроен сървър за самоличност. + + Обаждането се провали поради грешно настроен сървър + Попитайте администратора на сървъра (%1$s) да конфигурира TURN сървър за да може разговорите да работят надеждно. +\n +\nКато алтернатива, също може да използвате публичния сървър %2$s, но това няма да е толкова надеждно, а и ще сподели IP адреса ви със сървъра. Може да управлявате това в Настройки. + Опитай с %s + Не питай пак + + Настройте имейл за възстановяване на профила, а после по желание и за да бъдете откриваеми от познати. + Настройте телефон за възстановяване на профила, а после по желание и за да бъдете откриваеми от познати. + Настройте имейл за възстановяване на профила. По-късно използвайте имейл или телефонен номер за да бъдете откривани от хора, които ви познават. + Настройте имейл за възстановяване на профила. По-късно използвайте имейл или телефонен номер за да бъдете откривани от хора, които ви познават. + Неуспешна връзка със сървъра на този адрес, моля проверете + Позволи използването на резервен сървър за свързване на обаждания + Ще използва %s за асистиращ сървър, когато вашия сървър не предлага такъв (IP адресът ви ще бъде споделен по време на обаждане) + Добавете сървър за самоличност в настройки за да извършите това действие. + Режим на фонова синхронизация (експериментално) + Пестящ батерия + Riot ще синхронизира във фонов режим по начин, който пести ограничените ресурси на устройството (батерия). +\nВ зависимост от състоянието на ресурсите, синхронизацията може да бъде отложена от операционната система. + Целящ висока интерактивност + Riot ще синхронизира във фонов режим на определен интервал (конфигурируемо). +\nТова ще повлияе на използването на антената и батерията. Ще се показва перманентна нотификация, че Riot слуша за събития. + Без фонова синхронизация + Няма да бъдете уведомени за входящи съобщения, когато приложението е във фонов режим. + Неуспешно обновяване на настройките. + + + Предпочитан интервал за синхронизация + %s +\nСинхронизацията може да бъде отложена, в зависимост от ресурсите (батерия) или състоянието на устройството (заспиване). + Откриване + Управлявайте настройките на откриваемостта. + Публично име (видимо за хора, с които общувате) + Публичното име на устройството е видимо за хората, с които общувате + Не използвате сървър за самоличност + Не е настроен сървър за самоличност. Необходим е за възстановяване на паролата. + + Изглежда се опитвате да се свържете с друг сървър. Искате ли да излезете от профила\? + + Сървър за самоличност + Прекъсни сървъра за самоличност + Настрой сървър за самоличност + Промени сървъра за самоличност + В момента използвате %1$s за да откривате и да бъдете открити от съществуващи ваши контакти. + В момента не използвате сървър за самоличност. Настройте такъв по-долу, за да откривате и да бъдете открити от съществуващи ваши контакти. + Откриваеми имейл адреси + Ще се появят настройки за откриваемост след като добавите имейл. + Ще се появят настройки за откриваемост след като добавите телефонен номер. + Прекъсването на връзката със сървъра за самоличност означава, че няма да бъдете откриваеми от други потребители и няма да можете да каните други по имейл или телефон. + Откриваеми телефонни номера + Изпратихме имейл за потвърждение на %s. Проверете имейла и кликнете връзката за потвърждение + Изчакване + + Въведете нов сървър за самоличност + Неуспешна връзка със сървъра за самоличност + Въведете адреса на сървъра за самоличност + Сървъра за самоличност няма условия за ползване + Избрания сървър за самоличност няма условия за ползване на услугата. Продължете само ако вярвате на собственика на услугата + Беше изпратено текстово съобщение на %s. Въведете съдържащият се код за потвърждение. + + В момента споделяте имейл адреси или телефонни номера със сървър за самоличност %1$s. Ще трябва да се свържете наново с %2$s за да спрете да ги споделяте. + Съгласете се с условията за ползване на услугата на сървъра за самоличност (%s) за да бъдете откриваеми по имейл адрес или телефонен номер. + + Включи подробни логове. + Подробните логове помагат на разработчиците, понеже предоставят повече логове когато изпращате RageShake. Дори включени, приложението не записва съдържанието на съобщенията или други лични данни. + + + Опитайте пак след като приемете условията за ползване на сървъра. + + Изглежда сървъра отнема прекалено дълго за да отговори, поради лоша връзка или проблем със сървърите. Опитайте пак по-късно. + + Изпрати прикачен файл + + Отвори навигационния панел + Отвори менюто за създаване на стая + Затвори менюто за създаване на стая… + Създай нова директна кореспонденция + Създай нова стая + Затвори съобщението за резервно копие на ключовете + Покажи паролата + Скрий паролата + Отиди най-отдолу + + %1$s, %2$s и %3$d други прочетоха + %1$s, %2$s и %3$s прочетоха + %1$s и %2$s прочетоха + %s прочете + + 1 потребител прочете + %d потребителя прочетоха + + + Файлът \'%1$s\' (%2$s) е прекалено голям за да се качи. Ограничението е %3$s. + + Възникна грешка при извличане на прикачения файл. + Файл + Контакт + Камера + Аудио + Галерия + Стикер + Неуспешна обработка на споделени данни + + Това е спам + Това е неподходящо + Собствен доклад + Докладване на това съдържание + Причина за докладване на това съдържание + ДОКЛАДВАЙ + БЛОКИРАЙ ПОТРЕБИТЕЛЯ + + Съдържанието беше докладвано + Съдържанието беше докладвано. +\n +\nАко не искате да виждате повече съдържание от този потребител, може да го блокирате за да скриете съобщенията му + Докладвано като спам + Съдържанието беше докладвано като спам. +\n +\nАко не искате да виждате повече съдържание от този потребител, може да го блокирате за да скриете съобщенията му + Докладвано като неподходящо + Съдържанието беше докладвано като неподходящо. +\n +\nАко не искате да виждате повече съдържание от този потребител, може да го блокирате за да скриете съобщенията му + + Riot се нуждае от привилегии за да запази E2E ключовете върху диска. +\n +\nПозволете достъп на следващия екран, за да може в бъдеще да експортирате ключовете си ръчно. + + В момента няма връзка с мрежата + diff --git a/vector/src/main/res/values-de/strings.xml b/vector/src/main/res/values-de/strings.xml index 251c42c00c..47dc512cb4 100644 --- a/vector/src/main/res/values-de/strings.xml +++ b/vector/src/main/res/values-de/strings.xml @@ -473,7 +473,7 @@ Beachte: Diese Aktion wird die App neu starten und einige Zeit brauchen.Räume mit ungelesenen Benachrichtigungen anheften
Räume mit ungelesenen Nachrichten anheften Geräte - Geräte Information + Geräteinformationen ID Öffentlicher Name Öffentlichen Namen aktualisieren @@ -1707,5 +1707,5 @@ Wenn du diese neue Wiederherstellungsmethode nicht eingerichtet hast, kann ein A ausstehend Gib einen neuen Identitätsserver ein - Konnte keine Verbindung zum Heimserver herstellen. + Konnte keine Verbindung zum Heimserver herstellen diff --git a/vector/src/main/res/values-fi/strings.xml b/vector/src/main/res/values-fi/strings.xml index 651978f92f..fc9546d21d 100644 --- a/vector/src/main/res/values-fi/strings.xml +++ b/vector/src/main/res/values-fi/strings.xml @@ -121,8 +121,8 @@ Luo tili Kirjaudu sisään Kirjaudu ulos - Kotipalvelimen URL - Identiteettipalvelimen URL + Kotipalvelimen URL-osoite + Identiteettipalvelimen URL-osoite Etsi @@ -142,7 +142,7 @@ Luo tili Lähetä Ohita - Lähetä nollaussähköposti + Lähetä palautussähköposti Palaa kirjautumiseen Sähköposti tai käyttäjätunnus Salasana @@ -621,7 +621,7 @@ Lähettävän laitteen tiedot Julkinen nimi Julkinen nimi - Laitteen ID + Tunnus Laitteen avain Vahvistus Ed25519-sormenjälki @@ -672,7 +672,7 @@ Valitse huoneluettelo Palvelin saattaa olla tavoittamattomissa tai ylikuormitettu Syötä kotipalvelin, jolta julkiset huoneet listataan - Kotipalvelimen URL + Kotipalvelimen URL-osoite Kaikki huoneet palvelimella %s Kaikki alkuperäiset %s huoneet @@ -1707,4 +1707,6 @@ Jotta et menetä mitään, automaattiset päivitykset kannattaa pitää käytös Ole löydettävissä Tekstiviesti on lähetetty numeroon %s. Syötä sen sisältämä varmistuskoodi. + Push-sääntöjä ei ole määritetty + Luo uusi yksityiskeskustelu diff --git a/vector/src/main/res/values-ko/strings.xml b/vector/src/main/res/values-ko/strings.xml index 3f2469d766..76dafef323 100644 --- a/vector/src/main/res/values-ko/strings.xml +++ b/vector/src/main/res/values-ko/strings.xml @@ -32,7 +32,7 @@ 공유 고유 주소 소스 보기 - 해독된 소스 보기 + 복호화된 소스 보기 삭제 다시 이름 짓기 내용 신고하기 @@ -904,7 +904,7 @@ Ed25519 핑거프린트 키가 필요함 알고리즘 세션 ID - 암호 해독 오류 + 암호 복호화 오류 발신자 기기 정보 공개 이름 @@ -1264,7 +1264,7 @@ 메시지 복구 복구 키를 잃어버렸나요\? 설정에서 새로운 키를 만들 수 있습니다. - 이 암호로 백업을 해독할 수 없습니다: 올바른 복구 암호를 입력해서 확인해주세요. + 이 암호로 백업을 복호화할 수 없습니다: 올바른 복구 암호를 입력해서 확인해주세요. 네트워크 오류: 인터넷 연결 상태를 확인하고 다시 시도해주세요. 백업 복구: @@ -1273,7 +1273,7 @@ 키 가져오는 중… 기록 풀기 복구 키를 입력하세요 - 이 복구 키로 백업을 해독할 수 없습니다: 올바른 복구 키를 입력해서 확인해주세요. + 이 복구 키로 백업을 복호화할 수 없습니다: 올바른 복구 키를 입력해서 확인해주세요. 백업이 복구되었습니다 %s ! %1$d개의 세션 키를 복구했고, 이 기기에서 알려지지 않은 %2$d개의 새 키를 추가함 diff --git a/vector/src/main/res/values-land/styles_login.xml b/vector/src/main/res/values-land/styles_login.xml new file mode 100644 index 0000000000..29ddebedd2 --- /dev/null +++ b/vector/src/main/res/values-land/styles_login.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/vector/src/main/res/values-sq/strings.xml b/vector/src/main/res/values-sq/strings.xml index f299ad4385..10338ddde9 100644 --- a/vector/src/main/res/values-sq/strings.xml +++ b/vector/src/main/res/values-sq/strings.xml @@ -1654,4 +1654,71 @@ Që të garantoni se s’ju shpëton gjë, thjesht mbajeni të aktivizuar mekani Hëpërhë, ndani me të tjerë adresa email ose numra telefoni te shërbyesi i identiteteve %1$s. Do të duhet të rilidheni me %2$s që të ndalni ndarjen e tyre. Që të lejoni veten të jetë e zbulueshme nga adresë email apo numër telefoni, pajtohuni me Kushtet e Shërbimit të shërbyesit të identiteteve (%s). + Latn + + Zbulim + Aktivizo regjistra fjalamanë. + Regjistrat fjalamanë do t’i ndihmojnë zhvilluesit duke furnizuar më tepër regjistrim kur dërgoni një RageShake. Edhe kur është e aktivizuar kjo, aplikimi nuk regjistron lëndë mesazhesh apo çfarëdo të dhëne tjetër private. + + + Ju lutemi, riprovoni sapo të keni pranuar termat dhe kushtet e shërbyesit tuaj Home. + + Duket sikur shërbyesit po i duhet shumë kohë për t’u përgjigjur,kjo mund të shkaktohet ose nga lidhje e dobët, ose nga një gabim me shërbyesit tanë. Ju lutemi, riprovoni pas pak. + + Dërgo bashkëngjitje + + Hapni zonën e lëvizjeve + Hapni menunë e krijimit të dhomave + Mbylleni menunë e krijmit të dhomave… + Krijoni një bisedë të re të drejtpërdrejtë + Krijoni një dhomë të re + Shfaq fjalëkalim + Fshihe fjalëkalimin + Hidhu në fund + + %1$s, %2$s dhe %3$d të tjerë të lexuar + %1$s, %2$s dhe %3$s të lexuar + %1$s dhe %2$s të lexuar + %s i lexuar + + 1 përdorues lexoi + %d përdorues lexuan + + + "Kartela \'%1$s\' (%2$s) është shumë e madhe për ngarkim. Caku është %3$s." + + Ndodhi një gabim gjatë marrjes së bashkëngjitjes. + Kartelë + Kontakt + Kamerë + Audio + Galeri + Ngjitës + Është e padëshiruar + Është e papërshtatshme + Raport vetjak + Raportojeni këtë lëndë + Arsye për raportimin e kësaj lënde + RAPORTOJENI + BLLOKOJENI PËRDORUESIN + + Lënda u raportua + Kjo lëndë është raportuar. +\n +\nNëse s’doni të shihni më lëndë nga ky përdorues, mund ta bllokoni, që të fshihen mesazhet e tij + E raportuar si e padëshiruar + Kjo lëndë është raportuar si e padëshiruar. +\n +\nNëse s’doni të shihni më lëndë nga ky përdorues, mund ta bllokoni, që të fshihen mesazhet e tij + E raportuar si e papërshtatshme + Kjo lëndë është raportuar si e papërshtatshme. +\n +\nNëse s’doni të shihni më lëndë nga ky përdorues, mund ta bllokoni, që të fshihen mesazhet e tij + + Riot-i lyp leje për të ruajtur kyçet tuaj E2E në disk. +\n +\nJu lutemi, lejoni, te flluska pasuese, hyrje për të qenë e mundur të eksportohen kyçet tuaj dorazi. + + Tani për tani s’la lidhje rrjeti + diff --git a/vector/src/main/res/values-tr/strings.xml b/vector/src/main/res/values-tr/strings.xml index 58256833c9..2bee7a14f9 100644 --- a/vector/src/main/res/values-tr/strings.xml +++ b/vector/src/main/res/values-tr/strings.xml @@ -50,9 +50,10 @@ Yeniden Adlandır İçeriği bildir Şu anki görüşme - Devam eden konferans görüşmesi.\n%1$s veya %2$s katıl. + Devam eden konferans görüşmesi. +\n%1$s veya %2$s olarak katıl. Sesli - görüntülü + Görüntülü Görüşme başlatılamıyor, lütfen sonra tekrar deneyin Eksik izinler nedeni ile bazı özellikler eksik olabilir… Bu odada bir konferans başlatma davetiyesi göndermek için izniniz olması gerekmektedir @@ -88,10 +89,10 @@ Topluluklar Odaları ara - Favorileri ara - Kişileri ara - Odaları ara - Toplulukları ara + Favorileri filtrele + Kişileri filtrele + Oda adlarını filtrele + Topluluk adlarını filtrele Davetler Düşük öncelik @@ -137,7 +138,7 @@ Odaya Katıl Kullanıcı adı - Kayıt + Hesap oluştur Giriş yap Çıkış yap Ev Sunucusu URL\'si @@ -276,7 +277,7 @@ Eğer yeni kurtarma yöntemini siz ayarlamadıysanız, bir saldırgan hesabını Kayıt olunamadı: Ağ hatası Kayıt olunamadı Kayıt olunamadı: eposta sahiplik hatası - Geçerli bir URL girin + Lütfen geçerli bir URL girin Mobil Geçersiz kullanıcıadı/şifre @@ -1306,4 +1307,22 @@ Neden Riot.im’i seçmeliyim? Algoritma İmza + İşlem başlatılıyor + Cihazı doğrula + + Hiç (Yok) + Geri Al + Bağlantıyı Kes + Önemseme + Gözden Geçir + Reddet + + Okunmuş olarak işaretle + Arama yanlış yapılandırılmış odadan dolayı başarısız oldu + %s kullanmayı deneyin + Yeniden sorma + + Tek oturum açma ile giriş yap + Hesap kurtarması için email ayarla, ve sonradan da isteğe bağlı olarak başklarının seni tanıyan kişilerin bulması için kullan. + Bu adrese erişilemiyor, lütfen kontrol et 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..ea41a3c7ca 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. --> diff --git a/vector/src/main/res/values/text_appearances.xml b/vector/src/main/res/values/text_appearances.xml index 606aef6511..6c2a71631d 100644 --- a/vector/src/main/res/values/text_appearances.xml +++ b/vector/src/main/res/values/text_appearances.xml @@ -37,4 +37,23 @@ ?riotx_text_secondary + + + + + + \ No newline at end of file