diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index 00c6f6c865..a34f4219d9 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -18,6 +18,7 @@ pbkdf pkcs signin + signout signup diff --git a/CHANGES.md b/CHANGES.md index 7ea78c2417..5143253a06 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,27 @@ +Changes in RiotX 0.11.0 (2019-12-19) +=================================================== + +Features ✨: + - Implement soft logout (#281) + +Improvements 🙌: + - Handle navigation to room via room alias (#201) + - Open matrix.to link in RiotX (#57) + - Limit sticker size in the timeline + +Other changes: + - Use same default room colors than Riot-Web + +Bugfix 🐛: + - Scroll breadcrumbs to top when opened + - Render default room name when it starts with an emoji (#477) + - Do not display " (IRC)" in display names https://github.com/vector-im/riot-android/issues/444 + - Fix rendering issue with HTML formatted body + - Disable click on Stickers (#703) + +Build 🧱: + - Include diff-match-patch sources as dependency + Changes in RiotX 0.10.0 (2019-12-10) =================================================== diff --git a/build.gradle b/build.gradle index 714152370e..29351e403f 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:3.5.1' + classpath 'com.android.tools.build:gradle:3.5.3' classpath 'com.google.gms:google-services:4.3.2' classpath "com.airbnb.okreplay:gradle-plugin:1.5.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" @@ -45,12 +45,6 @@ allprojects { maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } google() jcenter() - maven { - url 'https://repo.adobe.com/nexus/content/repositories/public/' - content { - includeGroupByRegex "diff_match_patch" - } - } } tasks.withType(JavaCompile).all { diff --git a/diff-match-patch/.gitignore b/diff-match-patch/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/diff-match-patch/.gitignore @@ -0,0 +1 @@ +/build diff --git a/diff-match-patch/build.gradle b/diff-match-patch/build.gradle new file mode 100644 index 0000000000..82292e24db --- /dev/null +++ b/diff-match-patch/build.gradle @@ -0,0 +1,8 @@ +apply plugin: 'java-library' + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) +} + +sourceCompatibility = "8" +targetCompatibility = "8" diff --git a/diff-match-patch/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java b/diff-match-patch/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java new file mode 100644 index 0000000000..9d07867de5 --- /dev/null +++ b/diff-match-patch/src/main/java/name/fraser/neil/plaintext/diff_match_patch.java @@ -0,0 +1,2471 @@ +/* + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * 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 name.fraser.neil.plaintext; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/* + * Functions for diff, match and patch. + * Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * + * @author fraser@google.com (Neil Fraser) + */ + +/** + * Class containing the diff, match and patch methods. + * Also contains the behaviour settings. + */ +public class diff_match_patch { + + // Defaults. + // Set these on your diff_match_patch instance to override the defaults. + + /** + * Number of seconds to map a diff before giving up (0 for infinity). + */ + public float Diff_Timeout = 1.0f; + /** + * Cost of an empty edit operation in terms of edit characters. + */ + public short Diff_EditCost = 4; + /** + * At what point is no match declared (0.0 = perfection, 1.0 = very loose). + */ + public float Match_Threshold = 0.5f; + /** + * How far to search for a match (0 = exact location, 1000+ = broad match). + * A match this many characters away from the expected location will add + * 1.0 to the score (0.0 is a perfect match). + */ + public int Match_Distance = 1000; + /** + * When deleting a large block of text (over ~64 characters), how close do + * the contents have to be to match the expected contents. (0.0 = perfection, + * 1.0 = very loose). Note that Match_Threshold controls how closely the + * end points of a delete need to match. + */ + public float Patch_DeleteThreshold = 0.5f; + /** + * Chunk size for context length. + */ + public short Patch_Margin = 4; + + /** + * The number of bits in an int. + */ + private short Match_MaxBits = 32; + + /** + * Internal class for returning results from diff_linesToChars(). + * Other less paranoid languages just use a three-element array. + */ + protected static class LinesToCharsResult { + protected String chars1; + protected String chars2; + protected List lineArray; + + protected LinesToCharsResult(String chars1, String chars2, + List lineArray) { + this.chars1 = chars1; + this.chars2 = chars2; + this.lineArray = lineArray; + } + } + + + // DIFF FUNCTIONS + + + /** + * The data structure representing a diff is a Linked list of Diff objects: + * {Diff(Operation.DELETE, "Hello"), Diff(Operation.INSERT, "Goodbye"), + * Diff(Operation.EQUAL, " world.")} + * which means: delete "Hello", add "Goodbye" and keep " world." + */ + public enum Operation { + DELETE, INSERT, EQUAL + } + + /** + * Find the differences between two texts. + * Run a faster, slightly less optimal diff. + * This method allows the 'checklines' of diff_main() to be optional. + * Most of the time checklines is wanted, so default to true. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @return Linked List of Diff objects. + */ + public LinkedList diff_main(String text1, String text2) { + return diff_main(text1, text2, true); + } + + /** + * Find the differences between two texts. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @return Linked List of Diff objects. + */ + public LinkedList diff_main(String text1, String text2, + boolean checklines) { + // Set a deadline by which time the diff must be complete. + long deadline; + if (Diff_Timeout <= 0) { + deadline = Long.MAX_VALUE; + } else { + deadline = System.currentTimeMillis() + (long) (Diff_Timeout * 1000); + } + return diff_main(text1, text2, checklines, deadline); + } + + /** + * Find the differences between two texts. Simplifies the problem by + * stripping any common prefix or suffix off the texts before diffing. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @param deadline Time when the diff should be complete by. Used + * internally for recursive calls. Users should set DiffTimeout instead. + * @return Linked List of Diff objects. + */ + private LinkedList diff_main(String text1, String text2, + boolean checklines, long deadline) { + // Check for null inputs. + if (text1 == null || text2 == null) { + throw new IllegalArgumentException("Null inputs. (diff_main)"); + } + + // Check for equality (speedup). + LinkedList diffs; + if (text1.equals(text2)) { + diffs = new LinkedList(); + if (text1.length() != 0) { + diffs.add(new Diff(Operation.EQUAL, text1)); + } + return diffs; + } + + // Trim off common prefix (speedup). + int commonlength = diff_commonPrefix(text1, text2); + String commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = diff_commonSuffix(text1, text2); + String commonsuffix = text1.substring(text1.length() - commonlength); + text1 = text1.substring(0, text1.length() - commonlength); + text2 = text2.substring(0, text2.length() - commonlength); + + // Compute the diff on the middle block. + diffs = diff_compute(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix.length() != 0) { + diffs.addFirst(new Diff(Operation.EQUAL, commonprefix)); + } + if (commonsuffix.length() != 0) { + diffs.addLast(new Diff(Operation.EQUAL, commonsuffix)); + } + + diff_cleanupMerge(diffs); + return diffs; + } + + /** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster slightly less optimal diff. + * @param deadline Time when the diff should be complete by. + * @return Linked List of Diff objects. + */ + private LinkedList diff_compute(String text1, String text2, + boolean checklines, long deadline) { + LinkedList diffs = new LinkedList(); + + if (text1.length() == 0) { + // Just add some text (speedup). + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + if (text2.length() == 0) { + // Just delete some text (speedup). + diffs.add(new Diff(Operation.DELETE, text1)); + return diffs; + } + + String longtext = text1.length() > text2.length() ? text1 : text2; + String shorttext = text1.length() > text2.length() ? text2 : text1; + int i = longtext.indexOf(shorttext); + if (i != -1) { + // Shorter text is inside the longer text (speedup). + Operation op = (text1.length() > text2.length()) ? + Operation.DELETE : Operation.INSERT; + diffs.add(new Diff(op, longtext.substring(0, i))); + diffs.add(new Diff(Operation.EQUAL, shorttext)); + diffs.add(new Diff(op, longtext.substring(i + shorttext.length()))); + return diffs; + } + + if (shorttext.length() == 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + diffs.add(new Diff(Operation.DELETE, text1)); + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + // Check to see if the problem can be split in two. + String[] hm = diff_halfMatch(text1, text2); + if (hm != null) { + // A half-match was found, sort out the return data. + String text1_a = hm[0]; + String text1_b = hm[1]; + String text2_a = hm[2]; + String text2_b = hm[3]; + String mid_common = hm[4]; + // Send both pairs off for separate processing. + LinkedList diffs_a = diff_main(text1_a, text2_a, + checklines, deadline); + LinkedList diffs_b = diff_main(text1_b, text2_b, + checklines, deadline); + // Merge the results. + diffs = diffs_a; + diffs.add(new Diff(Operation.EQUAL, mid_common)); + diffs.addAll(diffs_b); + return diffs; + } + + if (checklines && text1.length() > 100 && text2.length() > 100) { + return diff_lineMode(text1, text2, deadline); + } + + return diff_bisect(text1, text2, deadline); + } + + /** + * Do a quick line-level diff on both strings, then rediff the parts for + * greater accuracy. + * This speedup can produce non-minimal diffs. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param deadline Time when the diff should be complete by. + * @return Linked List of Diff objects. + */ + private LinkedList diff_lineMode(String text1, String text2, + long deadline) { + // Scan the text on a line-by-line basis first. + LinesToCharsResult a = diff_linesToChars(text1, text2); + text1 = a.chars1; + text2 = a.chars2; + List linearray = a.lineArray; + + LinkedList diffs = diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + diff_charsToLines(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.add(new Diff(Operation.EQUAL, "")); + int count_delete = 0; + int count_insert = 0; + String text_delete = ""; + String text_insert = ""; + ListIterator pointer = diffs.listIterator(); + Diff thisDiff = pointer.next(); + while (thisDiff != null) { + switch (thisDiff.operation) { + case INSERT: + count_insert++; + text_insert += thisDiff.text; + break; + case DELETE: + count_delete++; + text_delete += thisDiff.text; + break; + case EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + pointer.previous(); + for (int j = 0; j < count_delete + count_insert; j++) { + pointer.previous(); + pointer.remove(); + } + for (Diff subDiff : diff_main(text_delete, text_insert, false, + deadline)) { + pointer.add(subDiff); + } + } + count_insert = 0; + count_delete = 0; + text_delete = ""; + text_insert = ""; + break; + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + diffs.removeLast(); // Remove the dummy entry at the end. + + return diffs; + } + + /** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param deadline Time at which to bail if not yet complete. + * @return LinkedList of Diff objects. + */ + protected LinkedList diff_bisect(String text1, String text2, + long deadline) { + // Cache the text lengths to prevent multiple calls. + int text1_length = text1.length(); + int text2_length = text2.length(); + int max_d = (text1_length + text2_length + 1) / 2; + int v_offset = max_d; + int v_length = 2 * max_d; + int[] v1 = new int[v_length]; + int[] v2 = new int[v_length]; + for (int x = 0; x < v_length; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + int delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will + // collide with the reverse path. + boolean front = (delta % 2 != 0); + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + int k1start = 0; + int k1end = 0; + int k2start = 0; + int k2end = 0; + for (int d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if (System.currentTimeMillis() > deadline) { + break; + } + + // Walk the front path one step. + for (int k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + int k1_offset = v_offset + k1; + int x1; + if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + int y1 = x1 - k1; + while (x1 < text1_length && y1 < text2_length + && text1.charAt(x1) == text2.charAt(y1)) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + int k2_offset = v_offset + delta - k1; + if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { + // Mirror x2 onto top-left coordinate system. + int x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + + // Walk the reverse path one step. + for (int k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + int k2_offset = v_offset + k2; + int x2; + if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + int y2 = x2 - k2; + while (x2 < text1_length && y2 < text2_length + && text1.charAt(text1_length - x2 - 1) + == text2.charAt(text2_length - y2 - 1)) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + int k1_offset = v_offset + delta - k2; + if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { + int x1 = v1[k1_offset]; + int y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + LinkedList diffs = new LinkedList(); + diffs.add(new Diff(Operation.DELETE, text1)); + diffs.add(new Diff(Operation.INSERT, text2)); + return diffs; + } + + /** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param text1 Old string to be diffed. + * @param text2 New string to be diffed. + * @param x Index of split point in text1. + * @param y Index of split point in text2. + * @param deadline Time at which to bail if not yet complete. + * @return LinkedList of Diff objects. + */ + private LinkedList diff_bisectSplit(String text1, String text2, + int x, int y, long deadline) { + String text1a = text1.substring(0, x); + String text2a = text2.substring(0, y); + String text1b = text1.substring(x); + String text2b = text2.substring(y); + + // Compute both diffs serially. + LinkedList diffs = diff_main(text1a, text2a, false, deadline); + LinkedList diffsb = diff_main(text1b, text2b, false, deadline); + + diffs.addAll(diffsb); + return diffs; + } + + /** + * Split two texts into a list of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param text1 First string. + * @param text2 Second string. + * @return An object containing the encoded text1, the encoded text2 and + * the List of unique strings. The zeroth element of the List of + * unique strings is intentionally blank. + */ + protected LinesToCharsResult diff_linesToChars(String text1, String text2) { + List lineArray = new ArrayList(); + Map lineHash = new HashMap(); + // e.g. linearray[4] == "Hello\n" + // e.g. linehash.get("Hello\n") == 4 + + // "\x00" is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray.add(""); + + // Allocate 2/3rds of the space for text1, the rest for text2. + String chars1 = diff_linesToCharsMunge(text1, lineArray, lineHash, 40000); + String chars2 = diff_linesToCharsMunge(text2, lineArray, lineHash, 65535); + return new LinesToCharsResult(chars1, chars2, lineArray); + } + + /** + * Split a text into a list of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param text String to encode. + * @param lineArray List of unique strings. + * @param lineHash Map of strings to indices. + * @param maxLines Maximum length of lineArray. + * @return Encoded string. + */ + private String diff_linesToCharsMunge(String text, List lineArray, + Map lineHash, int maxLines) { + int lineStart = 0; + int lineEnd = -1; + String line; + StringBuilder chars = new StringBuilder(); + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + while (lineEnd < text.length() - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd == -1) { + lineEnd = text.length() - 1; + } + line = text.substring(lineStart, lineEnd + 1); + + if (lineHash.containsKey(line)) { + chars.append(String.valueOf((char) (int) lineHash.get(line))); + } else { + if (lineArray.size() == maxLines) { + // Bail out at 65535 because + // String.valueOf((char) 65536).equals(String.valueOf(((char) 0))) + line = text.substring(lineStart); + lineEnd = text.length(); + } + lineArray.add(line); + lineHash.put(line, lineArray.size() - 1); + chars.append(String.valueOf((char) (lineArray.size() - 1))); + } + lineStart = lineEnd + 1; + } + return chars.toString(); + } + + /** + * Rehydrate the text in a diff from a string of line hashes to real lines of + * text. + * @param diffs List of Diff objects. + * @param lineArray List of unique strings. + */ + protected void diff_charsToLines(List diffs, + List lineArray) { + StringBuilder text; + for (Diff diff : diffs) { + text = new StringBuilder(); + for (int j = 0; j < diff.text.length(); j++) { + text.append(lineArray.get(diff.text.charAt(j))); + } + diff.text = text.toString(); + } + } + + /** + * Determine the common prefix of two strings + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the start of each string. + */ + public int diff_commonPrefix(String text1, String text2) { + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + int n = Math.min(text1.length(), text2.length()); + for (int i = 0; i < n; i++) { + if (text1.charAt(i) != text2.charAt(i)) { + return i; + } + } + return n; + } + + /** + * Determine the common suffix of two strings + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the end of each string. + */ + public int diff_commonSuffix(String text1, String text2) { + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + int text1_length = text1.length(); + int text2_length = text2.length(); + int n = Math.min(text1_length, text2_length); + for (int i = 1; i <= n; i++) { + if (text1.charAt(text1_length - i) != text2.charAt(text2_length - i)) { + return i - 1; + } + } + return n; + } + + /** + * Determine if the suffix of one string is the prefix of another. + * @param text1 First string. + * @param text2 Second string. + * @return The number of characters common to the end of the first + * string and the start of the second string. + */ + protected int diff_commonOverlap(String text1, String text2) { + // Cache the text lengths to prevent multiple calls. + int text1_length = text1.length(); + int text2_length = text2.length(); + // Eliminate the null case. + if (text1_length == 0 || text2_length == 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + int text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1.equals(text2)) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + int best = 0; + int length = 1; + while (true) { + String pattern = text1.substring(text_length - length); + int found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if (found == 0 || text1.substring(text_length - length).equals( + text2.substring(0, length))) { + best = length; + length++; + } + } + } + + /** + * Do the two texts share a substring which is at least half the length of + * the longer text? + * This speedup can produce non-minimal diffs. + * @param text1 First string. + * @param text2 Second string. + * @return Five element String array, containing the prefix of text1, the + * suffix of text1, the prefix of text2, the suffix of text2 and the + * common middle. Or null if there was no match. + */ + protected String[] diff_halfMatch(String text1, String text2) { + if (Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + String longtext = text1.length() > text2.length() ? text1 : text2; + String shorttext = text1.length() > text2.length() ? text2 : text1; + if (longtext.length() < 4 || shorttext.length() * 2 < longtext.length()) { + return null; // Pointless. + } + + // First check if the second quarter is the seed for a half-match. + String[] hm1 = diff_halfMatchI(longtext, shorttext, + (longtext.length() + 3) / 4); + // Check again based on the third quarter. + String[] hm2 = diff_halfMatchI(longtext, shorttext, + (longtext.length() + 1) / 2); + String[] hm; + if (hm1 == null && hm2 == null) { + return null; + } else if (hm2 == null) { + hm = hm1; + } else if (hm1 == null) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length() > hm2[4].length() ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + if (text1.length() > text2.length()) { + return hm; + //return new String[]{hm[0], hm[1], hm[2], hm[3], hm[4]}; + } else { + return new String[]{hm[2], hm[3], hm[0], hm[1], hm[4]}; + } + } + + /** + * Does a substring of shorttext exist within longtext such that the + * substring is at least half the length of longtext? + * @param longtext Longer string. + * @param shorttext Shorter string. + * @param i Start index of quarter length substring within longtext. + * @return Five element String array, containing the prefix of longtext, the + * suffix of longtext, the prefix of shorttext, the suffix of shorttext + * and the common middle. Or null if there was no match. + */ + private String[] diff_halfMatchI(String longtext, String shorttext, int i) { + // Start with a 1/4 length substring at position i as a seed. + String seed = longtext.substring(i, i + longtext.length() / 4); + int j = -1; + String best_common = ""; + String best_longtext_a = "", best_longtext_b = ""; + String best_shorttext_a = "", best_shorttext_b = ""; + while ((j = shorttext.indexOf(seed, j + 1)) != -1) { + int prefixLength = diff_commonPrefix(longtext.substring(i), + shorttext.substring(j)); + int suffixLength = diff_commonSuffix(longtext.substring(0, i), + shorttext.substring(0, j)); + if (best_common.length() < suffixLength + prefixLength) { + best_common = shorttext.substring(j - suffixLength, j) + + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length() * 2 >= longtext.length()) { + return new String[]{best_longtext_a, best_longtext_b, + best_shorttext_a, best_shorttext_b, best_common}; + } else { + return null; + } + } + + /** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupSemantic(LinkedList diffs) { + if (diffs.isEmpty()) { + return; + } + boolean changes = false; + Deque equalities = new ArrayDeque(); // Double-ended queue of qualities. + String lastEquality = null; // Always equal to equalities.peek().text + ListIterator pointer = diffs.listIterator(); + // Number of characters that changed prior to the equality. + int length_insertions1 = 0; + int length_deletions1 = 0; + // Number of characters that changed after the equality. + int length_insertions2 = 0; + int length_deletions2 = 0; + Diff thisDiff = pointer.next(); + while (thisDiff != null) { + if (thisDiff.operation == Operation.EQUAL) { + // Equality found. + equalities.push(thisDiff); + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = thisDiff.text; + } else { + // An insertion or deletion. + if (thisDiff.operation == Operation.INSERT) { + length_insertions2 += thisDiff.text.length(); + } else { + length_deletions2 += thisDiff.text.length(); + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality != null && (lastEquality.length() + <= Math.max(length_insertions1, length_deletions1)) + && (lastEquality.length() + <= Math.max(length_insertions2, length_deletions2))) { + //System.out.println("Splitting: '" + lastEquality + "'"); + // Walk back to offending equality. + while (thisDiff != equalities.peek()) { + thisDiff = pointer.previous(); + } + pointer.next(); + + // Replace equality with a delete. + pointer.set(new Diff(Operation.DELETE, lastEquality)); + // Insert a corresponding an insert. + pointer.add(new Diff(Operation.INSERT, lastEquality)); + + equalities.pop(); // Throw away the equality we just deleted. + if (!equalities.isEmpty()) { + // Throw away the previous equality (it needs to be reevaluated). + equalities.pop(); + } + if (equalities.isEmpty()) { + // There are no previous equalities, walk back to the start. + while (pointer.hasPrevious()) { + pointer.previous(); + } + } else { + // There is a safe equality we can fall back to. + thisDiff = equalities.peek(); + while (thisDiff != pointer.previous()) { + // Intentionally empty loop. + } + } + + length_insertions1 = 0; // Reset the counters. + length_insertions2 = 0; + length_deletions1 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + + // Normalize the diff. + if (changes) { + diff_cleanupMerge(diffs); + } + diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = diffs.listIterator(); + Diff prevDiff = null; + thisDiff = null; + if (pointer.hasNext()) { + prevDiff = pointer.next(); + if (pointer.hasNext()) { + thisDiff = pointer.next(); + } + } + while (thisDiff != null) { + if (prevDiff.operation == Operation.DELETE && + thisDiff.operation == Operation.INSERT) { + String deletion = prevDiff.text; + String insertion = thisDiff.text; + int overlap_length1 = this.diff_commonOverlap(deletion, insertion); + int overlap_length2 = this.diff_commonOverlap(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length() / 2.0 || + overlap_length1 >= insertion.length() / 2.0) { + // Overlap found. Insert an equality and trim the surrounding edits. + pointer.previous(); + pointer.add(new Diff(Operation.EQUAL, + insertion.substring(0, overlap_length1))); + prevDiff.text = + deletion.substring(0, deletion.length() - overlap_length1); + thisDiff.text = insertion.substring(overlap_length1); + // pointer.add inserts the element before the cursor, so there is + // no need to step past the new element. + } + } else { + if (overlap_length2 >= deletion.length() / 2.0 || + overlap_length2 >= insertion.length() / 2.0) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + pointer.previous(); + pointer.add(new Diff(Operation.EQUAL, + deletion.substring(0, overlap_length2))); + prevDiff.operation = Operation.INSERT; + prevDiff.text = + insertion.substring(0, insertion.length() - overlap_length2); + thisDiff.operation = Operation.DELETE; + thisDiff.text = deletion.substring(overlap_length2); + // pointer.add inserts the element before the cursor, so there is + // no need to step past the new element. + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + prevDiff = thisDiff; + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupSemanticLossless(LinkedList diffs) { + String equality1, edit, equality2; + String commonString; + int commonOffset; + int score, bestScore; + String bestEquality1, bestEdit, bestEquality2; + // Create a new iterator at the start. + ListIterator pointer = diffs.listIterator(); + Diff prevDiff = pointer.hasNext() ? pointer.next() : null; + Diff thisDiff = pointer.hasNext() ? pointer.next() : null; + Diff nextDiff = pointer.hasNext() ? pointer.next() : null; + // Intentionally ignore the first and last element (don't need checking). + while (nextDiff != null) { + if (prevDiff.operation == Operation.EQUAL && + nextDiff.operation == Operation.EQUAL) { + // This is a single edit surrounded by equalities. + equality1 = prevDiff.text; + edit = thisDiff.text; + equality2 = nextDiff.text; + + // First, shift the edit as far left as possible. + commonOffset = diff_commonSuffix(equality1, edit); + if (commonOffset != 0) { + commonString = edit.substring(edit.length() - commonOffset); + equality1 = equality1.substring(0, equality1.length() - commonOffset); + edit = commonString + edit.substring(0, edit.length() - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + bestScore = diff_cleanupSemanticScore(equality1, edit) + + diff_cleanupSemanticScore(edit, equality2); + while (edit.length() != 0 && equality2.length() != 0 + && edit.charAt(0) == equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + score = diff_cleanupSemanticScore(equality1, edit) + + diff_cleanupSemanticScore(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (!prevDiff.text.equals(bestEquality1)) { + // We have an improvement, save it back to the diff. + if (bestEquality1.length() != 0) { + prevDiff.text = bestEquality1; + } else { + pointer.previous(); // Walk past nextDiff. + pointer.previous(); // Walk past thisDiff. + pointer.previous(); // Walk past prevDiff. + pointer.remove(); // Delete prevDiff. + pointer.next(); // Walk past thisDiff. + pointer.next(); // Walk past nextDiff. + } + thisDiff.text = bestEdit; + if (bestEquality2.length() != 0) { + nextDiff.text = bestEquality2; + } else { + pointer.remove(); // Delete nextDiff. + nextDiff = thisDiff; + thisDiff = prevDiff; + } + } + } + prevDiff = thisDiff; + thisDiff = nextDiff; + nextDiff = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * @param one First string. + * @param two Second string. + * @return The score. + */ + private int diff_cleanupSemanticScore(String one, String two) { + if (one.length() == 0 || two.length() == 0) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + char char1 = one.charAt(one.length() - 1); + char char2 = two.charAt(0); + boolean nonAlphaNumeric1 = !Character.isLetterOrDigit(char1); + boolean nonAlphaNumeric2 = !Character.isLetterOrDigit(char2); + boolean whitespace1 = nonAlphaNumeric1 && Character.isWhitespace(char1); + boolean whitespace2 = nonAlphaNumeric2 && Character.isWhitespace(char2); + boolean lineBreak1 = whitespace1 + && Character.getType(char1) == Character.CONTROL; + boolean lineBreak2 = whitespace2 + && Character.getType(char2) == Character.CONTROL; + boolean blankLine1 = lineBreak1 && BLANKLINEEND.matcher(one).find(); + boolean blankLine2 = lineBreak2 && BLANKLINESTART.matcher(two).find(); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + // Define some regex patterns for matching boundaries. + private Pattern BLANKLINEEND + = Pattern.compile("\\n\\r?\\n\\Z", Pattern.DOTALL); + private Pattern BLANKLINESTART + = Pattern.compile("\\A\\r?\\n\\r?\\n", Pattern.DOTALL); + + /** + * Reduce the number of edits by eliminating operationally trivial equalities. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupEfficiency(LinkedList diffs) { + if (diffs.isEmpty()) { + return; + } + boolean changes = false; + Deque equalities = new ArrayDeque(); // Double-ended queue of equalities. + String lastEquality = null; // Always equal to equalities.peek().text + ListIterator pointer = diffs.listIterator(); + // Is there an insertion operation before the last equality. + boolean pre_ins = false; + // Is there a deletion operation before the last equality. + boolean pre_del = false; + // Is there an insertion operation after the last equality. + boolean post_ins = false; + // Is there a deletion operation after the last equality. + boolean post_del = false; + Diff thisDiff = pointer.next(); + Diff safeDiff = thisDiff; // The last Diff that is known to be unsplittable. + while (thisDiff != null) { + if (thisDiff.operation == Operation.EQUAL) { + // Equality found. + if (thisDiff.text.length() < Diff_EditCost && (post_ins || post_del)) { + // Candidate found. + equalities.push(thisDiff); + pre_ins = post_ins; + pre_del = post_del; + lastEquality = thisDiff.text; + } else { + // Not a candidate, and can never become one. + equalities.clear(); + lastEquality = null; + safeDiff = thisDiff; + } + post_ins = post_del = false; + } else { + // An insertion or deletion. + if (thisDiff.operation == Operation.DELETE) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if (lastEquality != null + && ((pre_ins && pre_del && post_ins && post_del) + || ((lastEquality.length() < Diff_EditCost / 2) + && ((pre_ins ? 1 : 0) + (pre_del ? 1 : 0) + + (post_ins ? 1 : 0) + (post_del ? 1 : 0)) == 3))) { + //System.out.println("Splitting: '" + lastEquality + "'"); + // Walk back to offending equality. + while (thisDiff != equalities.peek()) { + thisDiff = pointer.previous(); + } + pointer.next(); + + // Replace equality with a delete. + pointer.set(new Diff(Operation.DELETE, lastEquality)); + // Insert a corresponding an insert. + pointer.add(thisDiff = new Diff(Operation.INSERT, lastEquality)); + + equalities.pop(); // Throw away the equality we just deleted. + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalities.clear(); + safeDiff = thisDiff; + } else { + if (!equalities.isEmpty()) { + // Throw away the previous equality (it needs to be reevaluated). + equalities.pop(); + } + if (equalities.isEmpty()) { + // There are no previous questionable equalities, + // walk back to the last known safe diff. + thisDiff = safeDiff; + } else { + // There is an equality we can fall back to. + thisDiff = equalities.peek(); + } + while (thisDiff != pointer.previous()) { + // Intentionally empty loop. + } + post_ins = post_del = false; + } + + changes = true; + } + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param diffs LinkedList of Diff objects. + */ + public void diff_cleanupMerge(LinkedList diffs) { + diffs.add(new Diff(Operation.EQUAL, "")); // Add a dummy entry at the end. + ListIterator pointer = diffs.listIterator(); + int count_delete = 0; + int count_insert = 0; + String text_delete = ""; + String text_insert = ""; + Diff thisDiff = pointer.next(); + Diff prevEqual = null; + int commonlength; + while (thisDiff != null) { + switch (thisDiff.operation) { + case INSERT: + count_insert++; + text_insert += thisDiff.text; + prevEqual = null; + break; + case DELETE: + count_delete++; + text_delete += thisDiff.text; + prevEqual = null; + break; + case EQUAL: + if (count_delete + count_insert > 1) { + boolean both_types = count_delete != 0 && count_insert != 0; + // Delete the offending records. + pointer.previous(); // Reverse direction. + while (count_delete-- > 0) { + pointer.previous(); + pointer.remove(); + } + while (count_insert-- > 0) { + pointer.previous(); + pointer.remove(); + } + if (both_types) { + // Factor out any common prefixies. + commonlength = diff_commonPrefix(text_insert, text_delete); + if (commonlength != 0) { + if (pointer.hasPrevious()) { + thisDiff = pointer.previous(); + assert thisDiff.operation == Operation.EQUAL + : "Previous diff should have been an equality."; + thisDiff.text += text_insert.substring(0, commonlength); + pointer.next(); + } else { + pointer.add(new Diff(Operation.EQUAL, + text_insert.substring(0, commonlength))); + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = diff_commonSuffix(text_insert, text_delete); + if (commonlength != 0) { + thisDiff = pointer.next(); + thisDiff.text = text_insert.substring(text_insert.length() + - commonlength) + thisDiff.text; + text_insert = text_insert.substring(0, text_insert.length() + - commonlength); + text_delete = text_delete.substring(0, text_delete.length() + - commonlength); + pointer.previous(); + } + } + // Insert the merged records. + if (text_delete.length() != 0) { + pointer.add(new Diff(Operation.DELETE, text_delete)); + } + if (text_insert.length() != 0) { + pointer.add(new Diff(Operation.INSERT, text_insert)); + } + // Step forward to the equality. + thisDiff = pointer.hasNext() ? pointer.next() : null; + } else if (prevEqual != null) { + // Merge this equality with the previous one. + prevEqual.text += thisDiff.text; + pointer.remove(); + thisDiff = pointer.previous(); + pointer.next(); // Forward direction + } + count_insert = 0; + count_delete = 0; + text_delete = ""; + text_insert = ""; + prevEqual = thisDiff; + break; + } + thisDiff = pointer.hasNext() ? pointer.next() : null; + } + if (diffs.getLast().text.length() == 0) { + diffs.removeLast(); // Remove the dummy entry at the end. + } + + /* + * Second pass: look for single edits surrounded on both sides by equalities + * which can be shifted sideways to eliminate an equality. + * e.g: ABAC -> ABAC + */ + boolean changes = false; + // Create a new iterator at the start. + // (As opposed to walking the current one back.) + pointer = diffs.listIterator(); + Diff prevDiff = pointer.hasNext() ? pointer.next() : null; + thisDiff = pointer.hasNext() ? pointer.next() : null; + Diff nextDiff = pointer.hasNext() ? pointer.next() : null; + // Intentionally ignore the first and last element (don't need checking). + while (nextDiff != null) { + if (prevDiff.operation == Operation.EQUAL && + nextDiff.operation == Operation.EQUAL) { + // This is a single edit surrounded by equalities. + if (thisDiff.text.endsWith(prevDiff.text)) { + // Shift the edit over the previous equality. + thisDiff.text = prevDiff.text + + thisDiff.text.substring(0, thisDiff.text.length() + - prevDiff.text.length()); + nextDiff.text = prevDiff.text + nextDiff.text; + pointer.previous(); // Walk past nextDiff. + pointer.previous(); // Walk past thisDiff. + pointer.previous(); // Walk past prevDiff. + pointer.remove(); // Delete prevDiff. + pointer.next(); // Walk past thisDiff. + thisDiff = pointer.next(); // Walk past nextDiff. + nextDiff = pointer.hasNext() ? pointer.next() : null; + changes = true; + } else if (thisDiff.text.startsWith(nextDiff.text)) { + // Shift the edit over the next equality. + prevDiff.text += nextDiff.text; + thisDiff.text = thisDiff.text.substring(nextDiff.text.length()) + + nextDiff.text; + pointer.remove(); // Delete nextDiff. + nextDiff = pointer.hasNext() ? pointer.next() : null; + changes = true; + } + } + prevDiff = thisDiff; + thisDiff = nextDiff; + nextDiff = pointer.hasNext() ? pointer.next() : null; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /** + * loc is a location in text1, compute and return the equivalent location in + * text2. + * e.g. "The cat" vs "The big cat", 1->1, 5->8 + * @param diffs List of Diff objects. + * @param loc Location within text1. + * @return Location within text2. + */ + public int diff_xIndex(List diffs, int loc) { + int chars1 = 0; + int chars2 = 0; + int last_chars1 = 0; + int last_chars2 = 0; + Diff lastDiff = null; + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.INSERT) { + // Equality or deletion. + chars1 += aDiff.text.length(); + } + if (aDiff.operation != Operation.DELETE) { + // Equality or insertion. + chars2 += aDiff.text.length(); + } + if (chars1 > loc) { + // Overshot the location. + lastDiff = aDiff; + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + if (lastDiff != null && lastDiff.operation == Operation.DELETE) { + // The location was deleted. + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); + } + + /** + * Convert a Diff list into a pretty HTML report. + * @param diffs List of Diff objects. + * @return HTML representation. + */ + public String diff_prettyHtml(List diffs) { + StringBuilder html = new StringBuilder(); + for (Diff aDiff : diffs) { + String text = aDiff.text.replace("&", "&").replace("<", "<") + .replace(">", ">").replace("\n", "¶
"); + switch (aDiff.operation) { + case INSERT: + html.append("").append(text) + .append(""); + break; + case DELETE: + html.append("").append(text) + .append(""); + break; + case EQUAL: + html.append("").append(text).append(""); + break; + } + } + return html.toString(); + } + + /** + * Compute and return the source text (all equalities and deletions). + * @param diffs List of Diff objects. + * @return Source text. + */ + public String diff_text1(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.INSERT) { + text.append(aDiff.text); + } + } + return text.toString(); + } + + /** + * Compute and return the destination text (all equalities and insertions). + * @param diffs List of Diff objects. + * @return Destination text. + */ + public String diff_text2(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + if (aDiff.operation != Operation.DELETE) { + text.append(aDiff.text); + } + } + return text.toString(); + } + + /** + * Compute the Levenshtein distance; the number of inserted, deleted or + * substituted characters. + * @param diffs List of Diff objects. + * @return Number of changes. + */ + public int diff_levenshtein(List diffs) { + int levenshtein = 0; + int insertions = 0; + int deletions = 0; + for (Diff aDiff : diffs) { + switch (aDiff.operation) { + case INSERT: + insertions += aDiff.text.length(); + break; + case DELETE: + deletions += aDiff.text.length(); + break; + case EQUAL: + // A deletion and an insertion is one substitution. + levenshtein += Math.max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += Math.max(insertions, deletions); + return levenshtein; + } + + /** + * Crush the diff into an encoded string which describes the operations + * required to transform text1 into text2. + * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + * Operations are tab-separated. Inserted text is escaped using %xx notation. + * @param diffs List of Diff objects. + * @return Delta text. + */ + public String diff_toDelta(List diffs) { + StringBuilder text = new StringBuilder(); + for (Diff aDiff : diffs) { + switch (aDiff.operation) { + case INSERT: + try { + text.append("+").append(URLEncoder.encode(aDiff.text, "UTF-8") + .replace('+', ' ')).append("\t"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } + break; + case DELETE: + text.append("-").append(aDiff.text.length()).append("\t"); + break; + case EQUAL: + text.append("=").append(aDiff.text.length()).append("\t"); + break; + } + } + String delta = text.toString(); + if (delta.length() != 0) { + // Strip off trailing tab character. + delta = delta.substring(0, delta.length() - 1); + delta = unescapeForEncodeUriCompatability(delta); + } + return delta; + } + + /** + * Given the original text1, and an encoded string which describes the + * operations required to transform text1 into text2, compute the full diff. + * @param text1 Source string for the diff. + * @param delta Delta text. + * @return Array of Diff objects or null if invalid. + * @throws IllegalArgumentException If invalid input. + */ + public LinkedList diff_fromDelta(String text1, String delta) + throws IllegalArgumentException { + LinkedList diffs = new LinkedList(); + int pointer = 0; // Cursor in text1 + String[] tokens = delta.split("\t"); + for (String token : tokens) { + if (token.length() == 0) { + // Blank tokens are ok (from a trailing \t). + continue; + } + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + String param = token.substring(1); + switch (token.charAt(0)) { + case '+': + // decode would change all "+" to " " + param = param.replace("+", "%2B"); + try { + param = URLDecoder.decode(param, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } catch (IllegalArgumentException e) { + // Malformed URI sequence. + throw new IllegalArgumentException( + "Illegal escape in diff_fromDelta: " + param, e); + } + diffs.add(new Diff(Operation.INSERT, param)); + break; + case '-': + // Fall through. + case '=': + int n; + try { + n = Integer.parseInt(param); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid number in diff_fromDelta: " + param, e); + } + if (n < 0) { + throw new IllegalArgumentException( + "Negative number in diff_fromDelta: " + param); + } + String text; + try { + text = text1.substring(pointer, pointer += n); + } catch (StringIndexOutOfBoundsException e) { + throw new IllegalArgumentException("Delta length (" + pointer + + ") larger than source text length (" + text1.length() + + ").", e); + } + if (token.charAt(0) == '=') { + diffs.add(new Diff(Operation.EQUAL, text)); + } else { + diffs.add(new Diff(Operation.DELETE, text)); + } + break; + default: + // Anything else is an error. + throw new IllegalArgumentException( + "Invalid diff operation in diff_fromDelta: " + token.charAt(0)); + } + } + if (pointer != text1.length()) { + throw new IllegalArgumentException("Delta length (" + pointer + + ") smaller than source text length (" + text1.length() + ")."); + } + return diffs; + } + + + // MATCH FUNCTIONS + + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc'. + * Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @param loc The location to search around. + * @return Best match index or -1. + */ + public int match_main(String text, String pattern, int loc) { + // Check for null inputs. + if (text == null || pattern == null) { + throw new IllegalArgumentException("Null inputs. (match_main)"); + } + + loc = Math.max(0, Math.min(loc, text.length())); + if (text.equals(pattern)) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (text.length() == 0) { + // Nothing to match. + return -1; + } else if (loc + pattern.length() <= text.length() + && text.substring(loc, loc + pattern.length()).equals(pattern)) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return match_bitap(text, pattern, loc); + } + } + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @param loc The location to search around. + * @return Best match index or -1. + */ + protected int match_bitap(String text, String pattern, int loc) { + assert (Match_MaxBits == 0 || pattern.length() <= Match_MaxBits) + : "Pattern too long for this application."; + + // Initialise the alphabet. + Map s = match_alphabet(pattern); + + // Highest score beyond which we give up. + double score_threshold = Match_Threshold; + // Is there a nearby exact match? (speedup) + int best_loc = text.indexOf(pattern, loc); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), + score_threshold); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length()); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern), + score_threshold); + } + } + + // Initialise the bit arrays. + int matchmask = 1 << (pattern.length() - 1); + best_loc = -1; + + int bin_min, bin_mid; + int bin_max = pattern.length() + text.length(); + // Empty initialization added to appease Java compiler. + int[] last_rd = new int[0]; + for (int d = 0; d < pattern.length(); d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at + // this error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (match_bitapScore(d, loc + bin_mid, loc, pattern) + <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = (bin_max - bin_min) / 2 + bin_min; + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + int start = Math.max(1, loc - bin_mid + 1); + int finish = Math.min(loc + bin_mid, text.length()) + pattern.length(); + + int[] rd = new int[finish + 2]; + rd[finish + 1] = (1 << d) - 1; + for (int j = finish; j >= start; j--) { + int charMatch; + if (text.length() <= j - 1 || !s.containsKey(text.charAt(j - 1))) { + // Out of range. + charMatch = 0; + } else { + charMatch = s.get(text.charAt(j - 1)); + } + if (d == 0) { + // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { + // Subsequent passes: fuzzy match. + rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) + | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1]; + } + if ((rd[j] & matchmask) != 0) { + double score = match_bitapScore(d, j - 1, loc, pattern); + // This match will almost certainly be better than any existing + // match. But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = Math.max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + if (match_bitapScore(d + 1, loc, loc, pattern) > score_threshold) { + // No hope for a (better) match at greater error levels. + break; + } + last_rd = rd; + } + return best_loc; + } + + /** + * Compute and return the score for a match with e errors and x location. + * @param e Number of errors in match. + * @param x Location of match. + * @param loc Expected location of match. + * @param pattern Pattern being sought. + * @return Overall score for match (0.0 = good, 1.0 = bad). + */ + private double match_bitapScore(int e, int x, int loc, String pattern) { + float accuracy = (float) e / pattern.length(); + int proximity = Math.abs(loc - x); + if (Match_Distance == 0) { + // Dodge divide by zero error. + return proximity == 0 ? accuracy : 1.0; + } + return accuracy + (proximity / (float) Match_Distance); + } + + /** + * Initialise the alphabet for the Bitap algorithm. + * @param pattern The text to encode. + * @return Hash of character locations. + */ + protected Map match_alphabet(String pattern) { + Map s = new HashMap(); + char[] char_pattern = pattern.toCharArray(); + for (char c : char_pattern) { + s.put(c, 0); + } + int i = 0; + for (char c : char_pattern) { + s.put(c, s.get(c) | (1 << (pattern.length() - i - 1))); + i++; + } + return s; + } + + + // PATCH FUNCTIONS + + + /** + * Increase the context until it is unique, + * but don't let the pattern expand beyond Match_MaxBits. + * @param patch The patch to grow. + * @param text Source text. + */ + protected void patch_addContext(Patch patch, String text) { + if (text.length() == 0) { + return; + } + String pattern = text.substring(patch.start2, patch.start2 + patch.length1); + int padding = 0; + + // Look for the first and last matches of pattern in text. If two different + // matches are found, increase the pattern length. + while (text.indexOf(pattern) != text.lastIndexOf(pattern) + && pattern.length() < Match_MaxBits - Patch_Margin - Patch_Margin) { + padding += Patch_Margin; + pattern = text.substring(Math.max(0, patch.start2 - padding), + Math.min(text.length(), patch.start2 + patch.length1 + padding)); + } + // Add one chunk for good luck. + padding += Patch_Margin; + + // Add the prefix. + String prefix = text.substring(Math.max(0, patch.start2 - padding), + patch.start2); + if (prefix.length() != 0) { + patch.diffs.addFirst(new Diff(Operation.EQUAL, prefix)); + } + // Add the suffix. + String suffix = text.substring(patch.start2 + patch.length1, + Math.min(text.length(), patch.start2 + patch.length1 + padding)); + if (suffix.length() != 0) { + patch.diffs.addLast(new Diff(Operation.EQUAL, suffix)); + } + + // Roll back the start points. + patch.start1 -= prefix.length(); + patch.start2 -= prefix.length(); + // Extend the lengths. + patch.length1 += prefix.length() + suffix.length(); + patch.length2 += prefix.length() + suffix.length(); + } + + /** + * Compute a list of patches to turn text1 into text2. + * A set of diffs will be computed. + * @param text1 Old text. + * @param text2 New text. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(String text1, String text2) { + if (text1 == null || text2 == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + // No diffs provided, compute our own. + LinkedList diffs = diff_main(text1, text2, true); + if (diffs.size() > 2) { + diff_cleanupSemantic(diffs); + diff_cleanupEfficiency(diffs); + } + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text1 will be derived from the provided diffs. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(LinkedList diffs) { + if (diffs == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + // No origin string provided, compute our own. + String text1 = diff_text1(diffs); + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text2 is ignored, diffs are the delta between text1 and text2. + * @param text1 Old text + * @param text2 Ignored. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + * @deprecated Prefer patch_make(String text1, LinkedList diffs). + */ + @Deprecated public LinkedList patch_make(String text1, String text2, + LinkedList diffs) { + return patch_make(text1, diffs); + } + + /** + * Compute a list of patches to turn text1 into text2. + * text2 is not provided, diffs are the delta between text1 and text2. + * @param text1 Old text. + * @param diffs Array of Diff objects for text1 to text2. + * @return LinkedList of Patch objects. + */ + public LinkedList patch_make(String text1, LinkedList diffs) { + if (text1 == null || diffs == null) { + throw new IllegalArgumentException("Null inputs. (patch_make)"); + } + + LinkedList patches = new LinkedList(); + if (diffs.isEmpty()) { + return patches; // Get rid of the null case. + } + Patch patch = new Patch(); + int char_count1 = 0; // Number of characters into the text1 string. + int char_count2 = 0; // Number of characters into the text2 string. + // Start with text1 (prepatch_text) and apply the diffs until we arrive at + // text2 (postpatch_text). We recreate the patches one by one to determine + // context info. + String prepatch_text = text1; + String postpatch_text = text1; + for (Diff aDiff : diffs) { + if (patch.diffs.isEmpty() && aDiff.operation != Operation.EQUAL) { + // A new patch starts here. + patch.start1 = char_count1; + patch.start2 = char_count2; + } + + switch (aDiff.operation) { + case INSERT: + patch.diffs.add(aDiff); + patch.length2 += aDiff.text.length(); + postpatch_text = postpatch_text.substring(0, char_count2) + + aDiff.text + postpatch_text.substring(char_count2); + break; + case DELETE: + patch.length1 += aDiff.text.length(); + patch.diffs.add(aDiff); + postpatch_text = postpatch_text.substring(0, char_count2) + + postpatch_text.substring(char_count2 + aDiff.text.length()); + break; + case EQUAL: + if (aDiff.text.length() <= 2 * Patch_Margin + && !patch.diffs.isEmpty() && aDiff != diffs.getLast()) { + // Small equality inside a patch. + patch.diffs.add(aDiff); + patch.length1 += aDiff.text.length(); + patch.length2 += aDiff.text.length(); + } + + if (aDiff.text.length() >= 2 * Patch_Margin && !patch.diffs.isEmpty()) { + // Time for a new patch. + if (!patch.diffs.isEmpty()) { + patch_addContext(patch, prepatch_text); + patches.add(patch); + patch = new Patch(); + // Unlike Unidiff, our patch lists have a rolling context. + // https://github.com/google/diff-match-patch/wiki/Unidiff + // Update prepatch text & pos to reflect the application of the + // just completed patch. + prepatch_text = postpatch_text; + char_count1 = char_count2; + } + } + break; + } + + // Update the current character count. + if (aDiff.operation != Operation.INSERT) { + char_count1 += aDiff.text.length(); + } + if (aDiff.operation != Operation.DELETE) { + char_count2 += aDiff.text.length(); + } + } + // Pick up the leftover patch if not empty. + if (!patch.diffs.isEmpty()) { + patch_addContext(patch, prepatch_text); + patches.add(patch); + } + + return patches; + } + + /** + * Given an array of patches, return another array that is identical. + * @param patches Array of Patch objects. + * @return Array of Patch objects. + */ + public LinkedList patch_deepCopy(LinkedList patches) { + LinkedList patchesCopy = new LinkedList(); + for (Patch aPatch : patches) { + Patch patchCopy = new Patch(); + for (Diff aDiff : aPatch.diffs) { + Diff diffCopy = new Diff(aDiff.operation, aDiff.text); + patchCopy.diffs.add(diffCopy); + } + patchCopy.start1 = aPatch.start1; + patchCopy.start2 = aPatch.start2; + patchCopy.length1 = aPatch.length1; + patchCopy.length2 = aPatch.length2; + patchesCopy.add(patchCopy); + } + return patchesCopy; + } + + /** + * Merge a set of patches onto the text. Return a patched text, as well + * as an array of true/false values indicating which patches were applied. + * @param patches Array of Patch objects + * @param text Old text. + * @return Two element Object array, containing the new text and an array of + * boolean values. + */ + public Object[] patch_apply(LinkedList patches, String text) { + if (patches.isEmpty()) { + return new Object[]{text, new boolean[0]}; + } + + // Deep copy the patches so that no changes are made to originals. + patches = patch_deepCopy(patches); + + String nullPadding = patch_addPadding(patches); + text = nullPadding + text + nullPadding; + patch_splitMax(patches); + + int x = 0; + // delta keeps track of the offset between the expected and actual location + // of the previous patch. If there are patches expected at positions 10 and + // 20, but the first patch was found at 12, delta is 2 and the second patch + // has an effective expected position of 22. + int delta = 0; + boolean[] results = new boolean[patches.size()]; + for (Patch aPatch : patches) { + int expected_loc = aPatch.start2 + delta; + String text1 = diff_text1(aPatch.diffs); + int start_loc; + int end_loc = -1; + if (text1.length() > this.Match_MaxBits) { + // patch_splitMax will only provide an oversized pattern in the case of + // a monster delete. + start_loc = match_main(text, + text1.substring(0, this.Match_MaxBits), expected_loc); + if (start_loc != -1) { + end_loc = match_main(text, + text1.substring(text1.length() - this.Match_MaxBits), + expected_loc + text1.length() - this.Match_MaxBits); + if (end_loc == -1 || start_loc >= end_loc) { + // Can't find valid trailing context. Drop this patch. + start_loc = -1; + } + } + } else { + start_loc = match_main(text, text1, expected_loc); + } + if (start_loc == -1) { + // No match found. :( + results[x] = false; + // Subtract the delta for this failed patch from subsequent patches. + delta -= aPatch.length2 - aPatch.length1; + } else { + // Found a match. :) + results[x] = true; + delta = start_loc - expected_loc; + String text2; + if (end_loc == -1) { + text2 = text.substring(start_loc, + Math.min(start_loc + text1.length(), text.length())); + } else { + text2 = text.substring(start_loc, + Math.min(end_loc + this.Match_MaxBits, text.length())); + } + if (text1.equals(text2)) { + // Perfect match, just shove the replacement text in. + text = text.substring(0, start_loc) + diff_text2(aPatch.diffs) + + text.substring(start_loc + text1.length()); + } else { + // Imperfect match. Run a diff to get a framework of equivalent + // indices. + LinkedList diffs = diff_main(text1, text2, false); + if (text1.length() > this.Match_MaxBits + && diff_levenshtein(diffs) / (float) text1.length() + > this.Patch_DeleteThreshold) { + // The end points match, but the content is unacceptably bad. + results[x] = false; + } else { + diff_cleanupSemanticLossless(diffs); + int index1 = 0; + for (Diff aDiff : aPatch.diffs) { + if (aDiff.operation != Operation.EQUAL) { + int index2 = diff_xIndex(diffs, index1); + if (aDiff.operation == Operation.INSERT) { + // Insertion + text = text.substring(0, start_loc + index2) + aDiff.text + + text.substring(start_loc + index2); + } else if (aDiff.operation == Operation.DELETE) { + // Deletion + text = text.substring(0, start_loc + index2) + + text.substring(start_loc + diff_xIndex(diffs, + index1 + aDiff.text.length())); + } + } + if (aDiff.operation != Operation.DELETE) { + index1 += aDiff.text.length(); + } + } + } + } + } + x++; + } + // Strip the padding off. + text = text.substring(nullPadding.length(), text.length() + - nullPadding.length()); + return new Object[]{text, results}; + } + + /** + * Add some padding on text start and end so that edges can match something. + * Intended to be called only from within patch_apply. + * @param patches Array of Patch objects. + * @return The padding string added to each side. + */ + public String patch_addPadding(LinkedList patches) { + short paddingLength = this.Patch_Margin; + String nullPadding = ""; + for (short x = 1; x <= paddingLength; x++) { + nullPadding += String.valueOf((char) x); + } + + // Bump all the patches forward. + for (Patch aPatch : patches) { + aPatch.start1 += paddingLength; + aPatch.start2 += paddingLength; + } + + // Add some padding on start of first diff. + Patch patch = patches.getFirst(); + LinkedList diffs = patch.diffs; + if (diffs.isEmpty() || diffs.getFirst().operation != Operation.EQUAL) { + // Add nullPadding equality. + diffs.addFirst(new Diff(Operation.EQUAL, nullPadding)); + patch.start1 -= paddingLength; // Should be 0. + patch.start2 -= paddingLength; // Should be 0. + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs.getFirst().text.length()) { + // Grow first equality. + Diff firstDiff = diffs.getFirst(); + int extraLength = paddingLength - firstDiff.text.length(); + firstDiff.text = nullPadding.substring(firstDiff.text.length()) + + firstDiff.text; + patch.start1 -= extraLength; + patch.start2 -= extraLength; + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + // Add some padding on end of last diff. + patch = patches.getLast(); + diffs = patch.diffs; + if (diffs.isEmpty() || diffs.getLast().operation != Operation.EQUAL) { + // Add nullPadding equality. + diffs.addLast(new Diff(Operation.EQUAL, nullPadding)); + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs.getLast().text.length()) { + // Grow last equality. + Diff lastDiff = diffs.getLast(); + int extraLength = paddingLength - lastDiff.text.length(); + lastDiff.text += nullPadding.substring(0, extraLength); + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + return nullPadding; + } + + /** + * Look through the patches and break up any which are longer than the + * maximum limit of the match algorithm. + * Intended to be called only from within patch_apply. + * @param patches LinkedList of Patch objects. + */ + public void patch_splitMax(LinkedList patches) { + short patch_size = Match_MaxBits; + String precontext, postcontext; + Patch patch; + int start1, start2; + boolean empty; + Operation diff_type; + String diff_text; + ListIterator pointer = patches.listIterator(); + Patch bigpatch = pointer.hasNext() ? pointer.next() : null; + while (bigpatch != null) { + if (bigpatch.length1 <= Match_MaxBits) { + bigpatch = pointer.hasNext() ? pointer.next() : null; + continue; + } + // Remove the big old patch. + pointer.remove(); + start1 = bigpatch.start1; + start2 = bigpatch.start2; + precontext = ""; + while (!bigpatch.diffs.isEmpty()) { + // Create one of several smaller patches. + patch = new Patch(); + empty = true; + patch.start1 = start1 - precontext.length(); + patch.start2 = start2 - precontext.length(); + if (precontext.length() != 0) { + patch.length1 = patch.length2 = precontext.length(); + patch.diffs.add(new Diff(Operation.EQUAL, precontext)); + } + while (!bigpatch.diffs.isEmpty() + && patch.length1 < patch_size - Patch_Margin) { + diff_type = bigpatch.diffs.getFirst().operation; + diff_text = bigpatch.diffs.getFirst().text; + if (diff_type == Operation.INSERT) { + // Insertions are harmless. + patch.length2 += diff_text.length(); + start2 += diff_text.length(); + patch.diffs.addLast(bigpatch.diffs.removeFirst()); + empty = false; + } else if (diff_type == Operation.DELETE && patch.diffs.size() == 1 + && patch.diffs.getFirst().operation == Operation.EQUAL + && diff_text.length() > 2 * patch_size) { + // This is a large deletion. Let it pass in one chunk. + patch.length1 += diff_text.length(); + start1 += diff_text.length(); + empty = false; + patch.diffs.add(new Diff(diff_type, diff_text)); + bigpatch.diffs.removeFirst(); + } else { + // Deletion or equality. Only take as much as we can stomach. + diff_text = diff_text.substring(0, Math.min(diff_text.length(), + patch_size - patch.length1 - Patch_Margin)); + patch.length1 += diff_text.length(); + start1 += diff_text.length(); + if (diff_type == Operation.EQUAL) { + patch.length2 += diff_text.length(); + start2 += diff_text.length(); + } else { + empty = false; + } + patch.diffs.add(new Diff(diff_type, diff_text)); + if (diff_text.equals(bigpatch.diffs.getFirst().text)) { + bigpatch.diffs.removeFirst(); + } else { + bigpatch.diffs.getFirst().text = bigpatch.diffs.getFirst().text + .substring(diff_text.length()); + } + } + } + // Compute the head context for the next patch. + precontext = diff_text2(patch.diffs); + precontext = precontext.substring(Math.max(0, precontext.length() + - Patch_Margin)); + // Append the end context for this patch. + if (diff_text1(bigpatch.diffs).length() > Patch_Margin) { + postcontext = diff_text1(bigpatch.diffs).substring(0, Patch_Margin); + } else { + postcontext = diff_text1(bigpatch.diffs); + } + if (postcontext.length() != 0) { + patch.length1 += postcontext.length(); + patch.length2 += postcontext.length(); + if (!patch.diffs.isEmpty() + && patch.diffs.getLast().operation == Operation.EQUAL) { + patch.diffs.getLast().text += postcontext; + } else { + patch.diffs.add(new Diff(Operation.EQUAL, postcontext)); + } + } + if (!empty) { + pointer.add(patch); + } + } + bigpatch = pointer.hasNext() ? pointer.next() : null; + } + } + + /** + * Take a list of patches and return a textual representation. + * @param patches List of Patch objects. + * @return Text representation of patches. + */ + public String patch_toText(List patches) { + StringBuilder text = new StringBuilder(); + for (Patch aPatch : patches) { + text.append(aPatch); + } + return text.toString(); + } + + /** + * Parse a textual representation of patches and return a List of Patch + * objects. + * @param textline Text representation of patches. + * @return List of Patch objects. + * @throws IllegalArgumentException If invalid input. + */ + public List patch_fromText(String textline) + throws IllegalArgumentException { + List patches = new LinkedList(); + if (textline.length() == 0) { + return patches; + } + List textList = Arrays.asList(textline.split("\n")); + LinkedList text = new LinkedList(textList); + Patch patch; + Pattern patchHeader + = Pattern.compile("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$"); + Matcher m; + char sign; + String line; + while (!text.isEmpty()) { + m = patchHeader.matcher(text.getFirst()); + if (!m.matches()) { + throw new IllegalArgumentException( + "Invalid patch string: " + text.getFirst()); + } + patch = new Patch(); + patches.add(patch); + patch.start1 = Integer.parseInt(m.group(1)); + if (m.group(2).length() == 0) { + patch.start1--; + patch.length1 = 1; + } else if (m.group(2).equals("0")) { + patch.length1 = 0; + } else { + patch.start1--; + patch.length1 = Integer.parseInt(m.group(2)); + } + + patch.start2 = Integer.parseInt(m.group(3)); + if (m.group(4).length() == 0) { + patch.start2--; + patch.length2 = 1; + } else if (m.group(4).equals("0")) { + patch.length2 = 0; + } else { + patch.start2--; + patch.length2 = Integer.parseInt(m.group(4)); + } + text.removeFirst(); + + while (!text.isEmpty()) { + try { + sign = text.getFirst().charAt(0); + } catch (IndexOutOfBoundsException e) { + // Blank line? Whatever. + text.removeFirst(); + continue; + } + line = text.getFirst().substring(1); + line = line.replace("+", "%2B"); // decode would change all "+" to " " + try { + line = URLDecoder.decode(line, "UTF-8"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } catch (IllegalArgumentException e) { + // Malformed URI sequence. + throw new IllegalArgumentException( + "Illegal escape in patch_fromText: " + line, e); + } + if (sign == '-') { + // Deletion. + patch.diffs.add(new Diff(Operation.DELETE, line)); + } else if (sign == '+') { + // Insertion. + patch.diffs.add(new Diff(Operation.INSERT, line)); + } else if (sign == ' ') { + // Minor equality. + patch.diffs.add(new Diff(Operation.EQUAL, line)); + } else if (sign == '@') { + // Start of next patch. + break; + } else { + // WTF? + throw new IllegalArgumentException( + "Invalid patch mode '" + sign + "' in: " + line); + } + text.removeFirst(); + } + } + return patches; + } + + + /** + * Class representing one diff operation. + */ + public static class Diff { + /** + * One of: INSERT, DELETE or EQUAL. + */ + public Operation operation; + /** + * The text associated with this diff operation. + */ + public String text; + + /** + * Constructor. Initializes the diff with the provided values. + * @param operation One of INSERT, DELETE or EQUAL. + * @param text The text being applied. + */ + public Diff(Operation operation, String text) { + // Construct a diff with the specified operation and text. + this.operation = operation; + this.text = text; + } + + /** + * Display a human-readable version of this Diff. + * @return text version. + */ + public String toString() { + String prettyText = this.text.replace('\n', '\u00b6'); + return "Diff(" + this.operation + ",\"" + prettyText + "\")"; + } + + /** + * Create a numeric hash value for a Diff. + * This function is not used by DMP. + * @return Hash value. + */ + @Override + public int hashCode() { + final int prime = 31; + int result = (operation == null) ? 0 : operation.hashCode(); + result += prime * ((text == null) ? 0 : text.hashCode()); + return result; + } + + /** + * Is this Diff equivalent to another Diff? + * @param obj Another Diff to compare against. + * @return true or false. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Diff other = (Diff) obj; + if (operation != other.operation) { + return false; + } + if (text == null) { + if (other.text != null) { + return false; + } + } else if (!text.equals(other.text)) { + return false; + } + return true; + } + } + + + /** + * Class representing one patch operation. + */ + public static class Patch { + public LinkedList diffs; + public int start1; + public int start2; + public int length1; + public int length2; + + /** + * Constructor. Initializes with an empty list of diffs. + */ + public Patch() { + this.diffs = new LinkedList(); + } + + /** + * Emulate GNU diff's format. + * Header: @@ -382,8 +481,9 @@ + * Indices are printed as 1-based, not 0-based. + * @return The GNU diff string. + */ + public String toString() { + String coords1, coords2; + if (this.length1 == 0) { + coords1 = this.start1 + ",0"; + } else if (this.length1 == 1) { + coords1 = Integer.toString(this.start1 + 1); + } else { + coords1 = (this.start1 + 1) + "," + this.length1; + } + if (this.length2 == 0) { + coords2 = this.start2 + ",0"; + } else if (this.length2 == 1) { + coords2 = Integer.toString(this.start2 + 1); + } else { + coords2 = (this.start2 + 1) + "," + this.length2; + } + StringBuilder text = new StringBuilder(); + text.append("@@ -").append(coords1).append(" +").append(coords2) + .append(" @@\n"); + // Escape the body of the patch with %xx notation. + for (Diff aDiff : this.diffs) { + switch (aDiff.operation) { + case INSERT: + text.append('+'); + break; + case DELETE: + text.append('-'); + break; + case EQUAL: + text.append(' '); + break; + } + try { + text.append(URLEncoder.encode(aDiff.text, "UTF-8").replace('+', ' ')) + .append("\n"); + } catch (UnsupportedEncodingException e) { + // Not likely on modern system. + throw new Error("This system does not support UTF-8.", e); + } + } + return unescapeForEncodeUriCompatability(text.toString()); + } + } + + /** + * Unescape selected chars for compatability with JavaScript's encodeURI. + * In speed critical applications this could be dropped since the + * receiving application will certainly decode these fine. + * Note that this function is case-sensitive. Thus "%3f" would not be + * unescaped. But this is ok because it is only called with the output of + * URLEncoder.encode which returns uppercase hex. + * + * Example: "%3F" -> "?", "%24" -> "$", etc. + * + * @param str The string to escape. + * @return The escaped string. + */ + private static String unescapeForEncodeUriCompatability(String str) { + return str.replace("%21", "!").replace("%7E", "~") + .replace("%27", "'").replace("%28", "(").replace("%29", ")") + .replace("%3B", ";").replace("%2F", "/").replace("%3F", "?") + .replace("%3A", ":").replace("%40", "@").replace("%26", "&") + .replace("%3D", "=").replace("%2B", "+").replace("%24", "$") + .replace("%2C", ",").replace("%23", "#"); + } +} diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/MatrixCallbackCompletable.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/MatrixCallbackCompletable.kt deleted file mode 100644 index cf0e955b00..0000000000 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/MatrixCallbackCompletable.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.matrix.rx - -import im.vector.matrix.android.api.MatrixCallback -import im.vector.matrix.android.api.util.Cancelable -import io.reactivex.CompletableEmitter - -internal class MatrixCallbackCompletable(private val completableEmitter: CompletableEmitter) : MatrixCallback { - - override fun onSuccess(data: T) { - completableEmitter.onComplete() - } - - override fun onFailure(failure: Throwable) { - completableEmitter.tryOnError(failure) - } -} - -fun Cancelable.toCompletable(completableEmitter: CompletableEmitter) { - completableEmitter.setCancellable { - this.cancel() - } -} diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxCallbackBuilders.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxCallbackBuilders.kt new file mode 100644 index 0000000000..92886777af --- /dev/null +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxCallbackBuilders.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.rx + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.util.Cancelable +import io.reactivex.Completable +import io.reactivex.Single + +fun singleBuilder(builder: (callback: MatrixCallback) -> Cancelable): Single = Single.create { + val callback: MatrixCallback = object : MatrixCallback { + override fun onSuccess(data: T) { + it.onSuccess(data) + } + + override fun onFailure(failure: Throwable) { + it.tryOnError(failure) + } + } + val cancelable = builder(callback) + it.setCancellable { + cancelable.cancel() + } +} + +fun completableBuilder(builder: (callback: MatrixCallback) -> Cancelable): Completable = Completable.create { + val callback: MatrixCallback = object : MatrixCallback { + override fun onSuccess(data: T) { + it.onComplete() + } + + override fun onFailure(failure: Throwable) { + it.tryOnError(failure) + } + } + val cancelable = builder(callback) + it.setCancellable { + cancelable.cancel() + } +} diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt index bc0a866117..e5ebc536ff 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt @@ -53,13 +53,13 @@ class RxRoom(private val room: Room) { return room.getMyReadReceiptLive().asObservable() } - fun loadRoomMembersIfNeeded(): Single = Single.create { - room.loadRoomMembersIfNeeded(MatrixCallbackSingle(it)).toSingle(it) + fun loadRoomMembersIfNeeded(): Single = singleBuilder { + room.loadRoomMembersIfNeeded(it) } fun joinRoom(reason: String? = null, - viaServers: List = emptyList()): Single = Single.create { - room.join(reason, viaServers, MatrixCallbackSingle(it)).toSingle(it) + viaServers: List = emptyList()): Single = singleBuilder { + room.join(reason, viaServers, it) } fun liveEventReadReceipts(eventId: String): Observable> { diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt index 5a42dbb804..c9381b861d 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt @@ -66,20 +66,25 @@ class RxSession(private val session: Session) { return session.livePagedUsers(filter).asObservable() } - fun createRoom(roomParams: CreateRoomParams): Single = Single.create { - session.createRoom(roomParams, MatrixCallbackSingle(it)).toSingle(it) + fun createRoom(roomParams: CreateRoomParams): Single = singleBuilder { + session.createRoom(roomParams, it) } fun searchUsersDirectory(search: String, limit: Int, - excludedUserIds: Set): Single> = Single.create { - session.searchUsersDirectory(search, limit, excludedUserIds, MatrixCallbackSingle(it)).toSingle(it) + excludedUserIds: Set): Single> = singleBuilder { + session.searchUsersDirectory(search, limit, excludedUserIds, it) } fun joinRoom(roomId: String, reason: String? = null, - viaServers: List = emptyList()): Single = Single.create { - session.joinRoom(roomId, reason, viaServers, MatrixCallbackSingle(it)).toSingle(it) + viaServers: List = emptyList()): Single = singleBuilder { + session.joinRoom(roomId, reason, viaServers, it) + } + + fun getRoomIdByAlias(roomAlias: String, + searchOnServer: Boolean): Single> = singleBuilder { + session.getRoomIdByAlias(roomAlias, searchOnServer, it) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/SessionParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/SessionParams.kt index a104f2c031..2d65cac43d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/SessionParams.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/auth/data/SessionParams.kt @@ -22,5 +22,6 @@ package im.vector.matrix.android.api.auth.data */ data class SessionParams( val credentials: Credentials, - val homeServerConnectionConfig: HomeServerConnectionConfig + val homeServerConnectionConfig: HomeServerConnectionConfig, + val isTokenValid: Boolean ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/GlobalError.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/GlobalError.kt new file mode 100644 index 0000000000..b2bc585258 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/GlobalError.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.failure + +// This class will be sent to the bus +sealed class GlobalError { + data class InvalidToken(val softLogout: Boolean) : GlobalError() + data class ConsentNotGivenError(val consentUri: String) : GlobalError() +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt index f3f097bcc5..d7a6954fd5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/MatrixError.kt @@ -22,45 +22,112 @@ import com.squareup.moshi.JsonClass /** * This data class holds the error defined by the matrix specifications. * You shouldn't have to instantiate it. + * Ref: https://matrix.org/docs/spec/client_server/latest#api-standards */ @JsonClass(generateAdapter = true) data class MatrixError( + /** unique string which can be used to handle an error message */ @Json(name = "errcode") val code: String, + /** human-readable error message */ @Json(name = "error") val message: String, + // For M_CONSENT_NOT_GIVEN @Json(name = "consent_uri") val consentUri: String? = null, - // RESOURCE_LIMIT_EXCEEDED data + // For M_RESOURCE_LIMIT_EXCEEDED @Json(name = "limit_type") val limitType: String? = null, @Json(name = "admin_contact") val adminUri: String? = null, - // For LIMIT_EXCEEDED - @Json(name = "retry_after_ms") val retryAfterMillis: Long? = null) { + // For M_LIMIT_EXCEEDED + @Json(name = "retry_after_ms") val retryAfterMillis: Long? = null, + // For M_UNKNOWN_TOKEN + @Json(name = "soft_logout") val isSoftLogout: Boolean = false +) { companion object { - const val FORBIDDEN = "M_FORBIDDEN" - const val UNKNOWN = "M_UNKNOWN" - const val UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN" - const val MISSING_TOKEN = "M_MISSING_TOKEN" - const val BAD_JSON = "M_BAD_JSON" - const val NOT_JSON = "M_NOT_JSON" - const val NOT_FOUND = "M_NOT_FOUND" - const val LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED" - const val USER_IN_USE = "M_USER_IN_USE" - const val ROOM_IN_USE = "M_ROOM_IN_USE" - const val BAD_PAGINATION = "M_BAD_PAGINATION" - const val UNAUTHORIZED = "M_UNAUTHORIZED" - const val OLD_VERSION = "M_OLD_VERSION" - const val UNRECOGNIZED = "M_UNRECOGNIZED" + /** Forbidden access, e.g. joining a room without permission, failed login. */ + const val M_FORBIDDEN = "M_FORBIDDEN" + /** An unknown error has occurred. */ + const val M_UNKNOWN = "M_UNKNOWN" + /** The access token specified was not recognised. */ + const val M_UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN" + /** No access token was specified for the request. */ + const val M_MISSING_TOKEN = "M_MISSING_TOKEN" + /** Request contained valid JSON, but it was malformed in some way, e.g. missing required keys, invalid values for keys. */ + const val M_BAD_JSON = "M_BAD_JSON" + /** Request did not contain valid JSON. */ + const val M_NOT_JSON = "M_NOT_JSON" + /** No resource was found for this request. */ + const val M_NOT_FOUND = "M_NOT_FOUND" + /** Too many requests have been sent in a short period of time. Wait a while then try again. */ + const val M_LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED" - const val LOGIN_EMAIL_URL_NOT_YET = "M_LOGIN_EMAIL_URL_NOT_YET" - const val THREEPID_AUTH_FAILED = "M_THREEPID_AUTH_FAILED" - // Error code returned by the server when no account matches the given 3pid - const val THREEPID_NOT_FOUND = "M_THREEPID_NOT_FOUND" - const val THREEPID_IN_USE = "M_THREEPID_IN_USE" - const val SERVER_NOT_TRUSTED = "M_SERVER_NOT_TRUSTED" - const val TOO_LARGE = "M_TOO_LARGE" + /* ========================================================================================== + * Other error codes the client might encounter are + * ========================================================================================== */ + + /** Encountered when trying to register a user ID which has been taken. */ + const val M_USER_IN_USE = "M_USER_IN_USE" + /** Sent when the room alias given to the createRoom API is already in use. */ + const val M_ROOM_IN_USE = "M_ROOM_IN_USE" + /** (Not documented yet) */ + const val M_BAD_PAGINATION = "M_BAD_PAGINATION" + /** The request was not correctly authorized. Usually due to login failures. */ + const val M_UNAUTHORIZED = "M_UNAUTHORIZED" + /** (Not documented yet) */ + const val M_OLD_VERSION = "M_OLD_VERSION" + /** The server did not understand the request. */ + const val M_UNRECOGNIZED = "M_UNRECOGNIZED" + /** (Not documented yet) */ + const val M_LOGIN_EMAIL_URL_NOT_YET = "M_LOGIN_EMAIL_URL_NOT_YET" + /** Authentication could not be performed on the third party identifier. */ + const val M_THREEPID_AUTH_FAILED = "M_THREEPID_AUTH_FAILED" + /** Sent when a threepid given to an API cannot be used because no record matching the threepid was found. */ + const val M_THREEPID_NOT_FOUND = "M_THREEPID_NOT_FOUND" + /** Sent when a threepid given to an API cannot be used because the same threepid is already in use. */ + const val M_THREEPID_IN_USE = "M_THREEPID_IN_USE" + /** The client's request used a third party server, eg. identity server, that this server does not trust. */ + const val M_SERVER_NOT_TRUSTED = "M_SERVER_NOT_TRUSTED" + /** The request or entity was too large. */ + const val M_TOO_LARGE = "M_TOO_LARGE" + /** (Not documented yet) */ const val M_CONSENT_NOT_GIVEN = "M_CONSENT_NOT_GIVEN" - const val RESOURCE_LIMIT_EXCEEDED = "M_RESOURCE_LIMIT_EXCEEDED" - const val WRONG_ROOM_KEYS_VERSION = "M_WRONG_ROOM_KEYS_VERSION" + /** The request cannot be completed because the homeserver has reached a resource limit imposed on it. For example, + * a homeserver held in a shared hosting environment may reach a resource limit if it starts using too much memory + * or disk space. The error MUST have an admin_contact field to provide the user receiving the error a place to reach + * out to. Typically, this error will appear on routes which attempt to modify state (eg: sending messages, account + * data, etc) and not routes which only read state (eg: /sync, get account data, etc). */ + const val M_RESOURCE_LIMIT_EXCEEDED = "M_RESOURCE_LIMIT_EXCEEDED" + /** The user ID associated with the request has been deactivated. Typically for endpoints that prove authentication, such as /login. */ + const val M_USER_DEACTIVATED = "M_USER_DEACTIVATED" + /** Encountered when trying to register a user ID which is not valid. */ + const val M_INVALID_USERNAME = "M_INVALID_USERNAME" + /** Sent when the initial state given to the createRoom API is invalid. */ + const val M_INVALID_ROOM_STATE = "M_INVALID_ROOM_STATE" + /** The server does not permit this third party identifier. This may happen if the server only permits, + * for example, email addresses from a particular domain. */ + const val M_THREEPID_DENIED = "M_THREEPID_DENIED" + /** The client's request to create a room used a room version that the server does not support. */ + const val M_UNSUPPORTED_ROOM_VERSION = "M_UNSUPPORTED_ROOM_VERSION" + /** The client attempted to join a room that has a version the server does not support. + * Inspect the room_version property of the error response for the room's version. */ + const val M_INCOMPATIBLE_ROOM_VERSION = "M_INCOMPATIBLE_ROOM_VERSION" + /** The state change requested cannot be performed, such as attempting to unban a user who is not banned. */ + const val M_BAD_STATE = "M_BAD_STATE" + /** The room or resource does not permit guests to access it. */ + const val M_GUEST_ACCESS_FORBIDDEN = "M_GUEST_ACCESS_FORBIDDEN" + /** A Captcha is required to complete the request. */ + const val M_CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED" + /** The Captcha provided did not match what was expected. */ + const val M_CAPTCHA_INVALID = "M_CAPTCHA_INVALID" + /** A required parameter was missing from the request. */ + const val M_MISSING_PARAM = "M_MISSING_PARAM" + /** A parameter that was specified has the wrong value. For example, the server expected an integer and instead received a string. */ + const val M_INVALID_PARAM = "M_INVALID_PARAM" + /** The resource being requested is reserved by an application service, or the application service making the request has not created the resource. */ + const val M_EXCLUSIVE = "M_EXCLUSIVE" + /** The user is unable to reject an invite to join the server notices room. See the Server Notices module for more information. */ + const val M_CANNOT_LEAVE_SERVER_NOTICE_ROOM = "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM" + /** (Not documented yet) */ + const val M_WRONG_ROOM_KEYS_VERSION = "M_WRONG_ROOM_KEYS_VERSION" // Possible value for "limit_type" const val LIMIT_TYPE_MAU = "monthly_active_user" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkData.kt index 47f8bf4505..ecdbe86b98 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkData.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkData.kt @@ -24,9 +24,7 @@ import android.net.Uri */ sealed class PermalinkData { - data class EventLink(val roomIdOrAlias: String, val eventId: String) : PermalinkData() - - data class RoomLink(val roomIdOrAlias: String) : PermalinkData() + data class RoomLink(val roomIdOrAlias: String, val isRoomAlias: Boolean, val eventId: String?) : PermalinkData() data class UserLink(val userId: String) : PermalinkData() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkParser.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkParser.kt index 531f4ae523..d10152f4fe 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkParser.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/permalinks/PermalinkParser.kt @@ -60,16 +60,21 @@ object PermalinkParser { return PermalinkData.FallbackLink(uri) } return when { - MatrixPatterns.isUserId(identifier) -> PermalinkData.UserLink(userId = identifier) - MatrixPatterns.isGroupId(identifier) -> PermalinkData.GroupLink(groupId = identifier) - MatrixPatterns.isRoomId(identifier) -> { - if (!extraParameter.isNullOrEmpty() && MatrixPatterns.isEventId(extraParameter)) { - PermalinkData.EventLink(roomIdOrAlias = identifier, eventId = extraParameter) - } else { - PermalinkData.RoomLink(roomIdOrAlias = identifier) + MatrixPatterns.isUserId(identifier) -> PermalinkData.UserLink(userId = identifier) + MatrixPatterns.isGroupId(identifier) -> PermalinkData.GroupLink(groupId = identifier) + MatrixPatterns.isRoomId(identifier) -> { + val eventId = extraParameter.takeIf { + !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) } + PermalinkData.RoomLink(roomIdOrAlias = identifier, isRoomAlias = false, eventId = eventId) } - else -> PermalinkData.FallbackLink(uri) + MatrixPatterns.isRoomAlias(identifier) -> { + val eventId = extraParameter.takeIf { + !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) + } + PermalinkData.RoomLink(roomIdOrAlias = identifier, isRoomAlias = true, eventId = eventId) + } + else -> PermalinkData.FallbackLink(uri) } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index 2440713a40..1c1a3600c8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session import androidx.annotation.MainThread import androidx.lifecycle.LiveData import im.vector.matrix.android.api.auth.data.SessionParams -import im.vector.matrix.android.api.failure.ConsentNotGivenError +import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.pushrules.PushRuleService import im.vector.matrix.android.api.session.cache.CacheService import im.vector.matrix.android.api.session.content.ContentUploadStateTracker @@ -62,6 +62,11 @@ interface Session : */ val sessionParams: SessionParams + /** + * The session is valid, i.e. it has a valid token so far + */ + val isOpenable: Boolean + /** * Useful shortcut to get access to the userId */ @@ -81,7 +86,7 @@ interface Session : /** * Launches infinite periodic background syncs - * THis does not work in doze mode :/ + * This does not work in doze mode :/ * If battery optimization is on it can work in app standby but that's all :/ */ fun startAutomaticBackgroundSync(repeatDelay: Long = 30_000L) @@ -136,13 +141,10 @@ interface Session : */ interface Listener { /** - * The access token is not valid anymore + * Possible cases: + * - The access token is not valid anymore, + * - a M_CONSENT_NOT_GIVEN error has been received from the homeserver */ - fun onInvalidToken() - - /** - * A M_CONSENT_NOT_GIVEN error has been received from the homeserver - */ - fun onConsentNotGivenError(consentNotGivenError: ConsentNotGivenError) + fun onGlobalError(globalError: GlobalError) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentAttachmentData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentAttachmentData.kt index 933657b2fb..0d8ef2c52b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentAttachmentData.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentAttachmentData.kt @@ -30,7 +30,7 @@ data class ContentAttachmentData( val exifOrientation: Int = ExifInterface.ORIENTATION_UNDEFINED, val name: String? = null, val path: String, - val mimeType: String, + val mimeType: String?, val type: Type ) : Parcelable { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt index 98abce5898..afe7cf8bc3 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt @@ -21,6 +21,7 @@ import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.api.util.Optional /** * This interface defines methods to get rooms. It's implemented at the session level. @@ -74,4 +75,11 @@ interface RoomService { */ fun markAllAsRead(roomIds: List, callback: MatrixCallback): Cancelable + + /** + * Resolve a room alias to a room ID. + */ + fun getRoomIdByAlias(roomAlias: String, + searchOnServer: Boolean, + callback: MatrixCallback>): Cancelable } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt index b750c5347e..34af2cf572 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt @@ -59,7 +59,6 @@ interface MembershipService { /** * Join the room, or accept an invitation. */ - fun join(reason: String? = null, viaServers: List = emptyList(), callback: MatrixCallback): Cancelable diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt index 447ba563de..129c35a17e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt @@ -29,6 +29,8 @@ data class RoomSummary( val displayName: String = "", val topic: String = "", val avatarUrl: String = "", + val canonicalAlias: String? = null, + val aliases: List = emptyList(), val isDirect: Boolean = false, val latestPreviewableEvent: TimelineEvent? = null, val otherMemberIds: List = emptyList(), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/VideoInfo.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/VideoInfo.kt index a7d4708d33..da247810cb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/VideoInfo.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/VideoInfo.kt @@ -25,7 +25,7 @@ data class VideoInfo( /** * The mimetype of the video e.g. "video/mp4". */ - @Json(name = "mimetype") val mimeType: String, + @Json(name = "mimetype") val mimeType: String?, /** * The width of the video in pixels. diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt index 4cd8080dc3..71a422bac8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserMentionSpan.kt @@ -16,11 +16,12 @@ package im.vector.matrix.android.api.session.room.send +import im.vector.matrix.android.api.util.MatrixItem + /** * Tag class for spans that should mention a user. * These Spans will be transformed into pills when detected in message to send */ interface UserMentionSpan { - val displayName: String - val userId: String + val matrixItem: MatrixItem } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt index ed7f49aa46..caa64a85f8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt @@ -104,7 +104,7 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? { root.getClearContent().toModel() } else { annotations?.editSummary?.aggregatedContent?.toModel() - ?: root.getClearContent().toModel() + ?: root.getClearContent().toModel() } } @@ -116,7 +116,7 @@ fun TimelineEvent.getLastMessageBody(): String? { if (lastMessageContent != null) { return lastMessageContent.newContent?.toModel()?.body - ?: lastMessageContent.body + ?: lastMessageContent.body } return null diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/signout/SignOutService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/signout/SignOutService.kt index 5a0638fb6e..76ca9291ec 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/signout/SignOutService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/signout/SignOutService.kt @@ -17,14 +17,31 @@ package im.vector.matrix.android.api.session.signout import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.util.Cancelable /** - * This interface defines a method to sign out. It's implemented at the session level. + * This interface defines a method to sign out, or to renew the token. It's implemented at the session level. */ interface SignOutService { /** - * Sign out + * Ask the homeserver for a new access token. + * The same deviceId will be used */ - fun signOut(callback: MatrixCallback) + fun signInAgain(password: String, + callback: MatrixCallback): Cancelable + + /** + * Update the session with credentials received after SSO + */ + fun updateCredentials(credentials: Credentials, + callback: MatrixCallback): Cancelable + + /** + * Sign out, and release the session, clear all the session data, including crypto data + * @param sigOutFromHomeserver true if the sign out request has to be done + */ + fun signOut(sigOutFromHomeserver: Boolean, + callback: MatrixCallback): Cancelable } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt index 4db40b2c55..4890c28331 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/sync/SyncState.kt @@ -17,10 +17,11 @@ package im.vector.matrix.android.api.session.sync sealed class SyncState { - object IDLE : SyncState() - data class RUNNING(val afterPause: Boolean) : SyncState() - object PAUSED : SyncState() - object KILLING : SyncState() - object KILLED : SyncState() - object NO_NETWORK : SyncState() + object Idle : SyncState() + data class Running(val afterPause: Boolean) : SyncState() + object Paused : SyncState() + object Killing : SyncState() + object Killed : SyncState() + object NoNetwork : SyncState() + object InvalidToken : SyncState() } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt new file mode 100644 index 0000000000..4fed773ae2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/MatrixItem.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.api.util + +import im.vector.matrix.android.BuildConfig +import im.vector.matrix.android.api.session.group.model.GroupSummary +import im.vector.matrix.android.api.session.room.model.RoomSummary +import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom +import im.vector.matrix.android.api.session.user.model.User +import java.util.* + +sealed class MatrixItem( + open val id: String, + open val displayName: String?, + open val avatarUrl: String? +) { + data class UserItem(override val id: String, + override val displayName: String? = null, + override val avatarUrl: String? = null) + : MatrixItem(id, displayName?.removeSuffix(ircPattern), avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + } + + data class EventItem(override val id: String, + override val displayName: String? = null, + override val avatarUrl: String? = null) + : MatrixItem(id, displayName, avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + } + + data class RoomItem(override val id: String, + override val displayName: String? = null, + override val avatarUrl: String? = null) + : MatrixItem(id, displayName, avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + } + + data class RoomAliasItem(override val id: String, + override val displayName: String? = null, + override val avatarUrl: String? = null) + : MatrixItem(id, displayName, avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + } + + data class GroupItem(override val id: String, + override val displayName: String? = null, + override val avatarUrl: String? = null) + : MatrixItem(id, displayName, avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + } + + fun getBestName(): String { + return displayName?.takeIf { it.isNotBlank() } ?: id + } + + protected fun checkId() { + if (!id.startsWith(getIdPrefix())) { + error("Wrong usage of MatrixItem: check the id $id should start with ${getIdPrefix()}") + } + } + + /** + * Return the prefix as defined in the matrix spec (and not extracted from the id) + */ + fun getIdPrefix() = when (this) { + is UserItem -> '@' + is EventItem -> '$' + is RoomItem -> '!' + is RoomAliasItem -> '#' + is GroupItem -> '+' + } + + fun firstLetterOfDisplayName(): String { + return getBestName() + .let { dn -> + var startIndex = 0 + val initial = dn[startIndex] + + if (initial in listOf('@', '#', '+') && dn.length > 1) { + startIndex++ + } + + var length = 1 + var first = dn[startIndex] + + // LEFT-TO-RIGHT MARK + if (dn.length >= 2 && 0x200e == first.toInt()) { + startIndex++ + first = dn[startIndex] + } + + // check if it’s the start of a surrogate pair + if (first.toInt() in 0xD800..0xDBFF && dn.length > startIndex + 1) { + val second = dn[startIndex + 1] + if (second.toInt() in 0xDC00..0xDFFF) { + length++ + } + } + + dn.substring(startIndex, startIndex + length) + } + .toUpperCase(Locale.ROOT) + } + + companion object { + private const val ircPattern = " (IRC)" + } +} + +/* ========================================================================================== + * Extensions to create MatrixItem + * ========================================================================================== */ + +fun User.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) +fun GroupSummary.toMatrixItem() = MatrixItem.GroupItem(groupId, displayName, avatarUrl) +fun RoomSummary.toMatrixItem() = MatrixItem.RoomItem(roomId, displayName, avatarUrl) +fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name, avatarUrl) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt index 22ed0b9a37..6b6321de36 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/AuthModule.kt @@ -53,7 +53,7 @@ internal abstract class AuthModule { .name("matrix-sdk-auth.realm") .modules(AuthRealmModule()) .schemaVersion(AuthRealmMigration.SCHEMA_VERSION) - .migration(AuthRealmMigration()) + .migration(AuthRealmMigration) .build() } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt index f04f262d6e..95a9fbb506 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionCreator.kt @@ -60,7 +60,8 @@ internal class DefaultSessionCreator @Inject constructor( ?.also { Timber.d("Overriding identity server url to $it") } ?.let { Uri.parse(it) } ?: homeServerConnectionConfig.identityServerUri - )) + ), + isTokenValid = true) sessionParamsStore.save(sessionParams) return sessionManager.getOrCreateSession(sessionParams) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionParamsStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionParamsStore.kt index 17bcb9dc81..57c22b0053 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionParamsStore.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/SessionParamsStore.kt @@ -16,6 +16,7 @@ package im.vector.matrix.android.internal.auth +import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.SessionParams internal interface SessionParamsStore { @@ -28,6 +29,10 @@ internal interface SessionParamsStore { suspend fun save(sessionParams: SessionParams) + suspend fun setTokenInvalid(userId: String) + + suspend fun updateCredentials(newCredentials: Credentials) + suspend fun delete(userId: String) suspend fun deleteAll() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt index 5f1efb487b..83bf7b7822 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/AuthRealmMigration.kt @@ -20,12 +20,10 @@ import io.realm.DynamicRealm import io.realm.RealmMigration import timber.log.Timber -internal class AuthRealmMigration : RealmMigration { +internal object AuthRealmMigration : RealmMigration { - companion object { - // Current schema version - const val SCHEMA_VERSION = 1L - } + // Current schema version + const val SCHEMA_VERSION = 2L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Auth Realm from $oldVersion to $newVersion") @@ -46,5 +44,14 @@ internal class AuthRealmMigration : RealmMigration { .addField(PendingSessionEntityFields.IS_REGISTRATION_STARTED, Boolean::class.java) .addField(PendingSessionEntityFields.CURRENT_THREE_PID_DATA_JSON, String::class.java) } + + if (oldVersion <= 1) { + Timber.d("Step 1 -> 2") + Timber.d("Add boolean isTokenValid in SessionParamsEntity, with value true") + + realm.schema.get("SessionParamsEntity") + ?.addField(SessionParamsEntityFields.IS_TOKEN_VALID, Boolean::class.java) + ?.transform { it.set(SessionParamsEntityFields.IS_TOKEN_VALID, true) } + } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt index 1b15995ae6..a4774c632a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/RealmSessionParamsStore.kt @@ -16,6 +16,7 @@ package im.vector.matrix.android.internal.auth.db +import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.database.awaitTransaction @@ -75,6 +76,53 @@ internal class RealmSessionParamsStore @Inject constructor(private val mapper: S } } + override suspend fun setTokenInvalid(userId: String) { + awaitTransaction(realmConfiguration) { realm -> + val currentSessionParams = realm + .where(SessionParamsEntity::class.java) + .equalTo(SessionParamsEntityFields.USER_ID, userId) + .findAll() + .firstOrNull() + + if (currentSessionParams == null) { + // Should not happen + "Session param not found for user $userId" + .let { Timber.w(it) } + .also { error(it) } + } else { + currentSessionParams.isTokenValid = false + } + } + } + + override suspend fun updateCredentials(newCredentials: Credentials) { + awaitTransaction(realmConfiguration) { realm -> + val currentSessionParams = realm + .where(SessionParamsEntity::class.java) + .equalTo(SessionParamsEntityFields.USER_ID, newCredentials.userId) + .findAll() + .map { mapper.map(it) } + .firstOrNull() + + if (currentSessionParams == null) { + // Should not happen + "Session param not found for user ${newCredentials.userId}" + .let { Timber.w(it) } + .also { error(it) } + } else { + val newSessionParams = currentSessionParams.copy( + credentials = newCredentials, + isTokenValid = true + ) + + val entity = mapper.map(newSessionParams) + if (entity != null) { + realm.insertOrUpdate(entity) + } + } + } + } + override suspend fun delete(userId: String) { awaitTransaction(realmConfiguration) { it.where(SessionParamsEntity::class.java) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsEntity.kt index d0cc41d1ac..92511dccf7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsEntity.kt @@ -22,5 +22,8 @@ import io.realm.annotations.PrimaryKey internal open class SessionParamsEntity( @PrimaryKey var userId: String = "", var credentialsJson: String = "", - var homeServerConnectionConfigJson: String = "" + var homeServerConnectionConfigJson: String = "", + // Set to false when the token is invalid and the user has been soft logged out + // In case of hard logout, this object is deleted from DB + var isTokenValid: Boolean = true ) : RealmObject() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsMapper.kt index 8e64e86582..72e8087f3f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/auth/db/SessionParamsMapper.kt @@ -36,7 +36,7 @@ internal class SessionParamsMapper @Inject constructor(moshi: Moshi) { if (credentials == null || homeServerConnectionConfig == null) { return null } - return SessionParams(credentials, homeServerConnectionConfig) + return SessionParams(credentials, homeServerConnectionConfig, entity.isTokenValid) } fun map(sessionParams: SessionParams?): SessionParamsEntity? { @@ -48,6 +48,10 @@ internal class SessionParamsMapper @Inject constructor(moshi: Moshi) { if (credentialsJson == null || homeServerConnectionConfigJson == null) { return null } - return SessionParamsEntity(sessionParams.credentials.userId, credentialsJson, homeServerConnectionConfigJson) + return SessionParamsEntity( + sessionParams.credentials.userId, + credentialsJson, + homeServerConnectionConfigJson, + sessionParams.isTokenValid) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt index 5f90b636ac..503bf8e4ff 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt @@ -49,7 +49,7 @@ object MXEncryptedAttachments { * @param mimetype the mime type * @return the encryption file info */ - fun encryptAttachment(attachmentStream: InputStream, mimetype: String): EncryptionResult { + fun encryptAttachment(attachmentStream: InputStream, mimetype: String?): EncryptionResult { val t0 = System.currentTimeMillis() val secureRandom = SecureRandom() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt index 1cc1a8a05a..91b3d6b056 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/keysbackup/KeysBackup.kt @@ -807,7 +807,7 @@ internal class KeysBackup @Inject constructor( override fun onFailure(failure: Throwable) { if (failure is Failure.ServerError - && failure.error.code == MatrixError.NOT_FOUND) { + && failure.error.code == MatrixError.M_NOT_FOUND) { // Workaround because the homeserver currently returns M_NOT_FOUND when there is no key backup callback.onSuccess(null) } else { @@ -830,7 +830,7 @@ internal class KeysBackup @Inject constructor( override fun onFailure(failure: Throwable) { if (failure is Failure.ServerError - && failure.error.code == MatrixError.NOT_FOUND) { + && failure.error.code == MatrixError.M_NOT_FOUND) { // Workaround because the homeserver currently returns M_NOT_FOUND when there is no key backup callback.onSuccess(null) } else { @@ -1209,8 +1209,8 @@ internal class KeysBackup @Inject constructor( Timber.e(failure, "backupKeys: backupKeys failed.") when (failure.error.code) { - MatrixError.NOT_FOUND, - MatrixError.WRONG_ROOM_KEYS_VERSION -> { + MatrixError.M_NOT_FOUND, + MatrixError.M_WRONG_ROOM_KEYS_VERSION -> { // Backup has been deleted on the server, or we are not using the last backup version keysBackupStateManager.state = KeysBackupState.WrongBackUpVersion backupAllGroupSessionsCallback?.onFailure(failure) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt index 2577bec581..eeb340eacb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt @@ -68,7 +68,9 @@ internal class RoomSummaryMapper @Inject constructor( membership = roomSummaryEntity.membership, versioningState = roomSummaryEntity.versioningState, readMarkerId = roomSummaryEntity.readMarkerId, - userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList() + userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList(), + canonicalAlias = roomSummaryEntity.canonicalAlias, + aliases = roomSummaryEntity.aliases.toList() ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt index 47904380a0..406c8700b6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt @@ -39,7 +39,10 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "", var hasUnreadMessages: Boolean = false, var tags: RealmList = RealmList(), var userDrafts: UserDraftsEntity? = null, - var breadcrumbsIndex: Int = NOT_IN_BREADCRUMBS + var breadcrumbsIndex: Int = NOT_IN_BREADCRUMBS, + var canonicalAlias: String? = null, + var aliases: RealmList = RealmList(), + var flatAliases: String = "" ) : RealmObject() { private var membershipStr: String = Membership.NONE.name diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt index 79473f3b10..1f242ce83a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt @@ -32,6 +32,18 @@ internal fun RoomSummaryEntity.Companion.where(realm: Realm, roomId: String? = n return query } +internal fun RoomSummaryEntity.Companion.findByAlias(realm: Realm, roomAlias: String): RoomSummaryEntity? { + val roomSummary = realm.where() + .equalTo(RoomSummaryEntityFields.CANONICAL_ALIAS, roomAlias) + .findFirst() + if (roomSummary != null) { + return roomSummary + } + return realm.where() + .contains(RoomSummaryEntityFields.FLAT_ALIASES, "|$roomAlias") + .findFirst() +} + internal fun RoomSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: String): RoomSummaryEntity { return where(realm, roomId).findFirst() ?: realm.createObject(roomId) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/AccessTokenInterceptor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/AccessTokenInterceptor.kt index 2630560e45..e0257bfc83 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/AccessTokenInterceptor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/AccessTokenInterceptor.kt @@ -16,19 +16,29 @@ package im.vector.matrix.android.internal.network -import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.internal.auth.SessionParamsStore +import im.vector.matrix.android.internal.di.UserId import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject -internal class AccessTokenInterceptor @Inject constructor(private val credentials: Credentials) : Interceptor { +internal class AccessTokenInterceptor @Inject constructor( + @UserId private val userId: String, + private val sessionParamsStore: SessionParamsStore) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() - val newRequestBuilder = request.newBuilder() - // Add the access token to all requests if it is set - newRequestBuilder.addHeader(HttpHeaders.Authorization, "Bearer " + credentials.accessToken) - request = newRequestBuilder.build() + + accessToken?.let { + val newRequestBuilder = request.newBuilder() + // Add the access token to all requests if it is set + newRequestBuilder.addHeader(HttpHeaders.Authorization, "Bearer $it") + request = newRequestBuilder.build() + } + return chain.proceed(request) } + + private val accessToken + get() = sessionParamsStore.get(userId)?.credentials?.accessToken } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt index fa0b9a1f1c..2018868059 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/network/RetrofitExtensions.kt @@ -20,8 +20,8 @@ package im.vector.matrix.android.internal.network import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException -import im.vector.matrix.android.api.failure.ConsentNotGivenError import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.failure.MatrixError import im.vector.matrix.android.internal.di.MoshiProvider import kotlinx.coroutines.suspendCancellableCoroutine @@ -32,6 +32,7 @@ import retrofit2.Callback import retrofit2.Response import timber.log.Timber import java.io.IOException +import java.net.HttpURLConnection import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -99,7 +100,11 @@ private fun toFailure(errorBody: ResponseBody?, httpCode: Int): Failure { if (matrixError != null) { if (matrixError.code == MatrixError.M_CONSENT_NOT_GIVEN && !matrixError.consentUri.isNullOrBlank()) { // Also send this error to the bus, for a global management - EventBus.getDefault().post(ConsentNotGivenError(matrixError.consentUri)) + EventBus.getDefault().post(GlobalError.ConsentNotGivenError(matrixError.consentUri)) + } else if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */ + && matrixError.code == MatrixError.M_UNKNOWN_TOKEN) { + // Also send this error to the bus, for a global management + EventBus.getDefault().post(GlobalError.InvalidToken(matrixError.isSoftLogout)) } return Failure.ServerError(matrixError, httpCode) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt index 868d63665a..c160ac9b31 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt @@ -83,15 +83,13 @@ internal class DefaultFileService @Inject constructor(private val context: Conte if (elementToDecrypt != null) { Timber.v("## decrypt file") - MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) ?: throw IllegalStateException("Decryption error") - } else { - inputStream + MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) + ?: throw IllegalStateException("Decryption error") } + + writeToFile(inputStream, destFile) + destFile } - .map { inputStream -> - writeToFile(inputStream, destFile) - destFile - } } else { Try.just(destFile) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index 4b33e28000..d6a0206eca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt @@ -23,7 +23,7 @@ import androidx.lifecycle.LiveData import dagger.Lazy import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.auth.data.SessionParams -import im.vector.matrix.android.api.failure.ConsentNotGivenError +import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.pushrules.PushRuleService import im.vector.matrix.android.api.session.InitialSyncProgressService import im.vector.matrix.android.api.session.Session @@ -42,10 +42,14 @@ import im.vector.matrix.android.api.session.signout.SignOutService import im.vector.matrix.android.api.session.sync.FilterService import im.vector.matrix.android.api.session.sync.SyncState import im.vector.matrix.android.api.session.user.UserService +import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.crypto.DefaultCryptoService import im.vector.matrix.android.internal.database.LiveEntityObserver import im.vector.matrix.android.internal.session.sync.job.SyncThread import im.vector.matrix.android.internal.session.sync.job.SyncWorker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -72,6 +76,7 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se private val secureStorageService: Lazy, private val syncThreadProvider: Provider, private val contentUrlResolver: ContentUrlResolver, + private val sessionParamsStore: SessionParamsStore, private val contentUploadProgressTracker: ContentUploadStateTracker, private val initialSyncProgressService: Lazy, private val homeServerCapabilitiesService: Lazy) @@ -94,6 +99,9 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se private var syncThread: SyncThread? = null + override val isOpenable: Boolean + get() = sessionParamsStore.get(myUserId)?.isTokenValid ?: false + @MainThread override fun open() { assertMainThread() @@ -170,8 +178,16 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se } @Subscribe(threadMode = ThreadMode.MAIN) - fun onConsentNotGivenError(consentNotGivenError: ConsentNotGivenError) { - sessionListeners.dispatchConsentNotGiven(consentNotGivenError) + fun onGlobalError(globalError: GlobalError) { + if (globalError is GlobalError.InvalidToken + && globalError.softLogout) { + // Mark the token has invalid + GlobalScope.launch(Dispatchers.IO) { + sessionParamsStore.setTokenInvalid(myUserId) + } + } + + sessionListeners.dispatchGlobalError(globalError) } override fun contentUrlResolver() = contentUrlResolver diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt index 25678bef66..ff3bc0b073 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionListeners.kt @@ -16,7 +16,7 @@ package im.vector.matrix.android.internal.session -import im.vector.matrix.android.api.failure.ConsentNotGivenError +import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.session.Session import javax.inject.Inject @@ -36,10 +36,10 @@ internal class SessionListeners @Inject constructor() { } } - fun dispatchConsentNotGiven(consentNotGivenError: ConsentNotGivenError) { + fun dispatchGlobalError(globalError: GlobalError) { synchronized(listeners) { listeners.forEach { - it.onConsentNotGivenError(consentNotGivenError) + it.onGlobalError(globalError) } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/FileUploader.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/FileUploader.kt index 209f03ad9d..2f4e991e62 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/FileUploader.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/FileUploader.kt @@ -43,9 +43,9 @@ internal class FileUploader @Inject constructor(@Authenticated suspend fun uploadFile(file: File, filename: String?, - mimeType: String, + mimeType: String?, progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { - val uploadBody = file.asRequestBody(mimeType.toMediaTypeOrNull()) + val uploadBody = file.asRequestBody(mimeType?.toMediaTypeOrNull()) return upload(uploadBody, filename, progressListener) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt index 22caf76eaf..de60e6e7e4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoomService.kt @@ -25,11 +25,13 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.VersioningState import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.session.room.alias.GetRoomIdByAliasTask import im.vector.matrix.android.internal.session.room.create.CreateRoomTask import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask @@ -45,6 +47,7 @@ internal class DefaultRoomService @Inject constructor(private val monarchy: Mona private val joinRoomTask: JoinRoomTask, private val markAllRoomsReadTask: MarkAllRoomsReadTask, private val updateBreadcrumbsTask: UpdateBreadcrumbsTask, + private val roomIdByAliasTask: GetRoomIdByAliasTask, private val roomFactory: RoomFactory, private val taskExecutor: TaskExecutor) : RoomService { @@ -111,4 +114,12 @@ internal class DefaultRoomService @Inject constructor(private val monarchy: Mona } .executeBy(taskExecutor) } + + override fun getRoomIdByAlias(roomAlias: String, searchOnServer: Boolean, callback: MatrixCallback>): Cancelable { + return roomIdByAliasTask + .configureWith(GetRoomIdByAliasTask.Params(roomAlias, searchOnServer)) { + this.callback = callback + } + .executeBy(taskExecutor) + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt index 40164d1697..c5b3f03d35 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.room import im.vector.matrix.android.api.session.events.model.Content import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams @@ -258,4 +259,12 @@ internal interface RoomAPI { fun reportContent(@Path("roomId") roomId: String, @Path("eventId") eventId: String, @Body body: ReportContentBody): Call + + /** + * Get the room ID associated to the room alias. + * + * @param roomAlias the room alias. + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "directory/room/{roomAlias}") + fun getRoomIdByAlias(@Path("roomAlias") roomAlias: String): Call } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 1aca492b94..cc786a7493 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -24,6 +24,8 @@ import im.vector.matrix.android.api.session.room.RoomDirectoryService import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.internal.session.DefaultFileService import im.vector.matrix.android.internal.session.SessionScope +import im.vector.matrix.android.internal.session.room.alias.DefaultGetRoomIdByAliasTask +import im.vector.matrix.android.internal.session.room.alias.GetRoomIdByAliasTask import im.vector.matrix.android.internal.session.room.create.CreateRoomTask import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask import im.vector.matrix.android.internal.session.room.directory.DefaultGetPublicRoomTask @@ -133,4 +135,7 @@ internal abstract class RoomModule { @Binds abstract fun bindFetchEditHistoryTask(fetchEditHistoryTask: DefaultFetchEditHistoryTask): FetchEditHistoryTask + + @Binds + abstract fun bindGetRoomIdByAliasTask(getRoomIdByAliasTask: DefaultGetRoomIdByAliasTask): GetRoomIdByAliasTask } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt index 1158c08984..126d13c5db 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt @@ -20,6 +20,8 @@ import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.Membership +import im.vector.matrix.android.api.session.room.model.RoomAliasesContent +import im.vector.matrix.android.api.session.room.model.RoomCanonicalAliasContent import im.vector.matrix.android.api.session.room.model.RoomTopicContent import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.model.EventEntity @@ -68,7 +70,7 @@ internal class RoomSummaryUpdater @Inject constructor(@UserId private val userId unreadNotifications: RoomSyncUnreadNotifications? = null, updateMembers: Boolean = false) { val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst() - ?: realm.createObject(roomId) + ?: realm.createObject(roomId) if (roomSummary != null) { if (roomSummary.heroes.isNotEmpty()) { @@ -91,15 +93,24 @@ internal class RoomSummaryUpdater @Inject constructor(@UserId private val userId val latestPreviewableEvent = TimelineEventEntity.latestEvent(realm, roomId, includesSending = true, filterTypes = PREVIEWABLE_TYPES) val lastTopicEvent = EventEntity.where(realm, roomId, EventType.STATE_ROOM_TOPIC).prev() + val lastCanonicalAliasEvent = EventEntity.where(realm, roomId, EventType.STATE_CANONICAL_ALIAS).prev() + val lastAliasesEvent = EventEntity.where(realm, roomId, EventType.STATE_ROOM_ALIASES).prev() roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 - // avoid this call if we are sure there are unread events - || !isEventRead(monarchy, userId, roomId, latestPreviewableEvent?.eventId) + // avoid this call if we are sure there are unread events + || !isEventRead(monarchy, userId, roomId, latestPreviewableEvent?.eventId) roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(roomId).toString() roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(roomId) roomSummaryEntity.topic = ContentMapper.map(lastTopicEvent?.content).toModel()?.topic roomSummaryEntity.latestPreviewableEvent = latestPreviewableEvent + roomSummaryEntity.canonicalAlias = ContentMapper.map(lastCanonicalAliasEvent?.content).toModel() + ?.canonicalAlias + + val roomAliases = ContentMapper.map(lastAliasesEvent?.content).toModel()?.aliases ?: emptyList() + roomSummaryEntity.aliases.clear() + roomSummaryEntity.aliases.addAll(roomAliases) + roomSummaryEntity.flatAliases = roomAliases.joinToString(separator = "|", prefix = "|") if (updateMembers) { val otherRoomMembers = RoomMembers(realm, roomId) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/alias/GetRoomIdByAliasTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/alias/GetRoomIdByAliasTask.kt new file mode 100644 index 0000000000..1a726c3fe5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/alias/GetRoomIdByAliasTask.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.room.alias + +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.util.Optional +import im.vector.matrix.android.internal.database.model.RoomSummaryEntity +import im.vector.matrix.android.internal.database.query.findByAlias +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.task.Task +import io.realm.Realm +import javax.inject.Inject + +internal interface GetRoomIdByAliasTask : Task> { + data class Params( + val roomAlias: String, + val searchOnServer: Boolean + ) +} + +internal class DefaultGetRoomIdByAliasTask @Inject constructor(private val monarchy: Monarchy, + private val roomAPI: RoomAPI) : GetRoomIdByAliasTask { + + override suspend fun execute(params: GetRoomIdByAliasTask.Params): Optional { + var roomId = Realm.getInstance(monarchy.realmConfiguration).use { + RoomSummaryEntity.findByAlias(it, params.roomAlias)?.roomId + } + return if (roomId != null) { + Optional.from(roomId) + } else if (!params.searchOnServer) { + Optional.from(null) + } else { + roomId = executeRequest { + apiCall = roomAPI.getRoomIdByAlias(params.roomAlias) + }.roomId + Optional.from(roomId) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/alias/RoomAliasDescription.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/alias/RoomAliasDescription.kt new file mode 100644 index 0000000000..c7ddae0e10 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/alias/RoomAliasDescription.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.room.alias + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class RoomAliasDescription( + /** + * The room ID for this alias. + */ + @Json(name = "room_id") val roomId: String, + + /** + * A list of servers that are aware of this room ID. + */ + @Json(name = "servers") val servers: List = emptyList() +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt index ca86765cd9..9af8434b7c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt @@ -52,7 +52,7 @@ internal class DefaultCreateRoomTask @Inject constructor(private val roomAPI: Ro apiCall = roomAPI.createRoom(params) } val roomId = createRoomResponse.roomId!! - // Wait for room to come back from the sync (but it can maybe be in the DB is the sync response is received before) + // Wait for room to come back from the sync (but it can maybe be in the DB if the sync response is received before) val rql = RealmQueryLatch(realmConfiguration) { realm -> realm.where(RoomEntity::class.java) .equalTo(RoomEntityFields.ROOM_ID, roomId) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index 0fed1ca6f5..7a935783cf 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -251,7 +251,7 @@ internal class LocalEchoEventFactory @Inject constructor( type = MessageType.MSGTYPE_AUDIO, body = attachment.name ?: "audio", audioInfo = AudioInfo( - mimeType = attachment.mimeType.takeIf { it.isNotBlank() } ?: "audio/mpeg", + mimeType = attachment.mimeType?.takeIf { it.isNotBlank() } ?: "audio/mpeg", size = attachment.size ), url = attachment.path @@ -264,7 +264,7 @@ internal class LocalEchoEventFactory @Inject constructor( type = MessageType.MSGTYPE_FILE, body = attachment.name ?: "file", info = FileInfo( - mimeType = attachment.mimeType.takeIf { it.isNotBlank() } + mimeType = attachment.mimeType?.takeIf { it.isNotBlank() } ?: "application/octet-stream", size = attachment.size ), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt index 183b0ad9b8..6527113054 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt @@ -79,7 +79,7 @@ internal class SendEventWorker constructor(context: Context, params: WorkerParam private fun Throwable.shouldBeRetried(): Boolean { return this is Failure.NetworkConnection - || (this is Failure.ServerError && this.error.code == MatrixError.LIMIT_EXCEEDED) + || (this is Failure.ServerError && error.code == MatrixError.M_LIMIT_EXCEEDED) } private suspend fun sendEvent(eventId: String, eventType: String, content: Content?, roomId: String) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt index 580e49b2ce..c079e456c0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/pills/TextPillsUtils.kt @@ -65,7 +65,7 @@ internal class TextPillsUtils @Inject constructor( // append text before pill append(text, currIndex, start) // append the pill - append(String.format(template, urlSpan.userId, urlSpan.displayName)) + append(String.format(template, urlSpan.matrixItem.id, urlSpan.matrixItem.displayName)) currIndex = end } // append text after the last pill diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/DefaultSignOutService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/DefaultSignOutService.kt index b48ac2c78a..17c91011e7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/DefaultSignOutService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/DefaultSignOutService.kt @@ -17,17 +17,43 @@ package im.vector.matrix.android.internal.session.signout import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.session.signout.SignOutService +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith +import im.vector.matrix.android.internal.task.launchToCallback +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import kotlinx.coroutines.GlobalScope import javax.inject.Inject internal class DefaultSignOutService @Inject constructor(private val signOutTask: SignOutTask, + private val signInAgainTask: SignInAgainTask, + private val sessionParamsStore: SessionParamsStore, + private val coroutineDispatchers: MatrixCoroutineDispatchers, private val taskExecutor: TaskExecutor) : SignOutService { - override fun signOut(callback: MatrixCallback) { - signOutTask - .configureWith { + override fun signInAgain(password: String, + callback: MatrixCallback): Cancelable { + return signInAgainTask + .configureWith(SignInAgainTask.Params(password)) { + this.callback = callback + } + .executeBy(taskExecutor) + } + + override fun updateCredentials(credentials: Credentials, + callback: MatrixCallback): Cancelable { + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { + sessionParamsStore.updateCredentials(credentials) + } + } + + override fun signOut(sigOutFromHomeserver: Boolean, + callback: MatrixCallback): Cancelable { + return signOutTask + .configureWith(SignOutTask.Params(sigOutFromHomeserver)) { this.callback = callback } .executeBy(taskExecutor) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignInAgainTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignInAgainTask.kt new file mode 100644 index 0000000000..666852c988 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignInAgainTask.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.matrix.android.internal.session.signout + +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.auth.data.SessionParams +import im.vector.matrix.android.internal.auth.SessionParamsStore +import im.vector.matrix.android.internal.auth.data.PasswordLoginParams +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.task.Task +import javax.inject.Inject + +internal interface SignInAgainTask : Task { + data class Params( + val password: String + ) +} + +internal class DefaultSignInAgainTask @Inject constructor( + private val signOutAPI: SignOutAPI, + private val sessionParams: SessionParams, + private val sessionParamsStore: SessionParamsStore) : SignInAgainTask { + + override suspend fun execute(params: SignInAgainTask.Params) { + val newCredentials = executeRequest { + apiCall = signOutAPI.loginAgain( + PasswordLoginParams.userIdentifier( + // Reuse the same userId + sessionParams.credentials.userId, + params.password, + // The spec says the initial device name will be ignored + // https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login + // but https://github.com/matrix-org/synapse/issues/6525 + // Reuse the same deviceId + deviceId = sessionParams.credentials.deviceId + ) + ) + } + + sessionParamsStore.updateCredentials(newCredentials) + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutAPI.kt index 2f19fee847..9db7c7d915 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutAPI.kt @@ -16,12 +16,27 @@ package im.vector.matrix.android.internal.session.signout +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.internal.auth.data.PasswordLoginParams import im.vector.matrix.android.internal.network.NetworkConstants import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.Headers import retrofit2.http.POST internal interface SignOutAPI { + /** + * Attempt to login again to the same account. + * Set all the timeouts to 1 minute + * It is similar to [AuthAPI.login] + * + * @param loginParams the login parameters + */ + @Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000") + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "login") + fun loginAgain(@Body loginParams: PasswordLoginParams): Call + /** * Invalidate the access token, so that it can no longer be used for authorization. */ diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutModule.kt index c55c82274d..590729837b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutModule.kt @@ -37,8 +37,11 @@ internal abstract class SignOutModule { } @Binds - abstract fun bindSignOutTask(signOutTask: DefaultSignOutTask): SignOutTask + abstract fun bindSignOutTask(task: DefaultSignOutTask): SignOutTask @Binds - abstract fun bindSignOutService(signOutService: DefaultSignOutService): SignOutService + abstract fun bindSignInAgainTask(task: DefaultSignInAgainTask): SignInAgainTask + + @Binds + abstract fun bindSignOutService(service: DefaultSignOutService): SignOutService } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt index 7bff2936fd..51cb22c988 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/signout/SignOutTask.kt @@ -18,6 +18,8 @@ package im.vector.matrix.android.internal.session.signout import android.content.Context import im.vector.matrix.android.BuildConfig +import im.vector.matrix.android.api.failure.Failure +import im.vector.matrix.android.api.failure.MatrixError import im.vector.matrix.android.internal.SessionManager import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.crypto.CryptoModule @@ -32,9 +34,14 @@ import io.realm.Realm import io.realm.RealmConfiguration import timber.log.Timber import java.io.File +import java.net.HttpURLConnection import javax.inject.Inject -internal interface SignOutTask : Task +internal interface SignOutTask : Task { + data class Params( + val sigOutFromHomeserver: Boolean + ) +} internal class DefaultSignOutTask @Inject constructor(private val context: Context, @UserId private val userId: String, @@ -49,10 +56,26 @@ internal class DefaultSignOutTask @Inject constructor(private val context: Conte @CryptoDatabase private val realmCryptoConfiguration: RealmConfiguration, @UserMd5 private val userMd5: String) : SignOutTask { - override suspend fun execute(params: Unit) { - Timber.d("SignOut: send request...") - executeRequest { - apiCall = signOutAPI.signOut() + override suspend fun execute(params: SignOutTask.Params) { + // It should be done even after a soft logout, to be sure the deviceId is deleted on the + if (params.sigOutFromHomeserver) { + Timber.d("SignOut: send request...") + try { + executeRequest { + apiCall = signOutAPI.signOut() + } + } catch (throwable: Throwable) { + // Maybe due to https://github.com/matrix-org/synapse/issues/5755 + if (throwable is Failure.ServerError + && throwable.httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */ + && throwable.error.code == MatrixError.M_UNKNOWN_TOKEN) { + // Also throwable.error.isSoftLogout should be true + // Ignore + Timber.w("Ignore error due to https://github.com/matrix-org/synapse/issues/5755") + } else { + throw throwable + } + } } Timber.d("SignOut: release session...") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt index 51c02456d7..9f9e67bd2e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncTask.kt @@ -17,8 +17,6 @@ package im.vector.matrix.android.internal.session.sync import im.vector.matrix.android.R -import im.vector.matrix.android.api.failure.Failure -import im.vector.matrix.android.api.failure.MatrixError import im.vector.matrix.android.internal.auth.SessionParamsStore import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.network.executeRequest @@ -67,17 +65,8 @@ internal class DefaultSyncTask @Inject constructor(private val syncAPI: SyncAPI, initialSyncProgressService.endAll() initialSyncProgressService.startTask(R.string.initial_sync_start_importing_account, 100) } - val syncResponse = try { - executeRequest { - apiCall = syncAPI.sync(requestParams) - } - } catch (throwable: Throwable) { - // Intercept 401 - if (throwable is Failure.ServerError - && throwable.error.code == MatrixError.UNKNOWN_TOKEN) { - sessionParamsStore.delete(userId) - } - throw throwable + val syncResponse = executeRequest { + apiCall = syncAPI.sync(requestParams) } syncResponseHandler.handleResponse(syncResponse, token) syncTokenStore.saveToken(syncResponse.nextBatch) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncService.kt index 4e57aa5be1..71734cdfe7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncService.kt @@ -147,7 +147,7 @@ open class SyncService : Service() { } if (failure is Failure.ServerError - && (failure.error.code == MatrixError.UNKNOWN_TOKEN || failure.error.code == MatrixError.MISSING_TOKEN)) { + && (failure.error.code == MatrixError.M_UNKNOWN_TOKEN || failure.error.code == MatrixError.M_MISSING_TOKEN)) { // No token or invalid token, stop the thread stopSelf() } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt index d8de292d70..69e03c6269 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/job/SyncThread.kt @@ -44,19 +44,20 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, private val taskExecutor: TaskExecutor ) : Thread(), NetworkConnectivityChecker.Listener, BackgroundDetectionObserver.Listener { - private var state: SyncState = SyncState.IDLE + private var state: SyncState = SyncState.Idle private var liveState = MutableLiveData() private val lock = Object() private var cancelableTask: Cancelable? = null private var isStarted = false + private var isTokenValid = true init { - updateStateTo(SyncState.IDLE) + updateStateTo(SyncState.Idle) } fun setInitialForeground(initialForeground: Boolean) { - val newState = if (initialForeground) SyncState.IDLE else SyncState.PAUSED + val newState = if (initialForeground) SyncState.Idle else SyncState.Paused updateStateTo(newState) } @@ -64,6 +65,8 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, if (!isStarted) { Timber.v("Resume sync...") isStarted = true + // Check again the token validity + isTokenValid = true lock.notify() } } @@ -78,7 +81,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, fun kill() = synchronized(lock) { Timber.v("Kill sync...") - updateStateTo(SyncState.KILLING) + updateStateTo(SyncState.Killing) cancelableTask?.cancel() lock.notify() } @@ -100,26 +103,31 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, networkConnectivityChecker.register(this) backgroundDetectionObserver.register(this) - while (state != SyncState.KILLING) { + while (state != SyncState.Killing) { Timber.v("Entering loop, state: $state") if (!networkConnectivityChecker.hasInternetAccess) { Timber.v("No network. Waiting...") - updateStateTo(SyncState.NO_NETWORK) + updateStateTo(SyncState.NoNetwork) synchronized(lock) { lock.wait() } Timber.v("...unlocked") } else if (!isStarted) { Timber.v("Sync is Paused. Waiting...") - updateStateTo(SyncState.PAUSED) + updateStateTo(SyncState.Paused) + synchronized(lock) { lock.wait() } + Timber.v("...unlocked") + } else if (!isTokenValid) { + Timber.v("Token is invalid. Waiting...") + updateStateTo(SyncState.InvalidToken) synchronized(lock) { lock.wait() } Timber.v("...unlocked") } else { - if (state !is SyncState.RUNNING) { - updateStateTo(SyncState.RUNNING(afterPause = true)) + if (state !is SyncState.Running) { + updateStateTo(SyncState.Running(afterPause = true)) } // No timeout after a pause - val timeout = state.let { if (it is SyncState.RUNNING && it.afterPause) 0 else DEFAULT_LONG_POOL_TIMEOUT } + val timeout = state.let { if (it is SyncState.Running && it.afterPause) 0 else DEFAULT_LONG_POOL_TIMEOUT } Timber.v("Execute sync request with timeout $timeout") val latch = CountDownLatch(1) @@ -141,10 +149,11 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, } else if (failure is Failure.Cancelled) { Timber.v("Cancelled") } else if (failure is Failure.ServerError - && (failure.error.code == MatrixError.UNKNOWN_TOKEN || failure.error.code == MatrixError.MISSING_TOKEN)) { - // No token or invalid token, stop the thread + && (failure.error.code == MatrixError.M_UNKNOWN_TOKEN || failure.error.code == MatrixError.M_MISSING_TOKEN)) { + // No token or invalid token Timber.w(failure) - updateStateTo(SyncState.KILLING) + isTokenValid = false + isStarted = false } else { Timber.e(failure) @@ -163,8 +172,8 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, latch.await() state.let { - if (it is SyncState.RUNNING && it.afterPause) { - updateStateTo(SyncState.RUNNING(afterPause = false)) + if (it is SyncState.Running && it.afterPause) { + updateStateTo(SyncState.Running(afterPause = false)) } } @@ -172,7 +181,7 @@ internal class SyncThread @Inject constructor(private val syncTask: SyncTask, } } Timber.v("Sync killed") - updateStateTo(SyncState.KILLED) + updateStateTo(SyncState.Killed) backgroundDetectionObserver.unregister(this) networkConnectivityChecker.unregister(this) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringUtils.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringUtils.kt index 31da372bbe..f19bebe482 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringUtils.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringUtils.kt @@ -16,9 +16,7 @@ package im.vector.matrix.android.internal.util -import im.vector.matrix.android.api.MatrixPatterns import timber.log.Timber -import java.util.Locale /** * Convert a string to an UTF8 String @@ -51,10 +49,3 @@ fun convertFromUTF8(s: String): String { s } } - -fun String?.firstLetterOfDisplayName(): String { - if (this.isNullOrEmpty()) return "" - val isUserId = MatrixPatterns.isUserId(this) - val firstLetterIndex = if (isUserId) 1 else 0 - return this[firstLetterIndex].toString().toUpperCase(Locale.ROOT) -} diff --git a/matrix-sdk-android/src/main/res/values-cs/strings.xml b/matrix-sdk-android/src/main/res/values-cs/strings.xml index f6e23752c3..61f3db0b25 100644 --- a/matrix-sdk-android/src/main/res/values-cs/strings.xml +++ b/matrix-sdk-android/src/main/res/values-cs/strings.xml @@ -82,4 +82,86 @@ Prázdná místnost + %s upravil/a tuto místnost. + + Zpráva byla smazána [důvod: %1$s] + Zpráva smazána [smazal/a %1$s] [důvod: %2$s] + "%1$s obnovil/a pozvánku do místnosti pro %2$s" + Kočka + Lev + Kůň + Jednorožec + Prase + Slon + Králík + Panda + Kohout + Tučnák + Želva + Ryba + Chobotnice + Motýl + Květina + Strom + Kaktus + Houba + Glóbus + Měsíc + Mrak + Oheň + Banán + Jablko + Jahoda + Kukuřice + Pizza + Dort + Srdce + Smajlík + Robot + Klobouk + Brýle + Santa + Zvednutý palec + Deštník + Přesípací hodiny + Hodiny + Dárek + Žárovka + Knížka + Tužka + Sponka + Nůžky + Zámek + Klíč + Kladivo + Telefon + Vlajka + Vlak + Kolo + Letadlo + Raketa + Pohár + Míč + Kytara + Trumpeta + Zvon + Kotva + Sluchátka + Složka + Úvodní synchronizace: +\nStahuji účet… + Uvodní synchronizace: +\nStahuji klíče + Uvodní synchnizace: +\nStahuji místnost + Uvodní synchronizace: +\nStahuji moje místnosti + Uvodní synchonizace: +\nStahuji místnosti, které jsem opustil/a + Úvodní sychronizace: +\nImportuji komunity + Úvodní synchronizace: +\nImportuji data účtu + + Posílám zprávu… diff --git a/matrix-sdk-android/src/main/res/values-ko/strings.xml b/matrix-sdk-android/src/main/res/values-ko/strings.xml index 9dfbb6609b..68e94bb641 100644 --- a/matrix-sdk-android/src/main/res/values-ko/strings.xml +++ b/matrix-sdk-android/src/main/res/values-ko/strings.xml @@ -49,7 +49,7 @@ %1$s님이 %2$s님에게 방 초대를 보냈습니다 %1$s님이 %2$s의 초대를 수락했습니다 - ** 암호를 해독할 수 없음: %s ** + ** 암호를 복호화할 수 없음: %s ** 발신인의 기기에서 이 메시지의 키를 보내지 않았습니다. 관련 대화 diff --git a/matrix-sdk-android/src/main/res/values-sk/strings.xml b/matrix-sdk-android/src/main/res/values-sk/strings.xml index d5c7d95fd6..b729932b1f 100644 --- a/matrix-sdk-android/src/main/res/values-sk/strings.xml +++ b/matrix-sdk-android/src/main/res/values-sk/strings.xml @@ -88,70 +88,70 @@ Správa odstránená používateľom %1$s Správa odstránená [dôvod: %1$s] Správa odstránená používateľom %1$s [dôvod: %2$s] - Pes - Mačka - Lev + Hlava psa + Hlava mačky + Hlava leva Kôň - Jednorožec - Prasa + Hlava jednorožca + Hlava prasaťa Slon - Zajac - Panda + Hlava zajaca + Hlava pandy Kohút Tučniak Korytnačka Ryba Chobotnica Motýľ - Kvetina - Strom + Tulipán + Listnatý strom Kaktus - Hríb + Huba Zemeguľa - Mesiac + Polmesiac Oblak Oheň Banán - Jablko + Červené jablko Jahoda - Kukurica + Kukuričný klas Pizza - Koláč - Srdce - Úsmev + Narodeninová torta + Červené + Škeriaca sa tvár Robot - Klobúk + Cylinder Okuliare - Skrutkovač - Mikuláš + Francúzsky kľúč + Santa Claus Palec nahor Dáždnik Presýpacie hodiny - Hodiny - Darček + Budík + Zabalený darček Žiarovka - Kniha + Zatvorená kniha Ceruzka - Kancelárska sponka + Sponka na papier Nožnice - Zámok + Zatvorená zámka Kľúč Kladivo Telefón - Vlajka - Vlak + Kockovaná zástava + Rušeň Bicykel Lietadlo Raketa Trofej - Lopta + Futbal Gitara Trúbka - Zvonček + Zvon Kotva - Schlúchadlá - Priečinok - Pin + Slúchadlá + Fascikel + Špendlík Úvodná synchronizácia: \nPrebieha import účtu… @@ -173,4 +173,5 @@ Odosielanie správy… Vymazať správy na odoslanie + %1$s zamietol pozvanie používateľa %2$s vstúpiť do miestnosti diff --git a/matrix-sdk-android/src/main/res/values-zh-rCN/strings.xml b/matrix-sdk-android/src/main/res/values-zh-rCN/strings.xml index 3aed8858a3..6e3ced3048 100644 --- a/matrix-sdk-android/src/main/res/values-zh-rCN/strings.xml +++ b/matrix-sdk-android/src/main/res/values-zh-rCN/strings.xml @@ -13,7 +13,7 @@ %1$s 封禁了 %2$s %1$s 更换了他们的头像 %1$s 将他们的昵称设置为 %2$s - %1$s 把他们的昵称从 %2$s 改为 %3$s + %1$s 把他的昵称从 %2$s 改为 %3$s %1$s 移除了他们的昵称 (%2$s) %1$s 把主题改为: %2$s %1$s 把聊天室名称改为: %2$s @@ -167,4 +167,7 @@ 正在发送消息… 清除正在发送队列 + %1$s 撤回了对 %2$s 邀请 + 置顶 + diff --git a/matrix-sdk-android/src/main/res/values/strings.xml b/matrix-sdk-android/src/main/res/values/strings.xml index ce26c22137..e611ae25b0 100644 --- a/matrix-sdk-android/src/main/res/values/strings.xml +++ b/matrix-sdk-android/src/main/res/values/strings.xml @@ -243,4 +243,18 @@ Sending message… Clear sending queue + %1$s\'s invitation. Reason: %2$s + %1$s invited %2$s. Reason: %3$s + %1$s invited you. Reason: %2$s + %1$s joined. Reason: %2$s + %1$s left. Reason: %2$s + %1$s rejected the invitation. Reason: %2$s + %1$s kicked %2$s. Reason: %3$s + %1$s unbanned %2$s. Reason: %3$s + %1$s banned %2$s. Reason: %3$s + %1$s sent an invitation to %2$s to join the room. Reason: %3$s + %1$s revoked the invitation for %2$s to join the room. Reason: %3$s + %1$s accepted the invitation for %2$s. Reason: %3$s + %1$s withdrew %2$s\'s invitation. Reason: %3$s + diff --git a/matrix-sdk-android/src/main/res/values/strings_RiotX.xml b/matrix-sdk-android/src/main/res/values/strings_RiotX.xml index a22533c6d1..03bc6d3684 100644 --- a/matrix-sdk-android/src/main/res/values/strings_RiotX.xml +++ b/matrix-sdk-android/src/main/res/values/strings_RiotX.xml @@ -2,19 +2,6 @@ - %1$s\'s invitation. Reason: %2$s - %1$s invited %2$s. Reason: %3$s - %1$s invited you. Reason: %2$s - %1$s joined. Reason: %2$s - %1$s left. Reason: %2$s - %1$s rejected the invitation. Reason: %2$s - %1$s kicked %2$s. Reason: %3$s - %1$s unbanned %2$s. Reason: %3$s - %1$s banned %2$s. Reason: %3$s - %1$s sent an invitation to %2$s to join the room. Reason: %3$s - %1$s revoked the invitation for %2$s to join the room. Reason: %3$s - %1$s accepted the invitation for %2$s. Reason: %3$s - %1$s withdrew %2$s\'s invitation. Reason: %3$s - There is no network connection right now + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 793f7a3426..d020abade4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx' +include ':vector', ':matrix-sdk-android', ':matrix-sdk-android-rx', ':diff-match-patch' diff --git a/vector/build.gradle b/vector/build.gradle index d7f4a8b453..de15a67fbd 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -15,7 +15,7 @@ androidExtensions { } ext.versionMajor = 0 -ext.versionMinor = 10 +ext.versionMinor = 11 ext.versionPatch = 0 static def getGitTimestamp() { @@ -229,6 +229,7 @@ dependencies { implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android-rx") + implementation project(":diff-match-patch") implementation 'com.android.support:multidex:1.0.3' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" @@ -292,6 +293,7 @@ dependencies { implementation 'me.gujun.android:span:1.7' implementation "io.noties.markwon:core:$markwon_version" implementation "io.noties.markwon:html:$markwon_version" + implementation 'com.googlecode.htmlcompressor:htmlcompressor:1.4' implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'com.google.android:flexbox:1.1.1' implementation "androidx.autofill:autofill:$autofill_version" @@ -341,8 +343,6 @@ dependencies { exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' } - implementation 'diff_match_patch:diff_match_patch:current' - implementation "androidx.emoji:emoji-appcompat:1.0.0" // TESTS diff --git a/vector/lint.xml b/vector/lint.xml index b6da88aedd..6a9b0634a7 100644 --- a/vector/lint.xml +++ b/vector/lint.xml @@ -31,4 +31,8 @@ + + + + diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 5f1687c9c9..068e7423d8 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -65,7 +65,13 @@ - + + + @@ -98,6 +104,22 @@ + + + + + + + + + + + + + + Copyright 2018 Kumar Bibek +
  • + htmlcompressor +
    + Copyright 2017 Sergiy Kovalchuk +
  •  Apache License
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt
    index 12dfcbcaac..ff9865c3ea 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ActiveSessionHolder.kt
    @@ -37,7 +37,7 @@ class ActiveSessionHolder @Inject constructor(private val authenticationService:
     
         fun setActiveSession(session: Session) {
             activeSession.set(session)
    -        sessionObservableStore.post(Option.fromNullable(session))
    +        sessionObservableStore.post(Option.just(session))
             keyRequestHandler.start(session)
             incomingVerificationRequestHandler.start(session)
         }
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    index 442c5f6f96..c86436d56a 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt
    @@ -47,6 +47,7 @@ import im.vector.riotx.features.roomdirectory.roompreview.RoomPreviewNoPreviewFr
     import im.vector.riotx.features.settings.*
     import im.vector.riotx.features.settings.ignored.VectorSettingsIgnoredUsersFragment
     import im.vector.riotx.features.settings.push.PushGatewaysFragment
    +import im.vector.riotx.features.signout.soft.SoftLogoutFragment
     
     @Module
     interface FragmentModule {
    @@ -261,4 +262,9 @@ interface FragmentModule {
         @IntoMap
         @FragmentKey(EmojiChooserFragment::class)
         fun bindEmojiChooserFragment(fragment: EmojiChooserFragment): Fragment
    +
    +    @Binds
    +    @IntoMap
    +    @FragmentKey(SoftLogoutFragment::class)
    +    fun bindSoftLogoutFragment(fragment: SoftLogoutFragment): Fragment
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    index 9f0f83a41f..e0b14af9d0 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt
    @@ -21,6 +21,7 @@ import androidx.fragment.app.FragmentFactory
     import androidx.lifecycle.ViewModelProvider
     import dagger.BindsInstance
     import dagger.Component
    +import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.core.preference.UserAvatarPreference
     import im.vector.riotx.features.MainActivity
     import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActivity
    @@ -40,6 +41,7 @@ import im.vector.riotx.features.login.LoginActivity
     import im.vector.riotx.features.media.ImageMediaViewerActivity
     import im.vector.riotx.features.media.VideoMediaViewerActivity
     import im.vector.riotx.features.navigation.Navigator
    +import im.vector.riotx.features.permalink.PermalinkHandlerActivity
     import im.vector.riotx.features.rageshake.BugReportActivity
     import im.vector.riotx.features.rageshake.BugReporter
     import im.vector.riotx.features.rageshake.RageShake
    @@ -49,6 +51,7 @@ import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
     import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity
     import im.vector.riotx.features.settings.VectorSettingsActivity
     import im.vector.riotx.features.share.IncomingShareActivity
    +import im.vector.riotx.features.signout.soft.SoftLogoutActivity
     import im.vector.riotx.features.ui.UiStateRepository
     
     @Component(
    @@ -78,6 +81,8 @@ interface ScreenComponent {
     
         fun navigator(): Navigator
     
    +    fun errorFormatter(): ErrorFormatter
    +
         fun uiStateRepository(): UiStateRepository
     
         fun inject(activity: HomeActivity)
    @@ -126,6 +131,10 @@ interface ScreenComponent {
     
         fun inject(roomListActionsBottomSheet: RoomListQuickActionsBottomSheet)
     
    +    fun inject(activity: SoftLogoutActivity)
    +
    +    fun inject(permalinkHandlerActivity: PermalinkHandlerActivity)
    +
         @Component.Factory
         interface Factory {
             fun create(vectorComponent: VectorComponent,
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt
    index c4b2c40787..b78e291506 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/VectorComponent.kt
    @@ -27,6 +27,7 @@ import im.vector.riotx.ActiveSessionDataSource
     import im.vector.riotx.EmojiCompatFontProvider
     import im.vector.riotx.EmojiCompatWrapper
     import im.vector.riotx.VectorApplication
    +import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.core.pushers.PushersManager
     import im.vector.riotx.core.utils.AssetReader
     import im.vector.riotx.core.utils.DimensionConverter
    @@ -37,6 +38,7 @@ import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.HomeRoomListDataSource
     import im.vector.riotx.features.home.group.SelectedGroupDataSource
     import im.vector.riotx.features.html.EventHtmlRenderer
    +import im.vector.riotx.features.html.VectorHtmlCompressor
     import im.vector.riotx.features.navigation.Navigator
     import im.vector.riotx.features.notifications.*
     import im.vector.riotx.features.rageshake.BugReporter
    @@ -86,8 +88,12 @@ interface VectorComponent {
     
         fun eventHtmlRenderer(): EventHtmlRenderer
     
    +    fun vectorHtmlCompressor(): VectorHtmlCompressor
    +
         fun navigator(): Navigator
     
    +    fun errorFormatter(): ErrorFormatter
    +
         fun homeRoomListObservableStore(): HomeRoomListDataSource
     
         fun shareRoomListObservableStore(): ShareRoomListDataSource
    diff --git a/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt b/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt
    index 84441d88e1..848c1e0d97 100644
    --- a/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/di/VectorModule.kt
    @@ -26,6 +26,8 @@ import dagger.Provides
     import im.vector.matrix.android.api.Matrix
     import im.vector.matrix.android.api.auth.AuthenticationService
     import im.vector.matrix.android.api.session.Session
    +import im.vector.riotx.core.error.DefaultErrorFormatter
    +import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.features.navigation.DefaultNavigator
     import im.vector.riotx.features.navigation.Navigator
     import im.vector.riotx.features.ui.SharedPreferencesUiStateRepository
    @@ -72,6 +74,9 @@ abstract class VectorModule {
         @Binds
         abstract fun bindNavigator(navigator: DefaultNavigator): Navigator
     
    +    @Binds
    +    abstract fun bindErrorFormatter(errorFormatter: DefaultErrorFormatter): ErrorFormatter
    +
         @Binds
         abstract fun bindUiStateRepository(uiStateRepository: SharedPreferencesUiStateRepository): UiStateRepository
     }
    diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/MatrixCallbackSingle.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/ZeroItem.kt
    similarity index 50%
    rename from matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/MatrixCallbackSingle.kt
    rename to vector/src/main/java/im/vector/riotx/core/epoxy/ZeroItem.kt
    index d638354dfd..b64abdcc6c 100644
    --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/MatrixCallbackSingle.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/ZeroItem.kt
    @@ -14,25 +14,17 @@
      * limitations under the License.
      */
     
    -package im.vector.matrix.rx
    +package im.vector.riotx.core.epoxy
     
    -import im.vector.matrix.android.api.MatrixCallback
    -import im.vector.matrix.android.api.util.Cancelable
    -import io.reactivex.SingleEmitter
    +import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.riotx.R
     
    -internal class MatrixCallbackSingle(private val singleEmitter: SingleEmitter) : MatrixCallback {
    +/**
    + * Item of size (0, 0).
    + * It can be useful to avoid automatic scroll of RecyclerView with Epoxy controller, when the first valuable item changes.
    + */
    +@EpoxyModelClass(layout = R.layout.item_zero)
    +abstract class ZeroItem : VectorEpoxyModel() {
     
    -    override fun onSuccess(data: T) {
    -        singleEmitter.onSuccess(data)
    -    }
    -
    -    override fun onFailure(failure: Throwable) {
    -        singleEmitter.tryOnError(failure)
    -    }
    -}
    -
    -fun  Cancelable.toSingle(singleEmitter: SingleEmitter) {
    -    singleEmitter.setCancellable {
    -        this.cancel()
    -    }
    +    class Holder : VectorEpoxyHolder()
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt
    index 8105d7a7c0..7b79ce8549 100644
    --- a/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt
    @@ -21,6 +21,7 @@ import android.widget.ImageView
     import android.widget.TextView
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -37,11 +38,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel null
                 is Failure.NetworkConnection -> {
    @@ -41,6 +42,7 @@ class ErrorFormatter @Inject constructor(private val stringProvider: StringProvi
                             stringProvider.getString(R.string.error_network_timeout)
                         throwable.ioException is UnknownHostException   ->
                             // Invalid homeserver?
    +                        // TODO Check network state, airplane mode, etc.
                             stringProvider.getString(R.string.login_error_unknown_host)
                         else                                            ->
                             stringProvider.getString(R.string.error_no_network)
    @@ -52,23 +54,23 @@ class ErrorFormatter @Inject constructor(private val stringProvider: StringProvi
                             // Special case for terms and conditions
                             stringProvider.getString(R.string.error_terms_not_accepted)
                         }
    -                    throwable.error.code == MatrixError.FORBIDDEN
    +                    throwable.error.code == MatrixError.M_FORBIDDEN
                                 && throwable.error.message == "Invalid password" -> {
                             stringProvider.getString(R.string.auth_invalid_login_param)
                         }
    -                    throwable.error.code == MatrixError.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.BAD_JSON             -> {
    +                    throwable.error.code == MatrixError.M_BAD_JSON           -> {
                             stringProvider.getString(R.string.login_error_bad_json)
                         }
    -                    throwable.error.code == MatrixError.NOT_JSON             -> {
    +                    throwable.error.code == MatrixError.M_NOT_JSON           -> {
                             stringProvider.getString(R.string.login_error_not_json)
                         }
    -                    throwable.error.code == MatrixError.LIMIT_EXCEEDED       -> {
    +                    throwable.error.code == MatrixError.M_LIMIT_EXCEEDED     -> {
                             limitExceededError(throwable.error)
                         }
    -                    throwable.error.code == MatrixError.THREEPID_NOT_FOUND   -> {
    +                    throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> {
                             stringProvider.getString(R.string.login_reset_password_error_not_found)
                         }
                         else                                                     -> {
    diff --git a/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt b/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt
    index dd4257fe1f..614340bd3d 100644
    --- a/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/error/Extensions.kt
    @@ -21,6 +21,6 @@ import im.vector.matrix.android.api.failure.MatrixError
     import javax.net.ssl.HttpsURLConnection
     
     fun Throwable.is401(): Boolean {
    -    return (this is Failure.ServerError && this.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED /* 401 */
    -            && this.error.code == MatrixError.UNAUTHORIZED)
    +    return (this is Failure.ServerError && httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED /* 401 */
    +            && error.code == MatrixError.M_UNAUTHORIZED)
     }
    diff --git a/vector/src/main/java/im/vector/riotx/core/error/fatal.kt b/vector/src/main/java/im/vector/riotx/core/error/fatal.kt
    new file mode 100644
    index 0000000000..800e1ea7ad
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/core/error/fatal.kt
    @@ -0,0 +1,31 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.core.error
    +
    +import im.vector.riotx.BuildConfig
    +import timber.log.Timber
    +
    +/**
    + * throw in debug, only log in production. As this method does not always throw, next statement should be a return
    + */
    +fun fatalError(message: String) {
    +    if (BuildConfig.DEBUG) {
    +        error(message)
    +    } else {
    +        Timber.e(message)
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    index 0ce4d04497..67e866bb82 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Session.kt
    @@ -19,6 +19,7 @@ package im.vector.riotx.core.extensions
     import androidx.lifecycle.Lifecycle
     import androidx.lifecycle.ProcessLifecycleOwner
     import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
     import im.vector.matrix.android.api.session.sync.FilterService
     import im.vector.riotx.features.notifications.PushRuleTriggerListener
     import im.vector.riotx.features.session.SessionListener
    @@ -40,3 +41,11 @@ fun Session.configureAndStart(pushRuleTriggerListener: PushRuleTriggerListener,
         // @Inject lateinit var incomingVerificationRequestHandler: IncomingVerificationRequestHandler
         // @Inject lateinit var keyRequestHandler: KeyRequestHandler
     }
    +
    +/**
    + * Tell is the session has unsaved e2e keys in the backup
    + */
    +fun Session.hasUnsavedKeys(): Boolean {
    +    return inboundGroupSessionsCount(false) > 0
    +            && getKeysBackupService().state != KeysBackupState.ReadyToBackUp
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/UrlExtensions.kt b/vector/src/main/java/im/vector/riotx/core/extensions/UrlExtensions.kt
    index 9a26cedf9a..7614fda619 100644
    --- a/vector/src/main/java/im/vector/riotx/core/extensions/UrlExtensions.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/extensions/UrlExtensions.kt
    @@ -35,3 +35,12 @@ fun StringBuilder.appendParamToUrl(param: String, value: String): StringBuilder
     
         return this
     }
    +
    +/**
    + * Ex: "https://matrix.org/" -> "matrix.org"
    + */
    +fun String?.toReducedUrl(): String {
    +    return (this ?: "")
    +            .substringAfter("://")
    +            .trim { it == '/' }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt
    index 79b040cd41..f70aed9393 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseActivity.kt
    @@ -38,12 +38,15 @@ import butterknife.Unbinder
     import com.airbnb.mvrx.MvRx
     import com.bumptech.glide.util.Util
     import com.google.android.material.snackbar.Snackbar
    +import im.vector.matrix.android.api.failure.GlobalError
     import im.vector.riotx.BuildConfig
     import im.vector.riotx.R
     import im.vector.riotx.core.di.*
     import im.vector.riotx.core.dialogs.DialogLocker
     import im.vector.riotx.core.extensions.observeEvent
     import im.vector.riotx.core.utils.toast
    +import im.vector.riotx.features.MainActivity
    +import im.vector.riotx.features.MainActivityArgs
     import im.vector.riotx.features.configuration.VectorConfiguration
     import im.vector.riotx.features.consent.ConsentNotGivenHelper
     import im.vector.riotx.features.navigation.Navigator
    @@ -89,6 +92,9 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
         protected lateinit var navigator: Navigator
         private lateinit var activeSessionHolder: ActiveSessionHolder
     
    +    // Filter for multiple invalid token error
    +    private var mainActivityStarted = false
    +
         private var unBinder: Unbinder? = null
     
         private var savedInstanceState: Bundle? = null
    @@ -153,9 +159,8 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
             })
     
             sessionListener = getVectorComponent().sessionListener()
    -        sessionListener.consentNotGivenLiveData.observeEvent(this) {
    -            consentNotGivenHelper.displayDialog(it.consentUri,
    -                    activeSessionHolder.getActiveSession().sessionParams.homeServerConnectionConfig.homeServerUri.host ?: "")
    +        sessionListener.globalErrorLiveData.observeEvent(this) {
    +            handleGlobalError(it)
             }
     
             doBeforeSetContentView()
    @@ -180,6 +185,33 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
             }
         }
     
    +    private fun handleGlobalError(globalError: GlobalError) {
    +        when (globalError) {
    +            is GlobalError.InvalidToken         ->
    +                handleInvalidToken(globalError)
    +            is GlobalError.ConsentNotGivenError ->
    +                consentNotGivenHelper.displayDialog(globalError.consentUri,
    +                        activeSessionHolder.getActiveSession().sessionParams.homeServerConnectionConfig.homeServerUri.host ?: "")
    +        }
    +    }
    +
    +    protected open fun handleInvalidToken(globalError: GlobalError.InvalidToken) {
    +        Timber.w("Invalid token event received")
    +        if (mainActivityStarted) {
    +            return
    +        }
    +
    +        mainActivityStarted = true
    +
    +        MainActivity.restartApp(this,
    +                MainActivityArgs(
    +                        clearCredentials = !globalError.softLogout,
    +                        isUserLoggedOut = true,
    +                        isSoftLogout = globalError.softLogout
    +                )
    +        )
    +    }
    +
         override fun onDestroy() {
             super.onDestroy()
             unBinder?.unbind()
    @@ -190,8 +222,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), HasScreenInjector {
     
         override fun onResume() {
             super.onResume()
    -
    -        Timber.v("onResume Activity ${this.javaClass.simpleName}")
    +        Timber.i("onResume Activity ${this.javaClass.simpleName}")
     
             configurationViewModel.onActivityResumed()
     
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt
    index 70311e2f57..b3a56f48ee 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseBottomSheetDialogFragment.kt
    @@ -32,6 +32,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
     import im.vector.riotx.core.di.DaggerScreenComponent
     import im.vector.riotx.core.di.ScreenComponent
     import im.vector.riotx.core.utils.DimensionConverter
    +import timber.log.Timber
     
     /**
      * Add MvRx capabilities to bottomsheetdialog (like BaseMvRxFragment)
    @@ -80,6 +81,11 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomSheetDialogFragment()
             super.onCreate(savedInstanceState)
         }
     
    +    override fun onResume() {
    +        super.onResume()
    +        Timber.i("onResume BottomSheet ${this.javaClass.simpleName}")
    +    }
    +
         override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
             return super.onCreateDialog(savedInstanceState).apply {
                 val dialog = this as? BottomSheetDialog
    diff --git a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt
    index 924cb6c7bc..8e1ad72150 100644
    --- a/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/platform/VectorBaseFragment.kt
    @@ -34,6 +34,7 @@ import com.bumptech.glide.util.Util.assertMainThread
     import im.vector.riotx.core.di.DaggerScreenComponent
     import im.vector.riotx.core.di.HasScreenInjector
     import im.vector.riotx.core.di.ScreenComponent
    +import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.features.navigation.Navigator
     import io.reactivex.disposables.CompositeDisposable
     import io.reactivex.disposables.Disposable
    @@ -49,12 +50,14 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), HasScreenInjector {
         }
     
         /* ==========================================================================================
    -     * Navigator
    +     * Navigator and other common objects
          * ========================================================================================== */
     
    -    protected lateinit var navigator: Navigator
         private lateinit var screenComponent: ScreenComponent
     
    +    protected lateinit var navigator: Navigator
    +    protected lateinit var errorFormatter: ErrorFormatter
    +
         /* ==========================================================================================
          * View model
          * ========================================================================================== */
    @@ -74,6 +77,7 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), HasScreenInjector {
         override fun onAttach(context: Context) {
             screenComponent = DaggerScreenComponent.factory().create(vectorBaseActivity.getVectorComponent(), vectorBaseActivity)
             navigator = screenComponent.navigator()
    +        errorFormatter = screenComponent.errorFormatter()
             viewModelFactory = screenComponent.viewModelFactory()
             childFragmentManager.fragmentFactory = screenComponent.fragmentFactory()
             injectWith(injector())
    @@ -100,7 +104,7 @@ abstract class VectorBaseFragment : BaseMvRxFragment(), HasScreenInjector {
         @CallSuper
         override fun onResume() {
             super.onResume()
    -        Timber.v("onResume Fragment ${this.javaClass.simpleName}")
    +        Timber.i("onResume Fragment ${this.javaClass.simpleName}")
         }
     
         @CallSuper
    diff --git a/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt b/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt
    index a2c3e90910..c7fcf85a16 100755
    --- a/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/preference/UserAvatarPreference.kt
    @@ -23,6 +23,8 @@ import android.widget.ProgressBar
     import androidx.preference.Preference
     import androidx.preference.PreferenceViewHolder
     import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.util.MatrixItem
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.extensions.vectorComponent
     import im.vector.riotx.features.home.AvatarRenderer
    @@ -59,9 +61,9 @@ open class UserAvatarPreference : Preference {
             val session = mSession ?: return
             val view = mAvatarView ?: return
             session.getUser(session.myUserId)?.let {
    -            avatarRenderer.render(it, view)
    +            avatarRenderer.render(it.toMatrixItem(), view)
             } ?: run {
    -            avatarRenderer.render(null, session.myUserId, null, view)
    +            avatarRenderer.render(MatrixItem.UserItem(session.myUserId), view)
             }
         }
     
    diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/ConsentNotGivenError.kt b/vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt
    similarity index 79%
    rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/ConsentNotGivenError.kt
    rename to vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt
    index 80ee6811bb..65ab0ad2b2 100644
    --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/ConsentNotGivenError.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/model/Size.kt
    @@ -14,9 +14,7 @@
      * limitations under the License.
      */
     
    -package im.vector.matrix.android.api.failure
    +package im.vector.riotx.core.ui.model
     
    -// This data class will be sent to the bus
    -data class ConsentNotGivenError(
    -        val consentUri: String
    -)
    +// android.util.Size in API 21+
    +data class Size(val width: Int, val height: Int)
    diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadReceiptsView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadReceiptsView.kt
    index 6e4229908f..c5e2fdf375 100644
    --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadReceiptsView.kt
    +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadReceiptsView.kt
    @@ -26,6 +26,7 @@ import im.vector.riotx.R
     import im.vector.riotx.core.glide.GlideApp
     import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
    +import im.vector.riotx.features.home.room.detail.timeline.item.toMatrixItem
     import kotlinx.android.synthetic.main.view_read_receipts.view.*
     
     private const val MAX_RECEIPT_DISPLAYED = 5
    @@ -59,7 +60,7 @@ class ReadReceiptsView @JvmOverloads constructor(
                         receiptAvatars[index].visibility = View.INVISIBLE
                     } else {
                         receiptAvatars[index].visibility = View.VISIBLE
    -                    avatarRenderer.render(receiptData.avatarUrl, receiptData.userId, receiptData.displayName, receiptAvatars[index])
    +                    avatarRenderer.render(receiptData.toMatrixItem(), receiptAvatars[index])
                     }
                 }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/MainActivity.kt b/vector/src/main/java/im/vector/riotx/features/MainActivity.kt
    index 7064ad0d49..041eb85a11 100644
    --- a/vector/src/main/java/im/vector/riotx/features/MainActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/MainActivity.kt
    @@ -19,9 +19,11 @@ package im.vector.riotx.features
     import android.app.Activity
     import android.content.Intent
     import android.os.Bundle
    +import android.os.Parcelable
     import androidx.appcompat.app.AlertDialog
     import com.bumptech.glide.Glide
     import im.vector.matrix.android.api.MatrixCallback
    +import im.vector.matrix.android.api.failure.GlobalError
     import im.vector.riotx.R
     import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.di.ScreenComponent
    @@ -30,6 +32,10 @@ import im.vector.riotx.core.platform.VectorBaseActivity
     import im.vector.riotx.core.utils.deleteAllFiles
     import im.vector.riotx.features.home.HomeActivity
     import im.vector.riotx.features.login.LoginActivity
    +import im.vector.riotx.features.notifications.NotificationDrawerManager
    +import im.vector.riotx.features.signout.hard.SignedOutActivity
    +import im.vector.riotx.features.signout.soft.SoftLogoutActivity
    +import kotlinx.android.parcel.Parcelize
     import kotlinx.coroutines.Dispatchers
     import kotlinx.coroutines.GlobalScope
     import kotlinx.coroutines.launch
    @@ -37,23 +43,37 @@ import kotlinx.coroutines.withContext
     import timber.log.Timber
     import javax.inject.Inject
     
    +@Parcelize
    +data class MainActivityArgs(
    +        val clearCache: Boolean = false,
    +        val clearCredentials: Boolean = false,
    +        val isUserLoggedOut: Boolean = false,
    +        val isSoftLogout: Boolean = false
    +) : Parcelable
    +
    +/**
    + * This is the entry point of RiotX
    + * This Activity, when started with argument, is also doing some cleanup when user disconnects,
    + * clears cache, is logged out, or is soft logged out
    + */
     class MainActivity : VectorBaseActivity() {
     
         companion object {
    -        private const val EXTRA_CLEAR_CACHE = "EXTRA_CLEAR_CACHE"
    -        private const val EXTRA_CLEAR_CREDENTIALS = "EXTRA_CLEAR_CREDENTIALS"
    +        private const val EXTRA_ARGS = "EXTRA_ARGS"
     
             // Special action to clear cache and/or clear credentials
    -        fun restartApp(activity: Activity, clearCache: Boolean = false, clearCredentials: Boolean = false) {
    +        fun restartApp(activity: Activity, args: MainActivityArgs) {
                 val intent = Intent(activity, MainActivity::class.java)
                 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
     
    -            intent.putExtra(EXTRA_CLEAR_CACHE, clearCache)
    -            intent.putExtra(EXTRA_CLEAR_CREDENTIALS, clearCredentials)
    +            intent.putExtra(EXTRA_ARGS, args)
                 activity.startActivity(intent)
             }
         }
     
    +    private lateinit var args: MainActivityArgs
    +
    +    @Inject lateinit var notificationDrawerManager: NotificationDrawerManager
         @Inject lateinit var sessionHolder: ActiveSessionHolder
         @Inject lateinit var errorFormatter: ErrorFormatter
     
    @@ -63,42 +83,71 @@ class MainActivity : VectorBaseActivity() {
     
         override fun onCreate(savedInstanceState: Bundle?) {
             super.onCreate(savedInstanceState)
    -        val clearCache = intent.getBooleanExtra(EXTRA_CLEAR_CACHE, false)
    -        val clearCredentials = intent.getBooleanExtra(EXTRA_CLEAR_CREDENTIALS, false)
    +        args = parseArgs()
    +
    +        if (args.clearCredentials || args.isUserLoggedOut) {
    +            clearNotifications()
    +        }
     
             // Handle some wanted cleanup
    -        if (clearCache || clearCredentials) {
    -            doCleanUp(clearCache, clearCredentials)
    +        if (args.clearCache || args.clearCredentials) {
    +            doCleanUp()
             } else {
    -            start()
    +            startNextActivityAndFinish()
             }
         }
     
    -    private fun doCleanUp(clearCache: Boolean, clearCredentials: Boolean) {
    +    private fun clearNotifications() {
    +        // Dismiss all notifications
    +        notificationDrawerManager.clearAllEvents()
    +        notificationDrawerManager.persistInfo()
    +    }
    +
    +    private fun parseArgs(): MainActivityArgs {
    +        val argsFromIntent: MainActivityArgs? = intent.getParcelableExtra(EXTRA_ARGS)
    +        Timber.w("Starting MainActivity with $argsFromIntent")
    +
    +        return MainActivityArgs(
    +                clearCache = argsFromIntent?.clearCache ?: false,
    +                clearCredentials = argsFromIntent?.clearCredentials ?: false,
    +                isUserLoggedOut = argsFromIntent?.isUserLoggedOut ?: false,
    +                isSoftLogout = argsFromIntent?.isSoftLogout ?: false
    +        )
    +    }
    +
    +    private fun doCleanUp() {
             when {
    -            clearCredentials -> sessionHolder.getActiveSession().signOut(object : MatrixCallback {
    -                override fun onSuccess(data: Unit) {
    -                    Timber.w("SIGN_OUT: success, start app")
    -                    sessionHolder.clearActiveSession()
    -                    doLocalCleanupAndStart()
    -                }
    +            args.clearCredentials -> sessionHolder.getActiveSession().signOut(
    +                    !args.isUserLoggedOut,
    +                    object : MatrixCallback {
    +                        override fun onSuccess(data: Unit) {
    +                            Timber.w("SIGN_OUT: success, start app")
    +                            sessionHolder.clearActiveSession()
    +                            doLocalCleanupAndStart()
    +                        }
     
    -                override fun onFailure(failure: Throwable) {
    -                    displayError(failure, clearCache, clearCredentials)
    -                }
    -            })
    -            clearCache       -> sessionHolder.getActiveSession().clearCache(object : MatrixCallback {
    -                override fun onSuccess(data: Unit) {
    -                    doLocalCleanupAndStart()
    -                }
    +                        override fun onFailure(failure: Throwable) {
    +                            displayError(failure)
    +                        }
    +                    })
    +            args.clearCache       -> sessionHolder.getActiveSession().clearCache(
    +                    object : MatrixCallback {
    +                        override fun onSuccess(data: Unit) {
    +                            doLocalCleanupAndStart()
    +                        }
     
    -                override fun onFailure(failure: Throwable) {
    -                    displayError(failure, clearCache, clearCredentials)
    -                }
    -            })
    +                        override fun onFailure(failure: Throwable) {
    +                            displayError(failure)
    +                        }
    +                    })
             }
         }
     
    +    override fun handleInvalidToken(globalError: GlobalError.InvalidToken) {
    +        // No op here
    +        Timber.w("Ignoring invalid token global error")
    +    }
    +
         private fun doLocalCleanupAndStart() {
             GlobalScope.launch(Dispatchers.Main) {
                 // On UI Thread
    @@ -112,24 +161,43 @@ class MainActivity : VectorBaseActivity() {
                 }
             }
     
    -        start()
    +        startNextActivityAndFinish()
         }
     
    -    private fun displayError(failure: Throwable, clearCache: Boolean, clearCredentials: Boolean) {
    +    private fun displayError(failure: Throwable) {
             AlertDialog.Builder(this)
                     .setTitle(R.string.dialog_title_error)
                     .setMessage(errorFormatter.toHumanReadable(failure))
    -                .setPositiveButton(R.string.global_retry) { _, _ -> doCleanUp(clearCache, clearCredentials) }
    -                .setNegativeButton(R.string.cancel) { _, _ -> start() }
    +                .setPositiveButton(R.string.global_retry) { _, _ -> doCleanUp() }
    +                .setNegativeButton(R.string.cancel) { _, _ -> startNextActivityAndFinish() }
                     .setCancelable(false)
                     .show()
         }
     
    -    private fun start() {
    -        val intent = if (sessionHolder.hasActiveSession()) {
    -            HomeActivity.newIntent(this)
    -        } else {
    -            LoginActivity.newIntent(this, null)
    +    private fun startNextActivityAndFinish() {
    +        val intent = when {
    +            args.clearCredentials
    +                    && !args.isUserLoggedOut ->
    +                // User has explicitly asked to log out
    +                LoginActivity.newIntent(this, null)
    +            args.isSoftLogout                ->
    +                // The homeserver has invalidated the token, with a soft logout
    +                SoftLogoutActivity.newIntent(this)
    +            args.isUserLoggedOut             ->
    +                // the homeserver has invalidated the token (password changed, device deleted, other security reason
    +                SignedOutActivity.newIntent(this)
    +            sessionHolder.hasActiveSession() ->
    +                // We have a session.
    +                // Check it can be opened
    +                if (sessionHolder.getActiveSession().isOpenable) {
    +                    HomeActivity.newIntent(this)
    +                } else {
    +                    // The token is still invalid
    +                    SoftLogoutActivity.newIntent(this)
    +                }
    +            else                             ->
    +                // First start, or no active session
    +                LoginActivity.newIntent(this, null)
             }
             startActivity(intent)
             finish()
    diff --git a/vector/src/main/java/im/vector/riotx/features/attachments/AttachmentsMapper.kt b/vector/src/main/java/im/vector/riotx/features/attachments/AttachmentsMapper.kt
    index 5e843fcdfd..4b51c548a7 100644
    --- a/vector/src/main/java/im/vector/riotx/features/attachments/AttachmentsMapper.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/attachments/AttachmentsMapper.kt
    @@ -18,6 +18,7 @@ package im.vector.riotx.features.attachments
     
     import com.kbeanie.multipicker.api.entity.*
     import im.vector.matrix.android.api.session.content.ContentAttachmentData
    +import timber.log.Timber
     
     fun ChosenContact.toContactAttachment(): ContactAttachment {
         return ContactAttachment(
    @@ -29,6 +30,7 @@ fun ChosenContact.toContactAttachment(): ContactAttachment {
     }
     
     fun ChosenFile.toContentAttachmentData(): ContentAttachmentData {
    +    if (mimeType == null) Timber.w("No mimeType")
         return ContentAttachmentData(
                 path = originalPath,
                 mimeType = mimeType,
    @@ -40,6 +42,7 @@ fun ChosenFile.toContentAttachmentData(): ContentAttachmentData {
     }
     
     fun ChosenAudio.toContentAttachmentData(): ContentAttachmentData {
    +    if (mimeType == null) Timber.w("No mimeType")
         return ContentAttachmentData(
                 path = originalPath,
                 mimeType = mimeType,
    @@ -51,16 +54,17 @@ fun ChosenAudio.toContentAttachmentData(): ContentAttachmentData {
         )
     }
     
    -fun ChosenFile.mapType(): ContentAttachmentData.Type {
    +private fun ChosenFile.mapType(): ContentAttachmentData.Type {
         return when {
    -        mimeType.startsWith("image/") -> ContentAttachmentData.Type.IMAGE
    -        mimeType.startsWith("video/") -> ContentAttachmentData.Type.VIDEO
    -        mimeType.startsWith("audio/") -> ContentAttachmentData.Type.AUDIO
    -        else                          -> ContentAttachmentData.Type.FILE
    +        mimeType?.startsWith("image/") == true -> ContentAttachmentData.Type.IMAGE
    +        mimeType?.startsWith("video/") == true -> ContentAttachmentData.Type.VIDEO
    +        mimeType?.startsWith("audio/") == true -> ContentAttachmentData.Type.AUDIO
    +        else                                   -> ContentAttachmentData.Type.FILE
         }
     }
     
     fun ChosenImage.toContentAttachmentData(): ContentAttachmentData {
    +    if (mimeType == null) Timber.w("No mimeType")
         return ContentAttachmentData(
                 path = originalPath,
                 mimeType = mimeType,
    @@ -75,6 +79,7 @@ fun ChosenImage.toContentAttachmentData(): ContentAttachmentData {
     }
     
     fun ChosenVideo.toContentAttachmentData(): ContentAttachmentData {
    +    if (mimeType == null) Timber.w("No mimeType")
         return ContentAttachmentData(
                 path = originalPath,
                 mimeType = mimeType,
    diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt
    index 01b6bdd41a..8f0090001f 100644
    --- a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserController.kt
    @@ -18,11 +18,12 @@ package im.vector.riotx.features.autocomplete.user
     
     import com.airbnb.epoxy.TypedEpoxyController
     import im.vector.matrix.android.api.session.user.model.User
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.features.autocomplete.AutocompleteClickListener
     import im.vector.riotx.features.home.AvatarRenderer
     import javax.inject.Inject
     
    -class AutocompleteUserController @Inject constructor(): TypedEpoxyController>() {
    +class AutocompleteUserController @Inject constructor() : TypedEpoxyController>() {
     
         var listener: AutocompleteClickListener? = null
     
    @@ -35,9 +36,7 @@ class AutocompleteUserController @Inject constructor(): TypedEpoxyController
                 autocompleteUserItem {
                     id(user.userId)
    -                userId(user.userId)
    -                name(user.displayName)
    -                avatarUrl(user.avatarUrl)
    +                matrixItem(user.toMatrixItem())
                     avatarRenderer(avatarRenderer)
                     clickListener { _ ->
                         listener?.onItemClick(user)
    diff --git a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt b/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt
    index b32562d8e6..8581ba8e2c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/autocomplete/user/AutocompleteUserItem.kt
    @@ -21,6 +21,7 @@ import android.widget.ImageView
     import android.widget.TextView
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -30,15 +31,13 @@ import im.vector.riotx.features.home.AvatarRenderer
     abstract class AutocompleteUserItem : VectorEpoxyModel() {
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute var name: String? = null
    -    @EpoxyAttribute var userId: String = ""
    -    @EpoxyAttribute var avatarUrl: String? = null
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var clickListener: View.OnClickListener? = null
     
         override fun bind(holder: Holder) {
             holder.view.setOnClickListener(clickListener)
    -        holder.nameView.text = name
    -        avatarRenderer.render(avatarUrl, userId, name, holder.avatarImageView)
    +        holder.nameView.text = matrixItem.getBestName()
    +        avatarRenderer.render(matrixItem, holder.avatarImageView)
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/verification/SASVerificationIncomingFragment.kt b/vector/src/main/java/im/vector/riotx/features/crypto/verification/SASVerificationIncomingFragment.kt
    index 88df53d0f3..61f5c5f9fe 100644
    --- a/vector/src/main/java/im/vector/riotx/features/crypto/verification/SASVerificationIncomingFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/crypto/verification/SASVerificationIncomingFragment.kt
    @@ -22,6 +22,8 @@ import androidx.lifecycle.Observer
     import butterknife.BindView
     import butterknife.OnClick
     import im.vector.matrix.android.api.session.crypto.sas.IncomingSasVerificationTransaction
    +import im.vector.matrix.android.api.util.MatrixItem
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.platform.VectorBaseFragment
     import im.vector.riotx.features.home.AvatarRenderer
    @@ -57,10 +59,10 @@ class SASVerificationIncomingFragment @Inject constructor(
             otherDeviceTextView.text = viewModel.otherDeviceId
     
             viewModel.otherUser?.let {
    -            avatarRenderer.render(it, avatarImageView)
    +            avatarRenderer.render(it.toMatrixItem(), avatarImageView)
             } ?: run {
                 // Fallback to what we know
    -            avatarRenderer.render(null, viewModel.otherUserId ?: "", viewModel.otherUserId, avatarImageView)
    +            avatarRenderer.render(MatrixItem.UserItem(viewModel.otherUserId ?: "", viewModel.otherUserId), avatarImageView)
             }
     
             viewModel.transactionState.observe(viewLifecycleOwner, Observer {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt
    index 9975ee91cd..4e1808a48a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt
    @@ -27,10 +27,7 @@ import com.bumptech.glide.request.RequestOptions
     import com.bumptech.glide.request.target.DrawableImageViewTarget
     import com.bumptech.glide.request.target.Target
     import im.vector.matrix.android.api.session.content.ContentUrlResolver
    -import im.vector.matrix.android.api.session.room.model.RoomSummary
    -import im.vector.matrix.android.api.session.user.model.User
    -import im.vector.matrix.android.internal.util.firstLetterOfDisplayName
    -import im.vector.riotx.R
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.glide.GlideApp
     import im.vector.riotx.core.glide.GlideRequest
    @@ -45,76 +42,42 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
     
         companion object {
             private const val THUMBNAIL_SIZE = 250
    -
    -        private val AVATAR_COLOR_LIST = listOf(
    -                R.color.riotx_avatar_fill_1,
    -                R.color.riotx_avatar_fill_2,
    -                R.color.riotx_avatar_fill_3
    -        )
         }
     
         @UiThread
    -    fun render(roomSummary: RoomSummary, imageView: ImageView) {
    -        render(roomSummary.avatarUrl, roomSummary.roomId, roomSummary.displayName, imageView)
    -    }
    -
    -    @UiThread
    -    fun render(user: User, imageView: ImageView) {
    -        render(imageView.context, GlideApp.with(imageView), user.avatarUrl, user.userId, user.displayName, DrawableImageViewTarget(imageView))
    -    }
    -
    -    @UiThread
    -    fun render(avatarUrl: String?, identifier: String, name: String?, imageView: ImageView) {
    -        render(imageView.context, GlideApp.with(imageView), avatarUrl, identifier, name, DrawableImageViewTarget(imageView))
    +    fun render(matrixItem: MatrixItem, imageView: ImageView) {
    +        render(imageView.context,
    +                GlideApp.with(imageView),
    +                matrixItem,
    +                DrawableImageViewTarget(imageView))
         }
     
         @UiThread
         fun render(context: Context,
                    glideRequest: GlideRequests,
    -               avatarUrl: String?,
    -               identifier: String,
    -               name: String?,
    +               matrixItem: MatrixItem,
                    target: Target) {
    -        val displayName = if (name.isNullOrBlank()) {
    -            identifier
    -        } else {
    -            name
    -        }
    -        val placeholder = getPlaceholderDrawable(context, identifier, displayName)
    -        buildGlideRequest(glideRequest, avatarUrl)
    +        val placeholder = getPlaceholderDrawable(context, matrixItem)
    +        buildGlideRequest(glideRequest, matrixItem.avatarUrl)
                     .placeholder(placeholder)
                     .into(target)
         }
     
         @AnyThread
    -    fun getPlaceholderDrawable(context: Context, identifier: String, text: String): Drawable {
    -        val avatarColor = ContextCompat.getColor(context, getColorFromUserId(identifier))
    -        return if (text.isEmpty()) {
    -            TextDrawable.builder().buildRound("", avatarColor)
    -        } else {
    -            val firstLetter = text.firstLetterOfDisplayName()
    -            TextDrawable.builder()
    -                    .beginConfig()
    -                    .bold()
    -                    .endConfig()
    -                    .buildRound(firstLetter, avatarColor)
    +    fun getPlaceholderDrawable(context: Context, matrixItem: MatrixItem): Drawable {
    +        val avatarColor = when (matrixItem) {
    +            is MatrixItem.UserItem -> ContextCompat.getColor(context, getColorFromUserId(matrixItem.id))
    +            else                   -> ContextCompat.getColor(context, getColorFromRoomId(matrixItem.id))
             }
    +        return TextDrawable.builder()
    +                .beginConfig()
    +                .bold()
    +                .endConfig()
    +                .buildRound(matrixItem.firstLetterOfDisplayName(), avatarColor)
         }
     
         // PRIVATE API *********************************************************************************
     
    -//    private fun getAvatarColor(text: String? = null): Int {
    -//        var colorIndex: Long = 0
    -//        if (!text.isNullOrEmpty()) {
    -//            var sum: Long = 0
    -//            for (i in 0 until text.length) {
    -//                sum += text[i].toLong()
    -//            }
    -//            colorIndex = sum % AVATAR_COLOR_LIST.size
    -//        }
    -//        return AVATAR_COLOR_LIST[colorIndex.toInt()]
    -//    }
    -
         private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest {
             val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver()
                     .resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    index ac8d429cb1..fc0eeaf92c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailFragment.kt
    @@ -27,6 +27,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationItemView
     import com.google.android.material.bottomnavigation.BottomNavigationMenuView
     import im.vector.matrix.android.api.session.crypto.keysbackup.KeysBackupState
     import im.vector.matrix.android.api.session.group.model.GroupSummary
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.extensions.commitTransactionNow
     import im.vector.riotx.core.platform.ToolbarConfigurable
    @@ -74,12 +75,7 @@ class HomeDetailFragment @Inject constructor(
     
         private fun onGroupChange(groupSummary: GroupSummary?) {
             groupSummary?.let {
    -            avatarRenderer.render(
    -                    it.avatarUrl,
    -                    it.groupId,
    -                    it.displayName,
    -                    groupToolbarAvatarImageView
    -            )
    +            avatarRenderer.render(it.toMatrixItem(), groupToolbarAvatarImageView)
             }
         }
     
    @@ -155,7 +151,7 @@ class HomeDetailFragment @Inject constructor(
             bottomNavigationView.selectedItemId = when (displayMode) {
                 RoomListDisplayMode.PEOPLE -> R.id.bottom_action_people
                 RoomListDisplayMode.ROOMS  -> R.id.bottom_action_rooms
    -            else                                -> R.id.bottom_action_home
    +            else                       -> R.id.bottom_action_home
             }
         }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailViewState.kt
    index c7c5e4a233..1777fa03c1 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDetailViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDetailViewState.kt
    @@ -34,5 +34,5 @@ data class HomeDetailViewState(
             val notificationHighlightPeople: Boolean = false,
             val notificationCountRooms: Int = 0,
             val notificationHighlightRooms: Boolean = false,
    -        val syncState: SyncState = SyncState.IDLE
    +        val syncState: SyncState = SyncState.Idle
     ) : MvRxState
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt
    index 422b59671e..6ff836e8c8 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/HomeDrawerFragment.kt
    @@ -19,6 +19,7 @@ package im.vector.riotx.features.home
     import android.os.Bundle
     import android.view.View
     import im.vector.matrix.android.api.session.Session
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.extensions.observeK
     import im.vector.riotx.core.extensions.replaceChildFragment
    @@ -42,7 +43,7 @@ class HomeDrawerFragment @Inject constructor(
             session.liveUser(session.myUserId).observeK(this) { optionalUser ->
                 val user = optionalUser?.getOrNull()
                 if (user != null) {
    -                avatarRenderer.render(user.avatarUrl, user.userId, user.displayName, homeDrawerHeaderAvatarView)
    +                avatarRenderer.render(user.toMatrixItem(), homeDrawerHeaderAvatarView)
                     homeDrawerUsernameView.text = user.displayName
                     homeDrawerUserIdView.text = user.userId
                 }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/PermalinkHandler.kt b/vector/src/main/java/im/vector/riotx/features/home/PermalinkHandler.kt
    deleted file mode 100644
    index 00bcd87253..0000000000
    --- a/vector/src/main/java/im/vector/riotx/features/home/PermalinkHandler.kt
    +++ /dev/null
    @@ -1,87 +0,0 @@
    -/*
    - * Copyright 2019 New Vector Ltd
    - *
    - * Licensed under the Apache License, Version 2.0 (the "License");
    - * you may not use this file except in compliance with the License.
    - * You may obtain a copy of the License at
    - *
    - * http://www.apache.org/licenses/LICENSE-2.0
    - *
    - * Unless required by applicable law or agreed to in writing, software
    - * distributed under the License is distributed on an "AS IS" BASIS,
    - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    - * See the License for the specific language governing permissions and
    - * limitations under the License.
    - */
    -
    -package im.vector.riotx.features.home
    -
    -import android.content.Context
    -import android.net.Uri
    -import im.vector.matrix.android.api.permalinks.PermalinkData
    -import im.vector.matrix.android.api.permalinks.PermalinkParser
    -import im.vector.matrix.android.api.session.Session
    -import im.vector.riotx.features.navigation.Navigator
    -import javax.inject.Inject
    -
    -class PermalinkHandler @Inject constructor(private val session: Session,
    -                                           private val navigator: Navigator) {
    -
    -    fun launch(context: Context, deepLink: String?, navigateToRoomInterceptor: NavigateToRoomInterceptor? = null): Boolean {
    -        val uri = deepLink?.let { Uri.parse(it) }
    -        return launch(context, uri, navigateToRoomInterceptor)
    -    }
    -
    -    fun launch(context: Context, deepLink: Uri?, navigateToRoomInterceptor: NavigateToRoomInterceptor? = null): Boolean {
    -        if (deepLink == null) {
    -            return false
    -        }
    -
    -        return when (val permalinkData = PermalinkParser.parse(deepLink)) {
    -            is PermalinkData.EventLink    -> {
    -                if (navigateToRoomInterceptor?.navToRoom(permalinkData.roomIdOrAlias, permalinkData.eventId) != true) {
    -                    openRoom(context, permalinkData.roomIdOrAlias, permalinkData.eventId)
    -                }
    -
    -                true
    -            }
    -            is PermalinkData.RoomLink     -> {
    -                if (navigateToRoomInterceptor?.navToRoom(permalinkData.roomIdOrAlias) != true) {
    -                    openRoom(context, permalinkData.roomIdOrAlias)
    -                }
    -
    -                true
    -            }
    -            is PermalinkData.GroupLink    -> {
    -                navigator.openGroupDetail(permalinkData.groupId, context)
    -                true
    -            }
    -            is PermalinkData.UserLink     -> {
    -                navigator.openUserDetail(permalinkData.userId, context)
    -                true
    -            }
    -            is PermalinkData.FallbackLink -> {
    -                false
    -            }
    -        }
    -    }
    -
    -    /**
    -     * Open room either joined, or not unknown
    -     */
    -    private fun openRoom(context: Context, roomIdOrAlias: String, eventId: String? = null) {
    -        if (session.getRoom(roomIdOrAlias) != null) {
    -            navigator.openRoom(context, roomIdOrAlias, eventId)
    -        } else {
    -            navigator.openNotJoinedRoom(context, roomIdOrAlias, eventId)
    -        }
    -    }
    -}
    -
    -interface NavigateToRoomInterceptor {
    -
    -    /**
    -     * Return true if the navigation has been intercepted
    -     */
    -    fun navToRoom(roomId: String, eventId: String? = null): Boolean
    -}
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/RoomColor.kt b/vector/src/main/java/im/vector/riotx/features/home/RoomColor.kt
    new file mode 100644
    index 0000000000..0b3fd5396f
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/home/RoomColor.kt
    @@ -0,0 +1,29 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.home
    +
    +import androidx.annotation.ColorRes
    +import im.vector.riotx.R
    +
    +@ColorRes
    +fun getColorFromRoomId(roomId: String?): Int {
    +    return when ((roomId?.toList()?.sumBy { it.toInt() } ?: 0) % 3) {
    +        1    -> R.color.riotx_avatar_fill_2
    +        2    -> R.color.riotx_avatar_fill_3
    +        else -> R.color.riotx_avatar_fill_1
    +    }
    +}
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/UserColor.kt b/vector/src/main/java/im/vector/riotx/features/home/UserColor.kt
    index a88299cc25..d34ca6506a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/UserColor.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/UserColor.kt
    @@ -22,28 +22,18 @@ import kotlin.math.abs
     
     @ColorRes
     fun getColorFromUserId(userId: String?): Int {
    -    if (userId.isNullOrBlank()) {
    -        return R.color.riotx_username_1
    -    }
    -
         var hash = 0
    -    var i = 0
    -    var chr: Char
     
    -    while (i < userId.length) {
    -        chr = userId[i]
    -        hash = (hash shl 5) - hash + chr.toInt()
    -        i++
    -    }
    +    userId?.toList()?.map { chr -> hash = (hash shl 5) - hash + chr.toInt() }
     
    -    return when (abs(hash) % 8 + 1) {
    -        1    -> R.color.riotx_username_1
    -        2    -> R.color.riotx_username_2
    -        3    -> R.color.riotx_username_3
    -        4    -> R.color.riotx_username_4
    -        5    -> R.color.riotx_username_5
    -        6    -> R.color.riotx_username_6
    -        7    -> R.color.riotx_username_7
    -        else -> R.color.riotx_username_8
    +    return when (abs(hash) % 8) {
    +        1    -> R.color.riotx_username_2
    +        2    -> R.color.riotx_username_3
    +        3    -> R.color.riotx_username_4
    +        4    -> R.color.riotx_username_5
    +        5    -> R.color.riotx_username_6
    +        6    -> R.color.riotx_username_7
    +        7    -> R.color.riotx_username_8
    +        else -> R.color.riotx_username_1
         }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt
    index 0ff4c5baf8..401d4445fe 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/CreateDirectRoomUserItem.kt
    @@ -25,6 +25,7 @@ import androidx.core.content.ContextCompat
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import com.amulyakhare.textdrawable.TextDrawable
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -34,22 +35,20 @@ import im.vector.riotx.features.home.AvatarRenderer
     abstract class CreateDirectRoomUserItem : VectorEpoxyModel() {
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute var name: String? = null
    -    @EpoxyAttribute var userId: String = ""
    -    @EpoxyAttribute var avatarUrl: String? = null
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var clickListener: View.OnClickListener? = null
         @EpoxyAttribute var selected: Boolean = false
     
         override fun bind(holder: Holder) {
             holder.view.setOnClickListener(clickListener)
             // If name is empty, use userId as name and force it being centered
    -        if (name.isNullOrEmpty()) {
    +        if (matrixItem.displayName.isNullOrEmpty()) {
                 holder.userIdView.visibility = View.GONE
    -            holder.nameView.text = userId
    +            holder.nameView.text = matrixItem.id
             } else {
                 holder.userIdView.visibility = View.VISIBLE
    -            holder.nameView.text = name
    -            holder.userIdView.text = userId
    +            holder.nameView.text = matrixItem.displayName
    +            holder.userIdView.text = matrixItem.id
             }
             renderSelection(holder, selected)
         }
    @@ -62,7 +61,7 @@ abstract class CreateDirectRoomUserItem : VectorEpoxyModel
    -                                    users.sortedBy { it.displayName.firstLetterOfDisplayName() }
    +                                    users.sortedBy { it.toMatrixItem().firstLetterOfDisplayName() }
                                     }
                         }
                         stream.toAsync {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/DirectoryUsersController.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/DirectoryUsersController.kt
    index 265a38b2c9..8d2b3928be 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/DirectoryUsersController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/DirectoryUsersController.kt
    @@ -19,9 +19,13 @@
     package im.vector.riotx.features.home.createdirect
     
     import com.airbnb.epoxy.EpoxyController
    -import com.airbnb.mvrx.*
    +import com.airbnb.mvrx.Fail
    +import com.airbnb.mvrx.Loading
    +import com.airbnb.mvrx.Success
    +import com.airbnb.mvrx.Uninitialized
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.user.model.User
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.errorWithRetryItem
     import im.vector.riotx.core.epoxy.loadingItem
    @@ -94,9 +98,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
                 createDirectRoomUserItem {
                     id(user.userId)
                     selected(isSelected)
    -                userId(user.userId)
    -                name(user.displayName)
    -                avatarUrl(user.avatarUrl)
    +                matrixItem(user.toMatrixItem())
                     avatarRenderer(avatarRenderer)
                     clickListener { _ ->
                         callback?.onItemClick(user)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt
    index 3d1ee84254..8270683975 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt
    @@ -23,7 +23,7 @@ import com.airbnb.mvrx.Incomplete
     import com.airbnb.mvrx.Uninitialized
     import im.vector.matrix.android.api.session.Session
     import im.vector.matrix.android.api.session.user.model.User
    -import im.vector.matrix.android.internal.util.firstLetterOfDisplayName
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.EmptyItem_
     import im.vector.riotx.core.epoxy.loadingItem
    @@ -68,9 +68,7 @@ class KnownUsersController @Inject constructor(private val session: Session,
                 CreateDirectRoomUserItem_()
                         .id(item.userId)
                         .selected(isSelected)
    -                    .userId(item.userId)
    -                    .name(item.displayName)
    -                    .avatarUrl(item.avatarUrl)
    +                    .matrixItem(item.toMatrixItem())
                         .avatarRenderer(avatarRenderer)
                         .clickListener { _ ->
                             callback?.onItemClick(item)
    @@ -87,8 +85,8 @@ class KnownUsersController @Inject constructor(private val session: Session,
                 var lastFirstLetter: String? = null
                 for (model in models) {
                     if (model is CreateDirectRoomUserItem) {
    -                    if (model.userId == session.myUserId) continue
    -                    val currentFirstLetter = model.name.firstLetterOfDisplayName()
    +                    if (model.matrixItem.id == session.myUserId) continue
    +                    val currentFirstLetter = model.matrixItem.firstLetterOfDisplayName()
                         val showLetter = !isFiltering && currentFirstLetter.isNotEmpty() && lastFirstLetter != currentFirstLetter
                         lastFirstLetter = currentFirstLetter
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt
    index d9a38d5d9b..24318bc508 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/group/GroupListViewModel.kt
    @@ -36,7 +36,7 @@ import im.vector.riotx.core.utils.LiveEvent
     import io.reactivex.Observable
     import io.reactivex.functions.BiFunction
     
    -const val ALL_COMMUNITIES_GROUP_ID = "ALL_COMMUNITIES_GROUP_ID"
    +const val ALL_COMMUNITIES_GROUP_ID = "+ALL_COMMUNITIES_GROUP_ID"
     
     class GroupListViewModel @AssistedInject constructor(@Assisted initialState: GroupListViewState,
                                                          private val selectedGroupStore: SelectedGroupDataSource,
    @@ -68,14 +68,14 @@ class GroupListViewModel @AssistedInject constructor(@Assisted initialState: Gro
         }
     
         private fun observeSelectionState() {
    -        selectSubscribe(GroupListViewState::selectedGroup) {
    -            if (it != null) {
    +        selectSubscribe(GroupListViewState::selectedGroup) { groupSummary ->
    +            if (groupSummary != null) {
                     val selectedGroup = _openGroupLiveData.value?.peekContent()
    -                // We only wan to open group if the updated selectedGroup is a different one.
    -                if (selectedGroup?.groupId != it.groupId) {
    -                    _openGroupLiveData.postLiveEvent(it)
    +                // We only want to open group if the updated selectedGroup is a different one.
    +                if (selectedGroup?.groupId != groupSummary.groupId) {
    +                    _openGroupLiveData.postLiveEvent(groupSummary)
                     }
    -                val optionGroup = Option.fromNullable(it)
    +                val optionGroup = Option.just(groupSummary)
                     selectedGroupStore.post(optionGroup)
                 }
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryController.kt b/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryController.kt
    index 7c3cfd2a94..95054d1689 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryController.kt
    @@ -18,6 +18,7 @@ package im.vector.riotx.features.home.group
     
     import com.airbnb.epoxy.EpoxyController
     import im.vector.matrix.android.api.session.group.model.GroupSummary
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.features.home.AvatarRenderer
     import javax.inject.Inject
     
    @@ -49,10 +50,8 @@ class GroupSummaryController @Inject constructor(private val avatarRenderer: Ava
                 groupSummaryItem {
                     avatarRenderer(avatarRenderer)
                     id(groupSummary.groupId)
    -                groupId(groupSummary.groupId)
    -                groupName(groupSummary.displayName)
    +                matrixItem(groupSummary.toMatrixItem())
                     selected(isSelected)
    -                avatarUrl(groupSummary.avatarUrl)
                     listener { callback?.onGroupSelected(groupSummary) }
                 }
             }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryItem.kt b/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryItem.kt
    index 30c1852f1d..61c589cc00 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/group/GroupSummaryItem.kt
    @@ -20,6 +20,7 @@ import android.widget.ImageView
     import android.widget.TextView
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -30,18 +31,16 @@ import im.vector.riotx.features.home.AvatarRenderer
     abstract class GroupSummaryItem : VectorEpoxyModel() {
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute lateinit var groupName: CharSequence
    -    @EpoxyAttribute lateinit var groupId: String
    -    @EpoxyAttribute var avatarUrl: String? = null
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var selected: Boolean = false
         @EpoxyAttribute var listener: (() -> Unit)? = null
     
         override fun bind(holder: Holder) {
             super.bind(holder)
             holder.rootView.setOnClickListener { listener?.invoke() }
    -        holder.groupNameView.text = groupName
    +        holder.groupNameView.text = matrixItem.displayName
             holder.rootView.isChecked = selected
    -        avatarRenderer.render(avatarUrl, groupId, groupName.toString(), holder.avatarImageView)
    +        avatarRenderer.render(matrixItem, holder.avatarImageView)
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt
    index 3e400b37ea..3b77835917 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt
    @@ -18,6 +18,8 @@ package im.vector.riotx.features.home.room.breadcrumbs
     
     import android.view.View
     import com.airbnb.epoxy.EpoxyController
    +import im.vector.matrix.android.api.util.toMatrixItem
    +import im.vector.riotx.core.epoxy.zeroItem
     import im.vector.riotx.core.utils.DebouncedClickListener
     import im.vector.riotx.features.home.AvatarRenderer
     import javax.inject.Inject
    @@ -44,17 +46,19 @@ class BreadcrumbsController @Inject constructor(
         override fun buildModels() {
             val safeViewState = viewState ?: return
     
    +        // Add a ZeroItem to avoid automatic scroll when the breadcrumbs are updated from another client
    +        zeroItem {
    +            id("top")
    +        }
    +
             // An empty breadcrumbs list can only be temporary because when entering in a room,
             // this one is added to the breadcrumbs
    -
             safeViewState.asyncBreadcrumbs.invoke()
                     ?.forEach {
                         breadcrumbsItem {
                             id(it.roomId)
                             avatarRenderer(avatarRenderer)
    -                        roomId(it.roomId)
    -                        roomName(it.displayName)
    -                        avatarUrl(it.avatarUrl)
    +                        matrixItem(it.toMatrixItem())
                             unreadNotificationCount(it.notificationCount)
                             showHighlighted(it.highlightCount > 0)
                             hasUnreadMessage(it.hasUnreadMessages)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsFragment.kt
    index b8e2cf7987..5407c73f35 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsFragment.kt
    @@ -48,6 +48,7 @@ class BreadcrumbsFragment @Inject constructor(
     
         override fun onDestroyView() {
             breadcrumbsRecyclerView.cleanup()
    +        breadcrumbsController.listener = null
             super.onDestroyView()
         }
     
    @@ -56,6 +57,7 @@ class BreadcrumbsFragment @Inject constructor(
             breadcrumbsController.listener = this
         }
     
    +    // TODO Use invalidate() ?
         private fun renderState(state: BreadcrumbsViewState) {
             breadcrumbsController.update(state)
         }
    @@ -65,4 +67,8 @@ class BreadcrumbsFragment @Inject constructor(
         override fun onBreadcrumbClicked(roomId: String) {
             sharedActionViewModel.post(RoomDetailSharedAction.SwitchToRoom(roomId))
         }
    +
    +    fun scrollToTop() {
    +        breadcrumbsRecyclerView.scrollToPosition(0)
    +    }
     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt
    index 074c35af00..6d18a85b75 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt
    @@ -22,6 +22,7 @@ import android.widget.ImageView
     import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -32,9 +33,7 @@ import im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
     abstract class BreadcrumbsItem : VectorEpoxyModel() {
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute lateinit var roomId: String
    -    @EpoxyAttribute lateinit var roomName: CharSequence
    -    @EpoxyAttribute var avatarUrl: String? = null
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var unreadNotificationCount: Int = 0
         @EpoxyAttribute var showHighlighted: Boolean = false
         @EpoxyAttribute var hasUnreadMessage: Boolean = false
    @@ -45,7 +44,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel() {
             super.bind(holder)
             holder.rootView.setOnClickListener(itemClickListener)
             holder.unreadIndentIndicator.isVisible = hasUnreadMessage
    -        avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
    +        avatarRenderer.render(matrixItem, holder.avatarImageView)
             holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
             holder.draftIndentIndicator.isVisible = hasDraft
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt
    index 431c9e6395..14e9061c36 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActivity.kt
    @@ -86,9 +86,19 @@ class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
         private val drawerListener = object : DrawerLayout.SimpleDrawerListener() {
             override fun onDrawerStateChanged(newState: Int) {
                 hideKeyboard()
    +
    +            if (!drawerLayout.isDrawerOpen(GravityCompat.START) && newState == DrawerLayout.STATE_DRAGGING) {
    +                // User is starting to open the drawer, scroll the list to op
    +                scrollBreadcrumbsToTop()
    +            }
             }
         }
     
    +    private fun scrollBreadcrumbsToTop() {
    +        supportFragmentManager.fragments.filterIsInstance()
    +                .forEach { it.scrollToTop() }
    +    }
    +
         override fun onBackPressed() {
             if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
                 drawerLayout.closeDrawer(GravityCompat.START)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    index 80f54a9c1f..49f23f7f2c 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt
    @@ -66,10 +66,11 @@ import im.vector.matrix.android.api.session.room.timeline.Timeline
     import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
     import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
     import im.vector.matrix.android.api.session.user.model.User
    +import im.vector.matrix.android.api.util.MatrixItem
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.dialogs.withColoredButton
     import im.vector.riotx.core.epoxy.LayoutManagerStateRestorer
    -import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.core.extensions.*
     import im.vector.riotx.core.files.addEntryToDownloadManager
     import im.vector.riotx.core.glide.GlideApp
    @@ -85,8 +86,6 @@ import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy
     import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter
     import im.vector.riotx.features.command.Command
     import im.vector.riotx.features.home.AvatarRenderer
    -import im.vector.riotx.features.home.NavigateToRoomInterceptor
    -import im.vector.riotx.features.home.PermalinkHandler
     import im.vector.riotx.features.home.getColorFromUserId
     import im.vector.riotx.features.home.room.detail.composer.TextComposerAction
     import im.vector.riotx.features.home.room.detail.composer.TextComposerView
    @@ -108,10 +107,14 @@ import im.vector.riotx.features.media.ImageMediaViewerActivity
     import im.vector.riotx.features.media.VideoContentRenderer
     import im.vector.riotx.features.media.VideoMediaViewerActivity
     import im.vector.riotx.features.notifications.NotificationDrawerManager
    +import im.vector.riotx.features.permalink.NavigateToRoomInterceptor
    +import im.vector.riotx.features.permalink.PermalinkHandler
     import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
     import im.vector.riotx.features.settings.VectorPreferences
     import im.vector.riotx.features.share.SharedData
     import im.vector.riotx.features.themes.ThemeUtils
    +import io.reactivex.android.schedulers.AndroidSchedulers
    +import io.reactivex.schedulers.Schedulers
     import kotlinx.android.parcel.Parcelize
     import kotlinx.android.synthetic.main.fragment_room_detail.*
     import kotlinx.android.synthetic.main.merge_composer_layout.view.*
    @@ -141,7 +144,6 @@ class RoomDetailFragment @Inject constructor(
             private val notificationDrawerManager: NotificationDrawerManager,
             val roomDetailViewModelFactory: RoomDetailViewModel.Factory,
             val textComposerViewModelFactory: TextComposerViewModel.Factory,
    -        private val errorFormatter: ErrorFormatter,
             private val eventHtmlRenderer: EventHtmlRenderer,
             private val vectorPreferences: VectorPreferences
     ) :
    @@ -410,14 +412,14 @@ class RoomDetailFragment @Inject constructor(
             composerLayout.sendButton.setContentDescription(getString(descriptionRes))
     
             avatarRenderer.render(
    -                event.senderAvatar,
    -                event.root.senderId ?: "",
    -                event.getDisambiguatedDisplayName(),
    +                MatrixItem.UserItem(event.root.senderId ?: "", event.getDisambiguatedDisplayName(), event.senderAvatar),
                     composerLayout.composerRelatedMessageAvatar
             )
             composerLayout.expand {
    -            // need to do it here also when not using quick reply
    -            focusComposerAndShowKeyboard()
    +            if (isAdded) {
    +                // need to do it here also when not using quick reply
    +                focusComposerAndShowKeyboard()
    +            }
             }
             focusComposerAndShowKeyboard()
         }
    @@ -601,20 +603,19 @@ class RoomDetailFragment @Inject constructor(
                             }
     
                             // Replace the word by its completion
    -                        val displayName = item.displayName ?: item.userId
    +                        val matrixItem = item.toMatrixItem()
    +                        val displayName = matrixItem.getBestName()
     
                             // with a trailing space
                             editable.replace(startIndex, endIndex, "$displayName ")
     
                             // Add the span
    -                        val user = session.getUser(item.userId)
                             val span = PillImageSpan(
                                     glideRequests,
                                     avatarRenderer,
                                     requireContext(),
    -                                item.userId,
    -                                user?.displayName ?: item.userId,
    -                                user?.avatarUrl)
    +                                matrixItem
    +                        )
                             span.bind(composerLayout.composerEditText)
     
                             editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    @@ -686,7 +687,7 @@ class RoomDetailFragment @Inject constructor(
                 inviteView.visibility = View.GONE
                 val uid = session.myUserId
                 val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
    -            avatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
    +            avatarRenderer.render(MatrixItem.UserItem(uid, meMember?.displayName, meMember?.avatarUrl), composerLayout.composerAvatarImageView)
             } else if (summary?.membership == Membership.INVITE && inviter != null) {
                 inviteView.visibility = View.VISIBLE
                 inviteView.render(inviter, VectorInviteView.Mode.LARGE)
    @@ -713,7 +714,7 @@ class RoomDetailFragment @Inject constructor(
                     activity?.finish()
                 } else {
                     roomToolbarTitleView.text = it.displayName
    -                avatarRenderer.render(it, roomToolbarAvatarImageView)
    +                avatarRenderer.render(it.toMatrixItem(), roomToolbarAvatarImageView)
                     roomToolbarSubtitleView.setTextOrHide(it.topic)
                 }
                 jumpToBottomView.count = it.notificationCount
    @@ -854,30 +855,33 @@ class RoomDetailFragment @Inject constructor(
     // TimelineEventController.Callback ************************************************************
     
         override fun onUrlClicked(url: String): Boolean {
    -        val managed = permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor {
    -            override fun navToRoom(roomId: String, eventId: String?): Boolean {
    -                // Same room?
    -                if (roomId == roomDetailArgs.roomId) {
    -                    // Navigation to same room
    -                    if (eventId == null) {
    -                        showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room))
    -                    } else {
    -                        // Highlight and scroll to this event
    -                        roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true))
    +        permalinkHandler
    +                .launch(requireActivity(), url, object : NavigateToRoomInterceptor {
    +                    override fun navToRoom(roomId: String?, eventId: String?): Boolean {
    +                        // Same room?
    +                        if (roomId == roomDetailArgs.roomId) {
    +                            // Navigation to same room
    +                            if (eventId == null) {
    +                                showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room))
    +                            } else {
    +                                // Highlight and scroll to this event
    +                                roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true))
    +                            }
    +                            return true
    +                        }
    +                        // Not handled
    +                        return false
    +                    }
    +                })
    +                .subscribeOn(Schedulers.io())
    +                .observeOn(AndroidSchedulers.mainThread())
    +                .subscribe { managed ->
    +                    if (!managed) {
    +                        // Open in external browser, in a new Tab
    +                        openUrlInExternalBrowser(requireContext(), url)
                         }
    -                    return true
                     }
    -
    -                // Not handled
    -                return false
    -            }
    -        })
    -
    -        if (!managed) {
    -            // Open in external browser, in a new Tab
    -            openUrlInExternalBrowser(requireContext(), url)
    -        }
    -
    +                .disposeOnDestroyView()
             // In fact it is always managed
             return true
         }
    @@ -1025,12 +1029,15 @@ class RoomDetailFragment @Inject constructor(
         }
     
         override fun onRoomCreateLinkClicked(url: String) {
    -        permalinkHandler.launch(requireContext(), url, object : NavigateToRoomInterceptor {
    -            override fun navToRoom(roomId: String, eventId: String?): Boolean {
    -                requireActivity().finish()
    -                return false
    -            }
    -        })
    +        permalinkHandler
    +                .launch(requireContext(), url, object : NavigateToRoomInterceptor {
    +                    override fun navToRoom(roomId: String?, eventId: String?): Boolean {
    +                        requireActivity().finish()
    +                        return false
    +                    }
    +                })
    +                .subscribe()
    +                .disposeOnDestroyView()
         }
     
         override fun onReadReceiptsClicked(readReceipts: List) {
    @@ -1197,9 +1204,8 @@ class RoomDetailFragment @Inject constructor(
                                                 glideRequests,
                                                 avatarRenderer,
                                                 requireContext(),
    -                                            userId,
    -                                            displayName,
    -                                            roomMember?.avatarUrl)
    +                                            MatrixItem.UserItem(userId, displayName, roomMember?.avatarUrl)
    +                                    )
                                                 .also { it.bind(composerLayout.composerEditText) },
                                         0,
                                         displayName.length,
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt
    index a0be8fc9dc..b2ad29668e 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt
    @@ -58,7 +58,7 @@ data class RoomDetailViewState(
             val isEncrypted: Boolean = false,
             val tombstoneEvent: Event? = null,
             val tombstoneEventHandling: Async = Uninitialized,
    -        val syncState: SyncState = SyncState.IDLE,
    +        val syncState: SyncState = SyncState.Idle,
             val highlightedEventId: String? = null,
             val unreadState: UnreadState = UnreadState.Unknown,
             val canShowJumpToReadMarker: Boolean = true
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptItem.kt
    index 2b7d64a80e..6bc93f28dc 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptItem.kt
    @@ -22,6 +22,7 @@ import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
     import com.airbnb.epoxy.EpoxyModelWithHolder
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.features.home.AvatarRenderer
    @@ -29,15 +30,13 @@ import im.vector.riotx.features.home.AvatarRenderer
     @EpoxyModelClass(layout = R.layout.item_display_read_receipt)
     abstract class DisplayReadReceiptItem : EpoxyModelWithHolder() {
     
    -    @EpoxyAttribute var name: String? = null
    -    @EpoxyAttribute var userId: String = ""
    -    @EpoxyAttribute var avatarUrl: String? = null
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var timestamp: CharSequence? = null
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
     
         override fun bind(holder: Holder) {
    -        avatarRenderer.render(avatarUrl, userId, name, holder.avatarView)
    -        holder.displayNameView.text = name ?: userId
    +        avatarRenderer.render(matrixItem, holder.avatarView)
    +        holder.displayNameView.text = matrixItem.getBestName()
             timestamp?.let {
                 holder.timestampView.text = it
                 holder.timestampView.isVisible = true
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsController.kt
    index 6affa582bc..3ec60217a0 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/readreceipts/DisplayReadReceiptsController.kt
    @@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.Session
     import im.vector.riotx.core.date.VectorDateFormatter
     import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
    +import im.vector.riotx.features.home.room.detail.timeline.item.toMatrixItem
     import javax.inject.Inject
     
     /**
    @@ -36,9 +37,7 @@ class DisplayReadReceiptsController @Inject constructor(private val dateFormatte
                 val timestamp = dateFormatter.formatRelativeDateTime(it.timestamp)
                 DisplayReadReceiptItem_()
                         .id(it.userId)
    -                    .userId(it.userId)
    -                    .avatarUrl(it.avatarUrl)
    -                    .name(it.displayName)
    +                    .matrixItem(it.toMatrixItem())
                         .avatarRenderer(avatarRender)
                         .timestamp(timestamp)
                         .addIf(session.myUserId != it.userId, this)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt
    index efbfd3434c..939564e780 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt
    @@ -44,9 +44,7 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
                 bottomSheetMessagePreviewItem {
                     id("preview")
                     avatarRenderer(avatarRenderer)
    -                avatarUrl(state.informationData.avatarUrl ?: "")
    -                senderId(state.informationData.senderId)
    -                senderName(state.senderName())
    +                matrixItem(state.informationData.matrixItem)
                     movementMethod(createLinkMovementMethod(listener))
                     body(body.linkify(listener))
                     time(state.time())
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
    index 102412948b..1303c3aad9 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
    @@ -41,6 +41,7 @@ import im.vector.riotx.core.resources.StringProvider
     import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter
     import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
     import im.vector.riotx.features.html.EventHtmlRenderer
    +import im.vector.riotx.features.html.VectorHtmlCompressor
     import java.text.SimpleDateFormat
     import java.util.*
     
    @@ -82,6 +83,7 @@ data class MessageActionState(
     class MessageActionsViewModel @AssistedInject constructor(@Assisted
                                                               initialState: MessageActionState,
                                                               private val eventHtmlRenderer: Lazy,
    +                                                          private val htmlCompressor: VectorHtmlCompressor,
                                                               private val session: Session,
                                                               private val noticeEventFormatter: NoticeEventFormatter,
                                                               private val stringProvider: StringProvider
    @@ -100,6 +102,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
     
             val quickEmojis = listOf("👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀")
     
    +        @JvmStatic
             override fun create(viewModelContext: ViewModelContext, state: MessageActionState): MessageActionsViewModel? {
                 val fragment: MessageActionsBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
                 return fragment.messageActionViewModelFactory.create(state)
    @@ -167,11 +170,16 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
     
         private fun computeMessageBody(timelineEvent: Async): CharSequence? {
             return when (timelineEvent()?.root?.getClearType()) {
    -            EventType.MESSAGE     -> {
    +            EventType.MESSAGE,
    +            EventType.STICKER     -> {
                     val messageContent: MessageContent? = timelineEvent()?.getLastMessageContent()
                     if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
    -                    eventHtmlRenderer.get().render(messageContent.formattedBody
    -                            ?: messageContent.body)
    +                    val html = messageContent.formattedBody
    +                            ?.takeIf { it.isNotBlank() }
    +                            ?.let { htmlCompressor.compress(it) }
    +                            ?: messageContent.body
    +
    +                    eventHtmlRenderer.get().render(html)
                     } else {
                         messageContent?.body
                     }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryViewModel.kt
    index c1cccbef7a..64d8950420 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/edithistory/ViewEditHistoryViewModel.kt
    @@ -61,6 +61,7 @@ class ViewEditHistoryViewModel @AssistedInject constructor(@Assisted
     
         companion object : MvRxViewModelFactory {
     
    +        @JvmStatic
             override fun create(viewModelContext: ViewModelContext, state: ViewEditHistoryViewState): ViewEditHistoryViewModel? {
                 val fragment: ViewEditHistoryBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
                 return fragment.viewEditHistoryViewModelFactory.create(state)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    index 9c96f17022..9e05cdcc18 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt
    @@ -46,6 +46,7 @@ import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMoveme
     import im.vector.riotx.features.home.room.detail.timeline.tools.linkify
     import im.vector.riotx.features.html.CodeVisitor
     import im.vector.riotx.features.html.EventHtmlRenderer
    +import im.vector.riotx.features.html.VectorHtmlCompressor
     import im.vector.riotx.features.media.ImageContentRenderer
     import im.vector.riotx.features.media.VideoContentRenderer
     import me.gujun.android.span.span
    @@ -57,6 +58,7 @@ class MessageItemFactory @Inject constructor(
             private val dimensionConverter: DimensionConverter,
             private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
             private val htmlRenderer: Lazy,
    +        private val htmlCompressor: VectorHtmlCompressor,
             private val stringProvider: StringProvider,
             private val imageContentRenderer: ImageContentRenderer,
             private val messageInformationDataFactory: MessageInformationDataFactory,
    @@ -179,10 +181,16 @@ class MessageItemFactory @Inject constructor(
                     .playable(messageContent.info?.mimeType == "image/gif")
                     .highlighted(highlight)
                     .mediaData(data)
    -                .clickListener(
    -                        DebouncedClickListener(View.OnClickListener { view ->
    -                            callback?.onImageMessageClicked(messageContent, data, view)
    -                        }))
    +                .apply {
    +                    if (messageContent.type == MessageType.MSGTYPE_STICKER_LOCAL) {
    +                        mode(ImageContentRenderer.Mode.STICKER)
    +                    } else {
    +                        clickListener(
    +                                DebouncedClickListener(View.OnClickListener { view ->
    +                                    callback?.onImageMessageClicked(messageContent, data, view)
    +                                }))
    +                    }
    +                }
         }
     
         private fun buildVideoMessageItem(messageContent: MessageVideoContent,
    @@ -227,6 +235,7 @@ class MessageItemFactory @Inject constructor(
                                             attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? {
             val isFormatted = messageContent.formattedBody.isNullOrBlank().not()
             return if (isFormatted) {
    +            // First detect if the message contains some code block(s) or inline code
                 val localFormattedBody = htmlRenderer.get().parse(messageContent.body) as Document
                 val codeVisitor = CodeVisitor()
                 codeVisitor.visit(localFormattedBody)
    @@ -240,7 +249,8 @@ class MessageItemFactory @Inject constructor(
                         buildMessageTextItem(codeFormatted, false, informationData, highlight, callback, attributes)
                     }
                     CodeVisitor.Kind.NONE   -> {
    -                    val formattedBody = htmlRenderer.get().render(messageContent.formattedBody!!)
    +                    val compressed = htmlCompressor.compress(messageContent.formattedBody!!)
    +                    val formattedBody = htmlRenderer.get().render(compressed)
                         buildMessageTextItem(formattedBody, true, informationData, highlight, callback, attributes)
                     }
                 }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt
    index 784a180d00..3331fbf774 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt
    @@ -60,7 +60,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
             val avatarUrl = event.senderAvatar
             val memberName = event.getDisambiguatedDisplayName()
             val formattedMemberName = span(memberName) {
    -            textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: ""))
    +            textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId))
             }
     
             return MessageInformationData(
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt
    index 713b60d4d8..af4c55e742 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt
    @@ -77,12 +77,7 @@ abstract class AbsMessageItem : BaseEventItem() {
                 holder.timeView.visibility = View.VISIBLE
                 holder.timeView.text = attributes.informationData.time
                 holder.memberNameView.text = attributes.informationData.memberName
    -            attributes.avatarRenderer.render(
    -                    attributes.informationData.avatarUrl,
    -                    attributes.informationData.senderId,
    -                    attributes.informationData.memberName?.toString(),
    -                    holder.avatarImageView
    -            )
    +            attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView)
                 holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener)
                 holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener)
             } else {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt
    index a2a3c9ad3b..93f7dc271d 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt
    @@ -24,6 +24,7 @@ import androidx.core.view.children
     import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.features.home.AvatarRenderer
     import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
    @@ -54,7 +55,7 @@ abstract class MergedHeaderItem : BaseEventItem() {
                     val data = distinctMergeData.getOrNull(index)
                     if (data != null && view is ImageView) {
                         view.visibility = View.VISIBLE
    -                    attributes.avatarRenderer.render(data.avatarUrl, data.userId, data.memberName, view)
    +                    attributes.avatarRenderer.render(data.toMatrixItem(), view)
                     } else {
                         view.visibility = View.GONE
                     }
    @@ -87,6 +88,8 @@ abstract class MergedHeaderItem : BaseEventItem() {
                 val avatarUrl: String?
         )
     
    +    fun Data.toMatrixItem() = MatrixItem.UserItem(userId, memberName, avatarUrl)
    +
         data class Attributes(
                 val isCollapsed: Boolean,
                 val mergeData: List,
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt
    index 457f30cbf4..2fd46ddf12 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt
    @@ -36,6 +36,8 @@ abstract class MessageImageVideoItem : AbsMessageItem = emptyList()
    -) : Parcelable
    +) : Parcelable {
    +
    +    val matrixItem: MatrixItem
    +        get() = MatrixItem.UserItem(senderId, memberName?.toString(), avatarUrl)
    +}
     
     @Parcelize
     data class ReactionInfoData(
    @@ -51,3 +56,5 @@ data class ReadReceiptData(
             val displayName: String?,
             val timestamp: Long
     ) : Parcelable
    +
    +fun ReadReceiptData.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt
    index 05dedcfa22..189c358b48 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt
    @@ -39,13 +39,7 @@ abstract class NoticeItem : BaseEventItem() {
         override fun bind(holder: Holder) {
             super.bind(holder)
             holder.noticeTextView.text = attributes.noticeText
    -        attributes.avatarRenderer.render(
    -                attributes.informationData.avatarUrl,
    -                attributes.informationData.senderId,
    -                attributes.informationData.memberName?.toString()
    -                        ?: attributes.informationData.senderId,
    -                holder.avatarImageView
    -        )
    +        attributes.avatarRenderer.render(attributes.informationData.matrixItem, holder.avatarImageView)
             holder.view.setOnLongClickListener(attributes.itemLongClickListener)
             holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener)
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt
    index 9ec45b03b9..761e80dd59 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/reactions/ViewReactionsViewModel.kt
    @@ -68,6 +68,7 @@ class ViewReactionsViewModel @AssistedInject constructor(@Assisted
     
         companion object : MvRxViewModelFactory {
     
    +        @JvmStatic
             override fun create(viewModelContext: ViewModelContext, state: DisplayReactionsViewState): ViewReactionsViewModel? {
                 val fragment: ViewReactionsBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
                 return fragment.viewReactionsViewModelFactory.create(state)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt
    index 3bd097d67b..4e4e758aa2 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomInvitationItem.kt
    @@ -22,6 +22,7 @@ import android.widget.TextView
     import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -33,10 +34,8 @@ import im.vector.riotx.features.home.AvatarRenderer
     abstract class RoomInvitationItem : VectorEpoxyModel() {
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute lateinit var roomName: CharSequence
    -    @EpoxyAttribute lateinit var roomId: String
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute var secondLine: CharSequence? = null
    -    @EpoxyAttribute var avatarUrl: String? = null
         @EpoxyAttribute var listener: (() -> Unit)? = null
         @EpoxyAttribute var invitationAcceptInProgress: Boolean = false
         @EpoxyAttribute var invitationAcceptInError: Boolean = false
    @@ -85,9 +84,9 @@ abstract class RoomInvitationItem : VectorEpoxyModel(
                     rejectListener?.invoke()
                 }
             }
    -        holder.titleView.text = roomName
    +        holder.titleView.text = matrixItem.getBestName()
             holder.subtitleView.setTextOrHide(secondLine)
    -        avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
    +        avatarRenderer.render(matrixItem, holder.avatarImageView)
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt
    index 00d964b28c..9e54d5fc79 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomListFragment.kt
    @@ -35,7 +35,6 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.matrix.android.api.session.room.notification.RoomNotificationState
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.LayoutManagerStateRestorer
    -import im.vector.riotx.core.error.ErrorFormatter
     import im.vector.riotx.core.extensions.cleanup
     import im.vector.riotx.core.platform.OnBackPressed
     import im.vector.riotx.core.platform.StateView
    @@ -61,7 +60,6 @@ data class RoomListParams(
     class RoomListFragment @Inject constructor(
             private val roomController: RoomSummaryController,
             val roomListViewModelFactory: RoomListViewModel.Factory,
    -        private val errorFormatter: ErrorFormatter,
             private val notificationDrawerManager: NotificationDrawerManager
     
     ) : VectorBaseFragment(), RoomSummaryController.Listener, OnBackPressed, FabMenuView.Listener {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt
    index fe208a3085..23a0fd60a2 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt
    @@ -23,6 +23,7 @@ import android.widget.TextView
     import androidx.core.view.isVisible
     import com.airbnb.epoxy.EpoxyAttribute
     import com.airbnb.epoxy.EpoxyModelClass
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.epoxy.VectorEpoxyHolder
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -32,11 +33,9 @@ import im.vector.riotx.features.home.AvatarRenderer
     abstract class RoomSummaryItem : VectorEpoxyModel() {
     
         @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
    -    @EpoxyAttribute lateinit var roomName: CharSequence
    -    @EpoxyAttribute lateinit var roomId: String
    +    @EpoxyAttribute lateinit var matrixItem: MatrixItem
         @EpoxyAttribute lateinit var lastFormattedEvent: CharSequence
         @EpoxyAttribute lateinit var lastEventTime: CharSequence
    -    @EpoxyAttribute var avatarUrl: String? = null
         @EpoxyAttribute var unreadNotificationCount: Int = 0
         @EpoxyAttribute var hasUnreadMessage: Boolean = false
         @EpoxyAttribute var hasDraft: Boolean = false
    @@ -48,13 +47,13 @@ abstract class RoomSummaryItem : VectorEpoxyModel() {
             super.bind(holder)
             holder.rootView.setOnClickListener(itemClickListener)
             holder.rootView.setOnLongClickListener(itemLongClickListener)
    -        holder.titleView.text = roomName
    +        holder.titleView.text = matrixItem.getBestName()
             holder.lastEventTimeView.text = lastEventTime
             holder.lastEventView.text = lastFormattedEvent
             holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
             holder.unreadIndentIndicator.isVisible = hasUnreadMessage
             holder.draftView.isVisible = hasDraft
    -        avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
    +        avatarRenderer.render(matrixItem, holder.avatarImageView)
         }
     
         class Holder : VectorEpoxyHolder() {
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt
    index 85652c4139..84a5f942e8 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt
    @@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.events.model.EventType
     import im.vector.matrix.android.api.session.room.model.Membership
     import im.vector.matrix.android.api.session.room.model.RoomSummary
     import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.date.VectorDateFormatter
     import im.vector.riotx.core.epoxy.VectorEpoxyModel
    @@ -69,7 +70,7 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
             return RoomInvitationItem_()
                     .id(roomSummary.roomId)
                     .avatarRenderer(avatarRenderer)
    -                .roomId(roomSummary.roomId)
    +                .matrixItem(roomSummary.toMatrixItem())
                     .secondLine(secondLine)
                     .invitationAcceptInProgress(joiningRoomsIds.contains(roomSummary.roomId))
                     .invitationAcceptInError(joiningErrorRoomsIds.contains(roomSummary.roomId))
    @@ -77,8 +78,6 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
                     .invitationRejectInError(rejectingErrorRoomsIds.contains(roomSummary.roomId))
                     .acceptListener { listener?.onAcceptRoomInvitation(roomSummary) }
                     .rejectListener { listener?.onRejectRoomInvitation(roomSummary) }
    -                .roomName(roomSummary.displayName)
    -                .avatarUrl(roomSummary.avatarUrl)
                     .listener { listener?.onRoomClicked(roomSummary) }
         }
     
    @@ -125,11 +124,9 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
             return RoomSummaryItem_()
                     .id(roomSummary.roomId)
                     .avatarRenderer(avatarRenderer)
    -                .roomId(roomSummary.roomId)
    +                .matrixItem(roomSummary.toMatrixItem())
                     .lastEventTime(latestEventTime)
                     .lastFormattedEvent(latestFormattedEvent)
    -                .roomName(roomSummary.displayName)
    -                .avatarUrl(roomSummary.avatarUrl)
                     .showHighlighted(showHighlighted)
                     .unreadNotificationCount(unreadCount)
                     .hasUnreadMessage(roomSummary.hasUnreadMessages)
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt
    index 84fd5bc6f2..8d25f5713a 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsEpoxyController.kt
    @@ -18,6 +18,7 @@ package im.vector.riotx.features.home.room.list.actions
     import android.view.View
     import com.airbnb.epoxy.TypedEpoxyController
     import im.vector.matrix.android.api.session.room.notification.RoomNotificationState
    +import im.vector.matrix.android.api.util.toMatrixItem
     import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetActionItem
     import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetRoomPreviewItem
     import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetSeparatorItem
    @@ -39,9 +40,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar
             bottomSheetRoomPreviewItem {
                 id("preview")
                 avatarRenderer(avatarRenderer)
    -            roomName(roomSummary.displayName)
    -            avatarUrl(roomSummary.avatarUrl)
    -            roomId(roomSummary.roomId)
    +            matrixItem(roomSummary.toMatrixItem())
                 settingsClickListener(View.OnClickListener { listener?.didSelectMenuAction(RoomListQuickActionsSharedAction.Settings(roomSummary.roomId)) })
             }
     
    diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsViewModel.kt
    index 7f7a1f41c4..1c4d414f18 100644
    --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsViewModel.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/actions/RoomListQuickActionsViewModel.kt
    @@ -37,6 +37,7 @@ class RoomListQuickActionsViewModel @AssistedInject constructor(@Assisted initia
     
         companion object : MvRxViewModelFactory {
     
    +        @JvmStatic
             override fun create(viewModelContext: ViewModelContext, state: RoomListQuickActionsState): RoomListQuickActionsViewModel? {
                 val fragment: RoomListQuickActionsBottomSheet = (viewModelContext as FragmentViewModelContext).fragment()
                 return fragment.roomListActionsViewModelFactory.create(state)
    diff --git a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt
    index ecbf0da415..3f16666221 100644
    --- a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt
    @@ -20,6 +20,7 @@ import android.content.Context
     import android.text.style.URLSpan
     import im.vector.matrix.android.api.permalinks.PermalinkData
     import im.vector.matrix.android.api.permalinks.PermalinkParser
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.core.di.ActiveSessionHolder
     import im.vector.riotx.core.glide.GlideRequests
     import im.vector.riotx.features.home.AvatarRenderer
    @@ -41,8 +42,8 @@ class MxLinkTagHandler(private val glideRequests: GlideRequests,
                 when (permalinkData) {
                     is PermalinkData.UserLink -> {
                         val user = sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId)
    -                    val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user?.displayName
    -                            ?: permalinkData.userId, user?.avatarUrl)
    +                    val span = PillImageSpan(glideRequests, avatarRenderer, context, MatrixItem.UserItem(permalinkData.userId, user?.displayName
    +                            ?: permalinkData.userId, user?.avatarUrl))
                         SpannableBuilder.setSpans(
                                 visitor.builder(),
                                 span,
    diff --git a/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt b/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt
    index a192c71961..8b57006439 100644
    --- a/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt
    +++ b/vector/src/main/java/im/vector/riotx/features/html/PillImageSpan.kt
    @@ -29,6 +29,7 @@ import com.bumptech.glide.request.target.SimpleTarget
     import com.bumptech.glide.request.transition.Transition
     import com.google.android.material.chip.ChipDrawable
     import im.vector.matrix.android.api.session.room.send.UserMentionSpan
    +import im.vector.matrix.android.api.util.MatrixItem
     import im.vector.riotx.R
     import im.vector.riotx.core.glide.GlideRequests
     import im.vector.riotx.features.home.AvatarRenderer
    @@ -42,9 +43,8 @@ import java.lang.ref.WeakReference
     class PillImageSpan(private val glideRequests: GlideRequests,
                         private val avatarRenderer: AvatarRenderer,
                         private val context: Context,
    -                    override val userId: String,
    -                    override val displayName: String,
    -                    private val avatarUrl: String?) : ReplacementSpan(), UserMentionSpan {
    +                    override val matrixItem: MatrixItem
    +) : ReplacementSpan(), UserMentionSpan {
     
         private val pillDrawable = createChipDrawable()
         private val target = PillImageSpanTarget(this)
    @@ -53,7 +53,7 @@ class PillImageSpan(private val glideRequests: GlideRequests,
         @UiThread
         fun bind(textView: TextView) {
             tv = WeakReference(textView)
    -        avatarRenderer.render(context, glideRequests, avatarUrl, userId, displayName, target)
    +        avatarRenderer.render(context, glideRequests, matrixItem, target)
         }
     
         // ReplacementSpan *****************************************************************************
    @@ -101,12 +101,12 @@ class PillImageSpan(private val glideRequests: GlideRequests,
         private fun createChipDrawable(): ChipDrawable {
             val textPadding = context.resources.getDimension(R.dimen.pill_text_padding)
             return ChipDrawable.createFromResource(context, R.xml.pill_view).apply {
    -            text = displayName
    +            text = matrixItem.getBestName()
                 textEndPadding = textPadding
                 textStartPadding = textPadding
                 setChipMinHeightResource(R.dimen.pill_min_height)
                 setChipIconSizeResource(R.dimen.pill_avatar_size)
    -            chipIcon = avatarRenderer.getPlaceholderDrawable(context, userId, displayName)
    +            chipIcon = avatarRenderer.getPlaceholderDrawable(context, matrixItem)
                 setBounds(0, 0, intrinsicWidth, intrinsicHeight)
             }
         }
    diff --git a/vector/src/main/java/im/vector/riotx/features/html/VectorHtmlCompressor.kt b/vector/src/main/java/im/vector/riotx/features/html/VectorHtmlCompressor.kt
    new file mode 100644
    index 0000000000..9f3cf96a7e
    --- /dev/null
    +++ b/vector/src/main/java/im/vector/riotx/features/html/VectorHtmlCompressor.kt
    @@ -0,0 +1,40 @@
    +/*
    + * Copyright 2019 New Vector Ltd
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package im.vector.riotx.features.html
    +
    +import com.googlecode.htmlcompressor.compressor.Compressor
    +import com.googlecode.htmlcompressor.compressor.HtmlCompressor
    +import javax.inject.Inject
    +import javax.inject.Singleton
    +
    +@Singleton
    +class VectorHtmlCompressor @Inject constructor() {
    +
    +    // All default options are suitable so far
    +    private val htmlCompressor: Compressor = HtmlCompressor()
    +
    +    fun compress(html: String): String {
    +        var result = htmlCompressor.compress(html)
    +
    +        // Trim space after 
    and

    , unfortunately the method setRemoveSurroundingSpaces() from the doc does not exist + result = result.replace("
    ", "
    ") + result = result.replace("
    ", "
    ") + result = result.replace("

    ", "

    ") + + return result + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt b/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt index 71420448f4..b9bd9b0e1e 100644 --- a/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt +++ b/vector/src/main/java/im/vector/riotx/features/invite/VectorInviteView.kt @@ -22,6 +22,7 @@ import android.view.View import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.updateLayoutParams import im.vector.matrix.android.api.session.user.model.User +import im.vector.matrix.android.api.util.toMatrixItem import im.vector.riotx.R import im.vector.riotx.core.di.HasScreenInjector import im.vector.riotx.features.home.AvatarRenderer @@ -56,7 +57,7 @@ class VectorInviteView @JvmOverloads constructor(context: Context, attrs: Attrib fun render(sender: User, mode: Mode = Mode.LARGE) { if (mode == Mode.LARGE) { updateLayoutParams { height = LayoutParams.MATCH_CONSTRAINT } - avatarRenderer.render(sender.avatarUrl, sender.userId, sender.displayName, inviteAvatarView) + avatarRenderer.render(sender.toMatrixItem(), inviteAvatarView) inviteIdentifierView.text = sender.userId inviteNameView.text = sender.displayName inviteLabelView.text = context.getString(R.string.send_you_invite) diff --git a/vector/src/main/java/im/vector/riotx/features/link/LinkHandlerActivity.kt b/vector/src/main/java/im/vector/riotx/features/link/LinkHandlerActivity.kt index 90ed466695..f1782018a0 100644 --- a/vector/src/main/java/im/vector/riotx/features/link/LinkHandlerActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/link/LinkHandlerActivity.kt @@ -87,7 +87,7 @@ class LinkHandlerActivity : VectorBaseActivity() { .setMessage(R.string.error_user_already_logged_in) .setCancelable(false) .setPositiveButton(R.string.logout) { _, _ -> - sessionHolder.getSafeActiveSession()?.signOut(object : MatrixCallback { + sessionHolder.getSafeActiveSession()?.signOut(true, object : MatrixCallback { override fun onFailure(failure: Throwable) { displayError(failure) } diff --git a/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt index 6cca32cf7f..d7e37f762b 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/AbstractLoginFragment.kt @@ -29,6 +29,7 @@ import im.vector.matrix.android.api.failure.MatrixError import im.vector.riotx.R import im.vector.riotx.core.platform.OnBackPressed import im.vector.riotx.core.platform.VectorBaseFragment +import io.reactivex.android.schedulers.AndroidSchedulers import javax.net.ssl.HttpsURLConnection /** @@ -60,6 +61,7 @@ abstract class AbstractLoginFragment : VectorBaseFragment(), OnBackPressed { loginViewModel.viewEvents .observe() + .observeOn(AndroidSchedulers.mainThread()) .subscribe { handleLoginViewEvents(it) } @@ -78,7 +80,7 @@ abstract class AbstractLoginFragment : VectorBaseFragment(), OnBackPressed { private fun showError(throwable: Throwable) { when (throwable) { is Failure.ServerError -> { - if (throwable.error.code == MatrixError.FORBIDDEN + if (throwable.error.code == MatrixError.M_FORBIDDEN && throwable.httpCode == HttpsURLConnection.HTTP_FORBIDDEN /* 403 */) { AlertDialog.Builder(requireActivity()) .setTitle(R.string.dialog_title_error) @@ -93,7 +95,13 @@ abstract class AbstractLoginFragment : VectorBaseFragment(), OnBackPressed { } } - abstract fun onError(throwable: Throwable) + open fun onError(throwable: Throwable) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(errorFormatter.toHumanReadable(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + } override fun onBackPressed(toolbarButton: Boolean): Boolean { return when { diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt index 618b3ea85d..90d6754448 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginAction.kt @@ -55,4 +55,7 @@ sealed class LoginAction : VectorViewModelAction { object ResetSignMode : ResetAction() object ResetLogin : ResetAction() object ResetResetPassword : ResetAction() + + // For the soft logout case + data class SetupSsoForSessionRecovery(val homeServerUrl: String, val deviceId: String) : LoginAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt index 2dec402f85..d879212c3d 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginActivity.kt @@ -20,6 +20,7 @@ import android.content.Context import android.content.Intent import android.view.View import android.view.ViewGroup +import androidx.annotation.CallSuper import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar import androidx.core.view.ViewCompat @@ -43,19 +44,21 @@ import im.vector.riotx.features.home.HomeActivity import im.vector.riotx.features.login.terms.LoginTermsFragment import im.vector.riotx.features.login.terms.LoginTermsFragmentArgument import im.vector.riotx.features.login.terms.toLocalizedLoginTerms +import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.activity_login.* import javax.inject.Inject /** * The LoginActivity manages the fragment navigation and also display the loading View */ -class LoginActivity : VectorBaseActivity(), ToolbarConfigurable { +open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable { private val loginViewModel: LoginViewModel by viewModel() private lateinit var loginSharedActionViewModel: LoginSharedActionViewModel @Inject lateinit var loginViewModelFactory: LoginViewModel.Factory + @CallSuper override fun injectWith(injector: ScreenComponent) { injector.inject(this) } @@ -75,17 +78,17 @@ class LoginActivity : VectorBaseActivity(), ToolbarConfigurable { // Find findViewById does not work, I do not know why // findViewById(R.id.loginLogo) ?.children - ?.first { it.id == R.id.loginLogo } + ?.firstOrNull { it.id == R.id.loginLogo } ?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } // TODO ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) } - override fun getLayoutRes() = R.layout.activity_login + final override fun getLayoutRes() = R.layout.activity_login override fun initUiAndData() { if (isFirstCreation()) { - addFragment(R.id.loginFragmentContainer, LoginSplashFragment::class.java) + addFirstFragment() } // Get config extra @@ -96,7 +99,8 @@ class LoginActivity : VectorBaseActivity(), ToolbarConfigurable { } loginSharedActionViewModel = viewModelProvider.get(LoginSharedActionViewModel::class.java) - loginSharedActionViewModel.observe() + loginSharedActionViewModel + .observe() .subscribe { handleLoginNavigation(it) } @@ -106,16 +110,20 @@ class LoginActivity : VectorBaseActivity(), ToolbarConfigurable { .subscribe(this) { updateWithState(it) } - .disposeOnDestroy() loginViewModel.viewEvents .observe() + .observeOn(AndroidSchedulers.mainThread()) .subscribe { handleLoginViewEvents(it) } .disposeOnDestroy() } + protected open fun addFirstFragment() { + addFragment(R.id.loginFragmentContainer, LoginSplashFragment::class.java) + } + private fun handleLoginNavigation(loginNavigation: LoginNavigation) { // Assigning to dummy make sure we do not forget a case @Suppress("UNUSED_VARIABLE") diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt index 3ff3e902cb..e3bb539172 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginCaptchaFragment.kt @@ -29,7 +29,6 @@ import androidx.core.view.isVisible import com.airbnb.mvrx.args import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.utils.AssetReader import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_login_captcha.* @@ -47,8 +46,7 @@ data class LoginCaptchaFragmentArgument( * In this screen, the user is asked to confirm he is not a robot */ class LoginCaptchaFragment @Inject constructor( - private val assetReader: AssetReader, - private val errorFormatter: ErrorFormatter + private val assetReader: AssetReader ) : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_captcha @@ -172,14 +170,6 @@ class LoginCaptchaFragment @Inject constructor( } } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun resetViewModel() { loginViewModel.handle(LoginAction.ResetLogin) } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt index 67935c1ae8..93b1b1b525 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginFragment.kt @@ -29,9 +29,9 @@ import com.jakewharton.rxbinding3.widget.textChanges import im.vector.matrix.android.api.failure.Failure import im.vector.matrix.android.api.failure.MatrixError import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.showPassword +import im.vector.riotx.core.extensions.toReducedUrl import io.reactivex.Observable import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy @@ -45,9 +45,7 @@ import javax.inject.Inject * In signup mode: * - the user is asked for login and password */ -class LoginFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginFragment @Inject constructor() : AbstractLoginFragment() { private var passwordShown = false @@ -103,7 +101,7 @@ class LoginFragment @Inject constructor( ServerType.MatrixOrg -> { loginServerIcon.isVisible = true loginServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) - loginTitle.text = getString(resId, state.homeServerUrlSimple) + loginTitle.text = getString(resId, state.homeServerUrl.toReducedUrl()) loginNotice.text = getString(R.string.login_server_matrix_org_text) } ServerType.Modular -> { @@ -114,7 +112,7 @@ class LoginFragment @Inject constructor( } ServerType.Other -> { loginServerIcon.isVisible = false - loginTitle.text = getString(resId, state.homeServerUrlSimple) + loginTitle.text = getString(resId, state.homeServerUrl.toReducedUrl()) loginNotice.text = getString(R.string.login_server_other_text) } } @@ -134,7 +132,7 @@ class LoginFragment @Inject constructor( Observable .combineLatest( loginField.textChanges().map { it.trim().isNotEmpty() }, - passwordField.textChanges().map { it.trim().isNotEmpty() }, + passwordField.textChanges().map { it.isNotEmpty() }, BiFunction { isLoginNotEmpty, isPasswordNotEmpty -> isLoginNotEmpty && isPasswordNotEmpty } @@ -198,7 +196,7 @@ class LoginFragment @Inject constructor( is Fail -> { val error = state.asyncLoginAction.error if (error is Failure.ServerError - && error.error.code == MatrixError.FORBIDDEN + && error.error.code == MatrixError.M_FORBIDDEN && error.error.message.isEmpty()) { // Login with email, but email unknown loginFieldTil.error = getString(R.string.login_login_with_email_error) diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt index 527b0c6802..64fb01fa5f 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginGenericTextInputFormFragment.kt @@ -31,7 +31,6 @@ import com.jakewharton.rxbinding3.widget.textChanges import im.vector.matrix.android.api.auth.registration.RegisterThreePid import im.vector.matrix.android.api.failure.Failure import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.error.is401 import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.isEmail @@ -56,7 +55,7 @@ data class LoginGenericTextInputFormFragmentArgument( /** * In this screen, the user is asked for a text input */ -class LoginGenericTextInputFormFragment @Inject constructor(private val errorFormatter: ErrorFormatter) : AbstractLoginFragment() { +class LoginGenericTextInputFormFragment @Inject constructor() : AbstractLoginFragment() { private val params: LoginGenericTextInputFormFragmentArgument by args() diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt index 18fcd8938b..d3a86ef769 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordFragment.kt @@ -25,10 +25,10 @@ import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success import com.jakewharton.rxbinding3.widget.textChanges import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.isEmail import im.vector.riotx.core.extensions.showPassword +import im.vector.riotx.core.extensions.toReducedUrl import io.reactivex.Observable import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.subscribeBy @@ -38,9 +38,7 @@ import javax.inject.Inject /** * In this screen, the user is asked for email and new password to reset his password */ -class LoginResetPasswordFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginResetPasswordFragment @Inject constructor() : AbstractLoginFragment() { private var passwordShown = false @@ -57,7 +55,7 @@ class LoginResetPasswordFragment @Inject constructor( } private fun setupUi(state: LoginViewState) { - resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrlSimple) + resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrl.toReducedUrl()) } private fun setupSubmitButton() { @@ -138,14 +136,6 @@ class LoginResetPasswordFragment @Inject constructor( loginViewModel.handle(LoginAction.ResetResetPassword) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun updateWithState(state: LoginViewState) { setupUi(state) diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt index 03053a9718..e7ddc78853 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordMailConfirmationFragment.kt @@ -21,7 +21,6 @@ import butterknife.OnClick import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Success import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.error.is401 import kotlinx.android.synthetic.main.fragment_login_reset_password_mail_confirmation.* import javax.inject.Inject @@ -29,9 +28,7 @@ import javax.inject.Inject /** * In this screen, the user is asked to check his email and to click on a button once it's done */ -class LoginResetPasswordMailConfirmationFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginResetPasswordMailConfirmationFragment @Inject constructor() : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_reset_password_mail_confirmation @@ -44,14 +41,6 @@ class LoginResetPasswordMailConfirmationFragment @Inject constructor( loginViewModel.handle(LoginAction.ResetPasswordMailConfirmed) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun resetViewModel() { loginViewModel.handle(LoginAction.ResetResetPassword) } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt index 92d75b3998..4faeef1269 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginResetPasswordSuccessFragment.kt @@ -16,18 +16,14 @@ package im.vector.riotx.features.login -import androidx.appcompat.app.AlertDialog import butterknife.OnClick import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import javax.inject.Inject /** - * In this screen, the user is asked for email and new password to reset his password + * In this screen, we confirm to the user that his password has been reset */ -class LoginResetPasswordSuccessFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginResetPasswordSuccessFragment @Inject constructor() : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_reset_password_success @@ -36,14 +32,6 @@ class LoginResetPasswordSuccessFragment @Inject constructor( loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordMailConfirmationSuccessDone) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun resetViewModel() { loginViewModel.handle(LoginAction.ResetResetPassword) } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt index 6e427d0bdb..9050ea2688 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginServerSelectionFragment.kt @@ -18,11 +18,9 @@ package im.vector.riotx.features.login import android.os.Bundle import android.view.View -import androidx.appcompat.app.AlertDialog import butterknife.OnClick import com.airbnb.mvrx.withState import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.utils.openUrlInExternalBrowser import kotlinx.android.synthetic.main.fragment_login_server_selection.* import me.gujun.android.span.span @@ -31,9 +29,7 @@ import javax.inject.Inject /** * In this screen, the user will choose between matrix.org, modular or other type of homeserver */ -class LoginServerSelectionFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_server_selection @@ -107,14 +103,6 @@ class LoginServerSelectionFragment @Inject constructor( loginViewModel.handle(LoginAction.ResetHomeServerType) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun updateWithState(state: LoginViewState) { updateSelectedChoice(state) diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt index d632ffe100..898ee97656 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginServerUrlFormFragment.kt @@ -24,7 +24,6 @@ import androidx.core.view.isVisible import butterknife.OnClick import com.jakewharton.rxbinding3.widget.textChanges import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.utils.openUrlInExternalBrowser import kotlinx.android.synthetic.main.fragment_login_server_url_form.* @@ -33,9 +32,7 @@ import javax.inject.Inject /** * In this screen, the user is prompted to enter a homeserver url */ -class LoginServerUrlFormFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_server_url_form diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt index 0484357ae2..9f084299b7 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginSignUpSignInSelectionFragment.kt @@ -16,20 +16,17 @@ package im.vector.riotx.features.login -import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import butterknife.OnClick import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.extensions.toReducedUrl import kotlinx.android.synthetic.main.fragment_login_signup_signin_selection.* import javax.inject.Inject /** * In this screen, the user is asked to sign up or to sign in to the homeserver */ -class LoginSignUpSignInSelectionFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_signup_signin_selection @@ -40,19 +37,19 @@ class LoginSignUpSignInSelectionFragment @Inject constructor( ServerType.MatrixOrg -> { loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) loginSignupSigninServerIcon.isVisible = true - loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrlSimple) + loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrl.toReducedUrl()) loginSignupSigninText.text = getString(R.string.login_server_matrix_org_text) } ServerType.Modular -> { loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_modular) loginSignupSigninServerIcon.isVisible = true loginSignupSigninTitle.text = getString(R.string.login_connect_to_modular) - loginSignupSigninText.text = state.homeServerUrlSimple + loginSignupSigninText.text = state.homeServerUrl.toReducedUrl() } ServerType.Other -> { loginSignupSigninServerIcon.isVisible = false loginSignupSigninTitle.text = getString(R.string.login_server_other_title) - loginSignupSigninText.text = getString(R.string.login_connect_to, state.homeServerUrlSimple) + loginSignupSigninText.text = getString(R.string.login_connect_to, state.homeServerUrl.toReducedUrl()) } } } @@ -84,14 +81,6 @@ class LoginSignUpSignInSelectionFragment @Inject constructor( loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun resetViewModel() { loginViewModel.handle(LoginAction.ResetSignMode) } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt index ef17bea920..53de8c2c43 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginSplashFragment.kt @@ -16,18 +16,14 @@ package im.vector.riotx.features.login -import androidx.appcompat.app.AlertDialog import butterknife.OnClick import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import javax.inject.Inject /** * In this screen, the user is viewing an introduction to what he can do with this application */ -class LoginSplashFragment @Inject constructor( - private val errorFormatter: ErrorFormatter -) : AbstractLoginFragment() { +class LoginSplashFragment @Inject constructor() : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_splash @@ -36,14 +32,6 @@ class LoginSplashFragment @Inject constructor( loginSharedActionViewModel.post(LoginNavigation.OpenServerSelection) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun resetViewModel() { // Nothing to do } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt index 00207cbfbf..baa4160351 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewModel.kt @@ -16,6 +16,7 @@ package im.vector.riotx.features.login +import androidx.fragment.app.FragmentActivity import com.airbnb.mvrx.* import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject @@ -37,6 +38,7 @@ import im.vector.riotx.core.utils.DataSource import im.vector.riotx.core.utils.PublishDataSource import im.vector.riotx.features.notifications.PushRuleTriggerListener import im.vector.riotx.features.session.SessionListener +import im.vector.riotx.features.signout.soft.SoftLogoutActivity import timber.log.Timber import java.util.concurrent.CancellationException @@ -60,8 +62,11 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi @JvmStatic override fun create(viewModelContext: ViewModelContext, state: LoginViewState): LoginViewModel? { - val activity: LoginActivity = (viewModelContext as ActivityViewModelContext).activity() - return activity.loginViewModelFactory.create(state) + return when (val activity: FragmentActivity = (viewModelContext as ActivityViewModelContext).activity()) { + is LoginActivity -> activity.loginViewModelFactory.create(state) + is SoftLogoutActivity -> activity.loginViewModelFactory.create(state) + else -> error("Invalid Activity") + } } } @@ -97,6 +102,18 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi is LoginAction.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() is LoginAction.RegisterAction -> handleRegisterAction(action) is LoginAction.ResetAction -> handleResetAction(action) + is LoginAction.SetupSsoForSessionRecovery -> handleSetupSsoForSessionRecovery(action) + } + } + + private fun handleSetupSsoForSessionRecovery(action: LoginAction.SetupSsoForSessionRecovery) { + setState { + copy( + signMode = SignMode.SignIn, + loginMode = LoginMode.Sso, + homeServerUrl = action.homeServerUrl, + deviceId = action.deviceId + ) } } @@ -452,7 +469,7 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi } private fun startAuthenticationFlow() { - // No op + // Ensure Wizard is ready loginWizard } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt index e4b3fe214a..2887dd04f0 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginViewState.kt @@ -34,6 +34,9 @@ data class LoginViewState( val resetPasswordEmail: String? = null, @PersistState val homeServerUrl: String? = null, + // For SSO session recovery + @PersistState + val deviceId: String? = null, // Network result @PersistState @@ -49,17 +52,11 @@ data class LoginViewState( || asyncResetPassword is Loading || asyncResetMailConfirmed is Loading || asyncRegistration is Loading + // Keep loading when it is success because of the delay to switch to the next Activity + || asyncLoginAction is Success } fun isUserLogged(): Boolean { return asyncLoginAction is Success } - - /** - * Ex: "https://matrix.org/" -> "matrix.org" - */ - val homeServerUrlSimple: String - get() = (homeServerUrl ?: "") - .substringAfter("://") - .trim { it == '/' } } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt index 2436b1d2d1..8a12c67106 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginWaitForEmailFragment.kt @@ -19,10 +19,8 @@ package im.vector.riotx.features.login import android.os.Bundle import android.os.Parcelable import android.view.View -import androidx.appcompat.app.AlertDialog import com.airbnb.mvrx.args import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.error.is401 import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_login_wait_for_email.* @@ -36,7 +34,7 @@ data class LoginWaitForEmailFragmentArgument( /** * In this screen, the user is asked to check his emails */ -class LoginWaitForEmailFragment @Inject constructor(private val errorFormatter: ErrorFormatter) : AbstractLoginFragment() { +class LoginWaitForEmailFragment @Inject constructor() : AbstractLoginFragment() { private val params: LoginWaitForEmailFragmentArgument by args() @@ -69,11 +67,7 @@ class LoginWaitForEmailFragment @Inject constructor(private val errorFormatter: // Try again, with a delay loginViewModel.handle(LoginAction.CheckIfEmailHasBeenValidated(10_000)) } else { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() + super.onError(throwable) } } diff --git a/vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt index eac4511b57..47388653da 100644 --- a/vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/LoginWebFragment.kt @@ -30,10 +30,13 @@ import android.webkit.SslErrorHandler import android.webkit.WebView import android.webkit.WebViewClient import androidx.appcompat.app.AlertDialog +import com.airbnb.mvrx.activityViewModel +import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.utils.AssetReader +import im.vector.riotx.features.signout.soft.SoftLogoutAction +import im.vector.riotx.features.signout.soft.SoftLogoutViewModel import kotlinx.android.synthetic.main.fragment_login_web.* import timber.log.Timber import java.net.URLDecoder @@ -44,13 +47,13 @@ import javax.inject.Inject * of the homeserver, as a fallback to login or to create an account */ class LoginWebFragment @Inject constructor( - private val assetReader: AssetReader, - private val errorFormatter: ErrorFormatter + private val assetReader: AssetReader ) : AbstractLoginFragment() { override fun getLayoutResId() = R.layout.fragment_login_web private var isWebViewLoaded = false + private var isForSessionRecovery = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -60,6 +63,9 @@ class LoginWebFragment @Inject constructor( override fun updateWithState(state: LoginViewState) { setupTitle(state) + + isForSessionRecovery = state.deviceId?.isNotBlank() == true + if (!isWebViewLoaded) { setupWebView(state) isWebViewLoaded = true @@ -110,13 +116,22 @@ class LoginWebFragment @Inject constructor( } private fun launchWebView(state: LoginViewState) { - if (state.signMode == SignMode.SignIn) { - loginWebWebView.loadUrl(state.homeServerUrl?.trim { it == '/' } + "/_matrix/static/client/login/") - } else { - // MODE_REGISTER - loginWebWebView.loadUrl(state.homeServerUrl?.trim { it == '/' } + "/_matrix/static/client/register/") + val url = buildString { + append(state.homeServerUrl?.trim { it == '/' }) + if (state.signMode == SignMode.SignIn) { + append("/_matrix/static/client/login/") + state.deviceId?.takeIf { it.isNotBlank() }?.let { + // But https://github.com/matrix-org/synapse/issues/5755 + append("?device_id=$it") + } + } else { + // MODE_REGISTER + append("/_matrix/static/client/register/") + } } + loginWebWebView.loadUrl(url) + loginWebWebView.webViewClient = object : WebViewClient() { override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { @@ -212,10 +227,7 @@ class LoginWebFragment @Inject constructor( if (state.signMode == SignMode.SignIn) { try { if (action == "onLogin") { - val credentials = javascriptResponse.credentials - if (credentials != null) { - loginViewModel.handle(LoginAction.WebLoginSuccess(credentials)) - } + javascriptResponse.credentials?.let { notifyViewModel(it) } } } catch (e: Exception) { Timber.e(e, "## shouldOverrideUrlLoading() : failed") @@ -224,10 +236,7 @@ class LoginWebFragment @Inject constructor( // MODE_REGISTER // check the required parameters if (action == "onRegistered") { - val credentials = javascriptResponse.credentials - if (credentials != null) { - loginViewModel.handle(LoginAction.WebLoginSuccess(credentials)) - } + javascriptResponse.credentials?.let { notifyViewModel(it) } } } } @@ -239,16 +248,17 @@ class LoginWebFragment @Inject constructor( } } - override fun resetViewModel() { - loginViewModel.handle(LoginAction.ResetLogin) + private fun notifyViewModel(credentials: Credentials) { + if (isForSessionRecovery) { + val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + softLogoutViewModel.handle(SoftLogoutAction.WebLoginSuccess(credentials)) + } else { + loginViewModel.handle(LoginAction.WebLoginSuccess(credentials)) + } } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() + override fun resetViewModel() { + loginViewModel.handle(LoginAction.ResetLogin) } override fun onBackPressed(toolbarButton: Boolean): Boolean { diff --git a/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt index 83d68f5f31..09746adc87 100755 --- a/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/login/terms/LoginTermsFragment.kt @@ -19,13 +19,12 @@ package im.vector.riotx.features.login.terms import android.os.Bundle import android.os.Parcelable import android.view.View -import androidx.appcompat.app.AlertDialog import butterknife.OnClick import com.airbnb.mvrx.args import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.extensions.toReducedUrl import im.vector.riotx.core.utils.openUrlInExternalBrowser import im.vector.riotx.features.login.AbstractLoginFragment import im.vector.riotx.features.login.LoginAction @@ -44,8 +43,7 @@ data class LoginTermsFragmentArgument( * LoginTermsFragment displays the list of policies the user has to accept */ class LoginTermsFragment @Inject constructor( - private val policyController: PolicyController, - private val errorFormatter: ErrorFormatter + private val policyController: PolicyController ) : AbstractLoginFragment(), PolicyController.PolicyControllerListener { @@ -106,16 +104,8 @@ class LoginTermsFragment @Inject constructor( loginViewModel.handle(LoginAction.AcceptTerms) } - override fun onError(throwable: Throwable) { - AlertDialog.Builder(requireActivity()) - .setTitle(R.string.dialog_title_error) - .setMessage(errorFormatter.toHumanReadable(throwable)) - .setPositiveButton(R.string.ok, null) - .show() - } - override fun updateWithState(state: LoginViewState) { - policyController.homeServer = state.homeServerUrlSimple + policyController.homeServer = state.homeServerUrl.toReducedUrl() renderState() } diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt index df638b462b..909fd5b8eb 100644 --- a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt @@ -31,11 +31,13 @@ import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideRequest +import im.vector.riotx.core.ui.model.Size import im.vector.riotx.core.utils.DimensionConverter import im.vector.riotx.core.utils.isLocalFile import kotlinx.android.parcel.Parcelize import timber.log.Timber import javax.inject.Inject +import kotlin.math.min class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, private val dimensionConverter: DimensionConverter) { @@ -56,17 +58,18 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: enum class Mode { FULL_SIZE, - THUMBNAIL + THUMBNAIL, + STICKER } fun render(data: Data, mode: Mode, imageView: ImageView) { - val (width, height) = processSize(data, mode) - imageView.layoutParams.height = height - imageView.layoutParams.width = width + val size = processSize(data, mode) + imageView.layoutParams.width = size.width + imageView.layoutParams.height = size.height // a11y imageView.contentDescription = data.filename - createGlideRequest(data, mode, imageView, width, height) + createGlideRequest(data, mode, imageView, size) .dontAnimate() .transform(RoundedCorners(dimensionConverter.dpToPx(8))) .thumbnail(0.3f) @@ -74,12 +77,12 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: } fun renderFitTarget(data: Data, mode: Mode, imageView: ImageView, callback: ((Boolean) -> Unit)? = null) { - val (width, height) = processSize(data, mode) + val size = processSize(data, mode) // a11y imageView.contentDescription = data.filename - createGlideRequest(data, mode, imageView, width, height) + createGlideRequest(data, mode, imageView, size) .listener(object : RequestListener { override fun onLoadFailed(e: GlideException?, model: Any?, @@ -102,7 +105,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: .into(imageView) } - private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, width: Int, height: Int): GlideRequest { + private fun createGlideRequest(data: Data, mode: Mode, imageView: ImageView, size: Size): GlideRequest { return if (data.elementToDecrypt != null) { // Encrypted image GlideApp @@ -112,8 +115,9 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: // Clear image val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() val resolvedUrl = when (mode) { - Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url) - Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) + Mode.FULL_SIZE, + Mode.STICKER -> contentUrlResolver.resolveFullSize(data.url) + Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE) } // Fallback to base url ?: data.url @@ -144,23 +148,32 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ) } - private fun processSize(data: Data, mode: Mode): Pair { + private fun processSize(data: Data, mode: Mode): Size { val maxImageWidth = data.maxWidth val maxImageHeight = data.maxHeight val width = data.width ?: maxImageWidth val height = data.height ?: maxImageHeight - var finalHeight = -1 var finalWidth = -1 + var finalHeight = -1 // if the image size is known // compute the expected height if (width > 0 && height > 0) { - if (mode == Mode.FULL_SIZE) { - finalHeight = height - finalWidth = width - } else { - finalHeight = Math.min(maxImageWidth * height / width, maxImageHeight) - finalWidth = finalHeight * width / height + when (mode) { + Mode.FULL_SIZE -> { + finalHeight = height + finalWidth = width + } + Mode.THUMBNAIL -> { + finalHeight = min(maxImageWidth * height / width, maxImageHeight) + finalWidth = finalHeight * width / height + } + Mode.STICKER -> { + // limit on width + val maxWidthDp = min(dimensionConverter.dpToPx(120), maxImageWidth / 2) + finalWidth = min(dimensionConverter.dpToPx(width), maxWidthDp) + finalHeight = finalWidth * height / width + } } } // ensure that some values are properly initialized @@ -170,6 +183,6 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: if (finalWidth < 0) { finalWidth = maxImageWidth } - return Pair(finalWidth, finalHeight) + return Size(finalWidth, finalHeight) } } diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt index 685fa04fef..08ff11217d 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/DefaultNavigator.kt @@ -19,8 +19,11 @@ package im.vector.riotx.features.navigation import android.app.Activity import android.content.Context import android.content.Intent +import androidx.core.app.TaskStackBuilder import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom import im.vector.riotx.R +import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.error.fatalError import im.vector.riotx.core.platform.VectorBaseActivity import im.vector.riotx.core.utils.toast import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupManageActivity @@ -40,12 +43,49 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class DefaultNavigator @Inject constructor() : Navigator { +class DefaultNavigator @Inject constructor( + private val sessionHolder: ActiveSessionHolder +) : Navigator { + + override fun openRoom(context: Context, roomId: String, eventId: String?, buildTask: Boolean) { + if (sessionHolder.getSafeActiveSession()?.getRoom(roomId) == null) { + fatalError("Trying to open an unknown room $roomId") + return + } - override fun openRoom(context: Context, roomId: String, eventId: String?) { val args = RoomDetailArgs(roomId, eventId) val intent = RoomDetailActivity.newIntent(context, args) - context.startActivity(intent) + if (buildTask) { + val stackBuilder = TaskStackBuilder.create(context) + stackBuilder.addNextIntentWithParentStack(intent) + stackBuilder.startActivities() + } else { + context.startActivity(intent) + } + } + + override fun openNotJoinedRoom(context: Context, roomIdOrAlias: String?, eventId: String?, buildTask: Boolean) { + if (context is VectorBaseActivity) { + context.notImplemented("Open not joined room") + } else { + context.toast(R.string.not_implemented) + } + } + + override fun openGroupDetail(groupId: String, context: Context, buildTask: Boolean) { + if (context is VectorBaseActivity) { + context.notImplemented("Open group detail") + } else { + context.toast(R.string.not_implemented) + } + } + + override fun openUserDetail(userId: String, context: Context, buildTask: Boolean) { + if (context is VectorBaseActivity) { + context.notImplemented("Open user detail") + } else { + context.toast(R.string.not_implemented) + } } override fun openRoomForSharing(activity: Activity, roomId: String, sharedData: SharedData) { @@ -55,14 +95,6 @@ class DefaultNavigator @Inject constructor() : Navigator { activity.finish() } - override fun openNotJoinedRoom(context: Context, roomIdOrAlias: String, eventId: String?) { - if (context is VectorBaseActivity) { - context.notImplemented("Open not joined room") - } else { - context.toast(R.string.not_implemented) - } - } - override fun openRoomPreview(publicRoom: PublicRoom, context: Context) { val intent = RoomPreviewActivity.getIntent(context, publicRoom) context.startActivity(intent) @@ -105,14 +137,6 @@ class DefaultNavigator @Inject constructor() : Navigator { context.startActivity(KeysBackupManageActivity.intent(context)) } - override fun openGroupDetail(groupId: String, context: Context) { - Timber.v("Open group detail $groupId") - } - - override fun openUserDetail(userId: String, context: Context) { - Timber.v("Open user detail $userId") - } - override fun openRoomSettings(context: Context, roomId: String) { Timber.v("Open room settings$roomId") } diff --git a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt index 83c4f7ce20..278c8fdba0 100644 --- a/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/riotx/features/navigation/Navigator.kt @@ -23,11 +23,11 @@ import im.vector.riotx.features.share.SharedData interface Navigator { - fun openRoom(context: Context, roomId: String, eventId: String? = null) + fun openRoom(context: Context, roomId: String, eventId: String? = null, buildTask: Boolean = false) fun openRoomForSharing(activity: Activity, roomId: String, sharedData: SharedData) - fun openNotJoinedRoom(context: Context, roomIdOrAlias: String, eventId: String? = null) + fun openNotJoinedRoom(context: Context, roomIdOrAlias: String?, eventId: String? = null, buildTask: Boolean = false) fun openRoomPreview(publicRoom: PublicRoom, context: Context) @@ -47,9 +47,9 @@ interface Navigator { fun openKeysBackupManager(context: Context) - fun openGroupDetail(groupId: String, context: Context) + fun openGroupDetail(groupId: String, context: Context, buildTask: Boolean = false) - fun openUserDetail(userId: String, context: Context) + fun openUserDetail(userId: String, context: Context, buildTask: Boolean = false) fun openRoomSettings(context: Context, roomId: String) } diff --git a/vector/src/main/java/im/vector/riotx/features/permalink/PermalinkHandler.kt b/vector/src/main/java/im/vector/riotx/features/permalink/PermalinkHandler.kt new file mode 100644 index 0000000000..e46adc53fc --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/permalink/PermalinkHandler.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.permalink + +import android.content.Context +import android.net.Uri +import im.vector.matrix.android.api.permalinks.PermalinkData +import im.vector.matrix.android.api.permalinks.PermalinkParser +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.util.Optional +import im.vector.matrix.rx.rx +import im.vector.riotx.features.navigation.Navigator +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class PermalinkHandler @Inject constructor(private val session: Session, + private val navigator: Navigator) { + + fun launch( + context: Context, + deepLink: String?, + navigateToRoomInterceptor: NavigateToRoomInterceptor? = null, + buildTask: Boolean = false + ): Single { + val uri = deepLink?.let { Uri.parse(it) } + return launch(context, uri, navigateToRoomInterceptor, buildTask) + } + + fun launch( + context: Context, + deepLink: Uri?, + navigateToRoomInterceptor: NavigateToRoomInterceptor? = null, + buildTask: Boolean = false + ): Single { + if (deepLink == null) { + return Single.just(false) + } + return when (val permalinkData = PermalinkParser.parse(deepLink)) { + is PermalinkData.RoomLink -> { + permalinkData.getRoomId() + .observeOn(AndroidSchedulers.mainThread()) + .map { + val roomId = it.getOrNull() + if (navigateToRoomInterceptor?.navToRoom(roomId, permalinkData.eventId) != true) { + openRoom(context, roomId, permalinkData.eventId, buildTask) + } + true + } + } + is PermalinkData.GroupLink -> { + navigator.openGroupDetail(permalinkData.groupId, context, buildTask) + Single.just(true) + } + is PermalinkData.UserLink -> { + navigator.openUserDetail(permalinkData.userId, context, buildTask) + Single.just(true) + } + is PermalinkData.FallbackLink -> { + Single.just(false) + } + } + } + + private fun PermalinkData.RoomLink.getRoomId(): Single> { + return if (isRoomAlias) { + // At the moment we are not fetching on the server as we don't handle not join room + session.rx().getRoomIdByAlias(roomIdOrAlias, false).subscribeOn(Schedulers.io()) + } else { + Single.just(Optional.from(roomIdOrAlias)) + } + } + + /** + * Open room either joined, or not + */ + private fun openRoom(context: Context, roomId: String?, eventId: String?, buildTask: Boolean) { + return if (roomId != null && session.getRoom(roomId) != null) { + navigator.openRoom(context, roomId, eventId, buildTask) + } else { + navigator.openNotJoinedRoom(context, roomId, eventId, buildTask) + } + } +} + +interface NavigateToRoomInterceptor { + + /** + * Return true if the navigation has been intercepted + */ + fun navToRoom(roomId: String?, eventId: String? = null): Boolean +} diff --git a/vector/src/main/java/im/vector/riotx/features/permalink/PermalinkHandlerActivity.kt b/vector/src/main/java/im/vector/riotx/features/permalink/PermalinkHandlerActivity.kt new file mode 100644 index 0000000000..5339a2c6f9 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/permalink/PermalinkHandlerActivity.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.permalink + +import android.content.Intent +import android.os.Bundle +import im.vector.riotx.R +import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.di.ScreenComponent +import im.vector.riotx.core.extensions.replaceFragment +import im.vector.riotx.core.platform.VectorBaseActivity +import im.vector.riotx.core.utils.toast +import im.vector.riotx.features.home.LoadingFragment +import im.vector.riotx.features.login.LoginActivity +import io.reactivex.android.schedulers.AndroidSchedulers +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class PermalinkHandlerActivity : VectorBaseActivity() { + + @Inject lateinit var permalinkHandler: PermalinkHandler + @Inject lateinit var sessionHolder: ActiveSessionHolder + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_simple) + if (isFirstCreation()) { + replaceFragment(R.id.simpleFragmentContainer, LoadingFragment::class.java) + } + // If we are not logged in, open login screen. + // In the future, we might want to relaunch the process after login. + if (!sessionHolder.hasActiveSession()) { + startLoginActivity() + return + } + val uri = intent.dataString + permalinkHandler.launch(this, uri, buildTask = true) + .delay(500, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { isHandled -> + if (!isHandled) { + toast(R.string.permalink_malformed) + } + finish() + } + .disposeOnDestroy() + } + + private fun startLoginActivity() { + val intent = LoginActivity.newIntent(this, null) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt index 187566f660..5c1428cb54 100755 --- a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReportActivity.kt @@ -77,8 +77,7 @@ class BugReportActivity : VectorBaseActivity() { override fun onPrepareOptionsMenu(menu: Menu): Boolean { menu.findItem(R.id.ic_action_send_bug_report)?.let { - val isValid = bug_report_edit_text.text.toString().trim().length > 10 - && !bug_report_mask_view.isVisible + val isValid = !bug_report_mask_view.isVisible it.isEnabled = isValid it.icon.alpha = if (isValid) 255 else 100 @@ -90,7 +89,11 @@ class BugReportActivity : VectorBaseActivity() { override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.ic_action_send_bug_report -> { - sendBugReport() + if (bug_report_edit_text.text.toString().trim().length >= 10) { + sendBugReport() + } else { + bug_report_text_input_layout.error = getString(R.string.bug_report_error_too_short) + } return true } } @@ -150,7 +153,7 @@ class BugReportActivity : VectorBaseActivity() { val myProgress = progress.coerceIn(0, 100) bug_report_progress_view.progress = myProgress - bug_report_progress_text_view.text = getString(R.string.send_bug_report_progress, "$myProgress") + bug_report_progress_text_view.text = getString(R.string.send_bug_report_progress, myProgress.toString()) } override fun onUploadSucceed() { @@ -179,7 +182,7 @@ class BugReportActivity : VectorBaseActivity() { @OnTextChanged(R.id.bug_report_edit_text) internal fun textChanged() { - invalidateOptionsMenu() + bug_report_text_input_layout.error = null } @OnCheckedChanged(R.id.bug_report_button_include_screenshot) diff --git a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt index b96542a8ce..dc353363d5 100755 --- a/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt +++ b/vector/src/main/java/im/vector/riotx/features/rageshake/BugReporter.kt @@ -33,6 +33,7 @@ import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.extensions.toOnOff import im.vector.riotx.core.utils.getDeviceLocale import im.vector.riotx.features.settings.VectorLocale +import im.vector.riotx.features.settings.VectorPreferences import im.vector.riotx.features.themes.ThemeUtils import im.vector.riotx.features.version.VersionProvider import okhttp3.Call @@ -44,12 +45,15 @@ import okhttp3.Response import org.json.JSONException import org.json.JSONObject import timber.log.Timber -import java.io.* +import java.io.File +import java.io.IOException +import java.io.OutputStreamWriter import java.net.HttpURLConnection -import java.util.Locale +import java.util.* import java.util.zip.GZIPOutputStream import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.ArrayList /** * BugReporter creates and sends the bug reports. @@ -57,6 +61,7 @@ import javax.inject.Singleton @Singleton class BugReporter @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, private val versionProvider: VersionProvider, + private val vectorPreferences: VectorPreferences, private val vectorFileLogger: VectorFileLogger) { var inMultiWindowMode = false @@ -230,7 +235,7 @@ class BugReporter @Inject constructor(private val activeSessionHolder: ActiveSes .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion()) .addFormDataPart("olm_version", olmVersion) .addFormDataPart("device", Build.MODEL.trim()) - .addFormDataPart("lazy_loading", true.toOnOff()) + .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff()) .addFormDataPart("multi_window", inMultiWindowMode.toOnOff()) .addFormDataPart("os", Build.VERSION.RELEASE + " (API " + Build.VERSION.SDK_INT + ") " + Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME) diff --git a/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt b/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt index 95053790c8..6049db6180 100644 --- a/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt +++ b/vector/src/main/java/im/vector/riotx/features/rageshake/VectorFileLogger.kt @@ -24,9 +24,7 @@ import java.io.File import java.io.PrintWriter import java.io.StringWriter import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone +import java.util.* import java.util.logging.* import java.util.logging.Formatter import javax.inject.Inject @@ -83,7 +81,8 @@ class VectorFileLogger @Inject constructor(val context: Context, private val vec return if (vectorPreferences.labAllowedExtendedLogging()) { false } else { - priority < Log.ERROR + // Exclude debug and verbose logs + priority <= Log.DEBUG } } diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt index 057c5d8159..01debac5ed 100644 --- a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt @@ -42,6 +42,7 @@ class EmojiSearchResultViewModel @AssistedInject constructor( companion object : MvRxViewModelFactory { + @JvmStatic override fun create(viewModelContext: ViewModelContext, state: EmojiSearchResultViewState): EmojiSearchResultViewModel? { val activity: EmojiReactionPickerActivity = (viewModelContext as ActivityViewModelContext).activity() return activity.emojiSearchResultViewModelFactory.create(state) diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomItem.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomItem.kt index 5e5c4fc5f1..108627e3f8 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomItem.kt @@ -21,6 +21,7 @@ import android.widget.ImageView import android.widget.TextView import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass +import im.vector.matrix.android.api.util.MatrixItem import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel @@ -35,13 +36,7 @@ abstract class PublicRoomItem : VectorEpoxyModel() { lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute - var avatarUrl: String? = null - - @EpoxyAttribute - var roomId: String? = null - - @EpoxyAttribute - var roomName: String? = null + lateinit var matrixItem: MatrixItem @EpoxyAttribute var roomAlias: String? = null @@ -64,8 +59,8 @@ abstract class PublicRoomItem : VectorEpoxyModel() { override fun bind(holder: Holder) { holder.rootView.setOnClickListener { globalListener?.invoke() } - avatarRenderer.render(avatarUrl, roomId!!, roomName, holder.avatarView) - holder.nameView.text = roomName + avatarRenderer.render(matrixItem, holder.avatarView) + holder.nameView.text = matrixItem.displayName holder.aliasView.setTextOrHide(roomAlias) holder.topicView.setTextOrHide(roomTopic) // TODO Use formatter for big numbers? diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsController.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsController.kt index 183256a53e..83a1768843 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsController.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsController.kt @@ -22,6 +22,7 @@ import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Incomplete import com.airbnb.mvrx.Success import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom +import im.vector.matrix.android.api.util.toMatrixItem import im.vector.riotx.R import im.vector.riotx.core.epoxy.errorWithRetryItem import im.vector.riotx.core.epoxy.loadingItem @@ -83,9 +84,7 @@ class PublicRoomsController @Inject constructor(private val stringProvider: Stri publicRoomItem { avatarRenderer(avatarRenderer) id(publicRoom.roomId) - roomId(publicRoom.roomId) - avatarUrl(publicRoom.avatarUrl) - roomName(publicRoom.name) + matrixItem(publicRoom.toMatrixItem()) roomAlias(publicRoom.canonicalAlias) roomTopic(publicRoom.topic) nbOfMembers(publicRoom.numJoinedMembers) diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt index 1d8ed48b08..1e625cff75 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/PublicRoomsFragment.kt @@ -26,7 +26,6 @@ import com.google.android.material.snackbar.Snackbar import com.jakewharton.rxbinding3.appcompat.queryTextChanges import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.configureWith import im.vector.riotx.core.extensions.observeEvent @@ -42,8 +41,7 @@ import javax.inject.Inject * - When filtering more (when entering new chars), we could filter on result we already have, during the new server request, to avoid empty screen effect */ class PublicRoomsFragment @Inject constructor( - private val publicRoomsController: PublicRoomsController, - private val errorFormatter: ErrorFormatter + private val publicRoomsController: PublicRoomsController ) : VectorBaseFragment(), PublicRoomsController.Callback { private val viewModel: RoomDirectoryViewModel by activityViewModel() diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt index d89f0e2b99..dcd64c6a46 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/RoomDirectoryViewModel.kt @@ -178,7 +178,9 @@ class RoomDirectoryViewModel @AssistedInject constructor(@Assisted initialState: copy( asyncPublicRoomsRequest = Success(data.chunk!!), // It's ok to append at the end of the list, so I use publicRooms.size() - publicRooms = publicRooms.appendAt(data.chunk!!, publicRooms.size), + publicRooms = publicRooms.appendAt(data.chunk!!, publicRooms.size) + // Rageshake #8206 tells that we can have several times the same room + .distinctBy { it.roomId }, hasMore = since != null ) } diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt index 1bd138552e..0fdb504c23 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.os.Parcelable import androidx.appcompat.widget.Toolbar import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom +import im.vector.matrix.android.api.util.MatrixItem import im.vector.riotx.R import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.platform.ToolbarConfigurable @@ -34,7 +35,10 @@ data class RoomPreviewData( val topic: String?, val worldReadable: Boolean, val avatarUrl: String? -) : Parcelable +) : Parcelable { + val matrixItem: MatrixItem + get() = MatrixItem.RoomItem(roomId, roomName, avatarUrl) +} class RoomPreviewActivity : VectorBaseActivity(), ToolbarConfigurable { diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt index 9003421dc7..8999b88aba 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/roompreview/RoomPreviewNoPreviewFragment.kt @@ -24,7 +24,6 @@ import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.platform.ButtonStateView import im.vector.riotx.core.platform.VectorBaseFragment @@ -37,7 +36,6 @@ import javax.inject.Inject * Note: this Fragment is also used for world readable room for the moment */ class RoomPreviewNoPreviewFragment @Inject constructor( - private val errorFormatter: ErrorFormatter, val roomPreviewViewModelFactory: RoomPreviewViewModel.Factory, private val avatarRenderer: AvatarRenderer ) : VectorBaseFragment() { @@ -51,11 +49,11 @@ class RoomPreviewNoPreviewFragment @Inject constructor( super.onViewCreated(view, savedInstanceState) setupToolbar(roomPreviewNoPreviewToolbar) // Toolbar - avatarRenderer.render(roomPreviewData.avatarUrl, roomPreviewData.roomId, roomPreviewData.roomName, roomPreviewNoPreviewToolbarAvatar) + avatarRenderer.render(roomPreviewData.matrixItem, roomPreviewNoPreviewToolbarAvatar) roomPreviewNoPreviewToolbarTitle.text = roomPreviewData.roomName // Screen - avatarRenderer.render(roomPreviewData.avatarUrl, roomPreviewData.roomId, roomPreviewData.roomName, roomPreviewNoPreviewAvatar) + avatarRenderer.render(roomPreviewData.matrixItem, roomPreviewNoPreviewAvatar) roomPreviewNoPreviewName.text = roomPreviewData.roomName roomPreviewNoPreviewTopic.setTextOrHide(roomPreviewData.topic) diff --git a/vector/src/main/java/im/vector/riotx/features/session/SessionListener.kt b/vector/src/main/java/im/vector/riotx/features/session/SessionListener.kt index 46f8fe5e64..4aef387d7c 100644 --- a/vector/src/main/java/im/vector/riotx/features/session/SessionListener.kt +++ b/vector/src/main/java/im/vector/riotx/features/session/SessionListener.kt @@ -18,27 +18,21 @@ package im.vector.riotx.features.session import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import im.vector.matrix.android.api.failure.ConsentNotGivenError +import im.vector.matrix.android.api.failure.GlobalError import im.vector.matrix.android.api.session.Session import im.vector.riotx.core.extensions.postLiveEvent import im.vector.riotx.core.utils.LiveEvent -import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class SessionListener @Inject constructor() : Session.Listener { - private val _consentNotGivenLiveData = MutableLiveData>() - val consentNotGivenLiveData: LiveData> - get() = _consentNotGivenLiveData + private val _globalErrorLiveData = MutableLiveData>() + val globalErrorLiveData: LiveData> + get() = _globalErrorLiveData - override fun onInvalidToken() { - // TODO Handle this error - Timber.e("Token is not valid anymore: handle this properly") - } - - override fun onConsentNotGivenError(consentNotGivenError: ConsentNotGivenError) { - _consentNotGivenLiveData.postLiveEvent(consentNotGivenError) + override fun onGlobalError(globalError: GlobalError) { + _globalErrorLiveData.postLiveEvent(globalError) } } diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt index 666f1610b0..e32cc98123 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsBaseFragment.kt @@ -65,7 +65,7 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), HasScree override fun onResume() { super.onResume() - Timber.v("onResume Fragment ${this.javaClass.simpleName}") + Timber.i("onResume Fragment ${this.javaClass.simpleName}") vectorActivity.supportActionBar?.setTitle(titleRes) // find the view from parent activity mLoadingView = vectorActivity.findViewById(R.id.vector_settings_spinner_views) diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt index ca994db62c..17f440c3dc 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt @@ -43,6 +43,7 @@ import im.vector.riotx.core.preference.UserAvatarPreference import im.vector.riotx.core.preference.VectorPreference import im.vector.riotx.core.utils.* import im.vector.riotx.features.MainActivity +import im.vector.riotx.features.MainActivityArgs import im.vector.riotx.features.themes.ThemeUtils import im.vector.riotx.features.workers.signout.SignOutUiWorker import kotlinx.coroutines.Dispatchers @@ -176,7 +177,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() { it.onPreferenceClickListener = Preference.OnPreferenceClickListener { displayLoadingView() - MainActivity.restartApp(activity!!, clearCache = true, clearCredentials = false) + MainActivity.restartApp(activity!!, MainActivityArgs(clearCache = true)) false } } diff --git a/vector/src/main/java/im/vector/riotx/features/settings/ignored/IgnoredUsersController.kt b/vector/src/main/java/im/vector/riotx/features/settings/ignored/IgnoredUsersController.kt index 120781874d..5f4158b542 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/ignored/IgnoredUsersController.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/ignored/IgnoredUsersController.kt @@ -18,6 +18,7 @@ package im.vector.riotx.features.settings.ignored import com.airbnb.epoxy.EpoxyController import im.vector.matrix.android.api.session.user.model.User +import im.vector.matrix.android.api.util.toMatrixItem import im.vector.riotx.R import im.vector.riotx.core.epoxy.noResultItem import im.vector.riotx.core.resources.StringProvider @@ -44,19 +45,19 @@ class IgnoredUsersController @Inject constructor(private val stringProvider: Str buildIgnoredUserModels(nonNullViewState.ignoredUsers) } - private fun buildIgnoredUserModels(userIds: List) { - if (userIds.isEmpty()) { + private fun buildIgnoredUserModels(users: List) { + if (users.isEmpty()) { noResultItem { id("empty") text(stringProvider.getString(R.string.no_ignored_users)) } } else { - userIds.forEach { userId -> + users.forEach { user -> userItem { - id(userId.userId) + id(user.userId) avatarRenderer(avatarRenderer) - user(userId) - itemClickAction { callback?.onUserIdClicked(userId.userId) } + matrixItem(user.toMatrixItem()) + itemClickAction { callback?.onUserIdClicked(user.userId) } } } } diff --git a/vector/src/main/java/im/vector/riotx/features/settings/ignored/UserItem.kt b/vector/src/main/java/im/vector/riotx/features/settings/ignored/UserItem.kt index a9c1b98915..23fb03d59a 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/ignored/UserItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/ignored/UserItem.kt @@ -20,7 +20,7 @@ import android.widget.ImageView import android.widget.TextView import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass -import im.vector.matrix.android.api.session.user.model.User +import im.vector.matrix.android.api.util.MatrixItem import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel @@ -37,7 +37,7 @@ abstract class UserItem : VectorEpoxyModel() { lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute - lateinit var user: User + lateinit var matrixItem: MatrixItem @EpoxyAttribute var itemClickAction: (() -> Unit)? = null @@ -45,9 +45,9 @@ abstract class UserItem : VectorEpoxyModel() { override fun bind(holder: Holder) { holder.root.setOnClickListener { itemClickAction?.invoke() } - avatarRenderer.render(user, holder.avatarImage) - holder.userIdText.setTextOrHide(user.userId) - holder.displayNameText.setTextOrHide(user.displayName) + avatarRenderer.render(matrixItem, holder.avatarImage) + holder.userIdText.setTextOrHide(matrixItem.id) + holder.displayNameText.setTextOrHide(matrixItem.displayName) } class Holder : VectorEpoxyHolder() { diff --git a/vector/src/main/java/im/vector/riotx/features/settings/ignored/VectorSettingsIgnoredUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/ignored/VectorSettingsIgnoredUsersFragment.kt index a6b8a5414f..6435f43d87 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/ignored/VectorSettingsIgnoredUsersFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/ignored/VectorSettingsIgnoredUsersFragment.kt @@ -25,7 +25,6 @@ import com.airbnb.mvrx.Loading import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import im.vector.riotx.R -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.configureWith import im.vector.riotx.core.extensions.observeEvent @@ -37,8 +36,7 @@ import javax.inject.Inject class VectorSettingsIgnoredUsersFragment @Inject constructor( val ignoredUsersViewModelFactory: IgnoredUsersViewModel.Factory, - private val ignoredUsersController: IgnoredUsersController, - private val errorFormatter: ErrorFormatter + private val ignoredUsersController: IgnoredUsersController ) : VectorBaseFragment(), IgnoredUsersController.Callback { override fun getLayoutResId() = R.layout.fragment_generic_recycler diff --git a/vector/src/main/java/im/vector/riotx/features/settings/push/PushGatewaysViewModel.kt b/vector/src/main/java/im/vector/riotx/features/settings/push/PushGatewaysViewModel.kt index dd773f4c22..db4586dff5 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/push/PushGatewaysViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/push/PushGatewaysViewModel.kt @@ -40,6 +40,7 @@ class PushGatewaysViewModel @AssistedInject constructor(@Assisted initialState: companion object : MvRxViewModelFactory { + @JvmStatic override fun create(viewModelContext: ViewModelContext, state: PushGatewayViewState): PushGatewaysViewModel? { val fragment: PushGatewaysFragment = (viewModelContext as FragmentViewModelContext).fragment() return fragment.pushGatewaysViewModelFactory.create(state) diff --git a/vector/src/main/java/im/vector/riotx/features/signout/hard/SignedOutActivity.kt b/vector/src/main/java/im/vector/riotx/features/signout/hard/SignedOutActivity.kt new file mode 100644 index 0000000000..f3d81c8010 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/hard/SignedOutActivity.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.hard + +import android.content.Context +import android.content.Intent +import butterknife.OnClick +import im.vector.matrix.android.api.failure.GlobalError +import im.vector.riotx.R +import im.vector.riotx.core.platform.VectorBaseActivity +import im.vector.riotx.features.MainActivity +import im.vector.riotx.features.MainActivityArgs +import timber.log.Timber + +/** + * In this screen, the user is viewing a message informing that he has been logged out + */ +class SignedOutActivity : VectorBaseActivity() { + + override fun getLayoutRes() = R.layout.activity_signed_out + + @OnClick(R.id.signedOutSubmit) + fun submit() { + // All is already cleared when we are here + MainActivity.restartApp(this, MainActivityArgs()) + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, SignedOutActivity::class.java) + } + } + + override fun handleInvalidToken(globalError: GlobalError.InvalidToken) { + // No op here + Timber.w("Ignoring invalid token global error") + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutAction.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutAction.kt new file mode 100644 index 0000000000..5916c59c55 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutAction.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft + +import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.riotx.core.platform.VectorViewModelAction + +sealed class SoftLogoutAction : VectorViewModelAction { + // In case of failure to get the login flow + object RetryLoginFlow : SoftLogoutAction() + + // For password entering management + data class PasswordChanged(val password: String) : SoftLogoutAction() + object TogglePassword : SoftLogoutAction() + data class SignInAgain(val password: String) : SoftLogoutAction() + + // For signing again with SSO + data class WebLoginSuccess(val credentials: Credentials) : SoftLogoutAction() + + // To clear the current session + object ClearData : SoftLogoutAction() +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutActivity.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutActivity.kt new file mode 100644 index 0000000000..8d61fb00b5 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutActivity.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft + +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.viewModel +import im.vector.matrix.android.api.failure.GlobalError +import im.vector.matrix.android.api.session.Session +import im.vector.riotx.R +import im.vector.riotx.core.di.ScreenComponent +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.extensions.replaceFragment +import im.vector.riotx.features.MainActivity +import im.vector.riotx.features.MainActivityArgs +import im.vector.riotx.features.login.LoginActivity +import io.reactivex.android.schedulers.AndroidSchedulers +import kotlinx.android.synthetic.main.activity_login.* +import timber.log.Timber +import javax.inject.Inject + +/** + * In this screen, the user is viewing a message informing that he has been logged out + * Extends LoginActivity to get the login with SSO and forget password functionality for (nearly) free + */ +class SoftLogoutActivity : LoginActivity() { + + private val softLogoutViewModel: SoftLogoutViewModel by viewModel() + + @Inject lateinit var softLogoutViewModelFactory: SoftLogoutViewModel.Factory + @Inject lateinit var session: Session + @Inject lateinit var errorFormatter: ErrorFormatter + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + override fun initUiAndData() { + super.initUiAndData() + + softLogoutViewModel + .subscribe(this) { + updateWithState(it) + } + + softLogoutViewModel.viewEvents + .observe() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + handleSoftLogoutViewEvents(it) + } + .disposeOnDestroy() + } + + private fun handleSoftLogoutViewEvents(softLogoutViewEvents: SoftLogoutViewEvents) { + when (softLogoutViewEvents) { + is SoftLogoutViewEvents.Error -> + showError(errorFormatter.toHumanReadable(softLogoutViewEvents.throwable)) + is SoftLogoutViewEvents.ErrorNotSameUser -> { + // Pop the backstack + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + // And inform the user + showError(getString( + R.string.soft_logout_sso_not_same_user_error, + softLogoutViewEvents.currentUserId, + softLogoutViewEvents.newUserId) + ) + } + is SoftLogoutViewEvents.ClearData -> { + MainActivity.restartApp(this, MainActivityArgs(clearCredentials = true)) + } + } + } + + private fun showError(message: String) { + AlertDialog.Builder(this) + .setTitle(R.string.dialog_title_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun addFirstFragment() { + replaceFragment(R.id.loginFragmentContainer, SoftLogoutFragment::class.java) + } + + private fun updateWithState(softLogoutViewState: SoftLogoutViewState) { + if (softLogoutViewState.asyncLoginAction is Success) { + MainActivity.restartApp(this, MainActivityArgs()) + } + + loginLoading.isVisible = softLogoutViewState.isLoading() + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, SoftLogoutActivity::class.java) + } + } + + override fun handleInvalidToken(globalError: GlobalError.InvalidToken) { + // No op here + Timber.w("Ignoring invalid token global error") + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutController.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutController.kt new file mode 100644 index 0000000000..4f686a4a76 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutController.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft + +import com.airbnb.epoxy.EpoxyController +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Incomplete +import com.airbnb.mvrx.Success +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.loadingItem +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.extensions.toReducedUrl +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.login.LoginMode +import im.vector.riotx.features.signout.soft.epoxy.* +import javax.inject.Inject + +class SoftLogoutController @Inject constructor( + private val stringProvider: StringProvider, + private val errorFormatter: ErrorFormatter +) : EpoxyController() { + + var listener: Listener? = null + + private var viewState: SoftLogoutViewState? = null + + init { + // We are requesting a model build directly as the first build of epoxy is on the main thread. + // It avoids to build the whole list of breadcrumbs on the main thread. + requestModelBuild() + } + + fun update(viewState: SoftLogoutViewState) { + this.viewState = viewState + requestModelBuild() + } + + override fun buildModels() { + val safeViewState = viewState ?: return + + buildHeader(safeViewState) + buildForm(safeViewState) + buildClearDataSection() + } + + private fun buildHeader(state: SoftLogoutViewState) { + loginHeaderItem { + id("header") + } + loginTitleItem { + id("title") + text(stringProvider.getString(R.string.soft_logout_title)) + } + loginTitleSmallItem { + id("signTitle") + text(stringProvider.getString(R.string.soft_logout_signin_title)) + } + loginTextItem { + id("signText1") + text(stringProvider.getString(R.string.soft_logout_signin_notice, + state.homeServerUrl.toReducedUrl(), + state.userDisplayName, + state.userId)) + } + if (state.hasUnsavedKeys) { + loginTextItem { + id("signText2") + text(stringProvider.getString(R.string.soft_logout_signin_e2e_warning_notice)) + } + } + } + + private fun buildForm(state: SoftLogoutViewState) { + when (state.asyncHomeServerLoginFlowRequest) { + is Incomplete -> { + loadingItem { + id("loading") + } + } + is Fail -> { + loginErrorWithRetryItem { + id("errorRetry") + text(errorFormatter.toHumanReadable(state.asyncHomeServerLoginFlowRequest.error)) + listener { listener?.retry() } + } + } + is Success -> { + when (state.asyncHomeServerLoginFlowRequest.invoke()) { + LoginMode.Password -> { + loginPasswordFormItem { + id("passwordForm") + stringProvider(stringProvider) + passwordShown(state.passwordShown) + submitEnabled(state.submitEnabled) + onPasswordEdited { listener?.passwordEdited(it) } + errorText((state.asyncLoginAction as? Fail)?.error?.let { errorFormatter.toHumanReadable(it) }) + passwordRevealClickListener { listener?.revealPasswordClicked() } + forgetPasswordClickListener { listener?.forgetPasswordClicked() } + submitClickListener { password -> listener?.signinSubmit(password) } + } + } + LoginMode.Sso -> { + loginCenterButtonItem { + id("sso") + text(stringProvider.getString(R.string.login_signin_sso)) + listener { listener?.signinFallbackSubmit() } + } + } + LoginMode.Unsupported -> { + loginCenterButtonItem { + id("fallback") + text(stringProvider.getString(R.string.login_signin)) + listener { listener?.signinFallbackSubmit() } + } + } + LoginMode.Unknown -> Unit // Should not happen + } + } + } + } + + private fun buildClearDataSection() { + loginTitleSmallItem { + id("clearDataTitle") + text(stringProvider.getString(R.string.soft_logout_clear_data_title)) + } + loginTextItem { + id("clearDataText") + text(stringProvider.getString(R.string.soft_logout_clear_data_notice)) + } + loginRedButtonItem { + id("clearDataSubmit") + text(stringProvider.getString(R.string.soft_logout_clear_data_submit)) + listener { listener?.clearData() } + } + } + + interface Listener { + fun retry() + fun passwordEdited(password: String) + fun signinSubmit(password: String) + fun signinFallbackSubmit() + fun clearData() + fun forgetPasswordClicked() + fun revealPasswordClicked() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutFragment.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutFragment.kt new file mode 100644 index 0000000000..d3288c5b2e --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutFragment.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.riotx.R +import im.vector.riotx.core.dialogs.withColoredButton +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.extensions.hideKeyboard +import im.vector.riotx.features.login.AbstractLoginFragment +import im.vector.riotx.features.login.LoginAction +import im.vector.riotx.features.login.LoginMode +import im.vector.riotx.features.login.LoginNavigation +import kotlinx.android.synthetic.main.fragment_generic_recycler.* +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked to enter a password to sign in again to a homeserver. + * - or to cleanup all the data + */ +class SoftLogoutFragment @Inject constructor( + private val softLogoutController: SoftLogoutController +) : AbstractLoginFragment(), SoftLogoutController.Listener { + + private val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + + override fun getLayoutResId() = R.layout.fragment_generic_recycler + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + + softLogoutViewModel.subscribe(this) { softLogoutViewState -> + softLogoutController.update(softLogoutViewState) + + when (softLogoutViewState.asyncHomeServerLoginFlowRequest.invoke()) { + LoginMode.Sso, + LoginMode.Unsupported -> { + // Prepare the loginViewModel for a SSO/login fallback recovery + loginViewModel.handle(LoginAction.SetupSsoForSessionRecovery( + softLogoutViewState.homeServerUrl, + softLogoutViewState.deviceId + )) + } + else -> Unit + } + } + } + + private fun setupRecyclerView() { + recyclerView.configureWith(softLogoutController) + softLogoutController.listener = this + } + + override fun onDestroyView() { + recyclerView.cleanup() + softLogoutController.listener = null + super.onDestroyView() + } + + override fun retry() { + softLogoutViewModel.handle(SoftLogoutAction.RetryLoginFlow) + } + + override fun passwordEdited(password: String) { + softLogoutViewModel.handle(SoftLogoutAction.PasswordChanged(password)) + } + + override fun signinSubmit(password: String) { + cleanupUi() + softLogoutViewModel.handle(SoftLogoutAction.SignInAgain(password)) + } + + override fun signinFallbackSubmit() { + loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected) + } + + override fun clearData() { + withState(softLogoutViewModel) { state -> + cleanupUi() + + val messageResId = if (state.hasUnsavedKeys) { + R.string.soft_logout_clear_data_dialog_e2e_warning_content + } else { + R.string.soft_logout_clear_data_dialog_content + } + + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.soft_logout_clear_data_dialog_title) + .setMessage(messageResId) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.soft_logout_clear_data_submit) { _, _ -> + softLogoutViewModel.handle(SoftLogoutAction.ClearData) + } + .show() + .withColoredButton(DialogInterface.BUTTON_POSITIVE) + } + } + + private fun cleanupUi() { + recyclerView.hideKeyboard() + } + + override fun forgetPasswordClicked() { + loginSharedActionViewModel.post(LoginNavigation.OnForgetPasswordClicked) + } + + override fun revealPasswordClicked() { + softLogoutViewModel.handle(SoftLogoutAction.TogglePassword) + } + + override fun resetViewModel() { + // No op + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewEvents.kt new file mode 100644 index 0000000000..1e48fb2a25 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package im.vector.riotx.features.signout.soft + +/** + * Transient events for SoftLogout + */ +sealed class SoftLogoutViewEvents { + data class ErrorNotSameUser(val currentUserId: String, val newUserId: String) : SoftLogoutViewEvents() + data class Error(val throwable: Throwable) : SoftLogoutViewEvents() + object ClearData : SoftLogoutViewEvents() +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewModel.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewModel.kt new file mode 100644 index 0000000000..baf208636b --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewModel.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft + +import com.airbnb.mvrx.* +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.AuthenticationService +import im.vector.matrix.android.api.auth.data.LoginFlowResult +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.internal.auth.data.LoginFlowTypes +import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.extensions.hasUnsavedKeys +import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.core.utils.DataSource +import im.vector.riotx.core.utils.PublishDataSource +import im.vector.riotx.features.login.LoginMode +import timber.log.Timber + +/** + * TODO Test push: disable the pushers? + */ +class SoftLogoutViewModel @AssistedInject constructor( + @Assisted initialState: SoftLogoutViewState, + private val session: Session, + private val activeSessionHolder: ActiveSessionHolder, + private val authenticationService: AuthenticationService +) : VectorViewModel(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: SoftLogoutViewState): SoftLogoutViewModel + } + + companion object : MvRxViewModelFactory { + + override fun initialState(viewModelContext: ViewModelContext): SoftLogoutViewState? { + val activity: SoftLogoutActivity = (viewModelContext as ActivityViewModelContext).activity() + val userId = activity.session.myUserId + return SoftLogoutViewState( + homeServerUrl = activity.session.sessionParams.homeServerConnectionConfig.homeServerUri.toString(), + userId = userId, + deviceId = activity.session.sessionParams.credentials.deviceId ?: "", + userDisplayName = activity.session.getUser(userId)?.displayName ?: userId, + hasUnsavedKeys = activity.session.hasUnsavedKeys() + ) + } + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: SoftLogoutViewState): SoftLogoutViewModel? { + val activity: SoftLogoutActivity = (viewModelContext as ActivityViewModelContext).activity() + return activity.softLogoutViewModelFactory.create(state) + } + } + + private var currentTask: Cancelable? = null + + private val _viewEvents = PublishDataSource() + val viewEvents: DataSource = _viewEvents + + init { + // Get the supported login flow + getSupportedLoginFlow() + } + + private fun getSupportedLoginFlow() { + val homeServerConnectionConfig = session.sessionParams.homeServerConnectionConfig + + currentTask?.cancel() + currentTask = null + authenticationService.cancelPendingLoginOrRegistration() + + setState { + copy( + asyncHomeServerLoginFlowRequest = Loading() + ) + } + + currentTask = authenticationService.getLoginFlow(homeServerConnectionConfig, object : MatrixCallback { + override fun onFailure(failure: Throwable) { + setState { + copy( + asyncHomeServerLoginFlowRequest = Fail(failure) + ) + } + } + + override fun onSuccess(data: LoginFlowResult) { + when (data) { + is LoginFlowResult.Success -> { + val loginMode = when { + // SSO login is taken first + data.loginFlowResponse.flows.any { it.type == LoginFlowTypes.SSO } -> LoginMode.Sso + data.loginFlowResponse.flows.any { it.type == LoginFlowTypes.PASSWORD } -> LoginMode.Password + else -> LoginMode.Unsupported + } + + if (loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) { + notSupported() + } else { + setState { + copy( + asyncHomeServerLoginFlowRequest = Success(loginMode) + ) + } + } + } + is LoginFlowResult.OutdatedHomeserver -> { + notSupported() + } + } + } + + private fun notSupported() { + // Should not happen since it's a re-logout + // Notify the UI + setState { + copy( + asyncHomeServerLoginFlowRequest = Fail(IllegalStateException("Should not happen")) + ) + } + } + }) + } + + override fun handle(action: SoftLogoutAction) { + when (action) { + is SoftLogoutAction.RetryLoginFlow -> getSupportedLoginFlow() + is SoftLogoutAction.PasswordChanged -> handlePasswordChange(action) + is SoftLogoutAction.TogglePassword -> handleTogglePassword() + is SoftLogoutAction.SignInAgain -> handleSignInAgain(action) + is SoftLogoutAction.WebLoginSuccess -> handleWebLoginSuccess(action) + is SoftLogoutAction.ClearData -> handleClearData() + } + } + + private fun handleClearData() { + // Notify the Activity + _viewEvents.post(SoftLogoutViewEvents.ClearData) + } + + private fun handlePasswordChange(action: SoftLogoutAction.PasswordChanged) { + setState { + copy( + asyncLoginAction = Uninitialized, + submitEnabled = action.password.isNotBlank() + ) + } + } + + private fun handleTogglePassword() { + withState { + setState { + copy( + passwordShown = !this.passwordShown + ) + } + } + } + + private fun handleWebLoginSuccess(action: SoftLogoutAction.WebLoginSuccess) { + // User may have been connected with SSO with another userId + // We have to check this + withState { softLogoutViewState -> + if (softLogoutViewState.userId != action.credentials.userId) { + Timber.w("User login again with SSO, but using another account") + _viewEvents.post(SoftLogoutViewEvents.ErrorNotSameUser( + softLogoutViewState.userId, + action.credentials.userId)) + } else { + setState { + copy( + asyncLoginAction = Loading() + ) + } + currentTask = session.updateCredentials(action.credentials, + object : MatrixCallback { + override fun onFailure(failure: Throwable) { + _viewEvents.post(SoftLogoutViewEvents.Error(failure)) + setState { + copy( + asyncLoginAction = Uninitialized + ) + } + } + + override fun onSuccess(data: Unit) { + onSessionRestored() + } + } + ) + } + } + } + + private fun handleSignInAgain(action: SoftLogoutAction.SignInAgain) { + setState { + copy( + asyncLoginAction = Loading(), + // Ensure password is hidden + passwordShown = false + ) + } + currentTask = session.signInAgain(action.password, + object : MatrixCallback { + override fun onFailure(failure: Throwable) { + setState { + copy( + asyncLoginAction = Fail(failure) + ) + } + } + + override fun onSuccess(data: Unit) { + onSessionRestored() + } + } + ) + } + + private fun onSessionRestored() { + activeSessionHolder.setActiveSession(session) + // Start the sync + session.startSync(true) + + // TODO Configure and start ? Check that the push still works... + setState { + copy( + asyncLoginAction = Success(Unit) + ) + } + } + + override fun onCleared() { + super.onCleared() + + currentTask?.cancel() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewState.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewState.kt new file mode 100644 index 0000000000..01776d1982 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/SoftLogoutViewState.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft + +import com.airbnb.mvrx.* +import im.vector.riotx.features.login.LoginMode + +data class SoftLogoutViewState( + val asyncHomeServerLoginFlowRequest: Async = Uninitialized, + val asyncLoginAction: Async = Uninitialized, + val homeServerUrl: String, + val userId: String, + val deviceId: String, + val userDisplayName: String, + val hasUnsavedKeys: Boolean, + val passwordShown: Boolean = false, + val submitEnabled: Boolean = false +) : MvRxState { + + fun isLoading(): Boolean { + return asyncLoginAction is Loading + // Keep loading when it is success because of the delay to switch to the next Activity + || asyncLoginAction is Success + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/signout/soft/epoxy/LoginCenterButtonItem.kt b/vector/src/main/java/im/vector/riotx/features/signout/soft/epoxy/LoginCenterButtonItem.kt new file mode 100644 index 0000000000..d73955787c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/signout/soft/epoxy/LoginCenterButtonItem.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.signout.soft.epoxy + +import android.widget.Button +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_login_centered_button) +abstract class LoginCenterButtonItem : VectorEpoxyModel() { + + @EpoxyAttribute var text: String? = null + @EpoxyAttribute var listener: (() -> Unit)? = null + + override fun bind(holder: Holder) { + super.bind(holder) + + holder.button.setTextOrHide(text) + holder.button.setOnClickListener { + listener?.invoke() + } + } + + class Holder : VectorEpoxyHolder() { + val button by bind