Merge branch 'develop' into feature/enhance_big_files

This commit is contained in:
Valere 2020-09-03 17:09:40 +02:00 committed by GitHub
commit e0c5377968
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
86 changed files with 2757 additions and 600 deletions

View File

@ -2,22 +2,27 @@ Changes in Element 1.0.6 (2020-XX-XX)
===================================================
Features ✨:
-
- List phone numbers and emails added to the Matrix account, and add emails and phone numbers to account (#44, #45)
Improvements 🙌:
- You can now join room through permalink and within room directory search
- Add long click gesture to copy userId, user display name, room name, room topic and room alias (#1774)
- Fix several issues when uploading bug files (#1889)
- Do not propose to verify session if there is only one session and 4S is not configured (#1901)
Bugfix 🐛:
- Display name not shown under Settings/General (#1926)
- Editing message forgets line breaks and markdown (#1939)
- Words containing my name should not trigger notifications (#1781)
- Fix changing language issue
- Fix FontSize issue (#1483, #1787)
- Fix bad color for settings icon on Android < 24 (#1786)
- Change user or room avatar: when selecting Gallery, I'm not proposed to crop the selected image (#1590)
- Loudspeaker is always used (#1685)
- Fix uploads still don't work with room v6 (#1879)
- Can't handle ongoing call events in background (#1992)
- Crash / Attachment viewer: Cannot draw a recycled Bitmap #2034
- Login with Matrix-Id | Autodiscovery fails if identity server is invalid and Homeserver ok (#2027)
Translations 🗣:
-
@ -27,6 +32,10 @@ SDK API changes ⚠️:
Build 🧱:
- Some dependencies have been upgraded (coroutine, recyclerView, appCompat, core-ktx, firebase-messaging)
- Buildkite:
New pipeline location: https://github.com/matrix-org/pipelines/blob/master/element-android/pipeline.yml
New build location: https://buildkite.com/matrix-dot-org/element-android
Other changes:
- Use File extension functions to make code more concise (#1996)

View File

@ -1,4 +1,4 @@
[![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android/builds?branch=develop)
[![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop)
[![Weblate](https://translate.riot.im/widgets/element-android/-/svg-badge.svg)](https://translate.riot.im/engage/element-android/?utm_source=widget)
[![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org)
[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=im.vector.app.android&metric=alert_status)](https://sonarcloud.io/dashboard?id=im.vector.app.android)
@ -14,7 +14,7 @@ It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-androi
[<img src="resources/img/google-play-badge.png" alt="Get it on Google Play" height="60">](https://play.google.com/store/apps/details?id=im.vector.app)
[<img src="resources/img/f-droid-badge.png" alt="Get it on F-Droid" height="60">](https://f-droid.org/app/im.vector.app)
Nightly build: [![Buildkite](https://badge.buildkite.com/657d3db27364448d69d54f66c690f7788bc6aa80a7628e37f3.svg?branch=develop)](https://buildkite.com/matrix-dot-org/riotx-android/builds?branch=develop)
Nightly build: [![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop)
# New Android SDK

View File

@ -27,4 +27,9 @@ class AnimatedImageViewHolder constructor(itemView: View) :
val imageLoaderProgress: ProgressBar = itemView.findViewById(R.id.imageLoaderProgress)
internal val target = DefaultImageLoaderTarget(this, this.touchImageView)
override fun onRecycled() {
super.onRecycled()
touchImageView.setImageDrawable(null)
}
}

View File

@ -50,6 +50,7 @@ internal class DefaultImageLoaderTarget(val holder: AnimatedImageViewHolder, pri
override fun onLoadFailed(uid: String, errorDrawable: Drawable?) {
if (holder.boundResourceUid != uid) return
holder.imageLoaderProgress.isVisible = false
holder.touchImageView.setImageDrawable(errorDrawable)
}
override fun onResourceCleared(uid: String, placeholder: Drawable?) {
@ -77,11 +78,13 @@ internal class DefaultImageLoaderTarget(val holder: AnimatedImageViewHolder, pri
override fun onResourceLoading(uid: String, placeholder: Drawable?) {
if (holder.boundResourceUid != uid) return
holder.imageLoaderProgress.isVisible = true
holder.touchImageView.setImageDrawable(placeholder)
}
override fun onLoadFailed(uid: String, errorDrawable: Drawable?) {
if (holder.boundResourceUid != uid) return
holder.imageLoaderProgress.isVisible = false
holder.touchImageView.setImageDrawable(errorDrawable)
}
override fun onResourceCleared(uid: String, placeholder: Drawable?) {

View File

@ -48,6 +48,8 @@ internal class DefaultVideoLoaderTarget(val holder: VideoViewHolder, private val
}
override fun onThumbnailResourceCleared(uid: String, placeholder: Drawable?) {
if (holder.boundResourceUid != uid) return
holder.thumbnailImage.setImageDrawable(placeholder)
}
override fun onThumbnailResourceReady(uid: String, resource: Drawable) {

View File

@ -16,6 +16,7 @@
package im.vector.lib.attachmentviewer
import android.util.Log
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
@ -125,8 +126,13 @@ class VideoViewHolder constructor(itemView: View) :
eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration))
}
}
try {
videoView.setVideoPath(mVideoPath)
} catch (failure: Throwable) {
// Couldn't open
Log.v(VideoViewHolder::class.java.name, "Failed to start video")
}
videoView.setVideoPath(mVideoPath)
if (!wasPaused) {
videoView.start()
if (progress > 0) {

View File

@ -39,4 +39,9 @@ class ZoomableImageViewHolder constructor(itemView: View) :
}
internal val target = DefaultImageLoaderTarget.ZoomableImageTarget(this, touchImageView)
override fun onRecycled() {
super.onRecycled()
touchImageView.setImageDrawable(null)
}
}

View File

@ -87,7 +87,7 @@ sonarqube {
property "sonar.projectVersion", project(":vector").android.defaultConfig.versionName
property "sonar.sourceEncoding", "UTF-8"
property "sonar.links.homepage", "https://github.com/vector-im/element-android/"
property "sonar.links.ci", "https://buildkite.com/matrix-dot-org/riotx-android"
property "sonar.links.ci", "https://buildkite.com/matrix-dot-org/element-android"
property "sonar.links.scm", "https://github.com/vector-im/element-android/"
property "sonar.links.issue", "https://github.com/vector-im/element-android/issues"
property "sonar.organization", "new_vector_ltd_organization"

285
docs/add_threePids.md Normal file
View File

@ -0,0 +1,285 @@
# Adding and removing ThreePids to an account
## Add email
### User enter the email
> POST https://homeserver.org/_matrix/client/r0/account/3pid/email/requestToken
```json
{
"email": "alice@email-provider.org",
"client_secret": "TixzvOnw7nLEUdiQEmkHzkXKrY4HhiGh",
"send_attempt": 1
}
```
#### The email is already added to an account
400
```json
{
"errcode": "M_THREEPID_IN_USE",
"error": "Email is already in use"
}
```
#### The email is free
Wording: "We've sent you an email to verify your address. Please follow the instructions there and then click the button below."
200
```json
{
"sid": "bxyDHuJKsdkjMlTJ"
}
```
## User receive an e-mail
> [homeserver.org] Validate your email
>
> A request to add an email address to your Matrix account has been received. If this was you, please click the link below to confirm adding this email:
https://homeserver.org/_matrix/client/unstable/add_threepid/email/submit_token?token=WUnEhQAmJrXupdEbXgdWvnVIKaGYZFsU&client_secret=TixzvOnw7nLEUdiQEmkHzkXKrY4HhiGh&sid=bxyDHuJKsdkjMlTJ
>
> If this was not you, you can safely ignore this email. Thank you.
### User clicks on the link
The browser displays the following message:
> Your email has now been validated, please return to your client. You may now close this window.
### User returns on Element
User clicks on CONTINUE
> POST https://homeserver.org/_matrix/client/r0/account/3pid/add
```json
{
"sid": "bxyDHuJKsdkjMlTJ",
"client_secret": "TixzvOnw7nLEUdiQEmkHzkXKrY4HhiGh"
}
```
401 User Interactive Authentication
```json
{
"session": "ppvvnozXCQZFaggUBlHJYPjA",
"flows": [
{
"stages": [
"m.login.password"
]
}
],
"params": {
}
}
```
### User enters his password
POST https://homeserver.org/_matrix/client/r0/account/3pid/add
```json
{
"sid": "bxyDHuJKsdkjMlTJ",
"client_secret": "TixzvOnw7nLEUdiQEmkHzkXKrY4HhiGh",
"auth": {
"session": "ppvvnozXCQZFaggUBlHJYPjA",
"type": "m.login.password",
"user": "@benoitx:matrix.org",
"password": "weak_password"
}
}
```
#### The link has not been clicked
400
```json
{
"errcode": "M_THREEPID_AUTH_FAILED",
"error": "No validated 3pid session found"
}
```
#### Wrong password
401
```json
{
"session": "fXHOvoQsPMhEebVqTnIrzZJN",
"flows": [
{
"stages": [
"m.login.password"
]
}
],
"params": {
},
"completed":[
],
"error": "Invalid password",
"errcode": "M_FORBIDDEN"
}
```
#### The link has been clicked and the account password is correct
200
```json
{}
```
## Remove email
### User want to remove an email from his account
> POST https://homeserver.org/_matrix/client/r0/account/3pid/delete
```json
{
"medium": "email",
"address": "alice@email-provider.org"
}
```
#### Email was not bound to an identity server
200
```json
{
"id_server_unbind_result": "no-support"
}
```
#### Email was bound to an identity server
200
```json
{
"id_server_unbind_result": "success"
}
```
## Add phone number
> POST https://homeserver.org/_matrix/client/r0/account/3pid/msisdn/requestToken
```json
{
"country": "FR",
"phone_number": "611223344",
"client_secret": "f1K29wFZBEr4RZYatu7xj8nEbXiVpr7J",
"send_attempt": 1
}
```
Note that the phone number is sent without `+` and without the country code
#### The phone number is already added to an account
400
```json
{
"errcode": "M_THREEPID_IN_USE",
"error": "MSISDN is already in use"
}
```
#### The phone number is free
Wording: "A text message has been sent to +33611223344. Please enter the verification code it contains."
200
```json
{
"msisdn": "33651547677",
"intl_fmt": "+33 6 51 54 76 77",
"success": true,
"sid": "253299954",
"submit_url": "https://homeserver.org/_matrix/client/unstable/add_threepid/msisdn/submit_token"
}
```
## User receive a text message
> Riot
> Your Riot validation code is 892541, please enter this into the app
### User enter the code to the app
#### Wrong code
> POST https://homeserver.org/_matrix/client/unstable/add_threepid/msisdn/submit_token
```json
{
"sid": "253299954",
"client_secret": "f1K29wFZBEr4RZYatu7xj8nEbXiVpr7J",
"token": "111111"
}
```
400
```json
{
"errcode": "M_UNKNOWN",
"error": "Error contacting the identity server"
}
```
This is not an ideal, but the client will display a hint to check the entered code to the user.
#### Correct code
> POST https://homeserver.org/_matrix/client/unstable/add_threepid/msisdn/submit_token
```json
{
"sid": "253299954",
"client_secret": "f1K29wFZBEr4RZYatu7xj8nEbXiVpr7J",
"token": "892541"
}
```
200
````json
{
"success": true
}
````
Then the app call `https://homeserver.org/_matrix/client/r0/account/3pid/add` as per adding an email and follow the same UIS flow
## Remove phone number
### User wants to remove a phone number from his account
This is the same request and response than to remove email, but with this body:
```json
{
"medium": "msisdn",
"address": "33611223344"
}
```
Note that the phone number is provided without `+`, but with the country code.

View File

@ -18,9 +18,13 @@
package org.matrix.android.sdk.rx
import androidx.paging.PagedList
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.functions.Function3
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.accountdata.UserAccountDataEvent
import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME
import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
import org.matrix.android.sdk.api.session.crypto.crosssigning.MXCrossSigningInfo
@ -43,10 +47,6 @@ import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
import org.matrix.android.sdk.internal.crypto.store.PrivateKeysInfo
import org.matrix.android.sdk.api.session.accountdata.UserAccountDataEvent
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.functions.Function3
class RxSession(private val session: Session) {
@ -110,6 +110,11 @@ class RxSession(private val session: Session) {
.startWithCallable { session.getThreePids() }
}
fun livePendingThreePIds(): Observable<List<ThreePid>> {
return session.getPendingThreePidsLive().asObservable()
.startWithCallable { session.getPendingThreePids() }
}
fun createRoom(roomParams: CreateRoomParams): Single<String> = singleBuilder {
session.createRoom(roomParams, it)
}

View File

@ -17,23 +17,22 @@
package org.matrix.android.sdk.internal.session.room.send
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.matrix.android.sdk.InstrumentedTest
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.commonmark.renderer.text.TextContentRenderer
import org.junit.Assert.assertEquals
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import org.matrix.android.sdk.InstrumentedTest
/**
* It will not be possible to test all combinations. For the moment I add a few tests, then, depending on the problem discovered in the wild,
* we can add more tests to cover the edge cases.
* Some tests are suffixed with `_not_passing`, maybe one day we will fix them...
* Riot-Web should be used as a reference for expected results, but not always. Especially Riot-Web add lots of `\n` in the
* formatted body, which is quite useless.
* Also Riot-Web does not provide plain text body when formatted text is provided. The body contains what the user has entered.
* Element Web should be used as a reference for expected results, but not always.
* Also Element Web does not provide plain text body when formatted text is provided. The body contains what the user has entered. We are doing
* the same to be able to edit messages (See #1939)
* See https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes
*/
@Suppress("SpellCheckingInspection")
@ -46,8 +45,7 @@ class MarkdownParserTest : InstrumentedTest {
*/
private val markdownParser = MarkdownParser(
Parser.builder().build(),
HtmlRenderer.builder().build(),
TextContentRenderer.builder().build()
HtmlRenderer.builder().build()
)
@Test
@ -83,6 +81,15 @@ class MarkdownParserTest : InstrumentedTest {
)
}
@Test
fun parseBoldNewLines() {
testTypeNewLines(
name = "bold",
markdownPattern = "**",
htmlExpectedTag = "strong"
)
}
@Test
fun parseItalic() {
testType(
@ -92,14 +99,23 @@ class MarkdownParserTest : InstrumentedTest {
)
}
@Test
fun parseItalicNewLines() {
testTypeNewLines(
name = "italic",
markdownPattern = "*",
htmlExpectedTag = "em"
)
}
@Test
fun parseItalic2() {
// Riot-Web format
"_italic_".let { markdownParser.parse(it) }.expect("italic", "<em>italic</em>")
// Element Web format
"_italic_".let { markdownParser.parse(it).expect(it, "<em>italic</em>") }
}
/**
* Note: the test is not passing, it does not work on Riot-Web neither
* Note: the test is not passing, it does not work on Element Web neither
*/
@Test
fun parseStrike_not_passing() {
@ -110,14 +126,30 @@ class MarkdownParserTest : InstrumentedTest {
)
}
@Test
fun parseStrikeNewLines() {
testTypeNewLines(
name = "strike",
markdownPattern = "~~",
htmlExpectedTag = "del"
)
}
@Test
fun parseCode() {
testType(
name = "code",
markdownPattern = "`",
htmlExpectedTag = "code",
plainTextPrefix = "\"",
plainTextSuffix = "\""
htmlExpectedTag = "code"
)
}
@Test
fun parseCodeNewLines() {
testTypeNewLines(
name = "code",
markdownPattern = "`",
htmlExpectedTag = "code"
)
}
@ -126,9 +158,16 @@ class MarkdownParserTest : InstrumentedTest {
testType(
name = "code",
markdownPattern = "``",
htmlExpectedTag = "code",
plainTextPrefix = "\"",
plainTextSuffix = "\""
htmlExpectedTag = "code"
)
}
@Test
fun parseCode2NewLines() {
testTypeNewLines(
name = "code",
markdownPattern = "``",
htmlExpectedTag = "code"
)
}
@ -137,78 +176,85 @@ class MarkdownParserTest : InstrumentedTest {
testType(
name = "code",
markdownPattern = "```",
htmlExpectedTag = "code",
plainTextPrefix = "\"",
plainTextSuffix = "\""
htmlExpectedTag = "code"
)
}
@Test
fun parseCode3NewLines() {
testTypeNewLines(
name = "code",
markdownPattern = "```",
htmlExpectedTag = "code"
)
}
@Test
fun parseUnorderedList() {
"- item1".let { markdownParser.parse(it).expect(it, "<ul><li>item1</li></ul>") }
"- item1\n- item2".let { markdownParser.parse(it).expect(it, "<ul><li>item1</li><li>item2</li></ul>") }
"- item1".let { markdownParser.parse(it).expect(it, "<ul>\n<li>item1</li>\n</ul>") }
"- item1\n- item2".let { markdownParser.parse(it).expect(it, "<ul>\n<li>item1</li>\n<li>item2</li>\n</ul>") }
}
@Test
fun parseOrderedList() {
"1. item1".let { markdownParser.parse(it).expect(it, "<ol><li>item1</li></ol>") }
"1. item1\n2. item2".let { markdownParser.parse(it).expect(it, "<ol><li>item1</li><li>item2</li></ol>") }
"1. item1".let { markdownParser.parse(it).expect(it, "<ol>\n<li>item1</li>\n</ol>") }
"1. item1\n2. item2".let { markdownParser.parse(it).expect(it, "<ol>\n<li>item1</li>\n<li>item2</li>\n</ol>") }
}
@Test
fun parseHorizontalLine() {
"---".let { markdownParser.parse(it) }.expect("***", "<hr />")
"---".let { markdownParser.parse(it).expect(it, "<hr />") }
}
@Test
fun parseH2AndContent() {
"a\n---\nb".let { markdownParser.parse(it) }.expect("a\nb", "<h2>a</h2><p>b</p>")
"a\n---\nb".let { markdownParser.parse(it).expect(it, "<h2>a</h2>\n<p>b</p>") }
}
@Test
fun parseQuote() {
"> quoted".let { markdownParser.parse(it) }.expect("«quoted»", "<blockquote><p>quoted</p></blockquote>")
"> quoted".let { markdownParser.parse(it).expect(it, "<blockquote>\n<p>quoted</p>\n</blockquote>") }
}
@Test
fun parseQuote_not_passing() {
"> quoted\nline2".let { markdownParser.parse(it) }.expect("«quoted\nline2»", "<blockquote><p>quoted<br/>line2</p></blockquote>")
"> quoted\nline2".let { markdownParser.parse(it).expect(it, "<blockquote><p>quoted<br />line2</p></blockquote>") }
}
@Test
fun parseBoldItalic() {
"*italic* **bold**".let { markdownParser.parse(it) }.expect("italic bold", "<em>italic</em> <strong>bold</strong>")
"**bold** *italic*".let { markdownParser.parse(it) }.expect("bold italic", "<strong>bold</strong> <em>italic</em>")
"*italic* **bold**".let { markdownParser.parse(it).expect(it, "<em>italic</em> <strong>bold</strong>") }
"**bold** *italic*".let { markdownParser.parse(it).expect(it, "<strong>bold</strong> <em>italic</em>") }
}
@Test
fun parseHead() {
"# head1".let { markdownParser.parse(it) }.expect("head1", "<h1>head1</h1>")
"## head2".let { markdownParser.parse(it) }.expect("head2", "<h2>head2</h2>")
"### head3".let { markdownParser.parse(it) }.expect("head3", "<h3>head3</h3>")
"#### head4".let { markdownParser.parse(it) }.expect("head4", "<h4>head4</h4>")
"##### head5".let { markdownParser.parse(it) }.expect("head5", "<h5>head5</h5>")
"###### head6".let { markdownParser.parse(it) }.expect("head6", "<h6>head6</h6>")
"# head1".let { markdownParser.parse(it).expect(it, "<h1>head1</h1>") }
"## head2".let { markdownParser.parse(it).expect(it, "<h2>head2</h2>") }
"### head3".let { markdownParser.parse(it).expect(it, "<h3>head3</h3>") }
"#### head4".let { markdownParser.parse(it).expect(it, "<h4>head4</h4>") }
"##### head5".let { markdownParser.parse(it).expect(it, "<h5>head5</h5>") }
"###### head6".let { markdownParser.parse(it).expect(it, "<h6>head6</h6>") }
}
@Test
fun parseHeads() {
"# head1\n# head2".let { markdownParser.parse(it) }.expect("head1\nhead2", "<h1>head1</h1><h1>head2</h1>")
"# head1\n# head2".let { markdownParser.parse(it).expect(it, "<h1>head1</h1>\n<h1>head2</h1>") }
}
@Test
fun parseBoldNewLines_not_passing() {
"**bold**\nline2".let { markdownParser.parse(it) }.expect("bold\nline2", "<strong>bold</strong><br />line2")
"**bold**\nline2".let { markdownParser.parse(it).expect(it, "<strong>bold</strong><br />line2") }
}
@Test
fun parseLinks() {
"[link](target)".let { markdownParser.parse(it) }.expect(""""link" (target)""", """<a href="target">link</a>""")
"[link](target)".let { markdownParser.parse(it).expect(it, """<a href="target">link</a>""") }
}
@Test
fun parseParagraph() {
"# head\ncontent".let { markdownParser.parse(it) }.expect("head\ncontent", "<h1>head</h1><p>content</p>")
"# head\ncontent".let { markdownParser.parse(it).expect(it, "<h1>head</h1>\n<p>content</p>") }
}
private fun testIdentity(text: String) {
@ -217,59 +263,93 @@ class MarkdownParserTest : InstrumentedTest {
private fun testType(name: String,
markdownPattern: String,
htmlExpectedTag: String,
plainTextPrefix: String = "",
plainTextSuffix: String = "") {
htmlExpectedTag: String) {
// Test simple case
"$markdownPattern$name$markdownPattern"
.let { markdownParser.parse(it) }
.expect(expectedText = "$plainTextPrefix$name$plainTextSuffix",
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag>")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag>")
}
// Test twice the same tag
"$markdownPattern$name$markdownPattern and $markdownPattern$name bis$markdownPattern"
.let { markdownParser.parse(it) }
.expect(expectedText = "$plainTextPrefix$name$plainTextSuffix and $plainTextPrefix$name bis$plainTextSuffix",
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag> and <$htmlExpectedTag>$name bis</$htmlExpectedTag>")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag> and <$htmlExpectedTag>$name bis</$htmlExpectedTag>")
}
val textBefore = "a"
val textAfter = "b"
// With sticked text before
"$textBefore$markdownPattern$name$markdownPattern"
.let { markdownParser.parse(it) }
.expect(expectedText = "$textBefore$plainTextPrefix$name$plainTextSuffix",
expectedFormattedText = "$textBefore<$htmlExpectedTag>$name</$htmlExpectedTag>")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "$textBefore<$htmlExpectedTag>$name</$htmlExpectedTag>")
}
// With text before and space
"$textBefore $markdownPattern$name$markdownPattern"
.let { markdownParser.parse(it) }
.expect(expectedText = "$textBefore $plainTextPrefix$name$plainTextSuffix",
expectedFormattedText = "$textBefore <$htmlExpectedTag>$name</$htmlExpectedTag>")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "$textBefore <$htmlExpectedTag>$name</$htmlExpectedTag>")
}
// With sticked text after
"$markdownPattern$name$markdownPattern$textAfter"
.let { markdownParser.parse(it) }
.expect(expectedText = "$plainTextPrefix$name$plainTextSuffix$textAfter",
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag>$textAfter")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag>$textAfter")
}
// With space and text after
"$markdownPattern$name$markdownPattern $textAfter"
.let { markdownParser.parse(it) }
.expect(expectedText = "$plainTextPrefix$name$plainTextSuffix $textAfter",
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag> $textAfter")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag> $textAfter")
}
// With sticked text before and text after
"$textBefore$markdownPattern$name$markdownPattern$textAfter"
.let { markdownParser.parse(it) }
.expect(expectedText = "$textBefore$plainTextPrefix$name$plainTextSuffix$textAfter",
expectedFormattedText = "a<$htmlExpectedTag>$name</$htmlExpectedTag>$textAfter")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "a<$htmlExpectedTag>$name</$htmlExpectedTag>$textAfter")
}
// With text before and after, with spaces
"$textBefore $markdownPattern$name$markdownPattern $textAfter"
.let { markdownParser.parse(it) }
.expect(expectedText = "$textBefore $plainTextPrefix$name$plainTextSuffix $textAfter",
expectedFormattedText = "$textBefore <$htmlExpectedTag>$name</$htmlExpectedTag> $textAfter")
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "$textBefore <$htmlExpectedTag>$name</$htmlExpectedTag> $textAfter")
}
}
private fun testTypeNewLines(name: String,
markdownPattern: String,
htmlExpectedTag: String) {
// With new line inside the block
"$markdownPattern$name\n$name$markdownPattern"
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "<$htmlExpectedTag>$name<br />$name</$htmlExpectedTag>")
}
// With new line between two blocks
"$markdownPattern$name$markdownPattern\n$markdownPattern$name$markdownPattern"
.let {
markdownParser.parse(it)
.expect(expectedText = it,
expectedFormattedText = "<$htmlExpectedTag>$name</$htmlExpectedTag><$htmlExpectedTag>$name</$htmlExpectedTag>")
}
}
private fun TextContent.expect(expectedText: String, expectedFormattedText: String?) {

View File

@ -45,7 +45,7 @@ sealed class WellknownResult {
/**
* Inform the user that auto-discovery failed due to invalid/empty data and PROMPT for the parameter.
*/
object FailPrompt : WellknownResult()
data class FailPrompt(val homeServerUrl: String?, val wellKnown: WellKnown?) : WellknownResult()
/**
* Inform the user that auto-discovery did not return any usable URLs. Do not continue further with the current login process.

View File

@ -83,4 +83,43 @@ interface ProfileService {
* @param refreshData set to true to fetch data from the homeserver
*/
fun getThreePidsLive(refreshData: Boolean): LiveData<List<ThreePid>>
/**
* Get the pending 3Pids, i.e. ThreePids that have requested a token, but not yet validated by the user.
*/
fun getPendingThreePids(): List<ThreePid>
/**
* Get the pending 3Pids Live
*/
fun getPendingThreePidsLive(): LiveData<List<ThreePid>>
/**
* Add a 3Pids. This is the first step to add a ThreePid to an account. Then the threePid will be added to the pending threePid list.
*/
fun addThreePid(threePid: ThreePid, matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Validate a code received by text message
*/
fun submitSmsCode(threePid: ThreePid.Msisdn, code: String, matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Finalize adding a 3Pids. Call this method once the user has validated that he owns the ThreePid
*/
fun finalizeAddingThreePid(threePid: ThreePid,
uiaSession: String?,
accountPassword: String?,
matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Cancel adding a threepid. It will remove locally stored data about this ThreePid
*/
fun cancelAddingThreePid(threePid: ThreePid,
matrixCallback: MatrixCallback<Unit>): Cancelable
/**
* Remove a 3Pid from the Matrix account.
*/
fun deleteThreePid(threePid: ThreePid, matrixCallback: MatrixCallback<Unit>): Cancelable
}

View File

@ -17,6 +17,9 @@
package org.matrix.android.sdk.internal.auth.registration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import okhttp3.OkHttpClient
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
@ -33,9 +36,6 @@ import org.matrix.android.sdk.internal.auth.db.PendingSessionData
import org.matrix.android.sdk.internal.network.RetrofitFactory
import org.matrix.android.sdk.internal.task.launchToCallback
import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import okhttp3.OkHttpClient
/**
* This class execute the registration request and is responsible to keep the session of interactive authentication
@ -193,7 +193,7 @@ internal class DefaultRegistrationWizard(
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 url = safeCurrentData.addThreePidRegistrationResponse.submitUrl ?: throw IllegalStateException("Missing url to send the code")
val validationBody = ValidationCodeBody(
clientSecret = pendingSessionData.clientSecret,
sid = safeCurrentData.addThreePidRegistrationResponse.sid,

View File

@ -20,18 +20,24 @@ package org.matrix.android.sdk.internal.database
import io.realm.DynamicRealm
import io.realm.RealmMigration
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
import timber.log.Timber
import javax.inject.Inject
class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
companion object {
const val SESSION_STORE_SCHEMA_VERSION = 4L
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
Timber.v("Migrating Realm Session from $oldVersion to $newVersion")
if (oldVersion <= 0) migrateTo1(realm)
if (oldVersion <= 1) migrateTo2(realm)
if (oldVersion <= 2) migrateTo3(realm)
if (oldVersion <= 3) migrateTo4(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -63,4 +69,17 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
obj.setLong(HomeServerCapabilitiesEntityFields.LAST_UPDATED_TIMESTAMP, 0)
}
}
private fun migrateTo4(realm: DynamicRealm) {
Timber.d("Step 3 -> 4")
realm.schema.create("PendingThreePidEntity")
.addField(PendingThreePidEntityFields.CLIENT_SECRET, String::class.java)
.setRequired(PendingThreePidEntityFields.CLIENT_SECRET, true)
.addField(PendingThreePidEntityFields.EMAIL, String::class.java)
.addField(PendingThreePidEntityFields.MSISDN, String::class.java)
.addField(PendingThreePidEntityFields.SEND_ATTEMPT, Int::class.java)
.addField(PendingThreePidEntityFields.SID, String::class.java)
.setRequired(PendingThreePidEntityFields.SID, true)
.addField(PendingThreePidEntityFields.SUBMIT_URL, String::class.java)
}
}

View File

@ -19,13 +19,14 @@ package org.matrix.android.sdk.internal.database
import android.content.Context
import androidx.core.content.edit
import io.realm.Realm
import io.realm.RealmConfiguration
import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.internal.database.model.SessionRealmModule
import org.matrix.android.sdk.internal.di.SessionFilesDirectory
import org.matrix.android.sdk.internal.di.SessionId
import org.matrix.android.sdk.internal.di.UserMd5
import org.matrix.android.sdk.internal.session.SessionModule
import io.realm.Realm
import io.realm.RealmConfiguration
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@ -46,20 +47,16 @@ internal class SessionRealmConfigurationFactory @Inject constructor(
val migration: RealmSessionStoreMigration,
context: Context) {
companion object {
const val SESSION_STORE_SCHEMA_VERSION = 3L
}
// Keep legacy preferences name for compatibility reason
private val sharedPreferences = context.getSharedPreferences("im.vector.matrix.android.realm", Context.MODE_PRIVATE)
fun create(): RealmConfiguration {
val shouldClearRealm = sharedPreferences.getBoolean("$REALM_SHOULD_CLEAR_FLAG_$sessionId", false)
if (shouldClearRealm) {
Timber.v("************************************************************")
Timber.v("The realm file session was corrupted and couldn't be loaded.")
Timber.v("The file has been deleted to recover.")
Timber.v("************************************************************")
Timber.e("************************************************************")
Timber.e("The realm file session was corrupted and couldn't be loaded.")
Timber.e("The file has been deleted to recover.")
Timber.e("************************************************************")
deleteRealmFiles()
}
sharedPreferences.edit {
@ -74,7 +71,7 @@ internal class SessionRealmConfigurationFactory @Inject constructor(
realmKeysUtils.configureEncryption(this, SessionModule.getKeyAlias(userMd5))
}
.modules(SessionRealmModule())
.schemaVersion(SESSION_STORE_SCHEMA_VERSION)
.schemaVersion(RealmSessionStoreMigration.SESSION_STORE_SCHEMA_VERSION)
.migration(migration)
.build()
@ -90,6 +87,11 @@ internal class SessionRealmConfigurationFactory @Inject constructor(
// Delete all the realm files of the session
private fun deleteRealmFiles() {
if (BuildConfig.DEBUG) {
Timber.e("No op because it is a debug build")
return
}
listOf(REALM_NAME, "$REALM_NAME.lock", "$REALM_NAME.note", "$REALM_NAME.management").forEach { file ->
try {
File(directory, file).deleteRecursively()

View File

@ -0,0 +1,32 @@
/*
* Copyright 2019 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.database.model
import io.realm.RealmObject
/**
* This class is used to store pending threePid data, when user wants to add a threePid to his account
*/
internal open class PendingThreePidEntity(
var email: String? = null,
var msisdn: String? = null,
var clientSecret: String = "",
var sendAttempt: Int = 0,
var sid: String = "",
var submitUrl: String? = null
) : RealmObject()

View File

@ -36,6 +36,7 @@ import io.realm.annotations.RealmModule
RoomSummaryEntity::class,
RoomTagEntity::class,
SyncEntity::class,
PendingThreePidEntity::class,
UserEntity::class,
IgnoredUserEntity::class,
BreadcrumbsEntity::class,

View File

@ -0,0 +1,47 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AddEmailBody(
/**
* 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 email address to validate.
*/
@Json(name = "email")
val email: 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
)

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AddEmailResponse(
/**
* 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
)

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AddMsisdnBody(
/**
* 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 two-letter uppercase ISO-3166-1 alpha-2 country code that the number in
* phone_number should be parsed as if it were dialled from.
*/
@Json(name = "country")
val country: String,
/**
* Required. The phone number to validate.
*/
@Json(name = "phone_number")
val phoneNumber: String,
/**
* Required. The server will only send an SMS if the send_attempt is a number greater than the most
* recent one which it has seen, scoped to that country + phone_number + client_secret triple. This
* is to avoid repeatedly sending the same SMS 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 SMS (e.g. a
* reminder) to be sent.
*/
@Json(name = "send_attempt")
val sendAttempt: Int
)

View File

@ -0,0 +1,55 @@
/*
* Copyright 2019 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class AddMsisdnResponse(
/**
* 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 (without the requirement for an access token).
* 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
)

View File

@ -0,0 +1,111 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.zhuinden.monarchy.Monarchy
import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import java.util.UUID
import javax.inject.Inject
internal abstract class AddThreePidTask : Task<AddThreePidTask.Params, Unit> {
data class Params(
val threePid: ThreePid
)
}
internal class DefaultAddThreePidTask @Inject constructor(
private val profileAPI: ProfileAPI,
@SessionDatabase private val monarchy: Monarchy,
private val pendingThreePidMapper: PendingThreePidMapper,
private val eventBus: EventBus) : AddThreePidTask() {
override suspend fun execute(params: Params) {
when (params.threePid) {
is ThreePid.Email -> addEmail(params.threePid)
is ThreePid.Msisdn -> addMsisdn(params.threePid)
}
}
private suspend fun addEmail(threePid: ThreePid.Email) {
val clientSecret = UUID.randomUUID().toString()
val sendAttempt = 1
val result = executeRequest<AddEmailResponse>(eventBus) {
val body = AddEmailBody(
clientSecret = clientSecret,
email = threePid.email,
sendAttempt = sendAttempt
)
apiCall = profileAPI.addEmail(body)
}
// Store as a pending three pid
monarchy.awaitTransaction { realm ->
PendingThreePid(
threePid = threePid,
clientSecret = clientSecret,
sendAttempt = sendAttempt,
sid = result.sid,
submitUrl = null
)
.let { pendingThreePidMapper.map(it) }
.let { realm.copyToRealm(it) }
}
}
private suspend fun addMsisdn(threePid: ThreePid.Msisdn) {
val clientSecret = UUID.randomUUID().toString()
val sendAttempt = 1
// Get country code and national number from the phone number
val phoneNumber = threePid.msisdn
val phoneNumberUtil = PhoneNumberUtil.getInstance()
val parsedNumber = phoneNumberUtil.parse(phoneNumber, null)
val countryCode = parsedNumber.countryCode
val country = phoneNumberUtil.getRegionCodeForCountryCode(countryCode)
val result = executeRequest<AddMsisdnResponse>(eventBus) {
val body = AddMsisdnBody(
clientSecret = clientSecret,
country = country,
phoneNumber = parsedNumber.nationalNumber.toString(),
sendAttempt = sendAttempt
)
apiCall = profileAPI.addMsisdn(body)
}
// Store as a pending three pid
monarchy.awaitTransaction { realm ->
PendingThreePid(
threePid = threePid,
clientSecret = clientSecret,
sendAttempt = sendAttempt,
sid = result.sid,
submitUrl = result.submitUrl
)
.let { pendingThreePidMapper.map(it) }
.let { realm.copyToRealm(it) }
}
}
}

View File

@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.profile.ProfileService
import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity
import org.matrix.android.sdk.internal.database.model.UserThreePidEntity
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.content.FileUploader
@ -44,6 +45,11 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
private val getProfileInfoTask: GetProfileInfoTask,
private val setDisplayNameTask: SetDisplayNameTask,
private val setAvatarUrlTask: SetAvatarUrlTask,
private val addThreePidTask: AddThreePidTask,
private val validateSmsCodeTask: ValidateSmsCodeTask,
private val finalizeAddingThreePidTask: FinalizeAddingThreePidTask,
private val deleteThreePidTask: DeleteThreePidTask,
private val pendingThreePidMapper: PendingThreePidMapper,
private val fileUploader: FileUploader) : ProfileService {
override fun getDisplayName(userId: String, matrixCallback: MatrixCallback<Optional<String>>): Cancelable {
@ -116,9 +122,7 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
override fun getThreePidsLive(refreshData: Boolean): LiveData<List<ThreePid>> {
if (refreshData) {
// Force a refresh of the values
refreshUserThreePidsTask
.configureWith()
.executeBy(taskExecutor)
refreshThreePids()
}
return monarchy.findAllMappedWithChanges(
@ -126,6 +130,95 @@ internal class DefaultProfileService @Inject constructor(private val taskExecuto
{ it.asDomain() }
)
}
private fun refreshThreePids() {
refreshUserThreePidsTask
.configureWith()
.executeBy(taskExecutor)
}
override fun getPendingThreePids(): List<ThreePid> {
return monarchy.fetchAllMappedSync(
{ it.where<PendingThreePidEntity>() },
{ pendingThreePidMapper.map(it).threePid }
)
}
override fun getPendingThreePidsLive(): LiveData<List<ThreePid>> {
return monarchy.findAllMappedWithChanges(
{ it.where<PendingThreePidEntity>() },
{ pendingThreePidMapper.map(it).threePid }
)
}
override fun addThreePid(threePid: ThreePid, matrixCallback: MatrixCallback<Unit>): Cancelable {
return addThreePidTask
.configureWith(AddThreePidTask.Params(threePid)) {
callback = matrixCallback
}
.executeBy(taskExecutor)
}
override fun submitSmsCode(threePid: ThreePid.Msisdn, code: String, matrixCallback: MatrixCallback<Unit>): Cancelable {
return validateSmsCodeTask
.configureWith(ValidateSmsCodeTask.Params(threePid, code)) {
callback = matrixCallback
}
.executeBy(taskExecutor)
}
override fun finalizeAddingThreePid(threePid: ThreePid,
uiaSession: String?,
accountPassword: String?,
matrixCallback: MatrixCallback<Unit>): Cancelable {
return finalizeAddingThreePidTask
.configureWith(FinalizeAddingThreePidTask.Params(
threePid = threePid,
session = uiaSession,
accountPassword = accountPassword,
userWantsToCancel = false
)) {
callback = alsoRefresh(matrixCallback)
}
.executeBy(taskExecutor)
}
override fun cancelAddingThreePid(threePid: ThreePid, matrixCallback: MatrixCallback<Unit>): Cancelable {
return finalizeAddingThreePidTask
.configureWith(FinalizeAddingThreePidTask.Params(
threePid = threePid,
session = null,
accountPassword = null,
userWantsToCancel = true
)) {
callback = alsoRefresh(matrixCallback)
}
.executeBy(taskExecutor)
}
/**
* Wrap the callback to fetch 3Pids from the server in case of success
*/
private fun alsoRefresh(callback: MatrixCallback<Unit>): MatrixCallback<Unit> {
return object : MatrixCallback<Unit> {
override fun onFailure(failure: Throwable) {
callback.onFailure(failure)
}
override fun onSuccess(data: Unit) {
refreshThreePids()
callback.onSuccess(data)
}
}
}
override fun deleteThreePid(threePid: ThreePid, matrixCallback: MatrixCallback<Unit>): Cancelable {
return deleteThreePidTask
.configureWith(DeleteThreePidTask.Params(threePid)) {
callback = alsoRefresh(matrixCallback)
}
.executeBy(taskExecutor)
}
}
private fun UserThreePidEntity.asDomain(): ThreePid {

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class DeleteThreePidBody(
/**
* Required. The medium of the third party identifier being removed. One of: ["email", "msisdn"]
*/
@Json(name = "medium") val medium: String,
/**
* Required. The third party address being removed.
*/
@Json(name = "address") val address: String
)

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class DeleteThreePidResponse(
/**
* Required. An indicator as to whether or not the homeserver was able to unbind the 3PID from
* the identity server. success indicates that the identity server has unbound the identifier
* whereas no-support indicates that the identity server refuses to support the request or the
* homeserver was not able to determine an identity server to unbind from. One of: ["no-support", "success"]
*/
@Json(name = "id_server_unbind_result")
val idServerUnbindResult: String? = null
)

View File

@ -0,0 +1,48 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.identity.toMedium
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal abstract class DeleteThreePidTask : Task<DeleteThreePidTask.Params, Unit> {
data class Params(
val threePid: ThreePid
)
}
internal class DefaultDeleteThreePidTask @Inject constructor(
private val profileAPI: ProfileAPI,
private val eventBus: EventBus) : DeleteThreePidTask() {
override suspend fun execute(params: Params) {
executeRequest<DeleteThreePidResponse>(eventBus) {
val body = DeleteThreePidBody(
medium = params.threePid.toMedium(),
address = params.threePid.value
)
apiCall = profileAPI.deleteThreePid(body)
}
// We do not really care about the result for the moment
}
}

View File

@ -0,0 +1,42 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.internal.crypto.model.rest.UserPasswordAuth
@JsonClass(generateAdapter = true)
internal data class FinalizeAddThreePidBody(
/**
* Required. The client secret used in the session with the homeserver.
*/
@Json(name = "client_secret")
val clientSecret: String,
/**
* Required. The session identifier given by the homeserver.
*/
@Json(name = "sid")
val sid: String,
/**
* Additional authentication information for the user-interactive authentication API.
*/
@Json(name = "auth")
val auth: UserPasswordAuth?
)

View File

@ -0,0 +1,97 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.zhuinden.monarchy.Monarchy
import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.toRegistrationFlowResponse
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.internal.crypto.model.rest.UserPasswordAuth
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntityFields
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import javax.inject.Inject
internal abstract class FinalizeAddingThreePidTask : Task<FinalizeAddingThreePidTask.Params, Unit> {
data class Params(
val threePid: ThreePid,
val session: String?,
val accountPassword: String?,
val userWantsToCancel: Boolean
)
}
internal class DefaultFinalizeAddingThreePidTask @Inject constructor(
private val profileAPI: ProfileAPI,
@SessionDatabase private val monarchy: Monarchy,
private val pendingThreePidMapper: PendingThreePidMapper,
@UserId private val userId: String,
private val eventBus: EventBus) : FinalizeAddingThreePidTask() {
override suspend fun execute(params: Params) {
if (params.userWantsToCancel.not()) {
// Get the required pending data
val pendingThreePids = monarchy.fetchAllMappedSync(
{ it.where(PendingThreePidEntity::class.java) },
{ pendingThreePidMapper.map(it) }
)
.firstOrNull { it.threePid == params.threePid }
?: throw IllegalArgumentException("unknown threepid")
try {
executeRequest<Unit>(eventBus) {
val body = FinalizeAddThreePidBody(
clientSecret = pendingThreePids.clientSecret,
sid = pendingThreePids.sid,
auth = if (params.session != null && params.accountPassword != null) {
UserPasswordAuth(
session = params.session,
user = userId,
password = params.accountPassword
)
} else null
)
apiCall = profileAPI.finalizeAddThreePid(body)
}
} catch (throwable: Throwable) {
throw throwable.toRegistrationFlowResponse()
?.let { Failure.RegistrationFlowError(it) }
?: throwable
}
}
cleanupDatabase(params)
}
private suspend fun cleanupDatabase(params: Params) {
// Delete the pending three pid
monarchy.awaitTransaction { realm ->
realm.where(PendingThreePidEntity::class.java)
.equalTo(PendingThreePidEntityFields.EMAIL, params.threePid.value)
.or()
.equalTo(PendingThreePidEntityFields.MSISDN, params.threePid.value)
.findAll()
.deleteAllFromRealm()
}
}
}

View File

@ -0,0 +1,29 @@
/*
* Copyright (c) 2020 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.android.sdk.internal.session.profile
import org.matrix.android.sdk.api.session.identity.ThreePid
internal data class PendingThreePid(
val threePid: ThreePid,
val clientSecret: String,
val sendAttempt: Int,
// For Msisdn and Email
val sid: String,
// For Msisdn only
val submitUrl: String?
)

View File

@ -0,0 +1,47 @@
/*
* Copyright (c) 2020 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.android.sdk.internal.session.profile
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity
import javax.inject.Inject
internal class PendingThreePidMapper @Inject constructor() {
fun map(entity: PendingThreePidEntity): PendingThreePid {
return PendingThreePid(
threePid = entity.email?.let { ThreePid.Email(it) }
?: entity.msisdn?.let { ThreePid.Msisdn(it) }
?: error("Invalid data"),
clientSecret = entity.clientSecret,
sendAttempt = entity.sendAttempt,
sid = entity.sid,
submitUrl = entity.submitUrl
)
}
fun map(domain: PendingThreePid): PendingThreePidEntity {
return PendingThreePidEntity(
email = domain.threePid.takeIf { it is ThreePid.Email }?.value,
msisdn = domain.threePid.takeIf { it is ThreePid.Msisdn }?.value,
clientSecret = domain.clientSecret,
sendAttempt = domain.sendAttempt,
sid = domain.sid,
submitUrl = domain.submitUrl
)
}
}

View File

@ -19,6 +19,8 @@
package org.matrix.android.sdk.internal.session.profile
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.auth.registration.SuccessResult
import org.matrix.android.sdk.internal.auth.registration.ValidationCodeBody
import org.matrix.android.sdk.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.Body
@ -26,9 +28,9 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Url
internal interface ProfileAPI {
/**
* Get the combined profile information for this user.
* This API may be used to fetch the user's own profile information or other users; either locally or on remote homeservers.
@ -71,4 +73,35 @@ internal interface ProfileAPI {
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "account/3pid/unbind")
fun unbindThreePid(@Body body: UnbindThreePidBody): Call<UnbindThreePidResponse>
/**
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-account-3pid-email-requesttoken
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/3pid/email/requestToken")
fun addEmail(@Body body: AddEmailBody): Call<AddEmailResponse>
/**
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-account-3pid-msisdn-requesttoken
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/3pid/msisdn/requestToken")
fun addMsisdn(@Body body: AddMsisdnBody): Call<AddMsisdnResponse>
/**
* Validate Msisdn code (same model than for Identity server API)
*/
@POST
fun validateMsisdn(@Url url: String,
@Body params: ValidationCodeBody): Call<SuccessResult>
/**
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-account-3pid-add
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/3pid/add")
fun finalizeAddThreePid(@Body body: FinalizeAddThreePidBody): Call<Unit>
/**
* Ref: https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-account-3pid-delete
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "account/3pid/delete")
fun deleteThreePid(@Body body: DeleteThreePidBody): Call<DeleteThreePidResponse>
}

View File

@ -58,4 +58,16 @@ internal abstract class ProfileModule {
@Binds
abstract fun bindSetAvatarUrlTask(task: DefaultSetAvatarUrlTask): SetAvatarUrlTask
@Binds
abstract fun bindAddThreePidTask(task: DefaultAddThreePidTask): AddThreePidTask
@Binds
abstract fun bindValidateSmsCodeTask(task: DefaultValidateSmsCodeTask): ValidateSmsCodeTask
@Binds
abstract fun bindFinalizeAddingThreePidTask(task: DefaultFinalizeAddingThreePidTask): FinalizeAddingThreePidTask
@Binds
abstract fun bindDeleteThreePidTask(task: DefaultDeleteThreePidTask): DeleteThreePidTask
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2019 New Vector Ltd
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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.android.sdk.internal.session.profile
import com.zhuinden.monarchy.Monarchy
import org.greenrobot.eventbus.EventBus
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.internal.auth.registration.SuccessResult
import org.matrix.android.sdk.internal.auth.registration.ValidationCodeBody
import org.matrix.android.sdk.internal.database.model.PendingThreePidEntity
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal interface ValidateSmsCodeTask : Task<ValidateSmsCodeTask.Params, Unit> {
data class Params(
val threePid: ThreePid.Msisdn,
val code: String
)
}
internal class DefaultValidateSmsCodeTask @Inject constructor(
private val profileAPI: ProfileAPI,
@SessionDatabase
private val monarchy: Monarchy,
private val pendingThreePidMapper: PendingThreePidMapper,
private val eventBus: EventBus
) : ValidateSmsCodeTask {
override suspend fun execute(params: ValidateSmsCodeTask.Params) {
// Search the pending ThreePid
val pendingThreePids = monarchy.fetchAllMappedSync(
{ it.where(PendingThreePidEntity::class.java) },
{ pendingThreePidMapper.map(it) }
)
.firstOrNull { it.threePid == params.threePid }
?: throw IllegalArgumentException("unknown threepid")
val url = pendingThreePids.submitUrl ?: throw IllegalArgumentException("invalid threepid")
val body = ValidationCodeBody(
clientSecret = pendingThreePids.clientSecret,
sid = pendingThreePids.sid,
code = params.code
)
val result = executeRequest<SuccessResult>(eventBus) {
apiCall = profileAPI.validateMsisdn(url, body)
}
if (!result.isSuccess()) {
throw Failure.SuccessError
}
}
}

View File

@ -20,6 +20,8 @@ package org.matrix.android.sdk.internal.session.room
import dagger.Binds
import dagger.Module
import dagger.Provides
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.room.RoomDirectoryService
import org.matrix.android.sdk.api.session.room.RoomService
@ -75,9 +77,6 @@ import org.matrix.android.sdk.internal.session.room.typing.DefaultSendTypingTask
import org.matrix.android.sdk.internal.session.room.typing.SendTypingTask
import org.matrix.android.sdk.internal.session.room.uploads.DefaultGetUploadsTask
import org.matrix.android.sdk.internal.session.room.uploads.GetUploadsTask
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.commonmark.renderer.text.TextContentRenderer
import retrofit2.Retrofit
@Module
@ -105,14 +104,6 @@ internal abstract class RoomModule {
.builder()
.build()
}
@Provides
@JvmStatic
fun providesTextContentRenderer(): TextContentRenderer {
return TextContentRenderer
.builder()
.build()
}
}
@Binds

View File

@ -19,7 +19,6 @@ package org.matrix.android.sdk.internal.session.room.send
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.commonmark.renderer.text.TextContentRenderer
import javax.inject.Inject
/**
@ -29,11 +28,10 @@ import javax.inject.Inject
*/
internal class MarkdownParser @Inject constructor(
private val parser: Parser,
private val htmlRenderer: HtmlRenderer,
private val textContentRenderer: TextContentRenderer
private val htmlRenderer: HtmlRenderer
) {
private val mdSpecialChars = "[`_\\-\\*>\\.\\[\\]#~]".toRegex()
private val mdSpecialChars = "[`_\\-*>.\\[\\]#~]".toRegex()
fun parse(text: String): TextContent {
// If no special char are detected, just return plain text
@ -54,8 +52,8 @@ internal class MarkdownParser @Inject constructor(
return if (isFormattedTextPertinent(text, cleanHtmlText)) {
// According to https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes:
// The plain text version of the HTML should be provided in the body.
val plainText = textContentRenderer.render(document)
TextContent(plainText, cleanHtmlText.postTreatment())
// But it caused too many problems so it has been removed in #2002
TextContent(text, cleanHtmlText.postTreatment())
} else {
TextContent(text)
}
@ -72,6 +70,7 @@ internal class MarkdownParser @Inject constructor(
// Remove extra space before and after the content
.trim()
// There is no need to include new line in an html-like source
.replace("\n", "")
// But new line can be in embedded code block, so do not remove them
// .replace("\n", "")
}
}

View File

@ -97,7 +97,7 @@ internal class DefaultGetWellknownTask @Inject constructor(
// Success
val homeServerBaseUrl = wellKnown.homeServer?.baseURL
if (homeServerBaseUrl.isNullOrBlank()) {
WellknownResult.FailPrompt
WellknownResult.FailPrompt(null, null)
} else {
if (homeServerBaseUrl.isValidUrl()) {
// Check that HS is a real one
@ -120,11 +120,11 @@ internal class DefaultGetWellknownTask @Inject constructor(
is Failure.OtherServerError -> {
when (throwable.httpCode) {
HttpsURLConnection.HTTP_NOT_FOUND -> WellknownResult.Ignore
else -> WellknownResult.FailPrompt
else -> WellknownResult.FailPrompt(null, null)
}
}
is MalformedJsonException, is EOFException -> {
WellknownResult.FailPrompt
WellknownResult.FailPrompt(null, null)
}
else -> {
throw throwable
@ -162,7 +162,7 @@ internal class DefaultGetWellknownTask @Inject constructor(
// All is ok
WellknownResult.Prompt(homeServerBaseUrl, identityServerBaseUrl, wellKnown)
} else {
WellknownResult.FailError
WellknownResult.FailPrompt(homeServerBaseUrl, wellKnown)
}
} else {
WellknownResult.FailError

View File

@ -25,9 +25,9 @@ import requests
# This script downloads artifacts from buildkite.
# Ref: https://buildkite.com/docs/apis/rest-api/artifacts#download-an-artifact
# Those two variable are specific to the RiotX project
# Those two variables are specific to the Element Android project
ORG_SLUG = "matrix-dot-org"
PIPELINE_SLUG = "riotx-android"
PIPELINE_SLUG = "element-android"
### Arguments

View File

@ -2,7 +2,7 @@
<template
format="5"
revision="1"
name="RiotX Feature"
name="Element Feature"
minApi="19"
minBuildApi="19"
description="Creates a new activity and a fragment with view model, view state and actions">

View File

@ -16,10 +16,10 @@
# limitations under the License.
#
echo "Configure RiotX Template..."
echo "Configure Element Template..."
if [ -z ${ANDROID_STUDIO+x} ]; then ANDROID_STUDIO="/Applications/Android Studio.app/Contents"; fi
{
ln -s $(pwd)/RiotXFeature "${ANDROID_STUDIO%/}/plugins/android/lib/templates/other"
ln -s $(pwd)/ElementFeature "${ANDROID_STUDIO%/}/plugins/android/lib/templates/other"
} && {
echo "Please restart Android Studio."
}

View File

@ -103,6 +103,7 @@ import im.vector.app.features.settings.ignored.VectorSettingsIgnoredUsersFragmen
import im.vector.app.features.settings.locale.LocalePickerFragment
import im.vector.app.features.settings.push.PushGatewaysFragment
import im.vector.app.features.settings.push.PushRulesFragment
import im.vector.app.features.settings.threepids.ThreePidsSettingsFragment
import im.vector.app.features.share.IncomingShareFragment
import im.vector.app.features.signout.soft.SoftLogoutFragment
import im.vector.app.features.terms.ReviewTermsFragment
@ -313,6 +314,11 @@ interface FragmentModule {
@FragmentKey(VectorSettingsDevicesFragment::class)
fun bindVectorSettingsDevicesFragment(fragment: VectorSettingsDevicesFragment): Fragment
@Binds
@IntoMap
@FragmentKey(ThreePidsSettingsFragment::class)
fun bindThreePidsSettingsFragment(fragment: ThreePidsSettingsFragment): Fragment
@Binds
@IntoMap
@FragmentKey(PublicRoomsFragment::class)

View File

@ -59,35 +59,46 @@ class DefaultErrorFormatter @Inject constructor(
}
is Failure.ServerError -> {
when {
throwable.error.code == MatrixError.M_CONSENT_NOT_GIVEN -> {
throwable.error.code == MatrixError.M_CONSENT_NOT_GIVEN -> {
// Special case for terms and conditions
stringProvider.getString(R.string.error_terms_not_accepted)
}
throwable.isInvalidPassword() -> {
throwable.isInvalidPassword() -> {
stringProvider.getString(R.string.auth_invalid_login_param)
}
throwable.error.code == MatrixError.M_USER_IN_USE -> {
throwable.error.code == MatrixError.M_USER_IN_USE -> {
stringProvider.getString(R.string.login_signup_error_user_in_use)
}
throwable.error.code == MatrixError.M_BAD_JSON -> {
throwable.error.code == MatrixError.M_BAD_JSON -> {
stringProvider.getString(R.string.login_error_bad_json)
}
throwable.error.code == MatrixError.M_NOT_JSON -> {
throwable.error.code == MatrixError.M_NOT_JSON -> {
stringProvider.getString(R.string.login_error_not_json)
}
throwable.error.code == MatrixError.M_THREEPID_DENIED -> {
throwable.error.code == MatrixError.M_THREEPID_DENIED -> {
stringProvider.getString(R.string.login_error_threepid_denied)
}
throwable.error.code == MatrixError.M_LIMIT_EXCEEDED -> {
throwable.error.code == MatrixError.M_LIMIT_EXCEEDED -> {
limitExceededError(throwable.error)
}
throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> {
throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> {
stringProvider.getString(R.string.login_reset_password_error_not_found)
}
throwable.error.code == MatrixError.M_USER_DEACTIVATED -> {
throwable.error.code == MatrixError.M_USER_DEACTIVATED -> {
stringProvider.getString(R.string.auth_invalid_login_deactivated_account)
}
else -> {
throwable.error.code == MatrixError.M_THREEPID_IN_USE
&& throwable.error.message == "Email is already in use" -> {
stringProvider.getString(R.string.account_email_already_used_error)
}
throwable.error.code == MatrixError.M_THREEPID_IN_USE
&& throwable.error.message == "MSISDN is already in use" -> {
stringProvider.getString(R.string.account_phone_number_already_used_error)
}
throwable.error.code == MatrixError.M_THREEPID_AUTH_FAILED -> {
stringProvider.getString(R.string.error_threepid_auth_failed)
}
else -> {
throwable.error.message.takeIf { it.isNotEmpty() }
?: throwable.error.code.takeIf { it.isNotEmpty() }
}
@ -102,6 +113,7 @@ class DefaultErrorFormatter @Inject constructor(
throwable.localizedMessage
}
}
is SsoFlowNotSupportedYet -> stringProvider.getString(R.string.error_sso_flow_not_supported_yet)
else -> throwable.localizedMessage
}
?: stringProvider.getString(R.string.unknown_error)

View File

@ -0,0 +1,19 @@
/*
* Copyright (c) 2020 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.app.core.error
class SsoFlowNotSupportedYet : Throwable()

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2020 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.app.core.extensions
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.matrix.android.sdk.api.extensions.ensurePrefix
import org.matrix.android.sdk.api.extensions.tryThis
import org.matrix.android.sdk.api.session.identity.ThreePid
fun ThreePid.getFormattedValue(): String {
return when (this) {
is ThreePid.Email -> email
is ThreePid.Msisdn -> {
tryThis(message = "Unable to parse the phone number") {
PhoneNumberUtil.getInstance().parse(msisdn.ensurePrefix("+"), null)
}
?.let {
PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
}
?: msisdn
}
}
}

View File

@ -26,6 +26,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.vectorComponent
import im.vector.app.features.home.AvatarRenderer
import org.matrix.android.sdk.api.session.user.model.User
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem
class UserAvatarPreference : Preference {
@ -34,6 +35,8 @@ class UserAvatarPreference : Preference {
private var avatarRenderer: AvatarRenderer = context.vectorComponent().avatarRenderer()
private var userItem: MatrixItem.UserItem? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
@ -50,9 +53,16 @@ class UserAvatarPreference : Preference {
super.onBindViewHolder(holder)
mAvatarView = holder.itemView.findViewById(R.id.settings_avatar)
mLoadingProgressBar = holder.itemView.findViewById(R.id.avatar_update_progress_bar)
refreshUi()
}
fun refreshAvatar(user: User) {
mAvatarView?.let { avatarRenderer.render(user.toMatrixItem(), it) }
userItem = user.toMatrixItem()
refreshUi()
}
private fun refreshUi() {
val safeUserItem = userItem ?: return
mAvatarView?.let { avatarRenderer.render(safeUserItem, it) }
}
}

View File

@ -70,6 +70,9 @@ abstract class GenericItem : VectorEpoxyModel<GenericItem.Holder>() {
@EpoxyAttribute
var buttonAction: Action? = null
@EpoxyAttribute
var destructiveButtonAction: Action? = null
@EpoxyAttribute
var itemClickAction: Action? = null
@ -109,6 +112,11 @@ abstract class GenericItem : VectorEpoxyModel<GenericItem.Holder>() {
buttonAction?.perform?.run()
}
holder.destructiveButton.setTextOrHide(destructiveButtonAction?.title)
holder.destructiveButton.setOnClickListener {
destructiveButtonAction?.perform?.run()
}
holder.root.setOnClickListener {
itemClickAction?.perform?.run()
}
@ -122,5 +130,6 @@ abstract class GenericItem : VectorEpoxyModel<GenericItem.Holder>() {
val accessoryImage by bind<ImageView>(R.id.item_generic_accessory_image)
val progressBar by bind<ProgressBar>(R.id.item_generic_progress_bar)
val actionButton by bind<Button>(R.id.item_generic_action_button)
val destructiveButton by bind<Button>(R.id.item_generic_destructive_action_button)
}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright (c) 2020 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.app.core.utils
import java.util.concurrent.atomic.AtomicBoolean
/**
* Use this container to read a value only once
*/
class ReadOnce<T>(
private val value: T
) {
private val valueHasBeenRead = AtomicBoolean(false)
fun get(): T? {
return if (valueHasBeenRead.getAndSet(true)) {
null
} else {
value
}
}
}
/**
* Only the first call to isTrue() will return true
*/
class ReadOnceTrue {
private val readOnce = ReadOnce(true)
fun isTrue() = readOnce.get() == true
}

View File

@ -125,7 +125,7 @@ class CallAudioManager(
} else {
// if a wired headset is plugged, sound will be directed to it
// (can't really force earpiece when headset is plugged)
if (isBluetoothHeadsetOn()) {
if (isBluetoothHeadsetConnected(audioManager)) {
Timber.v("##VOIP: AudioManager default to WIRELESS_HEADSET ")
setCurrentSoundDevice(SoundDevice.WIRELESS_HEADSET)
// try now in case already connected?
@ -246,7 +246,7 @@ class CallAudioManager(
}
private fun isHeadsetOn(): Boolean {
return isWiredHeadsetOn() || isBluetoothHeadsetOn()
return isWiredHeadsetOn() || (audioManager?.let { isBluetoothHeadsetConnected(it) } ?: false)
}
private fun isWiredHeadsetOn(): Boolean {

View File

@ -23,19 +23,18 @@ import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.google.i18n.phonenumbers.PhoneNumberUtil
import im.vector.app.R
import im.vector.app.core.epoxy.attributes.ButtonStyle
import im.vector.app.core.epoxy.attributes.ButtonType
import im.vector.app.core.epoxy.attributes.IconMode
import im.vector.app.core.epoxy.loadingItem
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.getFormattedValue
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.identity.SharedState
import org.matrix.android.sdk.api.session.identity.ThreePid
import timber.log.Timber
import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection
@ -235,16 +234,7 @@ class DiscoverySettingsController @Inject constructor(
}
private fun buildMsisdn(pidInfo: PidInfo) {
val phoneNumber = try {
PhoneNumberUtil.getInstance().parse("+${pidInfo.threePid.value}", null)
} catch (t: Throwable) {
Timber.e(t, "Unable to parse the phone number")
null
}
?.let {
PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
}
?: pidInfo.threePid.value
val phoneNumber = pidInfo.threePid.getFormattedValue()
buildThreePid(pidInfo, phoneNumber)
@ -277,8 +267,8 @@ class DiscoverySettingsController @Inject constructor(
}
}
override fun onCodeChange(code: String) {
codes[pidInfo.threePid] = code
override fun onTextChange(text: String) {
codes[pidInfo.threePid] = text
}
})
}
@ -341,25 +331,22 @@ class DiscoverySettingsController @Inject constructor(
private fun buildContinueCancel(threePid: ThreePid) {
settingsContinueCancelItem {
id("bottom${threePid.value}")
interactionListener(object : SettingsContinueCancelItem.Listener {
override fun onContinue() {
when (threePid) {
is ThreePid.Email -> {
listener?.checkEmailVerification(threePid)
}
is ThreePid.Msisdn -> {
val code = codes[threePid]
if (code != null) {
listener?.sendMsisdnVerificationCode(threePid, code)
}
continueOnClick {
when (threePid) {
is ThreePid.Email -> {
listener?.checkEmailVerification(threePid)
}
is ThreePid.Msisdn -> {
val code = codes[threePid]
if (code != null) {
listener?.sendMsisdnVerificationCode(threePid, code)
}
}
}
override fun onCancel() {
listener?.cancelBinding(threePid)
}
})
}
cancelOnClick {
listener?.cancelBinding(threePid)
}
}
}

View File

@ -20,33 +20,28 @@ import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.onClick
@EpoxyModelClass(layout = R.layout.item_settings_continue_cancel)
abstract class SettingsContinueCancelItem : EpoxyModelWithHolder<SettingsContinueCancelItem.Holder>() {
@EpoxyAttribute
var interactionListener: Listener? = null
var continueOnClick: ClickListener? = null
@EpoxyAttribute
var cancelOnClick: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.cancelButton.setOnClickListener {
interactionListener?.onCancel()
}
holder.continueButton.setOnClickListener {
interactionListener?.onContinue()
}
holder.cancelButton.onClick(cancelOnClick)
holder.continueButton.onClick(continueOnClick)
}
class Holder : VectorEpoxyHolder() {
val cancelButton by bind<Button>(R.id.settings_item_cancel_button)
val continueButton by bind<Button>(R.id.settings_item_continue_button)
}
interface Listener {
fun onContinue()
fun onCancel()
}
}

View File

@ -27,19 +27,24 @@ import com.google.android.material.textfield.TextInputLayout
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.extensions.showKeyboard
@EpoxyModelClass(layout = R.layout.item_settings_edit_text)
abstract class SettingsEditTextItem : EpoxyModelWithHolder<SettingsEditTextItem.Holder>() {
@EpoxyAttribute var hint: String? = null
@EpoxyAttribute var value: String? = null
@EpoxyAttribute var requestFocus = false
@EpoxyAttribute var descriptionText: String? = null
@EpoxyAttribute var errorText: String? = null
@EpoxyAttribute var inProgress: Boolean = false
@EpoxyAttribute var inputType: Int? = null
@EpoxyAttribute
var interactionListener: Listener? = null
private val textChangeListener: (text: CharSequence?, start: Int, count: Int, after: Int) -> Unit = { code, _, _, _ ->
code?.let { interactionListener?.onCodeChange(it.toString()) }
private val textChangeListener: (text: CharSequence?, start: Int, count: Int, after: Int) -> Unit = { text, _, _, _ ->
text?.let { interactionListener?.onTextChange(it.toString()) }
}
private val editorActionListener = object : TextView.OnEditorActionListener {
@ -63,9 +68,17 @@ abstract class SettingsEditTextItem : EpoxyModelWithHolder<SettingsEditTextItem.
} else {
holder.textInputLayout.error = errorText
}
holder.textInputLayout.hint = hint
inputType?.let { holder.editText.inputType = it }
holder.editText.doOnTextChanged(textChangeListener)
holder.editText.setOnEditorActionListener(editorActionListener)
if (value != null) {
holder.editText.setText(value)
}
if (requestFocus) {
holder.editText.showKeyboard(andRequestFocus = true)
}
}
class Holder : VectorEpoxyHolder() {
@ -76,6 +89,6 @@ abstract class SettingsEditTextItem : EpoxyModelWithHolder<SettingsEditTextItem.
interface Listener {
fun onValidate()
fun onCodeChange(code: String)
fun onTextChange(text: String)
}
}

View File

@ -16,13 +16,13 @@
package im.vector.app.features.discovery
import android.view.View
import android.widget.Switch
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import com.google.android.material.switchmaterial.SwitchMaterial
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.extensions.setTextOrHide
@ -69,6 +69,6 @@ abstract class SettingsItem : EpoxyModelWithHolder<SettingsItem.Holder>() {
class Holder : VectorEpoxyHolder() {
val titleText by bind<TextView>(R.id.settings_item_title)
val descriptionText by bind<TextView>(R.id.settings_item_description)
val switchButton by bind<Switch>(R.id.settings_item_switch)
val switchButton by bind<SwitchMaterial>(R.id.settings_item_switch)
}
}

View File

@ -18,7 +18,6 @@ package im.vector.app.features.discovery
import android.widget.Button
import android.widget.CompoundButton
import android.widget.ProgressBar
import android.widget.Switch
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
@ -27,6 +26,7 @@ import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import com.google.android.material.switchmaterial.SwitchMaterial
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
@ -160,7 +160,7 @@ abstract class SettingsTextButtonSingleLineItem : EpoxyModelWithHolder<SettingsT
class Holder : VectorEpoxyHolder() {
val textView by bind<TextView>(R.id.settings_item_text)
val mainButton by bind<Button>(R.id.settings_item_button)
val switchButton by bind<Switch>(R.id.settings_item_switch)
val switchButton by bind<SwitchMaterial>(R.id.settings_item_switch)
val progress by bind<ProgressBar>(R.id.settings_item_progress)
}
}

View File

@ -17,6 +17,8 @@
package im.vector.app.features.form
import android.text.Editable
import android.view.View
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.google.android.material.textfield.TextInputEditText
@ -35,9 +37,18 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
@EpoxyAttribute
var value: String? = null
@EpoxyAttribute
var showBottomSeparator: Boolean = true
@EpoxyAttribute
var errorMessage: String? = null
@EpoxyAttribute
var enabled: Boolean = true
@EpoxyAttribute
var inputType: Int? = null
@EpoxyAttribute
var onTextChange: ((String) -> Unit)? = null
@ -51,14 +62,17 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
super.bind(holder)
holder.textInputLayout.isEnabled = enabled
holder.textInputLayout.hint = hint
holder.textInputLayout.error = errorMessage
// Update only if text is different
if (holder.textInputEditText.text.toString() != value) {
// Update only if text is different and value is not null
if (value != null && holder.textInputEditText.text.toString() != value) {
holder.textInputEditText.setText(value)
}
holder.textInputEditText.isEnabled = enabled
inputType?.let { holder.textInputEditText.inputType = it }
holder.textInputEditText.addTextChangedListener(onTextChangeListener)
holder.bottomSeparator.isVisible = showBottomSeparator
}
override fun shouldSaveViewState(): Boolean {
@ -73,5 +87,6 @@ abstract class FormEditTextItem : VectorEpoxyModel<FormEditTextItem.Holder>() {
class Holder : VectorEpoxyHolder() {
val textInputLayout by bind<TextInputLayout>(R.id.formTextInputTextInputLayout)
val textInputEditText by bind<TextInputEditText>(R.id.formTextInputTextInputEditText)
val bottomSeparator by bind<View>(R.id.formTextInputDivider)
}
}

View File

@ -522,6 +522,13 @@ class LoginViewModel @AssistedInject constructor(
when (data) {
is WellknownResult.Prompt ->
onWellknownSuccess(action, data, homeServerConnectionConfig)
is WellknownResult.FailPrompt ->
// Relax on IS discovery if home server is valid
if (data.homeServerUrl != null && data.wellKnown != null) {
onWellknownSuccess(action, WellknownResult.Prompt(data.homeServerUrl!!, null, data.wellKnown!!), homeServerConnectionConfig)
} else {
onWellKnownError()
}
is WellknownResult.InvalidMatrixId -> {
setState {
copy(
@ -531,12 +538,7 @@ class LoginViewModel @AssistedInject constructor(
_viewEvents.post(LoginViewEvents.Failure(Exception(stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id))))
}
else -> {
setState {
copy(
asyncLoginAction = Uninitialized
)
}
_viewEvents.post(LoginViewEvents.Failure(Exception(stringProvider.getString(R.string.autodiscover_well_known_error))))
onWellKnownError()
}
}.exhaustive
}
@ -547,6 +549,15 @@ class LoginViewModel @AssistedInject constructor(
})
}
private fun onWellKnownError() {
setState {
copy(
asyncLoginAction = Uninitialized
)
}
_viewEvents.post(LoginViewEvents.Failure(Exception(stringProvider.getString(R.string.autodiscover_well_known_error))))
}
private fun onWellknownSuccess(action: LoginAction.LoginOrRegister,
wellKnownPrompt: WellknownResult.Prompt,
homeServerConnectionConfig: HomeServerConnectionConfig?) {

View File

@ -23,13 +23,11 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Editable
import android.util.Patterns
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.preference.EditTextPreference
@ -54,13 +52,11 @@ import im.vector.app.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.app.core.utils.TextUtils
import im.vector.app.core.utils.allGranted
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.copyToClipboard
import im.vector.app.core.utils.getSizeOfFiles
import im.vector.app.core.utils.toast
import im.vector.app.features.MainActivity
import im.vector.app.features.MainActivityArgs
import im.vector.app.features.media.createUCropWithDefaultSettings
import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.workers.signout.SignOutUiWorker
import im.vector.lib.multipicker.MultiPicker
import im.vector.lib.multipicker.entity.MultiPickerImageType
@ -187,44 +183,6 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
mPasswordPreference.isVisible = false
}
// Add Email
findPreference<EditTextPreference>(ADD_EMAIL_PREFERENCE_KEY)!!.let {
// It does not work on XML, do it here
it.icon = activity?.let {
ThemeUtils.tintDrawable(it,
ContextCompat.getDrawable(it, R.drawable.ic_material_add)!!, R.attr.colorAccent)
}
// Unfortunately, this is not supported in lib v7
// it.editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
it.setOnPreferenceClickListener {
notImplemented()
true
}
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
notImplemented()
// addEmail((newValue as String).trim())
false
}
}
// Add phone number
findPreference<VectorPreference>(ADD_PHONE_NUMBER_PREFERENCE_KEY)!!.let {
// It does not work on XML, do it here
it.icon = activity?.let {
ThemeUtils.tintDrawable(it,
ContextCompat.getDrawable(it, R.drawable.ic_material_add)!!, R.attr.colorAccent)
}
it.setOnPreferenceClickListener {
notImplemented()
// TODO val intent = PhoneNumberAdditionActivity.getIntent(activity, session.credentials.userId)
// startActivityForResult(intent, REQUEST_NEW_PHONE_NUMBER)
true
}
}
// Advanced settings
// user account
@ -235,8 +193,6 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
findPreference<VectorPreference>(VectorPreferences.SETTINGS_HOME_SERVER_PREFERENCE_KEY)!!
.summary = session.sessionParams.homeServerUrl
refreshEmailsList()
refreshPhoneNumbersList()
// Contacts
setContactsPreferences()
@ -533,295 +489,6 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
* Refresh phone number list
*/
private fun refreshPhoneNumbersList() {
/* TODO
val currentPhoneNumber3PID = ArrayList(session.myUser.getlinkedPhoneNumbers())
val phoneNumberList = ArrayList<String>()
for (identifier in currentPhoneNumber3PID) {
phoneNumberList.add(identifier.address)
}
// check first if there is an update
var isNewList = true
if (phoneNumberList.size == mDisplayedPhoneNumber.size) {
isNewList = !mDisplayedPhoneNumber.containsAll(phoneNumberList)
}
if (isNewList) {
// remove the displayed one
run {
var index = 0
while (true) {
val preference = mUserSettingsCategory.findPreference(PHONE_NUMBER_PREFERENCE_KEY_BASE + index)
if (null != preference) {
mUserSettingsCategory.removePreference(preference)
} else {
break
}
index++
}
}
// add new phone number list
mDisplayedPhoneNumber = phoneNumberList
val addPhoneBtn = mUserSettingsCategory.findPreference(ADD_PHONE_NUMBER_PREFERENCE_KEY)
?: return
var order = addPhoneBtn.order
for ((index, phoneNumber3PID) in currentPhoneNumber3PID.withIndex()) {
val preference = VectorPreference(activity!!)
preference.title = getString(R.string.settings_phone_number)
var phoneNumberFormatted = phoneNumber3PID.address
try {
// Attempt to format phone number
val phoneNumber = PhoneNumberUtil.getInstance().parse("+$phoneNumberFormatted", null)
phoneNumberFormatted = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
} catch (e: NumberParseException) {
// Do nothing, we will display raw version
}
preference.summary = phoneNumberFormatted
preference.key = PHONE_NUMBER_PREFERENCE_KEY_BASE + index
preference.order = order
preference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
displayDelete3PIDConfirmationDialog(phoneNumber3PID, preference.summary)
true
}
preference.onPreferenceLongClickListener = object : VectorPreference.OnPreferenceLongClickListener {
override fun onPreferenceLongClick(preference: Preference): Boolean {
activity?.let { copyToClipboard(it, phoneNumber3PID.address) }
return true
}
}
order++
mUserSettingsCategory.addPreference(preference)
}
addPhoneBtn.order = order
} */
}
// ==============================================================================================================
// Email management
// ==============================================================================================================
/**
* Refresh the emails list
*/
private fun refreshEmailsList() {
val currentEmail3PID = emptyList<String>() // TODO ArrayList(session.myUser.getlinkedEmails())
val newEmailsList = ArrayList<String>()
for (identifier in currentEmail3PID) {
// TODO newEmailsList.add(identifier.address)
}
// check first if there is an update
var isNewList = true
if (newEmailsList.size == mDisplayedEmails.size) {
isNewList = !mDisplayedEmails.containsAll(newEmailsList)
}
if (isNewList) {
// remove the displayed one
run {
var index = 0
while (true) {
val preference = mUserSettingsCategory.findPreference<VectorPreference>(EMAIL_PREFERENCE_KEY_BASE + index)
if (null != preference) {
mUserSettingsCategory.removePreference(preference)
} else {
break
}
index++
}
}
// add new emails list
mDisplayedEmails = newEmailsList
val addEmailBtn = mUserSettingsCategory.findPreference<VectorPreference>(ADD_EMAIL_PREFERENCE_KEY) ?: return
var order = addEmailBtn.order
for ((index, email3PID) in currentEmail3PID.withIndex()) {
val preference = VectorPreference(requireActivity())
preference.title = getString(R.string.settings_email_address)
preference.summary = "TODO" // email3PID.address
preference.key = EMAIL_PREFERENCE_KEY_BASE + index
preference.order = order
preference.onPreferenceClickListener = Preference.OnPreferenceClickListener { pref ->
displayDelete3PIDConfirmationDialog(/* TODO email3PID, */ pref.summary)
true
}
preference.onPreferenceLongClickListener = object : VectorPreference.OnPreferenceLongClickListener {
override fun onPreferenceLongClick(preference: Preference): Boolean {
activity?.let { copyToClipboard(it, "TODO") } // email3PID.address) }
return true
}
}
mUserSettingsCategory.addPreference(preference)
order++
}
addEmailBtn.order = order
}
}
/**
* Attempt to add a new email to the account
*
* @param email the email to add.
*/
private fun addEmail(email: String) {
// check first if the email syntax is valid
// if email is null , then also its invalid email
if (email.isBlank() || !Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
activity?.toast(R.string.auth_invalid_email)
return
}
// check first if the email syntax is valid
if (mDisplayedEmails.indexOf(email) >= 0) {
activity?.toast(R.string.auth_email_already_defined)
return
}
notImplemented()
/* TODO
val pid = ThreePid(email, ThreePid.MEDIUM_EMAIL)
displayLoadingView()
session.myUser.requestEmailValidationToken(pid, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
activity?.runOnUiThread { showEmailValidationDialog(pid) }
}
override fun onNetworkError(e: Exception) {
onCommonDone(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
if (TextUtils.equals(MatrixError.THREEPID_IN_USE, e.errcode)) {
onCommonDone(getString(R.string.account_email_already_used_error))
} else {
onCommonDone(e.localizedMessage)
}
}
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
*/
}
/**
* Show an email validation dialog to warn the user tho valid his email link.
*
* @param pid the used pid.
*/
/* TODO
private fun showEmailValidationDialog(pid: ThreePid) {
activity?.let {
AlertDialog.Builder(it)
.setTitle(R.string.account_email_validation_title)
.setMessage(R.string.account_email_validation_message)
.setPositiveButton(R.string._continue) { _, _ ->
session.myUser.add3Pid(pid, true, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
it.runOnUiThread {
hideLoadingView()
refreshEmailsList()
}
}
override fun onNetworkError(e: Exception) {
onCommonDone(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
if (TextUtils.equals(e.errcode, MatrixError.THREEPID_AUTH_FAILED)) {
it.runOnUiThread {
hideLoadingView()
it.toast(R.string.account_email_validation_error)
}
} else {
onCommonDone(e.localizedMessage)
}
}
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
}
.setNegativeButton(R.string.cancel) { _, _ ->
hideLoadingView()
}
.show()
}
} */
/**
* Display a dialog which asks confirmation for the deletion of a 3pid
*
* @param pid the 3pid to delete
* @param preferenceSummary the displayed 3pid
*/
private fun displayDelete3PIDConfirmationDialog(/* TODO pid: ThirdPartyIdentifier,*/ preferenceSummary: CharSequence) {
val mediumFriendlyName = "TODO" // ThreePid.getMediumFriendlyName(pid.medium, activity).toLowerCase(VectorLocale.applicationLocale)
val dialogMessage = getString(R.string.settings_delete_threepid_confirmation, mediumFriendlyName, preferenceSummary)
activity?.let {
AlertDialog.Builder(it)
.setTitle(R.string.dialog_title_confirmation)
.setMessage(dialogMessage)
.setPositiveButton(R.string.remove) { _, _ ->
notImplemented()
/* TODO
displayLoadingView()
session.myUser.delete3Pid(pid, object : MatrixCallback<Unit> {
override fun onSuccess(info: Void?) {
when (pid.medium) {
ThreePid.MEDIUM_EMAIL -> refreshEmailsList()
ThreePid.MEDIUM_MSISDN -> refreshPhoneNumbersList()
}
onCommonDone(null)
}
override fun onNetworkError(e: Exception) {
onCommonDone(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
onCommonDone(e.localizedMessage)
}
override fun onUnexpectedError(e: Exception) {
onCommonDone(e.localizedMessage)
}
})
*/
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
/**
@ -985,12 +652,6 @@ private fun showEmailValidationDialog(pid: ThreePid) {
}
companion object {
private const val ADD_EMAIL_PREFERENCE_KEY = "ADD_EMAIL_PREFERENCE_KEY"
private const val ADD_PHONE_NUMBER_PREFERENCE_KEY = "ADD_PHONE_NUMBER_PREFERENCE_KEY"
private const val EMAIL_PREFERENCE_KEY_BASE = "EMAIL_PREFERENCE_KEY_BASE"
private const val PHONE_NUMBER_PREFERENCE_KEY_BASE = "PHONE_NUMBER_PREFERENCE_KEY_BASE"
private const val REQUEST_NEW_PHONE_NUMBER = 456
private const val REQUEST_PHONEBOOK_COUNTRY = 789
}

View File

@ -39,7 +39,7 @@ data class DeviceVerificationInfoArgs(
val deviceId: String
) : Parcelable
class DeviceVerificationInfoBottomSheet : VectorBaseBottomSheetDialogFragment(), DeviceVerificationInfoEpoxyController.Callback {
class DeviceVerificationInfoBottomSheet : VectorBaseBottomSheetDialogFragment(), DeviceVerificationInfoBottomSheetController.Callback {
private val viewModel: DeviceVerificationInfoBottomSheetViewModel by fragmentViewModel(DeviceVerificationInfoBottomSheetViewModel::class)
@ -54,17 +54,17 @@ class DeviceVerificationInfoBottomSheet : VectorBaseBottomSheetDialogFragment(),
injector.inject(this)
}
@Inject lateinit var epoxyController: DeviceVerificationInfoEpoxyController
@Inject lateinit var controller: DeviceVerificationInfoBottomSheetController
override fun getLayoutResId() = R.layout.bottom_sheet_generic_list_with_title
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
recyclerView.configureWith(
epoxyController,
controller,
showDivider = false,
hasFixedSize = false)
epoxyController.callback = this
controller.callback = this
bottomSheetTitle.isVisible = false
}
@ -74,7 +74,7 @@ class DeviceVerificationInfoBottomSheet : VectorBaseBottomSheetDialogFragment(),
}
override fun invalidate() = withState(viewModel) {
epoxyController.setData(it)
controller.setData(it)
super.invalidate()
}

View File

@ -16,9 +16,6 @@
package im.vector.app.features.settings.devices
import com.airbnb.epoxy.TypedEpoxyController
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import im.vector.app.R
import im.vector.app.core.epoxy.dividerItem
import im.vector.app.core.epoxy.loadingItem
@ -28,12 +25,14 @@ import im.vector.app.core.ui.list.GenericItem
import im.vector.app.core.ui.list.genericFooterItem
import im.vector.app.core.ui.list.genericItem
import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationActionItem
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import timber.log.Timber
import javax.inject.Inject
class DeviceVerificationInfoEpoxyController @Inject constructor(private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
private val session: Session)
class DeviceVerificationInfoBottomSheetController @Inject constructor(
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider)
: TypedEpoxyController<DeviceVerificationInfoBottomSheetViewState>() {
var callback: Callback? = null
@ -67,16 +66,18 @@ class DeviceVerificationInfoEpoxyController @Inject constructor(private val stri
if (data.hasAccountCrossSigning) {
// Cross Signing is enabled
handleE2EWithCrossSigning(data.isMine, data.accountCrossSigningIsTrusted, cryptoDeviceInfo, shield)
handleE2EWithCrossSigning(data, cryptoDeviceInfo, shield)
} else {
handleE2EInLegacy(data.isMine, cryptoDeviceInfo, shield)
handleE2EInLegacy(data, cryptoDeviceInfo, shield)
}
// COMMON ACTIONS (Rename / signout)
addGenericDeviceManageActions(data, cryptoDeviceInfo.deviceId)
}
private fun handleE2EWithCrossSigning(isMine: Boolean, currentSessionIsTrusted: Boolean, cryptoDeviceInfo: CryptoDeviceInfo, shield: Int) {
private fun handleE2EWithCrossSigning(data: DeviceVerificationInfoBottomSheetViewState, cryptoDeviceInfo: CryptoDeviceInfo, shield: Int) {
val isMine = data.isMine
val currentSessionIsTrusted = data.accountCrossSigningIsTrusted
Timber.v("handleE2EWithCrossSigning $isMine, $cryptoDeviceInfo, $shield")
if (isMine) {
@ -88,14 +89,18 @@ class DeviceVerificationInfoEpoxyController @Inject constructor(private val stri
title(stringProvider.getString(R.string.encryption_information_verified))
description(stringProvider.getString(R.string.settings_active_sessions_verified_device_desc))
}
} else {
// You need to complete security
} else if (data.canVerifySession) {
// You need to complete security, only if there are other session(s) available, or if 4S contains secrets
genericItem {
id("trust${cryptoDeviceInfo.deviceId}")
style(GenericItem.STYLE.BIG_TEXT)
titleIconResourceId(shield)
title(stringProvider.getString(R.string.crosssigning_verify_this_session))
description(stringProvider.getString(R.string.confirm_your_identity))
if (data.hasOtherSessions) {
description(stringProvider.getString(R.string.confirm_your_identity))
} else {
description(stringProvider.getString(R.string.confirm_your_identity_quad_s))
}
}
}
} else {
@ -132,7 +137,7 @@ class DeviceVerificationInfoEpoxyController @Inject constructor(private val stri
description("(${cryptoDeviceInfo.deviceId})")
}
if (isMine && !currentSessionIsTrusted) {
if (isMine && !currentSessionIsTrusted && data.canVerifySession) {
// Add complete security
dividerItem {
id("completeSecurityDiv")
@ -158,8 +163,9 @@ class DeviceVerificationInfoEpoxyController @Inject constructor(private val stri
}
}
private fun handleE2EInLegacy(isMine: Boolean, cryptoDeviceInfo: CryptoDeviceInfo, shield: Int) {
private fun handleE2EInLegacy(data: DeviceVerificationInfoBottomSheetViewState, cryptoDeviceInfo: CryptoDeviceInfo, shield: Int) {
// ==== Legacy
val isMine = data.isMine
// TRUST INFO SECTION
if (cryptoDeviceInfo.trustLevel?.isLocallyVerified() == true) {

View File

@ -15,31 +15,19 @@
*/
package im.vector.app.features.settings.devices
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
import org.matrix.android.sdk.rx.rx
data class DeviceVerificationInfoBottomSheetViewState(
val cryptoDeviceInfo: Async<CryptoDeviceInfo?> = Uninitialized,
val deviceInfo: Async<DeviceInfo> = Uninitialized,
val hasAccountCrossSigning: Boolean = false,
val accountCrossSigningIsTrusted: Boolean = false,
val isMine: Boolean = false
) : MvRxState
class DeviceVerificationInfoBottomSheetViewModel @AssistedInject constructor(@Assisted initialState: DeviceVerificationInfoBottomSheetViewState,
@Assisted val deviceId: String,
val session: Session
@ -55,7 +43,8 @@ class DeviceVerificationInfoBottomSheetViewModel @AssistedInject constructor(@As
setState {
copy(
hasAccountCrossSigning = session.cryptoService().crossSigningService().isCrossSigningInitialized(),
accountCrossSigningIsTrusted = session.cryptoService().crossSigningService().isCrossSigningVerified()
accountCrossSigningIsTrusted = session.cryptoService().crossSigningService().isCrossSigningVerified(),
isRecoverySetup = session.sharedSecretStorageService.isRecoverySetup()
)
}
session.rx().liveCrossSigningInfo(session.myUserId)
@ -77,6 +66,14 @@ class DeviceVerificationInfoBottomSheetViewModel @AssistedInject constructor(@As
)
}
session.rx().liveUserCryptoDevices(session.myUserId)
.map { it.size }
.execute {
copy(
hasOtherSessions = it.invoke() ?: 0 > 1
)
}
setState {
copy(deviceInfo = Loading())
}

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2020 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.app.features.settings.devices
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
data class DeviceVerificationInfoBottomSheetViewState(
val cryptoDeviceInfo: Async<CryptoDeviceInfo?> = Uninitialized,
val deviceInfo: Async<DeviceInfo> = Uninitialized,
val hasAccountCrossSigning: Boolean = false,
val accountCrossSigningIsTrusted: Boolean = false,
val isMine: Boolean = false,
val hasOtherSessions: Boolean = false,
val isRecoverySetup: Boolean = false
) : MvRxState {
val canVerifySession: Boolean
get() = hasOtherSessions || isRecoverySetup
}

View File

@ -28,6 +28,12 @@ import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.app.core.error.SsoFlowNotSupportedYet
import im.vector.app.core.platform.VectorViewModel
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.subjects.PublishSubject
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.NoOpMatrixCallback
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
@ -41,11 +47,6 @@ import org.matrix.android.sdk.internal.crypto.crosssigning.DeviceTrustLevel
import org.matrix.android.sdk.internal.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo
import org.matrix.android.sdk.internal.util.awaitCallback
import im.vector.app.core.platform.VectorViewModel
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.subjects.PublishSubject
import kotlinx.coroutines.launch
import org.matrix.android.sdk.rx.rx
import timber.log.Timber
import java.util.concurrent.TimeUnit
@ -309,14 +310,14 @@ class DevicesViewModel @AssistedInject constructor(
}
if (!isPasswordRequestFound) {
// LoginFlowTypes.PASSWORD not supported, and this is the only one RiotX supports so far...
// LoginFlowTypes.PASSWORD not supported, and this is the only one Element supports so far...
setState {
copy(
request = Fail(failure)
)
}
_viewEvents.post(DevicesViewEvents.Failure(failure))
_viewEvents.post(DevicesViewEvents.Failure(SsoFlowNotSupportedYet()))
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.onClick
@EpoxyModelClass(layout = R.layout.item_settings_three_pid)
abstract class ThreePidItem : EpoxyModelWithHolder<ThreePidItem.Holder>() {
@EpoxyAttribute
var title: String? = null
@EpoxyAttribute
@DrawableRes
var iconResId: Int? = null
@EpoxyAttribute
var deleteClickListener: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
val safeIconResId = iconResId
if (safeIconResId != null) {
holder.icon.isVisible = true
holder.icon.setImageResource(safeIconResId)
} else {
holder.icon.isVisible = false
}
holder.title.text = title
holder.delete.onClick { deleteClickListener?.invoke() }
holder.delete.isVisible = deleteClickListener != null
}
class Holder : VectorEpoxyHolder() {
val icon by bind<ImageView>(R.id.item_settings_three_pid_icon)
val title by bind<TextView>(R.id.item_settings_three_pid_title)
val delete by bind<View>(R.id.item_settings_three_pid_delete)
}
}

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import im.vector.app.core.platform.VectorViewModelAction
import org.matrix.android.sdk.api.session.identity.ThreePid
sealed class ThreePidsSettingsAction : VectorViewModelAction {
data class ChangeUiState(val newUiState: ThreePidsSettingsUiState) : ThreePidsSettingsAction()
data class AddThreePid(val threePid: ThreePid) : ThreePidsSettingsAction()
data class SubmitCode(val threePid: ThreePid.Msisdn, val code: String) : ThreePidsSettingsAction()
data class ContinueThreePid(val threePid: ThreePid) : ThreePidsSettingsAction()
data class CancelThreePid(val threePid: ThreePid) : ThreePidsSettingsAction()
data class AccountPassword(val password: String) : ThreePidsSettingsAction()
data class DeleteThreePid(val threePid: ThreePid) : ThreePidsSettingsAction()
}

View File

@ -0,0 +1,303 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import android.text.InputType
import android.view.View
import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import im.vector.app.R
import im.vector.app.core.epoxy.loadingItem
import im.vector.app.core.epoxy.noResultItem
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.getFormattedValue
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.ui.list.genericButtonItem
import im.vector.app.core.ui.list.genericFooterItem
import im.vector.app.features.discovery.SettingsEditTextItem
import im.vector.app.features.discovery.settingsContinueCancelItem
import im.vector.app.features.discovery.settingsEditTextItem
import im.vector.app.features.discovery.settingsInfoItem
import im.vector.app.features.discovery.settingsInformationItem
import im.vector.app.features.discovery.settingsSectionTitleItem
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.session.identity.ThreePid
import javax.inject.Inject
class ThreePidsSettingsController @Inject constructor(
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider,
private val errorFormatter: ErrorFormatter
) : TypedEpoxyController<ThreePidsSettingsViewState>() {
interface InteractionListener {
fun addEmail()
fun addMsisdn()
fun cancelAdding()
fun doAddEmail(email: String)
fun doAddMsisdn(msisdn: String)
fun submitCode(threePid: ThreePid.Msisdn, code: String)
fun continueThreePid(threePid: ThreePid)
fun cancelThreePid(threePid: ThreePid)
fun deleteThreePid(threePid: ThreePid)
}
var interactionListener: InteractionListener? = null
// For phone number or email (exclusive)
private var currentInputValue = ""
// For validation code
private val currentCodes = mutableMapOf<ThreePid, String>()
override fun buildModels(data: ThreePidsSettingsViewState?) {
if (data == null) return
if (data.uiState is ThreePidsSettingsUiState.Idle) {
currentInputValue = ""
}
when (data.threePids) {
is Loading -> {
loadingItem {
id("loading")
loadingText(stringProvider.getString(R.string.loading))
}
}
is Fail -> {
genericFooterItem {
id("fail")
text(data.threePids.error.localizedMessage)
}
}
is Success -> {
val dataList = data.threePids.invoke()
buildThreePids(dataList, data)
}
}
}
private fun buildThreePids(list: List<ThreePid>, data: ThreePidsSettingsViewState) {
val splited = list.groupBy { it is ThreePid.Email }
val emails = splited[true].orEmpty()
val msisdn = splited[false].orEmpty()
settingsSectionTitleItem {
id("email")
title(stringProvider.getString(R.string.settings_emails))
}
emails.forEach { buildThreePid("email ", it) }
// Pending emails
data.pendingThreePids.invoke()
?.filterIsInstance(ThreePid.Email::class.java)
.orEmpty()
.let { pendingList ->
if (pendingList.isEmpty() && emails.isEmpty()) {
noResultItem {
id("noEmail")
text(stringProvider.getString(R.string.settings_emails_empty))
}
}
pendingList.forEach { buildPendingThreePid(data, "p_email ", it) }
}
when (data.uiState) {
ThreePidsSettingsUiState.Idle ->
genericButtonItem {
id("addEmail")
text(stringProvider.getString(R.string.settings_add_email_address))
textColor(colorProvider.getColor(R.color.riotx_accent))
buttonClickAction(View.OnClickListener { interactionListener?.addEmail() })
}
is ThreePidsSettingsUiState.AddingEmail -> {
settingsEditTextItem {
id("addingEmail")
inputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS)
hint(stringProvider.getString(R.string.medium_email))
if (data.editTextReinitiator.isTrue()) {
value("")
requestFocus(true)
}
errorText(data.uiState.error)
interactionListener(object : SettingsEditTextItem.Listener {
override fun onValidate() {
interactionListener?.doAddEmail(currentInputValue)
}
override fun onTextChange(text: String) {
currentInputValue = text
}
})
}
settingsContinueCancelItem {
id("contAddingEmail")
continueOnClick { interactionListener?.doAddEmail(currentInputValue) }
cancelOnClick { interactionListener?.cancelAdding() }
}
}
is ThreePidsSettingsUiState.AddingPhoneNumber -> Unit
}.exhaustive
settingsSectionTitleItem {
id("msisdn")
title(stringProvider.getString(R.string.settings_phone_numbers))
}
msisdn.forEach { buildThreePid("msisdn ", it) }
// Pending msisdn
data.pendingThreePids.invoke()
?.filterIsInstance(ThreePid.Msisdn::class.java)
.orEmpty()
.let { pendingList ->
if (pendingList.isEmpty() && msisdn.isEmpty()) {
noResultItem {
id("noMsisdn")
text(stringProvider.getString(R.string.settings_phone_number_empty))
}
}
pendingList.forEach { buildPendingThreePid(data, "p_msisdn ", it) }
}
when (data.uiState) {
ThreePidsSettingsUiState.Idle ->
genericButtonItem {
id("addMsisdn")
text(stringProvider.getString(R.string.settings_add_phone_number))
textColor(colorProvider.getColor(R.color.riotx_accent))
buttonClickAction(View.OnClickListener { interactionListener?.addMsisdn() })
}
is ThreePidsSettingsUiState.AddingEmail -> Unit
is ThreePidsSettingsUiState.AddingPhoneNumber -> {
settingsInfoItem {
id("addingMsisdnInfo")
helperText(stringProvider.getString(R.string.login_msisdn_notice))
}
settingsEditTextItem {
id("addingMsisdn")
inputType(InputType.TYPE_CLASS_PHONE)
hint(stringProvider.getString(R.string.medium_phone_number))
if (data.editTextReinitiator.isTrue()) {
value("")
requestFocus(true)
}
errorText(data.uiState.error)
interactionListener(object : SettingsEditTextItem.Listener {
override fun onValidate() {
interactionListener?.doAddMsisdn(currentInputValue)
}
override fun onTextChange(text: String) {
currentInputValue = text
}
})
}
settingsContinueCancelItem {
id("contAddingMsisdn")
continueOnClick { interactionListener?.doAddMsisdn(currentInputValue) }
cancelOnClick { interactionListener?.cancelAdding() }
}
}
}.exhaustive
}
private fun buildThreePid(idPrefix: String, threePid: ThreePid) {
threePidItem {
id(idPrefix + threePid.value)
// TODO Add an icon for emails
// iconResId(if (threePid is ThreePid.Msisdn) R.drawable.ic_phone else null)
title(threePid.getFormattedValue())
deleteClickListener { interactionListener?.deleteThreePid(threePid) }
}
}
private fun buildPendingThreePid(data: ThreePidsSettingsViewState, idPrefix: String, threePid: ThreePid) {
threePidItem {
id(idPrefix + threePid.value)
// TODO Add an icon for emails
// iconResId(if (threePid is ThreePid.Msisdn) R.drawable.ic_phone else null)
title(threePid.getFormattedValue())
}
when (threePid) {
is ThreePid.Email -> {
settingsInformationItem {
id("info" + idPrefix + threePid.value)
message(stringProvider.getString(R.string.account_email_validation_message))
colorProvider(colorProvider)
}
settingsContinueCancelItem {
id("cont" + idPrefix + threePid.value)
continueOnClick { interactionListener?.continueThreePid(threePid) }
cancelOnClick { interactionListener?.cancelThreePid(threePid) }
}
}
is ThreePid.Msisdn -> {
settingsInformationItem {
id("info" + idPrefix + threePid.value)
message(stringProvider.getString(R.string.settings_text_message_sent, threePid.getFormattedValue()))
colorProvider(colorProvider)
}
settingsEditTextItem {
id("msisdnVerification${threePid.value}")
inputType(InputType.TYPE_CLASS_NUMBER)
hint(stringProvider.getString(R.string.settings_text_message_sent_hint))
if (data.msisdnValidationReinitiator[threePid]?.isTrue() == true) {
value("")
}
errorText(getCodeError(data, threePid))
interactionListener(object : SettingsEditTextItem.Listener {
override fun onValidate() {
interactionListener?.submitCode(threePid, currentCodes[threePid] ?: "")
}
override fun onTextChange(text: String) {
currentCodes[threePid] = text
}
})
}
settingsContinueCancelItem {
id("cont" + idPrefix + threePid.value)
continueOnClick { interactionListener?.submitCode(threePid, currentCodes[threePid] ?: "") }
cancelOnClick { interactionListener?.cancelThreePid(threePid) }
}
}
}
}
private fun getCodeError(data: ThreePidsSettingsViewState, threePid: ThreePid.Msisdn): String? {
val failure = (data.msisdnValidationRequests[threePid.value] as? Fail)?.error ?: return null
// Wrong code?
// See https://github.com/matrix-org/synapse/issues/8218
return if (failure is Failure.ServerError
&& failure.httpCode == 400
&& failure.error.code == MatrixError.M_UNKNOWN) {
stringProvider.getString(R.string.settings_text_message_sent_wrong_code)
} else {
errorFormatter.toHumanReadable(failure)
}
}
}

View File

@ -0,0 +1,181 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.dialogs.PromptPasswordDialog
import im.vector.app.core.dialogs.withColoredButton
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.getFormattedValue
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.isMsisdn
import im.vector.app.core.platform.OnBackPressed
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_generic_recycler.*
import org.matrix.android.sdk.api.session.identity.ThreePid
import javax.inject.Inject
class ThreePidsSettingsFragment @Inject constructor(
private val viewModelFactory: ThreePidsSettingsViewModel.Factory,
private val epoxyController: ThreePidsSettingsController
) :
VectorBaseFragment(),
OnBackPressed,
ThreePidsSettingsViewModel.Factory by viewModelFactory,
ThreePidsSettingsController.InteractionListener {
private val viewModel: ThreePidsSettingsViewModel by fragmentViewModel()
override fun getLayoutResId() = R.layout.fragment_generic_recycler
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.configureWith(epoxyController)
epoxyController.interactionListener = this
viewModel.observeViewEvents {
when (it) {
is ThreePidsSettingsViewEvents.Failure -> displayErrorDialog(it.throwable)
ThreePidsSettingsViewEvents.RequestPassword -> askUserPassword()
}.exhaustive
}
}
private fun askUserPassword() {
PromptPasswordDialog().show(requireActivity()) { password ->
viewModel.handle(ThreePidsSettingsAction.AccountPassword(password))
}
}
override fun onDestroyView() {
super.onDestroyView()
recyclerView.cleanup()
epoxyController.interactionListener = null
}
override fun onResume() {
super.onResume()
(activity as? VectorBaseActivity)?.supportActionBar?.setTitle(R.string.settings_emails_and_phone_numbers_title)
}
override fun invalidate() = withState(viewModel) { state ->
if (state.isLoading) {
showLoadingDialog()
} else {
dismissLoadingDialog()
}
epoxyController.setData(state)
}
override fun addEmail() {
viewModel.handle(ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingEmail(null)))
}
override fun doAddEmail(email: String) {
// Sanity
val safeEmail = email.trim().replace(" ", "")
viewModel.handle(ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingEmail(null)))
// Check that email is valid
if (!safeEmail.isEmail()) {
viewModel.handle(ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingEmail(getString(R.string.auth_invalid_email))))
return
}
viewModel.handle(ThreePidsSettingsAction.AddThreePid(ThreePid.Email(safeEmail)))
}
override fun addMsisdn() {
viewModel.handle(ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingPhoneNumber(null)))
}
override fun doAddMsisdn(msisdn: String) {
// Sanity
val safeMsisdn = msisdn.trim().replace(" ", "")
viewModel.handle(ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingPhoneNumber(null)))
// Check that phone number is valid
if (!msisdn.startsWith("+")) {
viewModel.handle(
ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingPhoneNumber(getString(R.string.login_msisdn_error_not_international)))
)
return
}
if (!msisdn.isMsisdn()) {
viewModel.handle(
ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.AddingPhoneNumber(getString(R.string.login_msisdn_error_other)))
)
return
}
viewModel.handle(ThreePidsSettingsAction.AddThreePid(ThreePid.Msisdn(safeMsisdn)))
}
override fun submitCode(threePid: ThreePid.Msisdn, code: String) {
viewModel.handle(ThreePidsSettingsAction.SubmitCode(threePid, code))
// Hide the keyboard
view?.hideKeyboard()
}
override fun cancelAdding() {
viewModel.handle(ThreePidsSettingsAction.ChangeUiState(ThreePidsSettingsUiState.Idle))
// Hide the keyboard
view?.hideKeyboard()
}
override fun continueThreePid(threePid: ThreePid) {
viewModel.handle(ThreePidsSettingsAction.ContinueThreePid(threePid))
}
override fun cancelThreePid(threePid: ThreePid) {
viewModel.handle(ThreePidsSettingsAction.CancelThreePid(threePid))
}
override fun deleteThreePid(threePid: ThreePid) {
AlertDialog.Builder(requireActivity())
.setMessage(getString(R.string.settings_remove_three_pid_confirmation_content, threePid.getFormattedValue()))
.setPositiveButton(R.string.remove) { _, _ ->
viewModel.handle(ThreePidsSettingsAction.DeleteThreePid(threePid))
}
.setNegativeButton(R.string.cancel, null)
.show()
.withColoredButton(DialogInterface.BUTTON_POSITIVE)
}
override fun onBackPressed(toolbarButton: Boolean): Boolean {
return withState(viewModel) {
if (it.uiState is ThreePidsSettingsUiState.Idle) {
false
} else {
cancelAdding()
true
}
}
}
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
sealed class ThreePidsSettingsUiState {
object Idle : ThreePidsSettingsUiState()
data class AddingEmail(val error: String?) : ThreePidsSettingsUiState()
data class AddingPhoneNumber(val error: String?) : ThreePidsSettingsUiState()
}

View File

@ -0,0 +1,24 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import im.vector.app.core.platform.VectorViewEvents
sealed class ThreePidsSettingsViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : ThreePidsSettingsViewEvents()
object RequestPassword : ThreePidsSettingsViewEvents()
}

View File

@ -0,0 +1,262 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.error.SsoFlowNotSupportedYet
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ReadOnceTrue
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.rx.rx
class ThreePidsSettingsViewModel @AssistedInject constructor(
@Assisted initialState: ThreePidsSettingsViewState,
private val session: Session,
private val stringProvider: StringProvider
) : VectorViewModel<ThreePidsSettingsViewState, ThreePidsSettingsAction, ThreePidsSettingsViewEvents>(initialState) {
// UIA session
private var pendingThreePid: ThreePid? = null
private var pendingSession: String? = null
private val loadingCallback: MatrixCallback<Unit> = object : MatrixCallback<Unit> {
override fun onFailure(failure: Throwable) {
isLoading(false)
if (failure is Failure.RegistrationFlowError) {
var isPasswordRequestFound = false
// We only support LoginFlowTypes.PASSWORD
// Check if we can provide the user password
failure.registrationFlowResponse.flows?.forEach { interactiveAuthenticationFlow ->
isPasswordRequestFound = isPasswordRequestFound || interactiveAuthenticationFlow.stages?.any { it == LoginFlowTypes.PASSWORD } == true
}
if (isPasswordRequestFound) {
pendingSession = failure.registrationFlowResponse.session
_viewEvents.post(ThreePidsSettingsViewEvents.RequestPassword)
} else {
// LoginFlowTypes.PASSWORD not supported, and this is the only one Element supports so far...
_viewEvents.post(ThreePidsSettingsViewEvents.Failure(SsoFlowNotSupportedYet()))
}
} else {
_viewEvents.post(ThreePidsSettingsViewEvents.Failure(failure))
}
}
override fun onSuccess(data: Unit) {
pendingThreePid = null
pendingSession = null
isLoading(false)
}
}
private fun isLoading(isLoading: Boolean) {
setState {
copy(
isLoading = isLoading
)
}
}
@AssistedInject.Factory
interface Factory {
fun create(initialState: ThreePidsSettingsViewState): ThreePidsSettingsViewModel
}
companion object : MvRxViewModelFactory<ThreePidsSettingsViewModel, ThreePidsSettingsViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: ThreePidsSettingsViewState): ThreePidsSettingsViewModel? {
val factory = when (viewModelContext) {
is FragmentViewModelContext -> viewModelContext.fragment as? Factory
is ActivityViewModelContext -> viewModelContext.activity as? Factory
}
return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface")
}
}
init {
observeThreePids()
observePendingThreePids()
}
private fun observeThreePids() {
session.rx()
.liveThreePIds(true)
.execute {
copy(
threePids = it
)
}
}
private fun observePendingThreePids() {
session.rx()
.livePendingThreePIds()
.execute {
copy(
pendingThreePids = it,
// Ensure the editText for code will be reset
msisdnValidationReinitiator = msisdnValidationReinitiator.toMutableMap().apply {
it.invoke()
?.filterIsInstance(ThreePid.Msisdn::class.java)
?.forEach { threePid ->
getOrPut(threePid) { ReadOnceTrue() }
}
}
)
}
}
override fun handle(action: ThreePidsSettingsAction) {
when (action) {
is ThreePidsSettingsAction.AddThreePid -> handleAddThreePid(action)
is ThreePidsSettingsAction.ContinueThreePid -> handleContinueThreePid(action)
is ThreePidsSettingsAction.SubmitCode -> handleSubmitCode(action)
is ThreePidsSettingsAction.CancelThreePid -> handleCancelThreePid(action)
is ThreePidsSettingsAction.AccountPassword -> handleAccountPassword(action)
is ThreePidsSettingsAction.DeleteThreePid -> handleDeleteThreePid(action)
is ThreePidsSettingsAction.ChangeUiState -> handleChangeUiState(action)
}.exhaustive
}
private fun handleSubmitCode(action: ThreePidsSettingsAction.SubmitCode) {
isLoading(true)
setState {
copy(
msisdnValidationRequests = msisdnValidationRequests.toMutableMap().apply {
put(action.threePid.value, Loading())
}
)
}
viewModelScope.launch {
// First submit the code
session.submitSmsCode(action.threePid, action.code, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
// then finalize
pendingThreePid = action.threePid
session.finalizeAddingThreePid(action.threePid, null, null, loadingCallback)
}
override fun onFailure(failure: Throwable) {
isLoading(false)
setState {
copy(
msisdnValidationRequests = msisdnValidationRequests.toMutableMap().apply {
put(action.threePid.value, Fail(failure))
}
)
}
}
})
}
}
private fun handleChangeUiState(action: ThreePidsSettingsAction.ChangeUiState) {
setState {
copy(
uiState = action.newUiState,
editTextReinitiator = ReadOnceTrue()
)
}
}
private fun handleAddThreePid(action: ThreePidsSettingsAction.AddThreePid) {
isLoading(true)
withState { state ->
val allThreePids = state.threePids.invoke().orEmpty() + state.pendingThreePids.invoke().orEmpty()
if (allThreePids.any { it.value == action.threePid.value }) {
_viewEvents.post(ThreePidsSettingsViewEvents.Failure(IllegalArgumentException(stringProvider.getString(
when (action.threePid) {
is ThreePid.Email -> R.string.auth_email_already_defined
is ThreePid.Msisdn -> R.string.auth_msisdn_already_defined
}
))))
} else {
viewModelScope.launch {
session.addThreePid(action.threePid, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
// Also reset the state
setState {
copy(
uiState = ThreePidsSettingsUiState.Idle
)
}
loadingCallback.onSuccess(data)
}
override fun onFailure(failure: Throwable) {
loadingCallback.onFailure(failure)
}
})
}
}
}
}
private fun handleContinueThreePid(action: ThreePidsSettingsAction.ContinueThreePid) {
isLoading(true)
pendingThreePid = action.threePid
viewModelScope.launch {
session.finalizeAddingThreePid(action.threePid, null, null, loadingCallback)
}
}
private fun handleCancelThreePid(action: ThreePidsSettingsAction.CancelThreePid) {
isLoading(true)
viewModelScope.launch {
session.cancelAddingThreePid(action.threePid, loadingCallback)
}
}
private fun handleAccountPassword(action: ThreePidsSettingsAction.AccountPassword) {
val safeSession = pendingSession ?: return Unit
.also { _viewEvents.post(ThreePidsSettingsViewEvents.Failure(IllegalStateException("No pending session"))) }
val safeThreePid = pendingThreePid ?: return Unit
.also { _viewEvents.post(ThreePidsSettingsViewEvents.Failure(IllegalStateException("No pending threePid"))) }
isLoading(true)
viewModelScope.launch {
session.finalizeAddingThreePid(safeThreePid, safeSession, action.password, loadingCallback)
}
}
private fun handleDeleteThreePid(action: ThreePidsSettingsAction.DeleteThreePid) {
isLoading(true)
viewModelScope.launch {
session.deleteThreePid(action.threePid, loadingCallback)
}
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2020 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.app.features.settings.threepids
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.core.utils.ReadOnceTrue
import org.matrix.android.sdk.api.session.identity.ThreePid
data class ThreePidsSettingsViewState(
val uiState: ThreePidsSettingsUiState = ThreePidsSettingsUiState.Idle,
val isLoading: Boolean = false,
val threePids: Async<List<ThreePid>> = Uninitialized,
val pendingThreePids: Async<List<ThreePid>> = Uninitialized,
val msisdnValidationRequests: Map<String, Async<Unit>> = emptyMap(),
val editTextReinitiator: ReadOnceTrue = ReadOnceTrue(),
val msisdnValidationReinitiator: Map<ThreePid, ReadOnceTrue> = emptyMap()
) : MvRxState

View File

@ -14,6 +14,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:errorEnabled="true"
app:layout_constraintBottom_toTopOf="@+id/formTextInputDivider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@ -91,7 +91,7 @@
app:layout_constraintTop_toTopOf="@+id/item_generic_title_text"
tools:visibility="visible" />
<!-- Set a maw width because the text can be long -->
<!-- Set a max width because the text can be long -->
<com.google.android.material.button.MaterialButton
android:id="@+id/item_generic_action_button"
style="@style/VectorButtonStyle"
@ -102,10 +102,26 @@
android:layout_marginBottom="16dp"
android:maxWidth="@dimen/button_max_width"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/item_generic_destructive_action_button"
app:layout_constraintEnd_toStartOf="@+id/item_generic_barrier"
app:layout_constraintTop_toBottomOf="@+id/item_generic_description_text"
tools:text="@string/settings_troubleshoot_test_device_settings_quickfix"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/item_generic_destructive_action_button"
style="@style/VectorButtonStyleDestructive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:maxWidth="@dimen/button_max_width"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/item_generic_barrier"
app:layout_constraintTop_toBottomOf="@+id/item_generic_action_button"
tools:text="@string/delete"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,7 +9,7 @@
<Button
android:id="@+id/settings_item_button"
style="@style/VectorButtonStyleText"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="@string/action_change" />

View File

@ -51,7 +51,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<Switch
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/settings_item_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -21,7 +21,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:orientation="vertical"
android:textColor="?android:textColorPrimary"
android:textSize="15sp"
android:textStyle="bold"
@ -38,7 +37,7 @@
tools:text="Description / Value" />
</LinearLayout>
<Switch
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/settings_item_switch"
android:layout_width="50dp"
android:layout_height="50dp"

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp"
android:paddingStart="@dimen/layout_horizontal_margin"
android:paddingEnd="@dimen/layout_horizontal_margin">
<ImageView
android:id="@+id/item_settings_three_pid_icon"
android:layout_width="16dp"
android:layout_height="16dp"
android:scaleType="center"
android:tint="?riotx_text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_phone" />
<TextView
android:id="@+id/item_settings_three_pid_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="4dp"
android:textColor="?riotx_text_primary"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/item_settings_three_pid_delete"
app:layout_constraintStart_toEndOf="@+id/item_settings_three_pid_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="alice@email-provider.org" />
<ImageView
android:id="@+id/item_settings_three_pid_delete"
android:layout_width="@dimen/layout_touch_size"
android:layout_height="@dimen/layout_touch_size"
android:scaleType="center"
android:src="@drawable/ic_trash_24"
android:tint="@color/riotx_destructive_accent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -281,6 +281,7 @@
<string name="auth_invalid_email">"This doesnt look like a valid email address"</string>
<string name="auth_invalid_phone">"This doesnt look like a valid phone number"</string>
<string name="auth_email_already_defined">This email address is already defined.</string>
<string name="auth_msisdn_already_defined">This phone number is already defined.</string>
<string name="auth_missing_email">Missing email address</string>
<string name="auth_missing_phone">Missing phone number</string>
<string name="auth_missing_email_or_phone">Missing email address or phone number</string>
@ -675,6 +676,7 @@
<string name="settings_email_address">Email</string>
<string name="settings_add_email_address">Add email address</string>
<string name="settings_phone_number">Phone</string>
<string name="settings_phone_number_empty">No phone number has been added to your account</string>
<string name="settings_add_phone_number">Add phone number</string>
<string name="settings_app_info_link_title">Application info</string>
<string name="settings_app_info_link_summary">Show the application info in the system settings.</string>
@ -682,6 +684,11 @@
<string name="settings_add_3pid_flow_not_supported">You can\'t do this from Element mobile</string>
<string name="settings_add_3pid_authentication_needed">Authentication is required</string>
<string name="settings_emails">Email addresses</string>
<string name="settings_emails_empty">No email has been added to your account</string>
<string name="settings_phone_numbers">Phone numbers</string>
<string name="settings_remove_three_pid_confirmation_content">Remove %s?</string>
<string name="error_threepid_auth_failed">Ensure that you have clicked on the link in the email we have sent to you.</string>
<string name="settings_notification_advanced">Advanced Notification Settings</string>
<string name="settings_notification_by_event">Notification importance by event</string>
@ -927,6 +934,9 @@
<string name="settings_unignore_user">Show all messages from %s?\n\nNote that this action will restart the app and it may take some time.</string>
<string name="passwords_do_not_match">Passwords do not match</string>
<string name="settings_emails_and_phone_numbers_title">Emails and phone numbers</string>
<string name="settings_emails_and_phone_numbers_summary">Manage emails and phone numbers linked to your Matrix account</string>
<string name="settings_delete_notification_targets_confirmation">Are you sure you want to remove this notification target?</string>
<string name="settings_delete_threepid_confirmation">Are you sure you want to remove the %1$s %2$s?</string>
@ -1756,6 +1766,7 @@
<string name="settings_discovery_no_terms_title">Identity server has no terms of services</string>
<string name="settings_discovery_no_terms">The identity server you have chosen does not have any terms of services. Only continue if you trust the owner of the service</string>
<string name="settings_text_message_sent">A text message has been sent to %s. Please enter the verification code it contains.</string>
<string name="settings_text_message_sent_hint">Code</string>
<string name="settings_text_message_sent_wrong_code">The verification code is not correct.</string>
<string name="settings_discovery_disconnect_with_bound_pid">You are currently sharing email addresses or phone numbers on the identity server %1$s. You will need to reconnect to %2$s to stop sharing them.</string>
@ -1944,6 +1955,7 @@
<string name="login_msisdn_confirm_send_again">Send again</string>
<string name="login_msisdn_confirm_submit">Next</string>
<string name="login_msisdn_notice">"Please use the international format (phone number must start with '+')"</string>
<string name="login_msisdn_error_not_international">"International phone numbers must start with '+'"</string>
<string name="login_msisdn_error_other">"Phone number seems invalid. Please check it"</string>
@ -2413,8 +2425,11 @@
<string name="crosssigning_verify_session">Verify login</string>
<string name="cross_signing_verify_by_emoji">Interactively Verify by Emoji</string>
<string name="confirm_your_identity">Confirm your identity by verifying this login from one of your other sessions, granting it access to encrypted messages.</string>
<string name="confirm_your_identity_quad_s">Confirm your identity by verifying this login, granting it access to encrypted messages.</string>
<string name="mark_as_verified">Mark as Trusted</string>
<string name="error_sso_flow_not_supported_yet">Sorry, this operation is not possible yet for accounts connected using Single Sign-On.</string>
<string name="error_empty_field_choose_user_name">Please choose a username.</string>
<string name="error_empty_field_choose_password">Please choose a password.</string>
<string name="external_link_confirmation_title">Double-check this link</string>

View File

@ -3,7 +3,6 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<im.vector.app.core.preference.VectorPreferenceCategory
android:key="SETTINGS_USER_SETTINGS_PREFERENCE_KEY"
android:title="@string/settings_user_settings">
@ -22,29 +21,13 @@
android:summary="@string/change_password_summary"
android:title="@string/settings_password" />
<!-- Email will be added here -->
<!-- Note: inputType does not work, it is set also in code, as well as iconTint -->
<im.vector.app.core.preference.VectorEditTextPreference
android:icon="@drawable/ic_material_add"
android:inputType="textEmailAddress"
android:key="ADD_EMAIL_PREFERENCE_KEY"
android:order="100"
android:title="@string/settings_add_email_address"
app:iconTint="@color/riotx_accent" />
<!-- Phone will be added here -->
<!-- Note: iconTint does not work, it is also done in code -->
<im.vector.app.core.preference.VectorPreference
android:icon="@drawable/ic_material_add"
android:key="ADD_PHONE_NUMBER_PREFERENCE_KEY"
android:order="200"
android:title="@string/settings_add_phone_number"
app:iconTint="@color/riotx_accent" />
android:key="SETTINGS_EMAILS_AND_PHONE_NUMBERS_PREFERENCE_KEY"
android:summary="@string/settings_emails_and_phone_numbers_summary"
android:title="@string/settings_emails_and_phone_numbers_title"
app:fragment="im.vector.app.features.settings.threepids.ThreePidsSettingsFragment" />
<im.vector.app.core.preference.VectorPreference
android:order="1000"
android:persistent="false"
android:summary="@string/settings_discovery_manage"
android:title="@string/settings_discovery_category"