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 654f2e4fa4..e752e1879c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,22 +2,24 @@ Changes in RiotX 0.11.0 (2019-XX-XX) =================================================== Features ✨: - - + - Implement soft logout (#281) Improvements 🙌: - 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 Translations 🗣: - 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/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/ConsentNotGivenError.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/GlobalError.kt similarity index 75% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/ConsentNotGivenError.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/GlobalError.kt index 80ee6811bb..b2bc585258 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/ConsentNotGivenError.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/failure/GlobalError.kt @@ -16,7 +16,8 @@ package im.vector.matrix.android.api.failure -// This data class will be sent to the bus -data class ConsentNotGivenError( - val consentUri: String -) +// 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/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/room/model/message/MessageVerificationCancelContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVerificationCancelContent.kt index 2070845f46..fe2c958703 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVerificationCancelContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVerificationCancelContent.kt @@ -24,7 +24,7 @@ import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultC import im.vector.matrix.android.internal.crypto.verification.VerificationInfoCancel @JsonClass(generateAdapter = true) -internal data class MessageVerificationCancelContent( +data class MessageVerificationCancelContent( @Json(name = "code") override val code: String? = null, @Json(name = "reason") override val reason: String? = null, @Json(name = "m.relates_to") val relatesTo: RelationDefaultContent? 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/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/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/crypto/tasks/RequestVerificationDMTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/RequestVerificationDMTask.kt index ae8a40f296..00b7d7320a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/RequestVerificationDMTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/RequestVerificationDMTask.kt @@ -29,12 +29,11 @@ import javax.inject.Inject internal interface RequestVerificationDMTask : Task { data class Params( - val roomId: String, - val from: String, - val methods: List, - val to: String, + val event: Event, val cryptoService: CryptoService ) + + fun createParamsAndLocalEcho(roomId: String, from: String, methods: List, to: String, cryptoService: CryptoService): Params } internal class DefaultRequestVerificationDMTask @Inject constructor( @@ -45,8 +44,18 @@ internal class DefaultRequestVerificationDMTask @Inject constructor( private val roomAPI: RoomAPI) : RequestVerificationDMTask { + override fun createParamsAndLocalEcho(roomId: String, from: String, methods: List, to: String, cryptoService: CryptoService) + : RequestVerificationDMTask.Params { + val event = localEchoEventFactory.createVerificationRequest(roomId, from, to, methods) + .also { localEchoEventFactory.saveLocalEcho(monarchy, it) } + return RequestVerificationDMTask.Params( + event, + cryptoService + ) + } + override suspend fun execute(params: RequestVerificationDMTask.Params): SendResponse { - val event = createRequestEvent(params) + val event = handleEncryption(params) val localID = event.eventId!! try { @@ -54,7 +63,7 @@ internal class DefaultRequestVerificationDMTask @Inject constructor( val executeRequest = executeRequest { apiCall = roomAPI.send( localID, - roomId = params.roomId, + roomId = event.roomId ?: "", content = event.content, eventType = event.type // message or room.encrypted ) @@ -67,14 +76,13 @@ internal class DefaultRequestVerificationDMTask @Inject constructor( } } - private suspend fun createRequestEvent(params: RequestVerificationDMTask.Params): Event { - val event = localEchoEventFactory.createVerificationRequest(params.roomId, params.from, params.to, params.methods) - .also { localEchoEventFactory.saveLocalEcho(monarchy, it) } - if (params.cryptoService.isRoomEncrypted(params.roomId)) { + private suspend fun handleEncryption(params: RequestVerificationDMTask.Params): Event { + val roomId = params.event.roomId ?: "" + if (params.cryptoService.isRoomEncrypted(roomId)) { try { return encryptEventTask.execute(EncryptEventTask.Params( - params.roomId, - event, + roomId, + params.event, listOf("m.relates_to"), params.cryptoService )) @@ -82,6 +90,6 @@ internal class DefaultRequestVerificationDMTask @Inject constructor( // We said it's ok to send verification request in clear } } - return event + return params.event } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendVerificationMessageTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendVerificationMessageTask.kt index b850a1a1e6..cf1b2d233a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendVerificationMessageTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/tasks/SendVerificationMessageTask.kt @@ -34,10 +34,14 @@ import javax.inject.Inject internal interface SendVerificationMessageTask : Task { data class Params( val type: String, - val roomId: String, - val content: Content, + val event: Event, val cryptoService: CryptoService? ) + + fun createParamsAndLocalEcho(type: String, + roomId: String, + content: Content, + cryptoService: CryptoService?) : Params } internal class DefaultSendVerificationMessageTask @Inject constructor( @@ -48,8 +52,28 @@ internal class DefaultSendVerificationMessageTask @Inject constructor( @UserId private val userId: String, private val roomAPI: RoomAPI) : SendVerificationMessageTask { + override fun createParamsAndLocalEcho(type: String, roomId: String, content: Content, cryptoService: CryptoService?): SendVerificationMessageTask.Params { + val localID = LocalEcho.createLocalEchoId() + val event = Event( + roomId = roomId, + originServerTs = System.currentTimeMillis(), + senderId = userId, + eventId = localID, + type = type, + content = content, + unsignedData = UnsignedData(age = null, transactionId = localID) + ).also { + localEchoEventFactory.saveLocalEcho(monarchy, it) + } + return SendVerificationMessageTask.Params( + type, + event, + cryptoService + ) + } + override suspend fun execute(params: SendVerificationMessageTask.Params): SendResponse { - val event = createRequestEvent(params) + val event = handleEncryption(params) val localID = event.eventId!! try { @@ -57,7 +81,7 @@ internal class DefaultSendVerificationMessageTask @Inject constructor( val executeRequest = executeRequest { apiCall = roomAPI.send( localID, - roomId = params.roomId, + roomId = event.roomId ?: "", content = event.content, eventType = event.type ) @@ -70,25 +94,12 @@ internal class DefaultSendVerificationMessageTask @Inject constructor( } } - private suspend fun createRequestEvent(params: SendVerificationMessageTask.Params): Event { - val localID = LocalEcho.createLocalEchoId() - val event = Event( - roomId = params.roomId, - originServerTs = System.currentTimeMillis(), - senderId = userId, - eventId = localID, - type = params.type, - content = params.content, - unsignedData = UnsignedData(age = null, transactionId = localID) - ).also { - localEchoEventFactory.saveLocalEcho(monarchy, it) - } - - if (params.cryptoService?.isRoomEncrypted(params.roomId) == true) { + private suspend fun handleEncryption(params: SendVerificationMessageTask.Params): Event { + if (params.cryptoService?.isRoomEncrypted(params.event.roomId ?: "") == true) { try { return encryptEventTask.execute(EncryptEventTask.Params( - params.roomId, - event, + params.event.roomId ?: "", + params.event, listOf("m.relates_to"), params.cryptoService )) @@ -96,6 +107,6 @@ internal class DefaultSendVerificationMessageTask @Inject constructor( // We said it's ok to send verification request in clear } } - return event + return params.event } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt index d54ae3a22a..1d50fc89fe 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultSasVerificationService.kt @@ -38,7 +38,6 @@ import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap import im.vector.matrix.android.internal.crypto.model.rest.* import im.vector.matrix.android.internal.crypto.store.IMXCryptoStore import im.vector.matrix.android.internal.crypto.tasks.DefaultRequestVerificationDMTask -import im.vector.matrix.android.internal.crypto.tasks.RequestVerificationDMTask import im.vector.matrix.android.internal.session.SessionScope import im.vector.matrix.android.internal.session.room.send.SendResponse import im.vector.matrix.android.internal.task.TaskConstraints @@ -109,6 +108,7 @@ internal class DefaultSasVerificationService @Inject constructor( onRoomStartRequestReceived(event) } EventType.KEY_VERIFICATION_CANCEL -> { + // MultiSessions | ignore events if i didn't sent the start from this device, or accepted from this device onRoomCancelReceived(event) } EventType.KEY_VERIFICATION_ACCEPT -> { @@ -538,7 +538,7 @@ internal class DefaultSasVerificationService @Inject constructor( override fun requestKeyVerificationInDMs(userId: String, roomId: String, callback: MatrixCallback?) { requestVerificationDMTask.configureWith( - RequestVerificationDMTask.Params( + requestVerificationDMTask.createParamsAndLocalEcho( roomId = roomId, from = credentials.deviceId ?: "", methods = listOf(KeyVerificationStart.VERIF_METHOD_SAS), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SasTransportRoomMessage.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SasTransportRoomMessage.kt index e40e8be31f..91adbbd705 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SasTransportRoomMessage.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/SasTransportRoomMessage.kt @@ -50,7 +50,7 @@ internal class SasTransportRoomMessage( Timber.d("## SAS sending msg type $type") Timber.v("## SAS sending msg info $verificationInfo") sendVerificationMessageTask.configureWith( - SendVerificationMessageTask.Params( + sendVerificationMessageTask.createParamsAndLocalEcho( type, roomId, verificationInfo.toEventContent()!!, @@ -82,7 +82,7 @@ internal class SasTransportRoomMessage( override fun cancelTransaction(transactionId: String, userId: String, userDevice: String, code: CancelCode) { Timber.d("## SAS canceling transaction $transactionId for reason $code") sendVerificationMessageTask.configureWith( - SendVerificationMessageTask.Params( + sendVerificationMessageTask.createParamsAndLocalEcho( EventType.KEY_VERIFICATION_CANCEL, roomId, MessageVerificationCancelContent.create(transactionId, code).toContent(), @@ -108,7 +108,7 @@ internal class SasTransportRoomMessage( override fun done(transactionId: String) { sendVerificationMessageTask.configureWith( - SendVerificationMessageTask.Params( + sendVerificationMessageTask.createParamsAndLocalEcho( EventType.KEY_VERIFICATION_DONE, roomId, MessageVerificationDoneContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt index 2bca40c855..2fee568895 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationMessageLiveObserver.kt @@ -19,14 +19,15 @@ import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.crypto.MXCryptoError import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.LocalEcho import im.vector.matrix.android.api.session.events.model.toModel -import im.vector.matrix.android.api.session.room.model.message.MessageContent -import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.database.RealmLiveEntityObserver import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.query.types +import im.vector.matrix.android.internal.di.DeviceId import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.task.TaskExecutor @@ -36,13 +37,16 @@ import io.realm.RealmResults import timber.log.Timber import java.util.* import javax.inject.Inject +import kotlin.collections.ArrayList -internal class VerificationMessageLiveObserver @Inject constructor(@SessionDatabase realmConfiguration: RealmConfiguration, - @UserId private val userId: String, - private val cryptoService: CryptoService, - private val sasVerificationService: DefaultSasVerificationService, - private val taskExecutor: TaskExecutor) : - RealmLiveEntityObserver(realmConfiguration) { +internal class VerificationMessageLiveObserver @Inject constructor( + @SessionDatabase realmConfiguration: RealmConfiguration, + @UserId private val userId: String, + @DeviceId private val deviceId: String?, + private val cryptoService: CryptoService, + private val sasVerificationService: DefaultSasVerificationService, + private val taskExecutor: TaskExecutor +) : RealmLiveEntityObserver(realmConfiguration) { override val query = Monarchy.Query { EventEntity.types(it, listOf( @@ -57,6 +61,8 @@ internal class VerificationMessageLiveObserver @Inject constructor(@SessionDatab ) } + val transactionsHandledByOtherDevice = ArrayList() + override fun onChange(results: RealmResults, changeSet: OrderedCollectionChangeSet) { // TODO do that in a task // TODO how to ignore when it's an initial sync? @@ -64,8 +70,8 @@ internal class VerificationMessageLiveObserver @Inject constructor(@SessionDatab .asSequence() .mapNotNull { results[it]?.asDomain() } .filterNot { - // ignore mines ^^ - it.senderId == userId + // ignore local echos + LocalEcho.isLocalEchoId(it.eventId ?: "") } .toList() @@ -77,7 +83,7 @@ internal class VerificationMessageLiveObserver @Inject constructor(@SessionDatab val tooInTheFuture = System.currentTimeMillis() + fiveMinInMs events.forEach { event -> - Timber.d("## SAS Verification live observer: received msgId: ${event.eventId} msgtype: ${event.type} from ${event.senderId}") + Timber.d("## SAS Verification live observer: received msgId: ${event.eventId} msgtype: ${event.type} from ${event.senderId}") Timber.v("## SAS Verification live observer: received msgId: $event") // If the request is in the future by more than 5 minutes or more than 10 minutes in the past, @@ -111,6 +117,45 @@ internal class VerificationMessageLiveObserver @Inject constructor(@SessionDatab } } Timber.v("## SAS Verification live observer: received msgId: ${event.eventId} type: ${event.getClearType()}") + + if (event.senderId == userId) { + // If it's send from me, we need to keep track of Requests or Start + // done from another device of mine + + if (EventType.MESSAGE == event.type) { + val msgType = event.getClearContent().toModel()?.type + if (MessageType.MSGTYPE_VERIFICATION_REQUEST == msgType) { + event.getClearContent().toModel()?.let { + if (it.fromDevice != deviceId) { + // The verification is requested from another device + Timber.v("## SAS Verification live observer: Transaction requested from other device tid:${event.eventId} ") + event.eventId?.let { txId -> transactionsHandledByOtherDevice.add(txId) } + } + } + } + } else if (EventType.KEY_VERIFICATION_START == event.type) { + event.getClearContent().toModel()?.let { + if (it.fromDevice != deviceId) { + // The verification is started from another device + Timber.v("## SAS Verification live observer: Transaction started by other device tid:${it.transactionID} ") + it.transactionID?.let { txId -> transactionsHandledByOtherDevice.add(txId) } + } + } + } else if (EventType.KEY_VERIFICATION_CANCEL == event.type || EventType.KEY_VERIFICATION_DONE == event.type) { + event.getClearContent().toModel()?.relatesTo?.eventId?.let { + transactionsHandledByOtherDevice.remove(it) + } + } + + return@forEach + } + + val relatesTo = event.getClearContent().toModel()?.relatesTo?.eventId + if (relatesTo != null && transactionsHandledByOtherDevice.contains(relatesTo)) { + // Ignore this event, it is directed to another of my devices + Timber.v("## SAS Verification live observer: Ignore Transaction handled by other device tid:$relatesTo ") + return@forEach + } when (event.getClearType()) { EventType.KEY_VERIFICATION_START, EventType.KEY_VERIFICATION_ACCEPT, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/StringQualifiers.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/StringQualifiers.kt index 0e38618590..3444a8fa70 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/StringQualifiers.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/StringQualifiers.kt @@ -25,6 +25,13 @@ import javax.inject.Qualifier @Retention(AnnotationRetention.RUNTIME) internal annotation class UserId +/** + * Used to inject the userId + */ +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +internal annotation class DeviceId + /** * Used to inject the md5 of the userId */ 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 0bce924abc..e95c161491 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 @@ -19,8 +19,8 @@ package im.vector.matrix.android.internal.network 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 @@ -31,6 +31,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 @@ -98,7 +99,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/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/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index 883fd37745..6f4136405e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -77,6 +77,13 @@ internal abstract class SessionModule { return credentials.userId } + @JvmStatic + @DeviceId + @Provides + fun providesDeviceId(credentials: Credentials): String? { + return credentials.deviceId + } + @JvmStatic @UserMd5 @Provides diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt index a162849fa9..cf7a8a9275 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt @@ -53,6 +53,9 @@ enum class VerificationState { DONE } +fun VerificationState.isCanceled() : Boolean { + return this == VerificationState.CANCELED_BY_ME || this == VerificationState.CANCELED_BY_OTHER +} /** * Called by EventRelationAggregationUpdater, when new events that can affect relations are inserted in base. */ @@ -433,26 +436,27 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( ?: ReferencesAggregatedContent(VerificationState.REQUEST.name) // TODO ignore invalid messages? e.g a START after a CANCEL? // i.e. never change state if already canceled/done + val currentState = VerificationState.values().firstOrNull { data.verificationSummary == it.name } val newState = when (event.getClearType()) { EventType.KEY_VERIFICATION_START -> { - VerificationState.WAITING + updateVerificationState(currentState, VerificationState.WAITING) } EventType.KEY_VERIFICATION_ACCEPT -> { - VerificationState.WAITING + updateVerificationState(currentState, VerificationState.WAITING) } EventType.KEY_VERIFICATION_KEY -> { - VerificationState.WAITING + updateVerificationState(currentState, VerificationState.WAITING) } EventType.KEY_VERIFICATION_MAC -> { - VerificationState.WAITING + updateVerificationState(currentState, VerificationState.WAITING) } EventType.KEY_VERIFICATION_CANCEL -> { - if (event.senderId == userId) { + updateVerificationState(currentState, if (event.senderId == userId) { VerificationState.CANCELED_BY_ME - } else VerificationState.CANCELED_BY_OTHER + } else VerificationState.CANCELED_BY_OTHER) } EventType.KEY_VERIFICATION_DONE -> { - VerificationState.DONE + updateVerificationState(currentState, VerificationState.DONE) } else -> VerificationState.REQUEST } @@ -468,4 +472,18 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( verifSummary.sourceEvents.add(event.eventId) } } + + private fun updateVerificationState(oldState: VerificationState?, newState: VerificationState) : VerificationState { + // Cancel is always prioritary ? + // Eg id i found that mac or keys mismatch and send a cancel and the other send a done, i have to + // consider as canceled + if (newState == VerificationState.CANCELED_BY_OTHER || newState == VerificationState.CANCELED_BY_ME) { + return newState + } + // never move out of cancel + if (oldState == VerificationState.CANCELED_BY_OTHER || oldState == VerificationState.CANCELED_BY_ME) { + return oldState + } + return newState + } } 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/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 8d35851bbd..8a2df7c120 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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" @@ -341,8 +342,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/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 5f1687c9c9..61eebc99db 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -98,6 +98,10 @@ + + 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/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..02e28e079c 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() 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..efcbdfff39 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()) 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/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/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/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..bbeda127fc 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, 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..bfc91bf5a1 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,7 @@ 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.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import javax.inject.Inject @@ -52,9 +53,7 @@ class BreadcrumbsController @Inject constructor( 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/RoomDetailAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt index c1743ae3fc..5d00b09204 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt @@ -63,4 +63,7 @@ sealed class RoomDetailAction : VectorViewModelAction { object ClearSendQueue : RoomDetailAction() object ResendAll : RoomDetailAction() + + data class AcceptVerificationRequest(val transactionId: String, val otherUserId: String, val otherdDeviceId: String) : RoomDetailAction() + data class DeclineVerificationRequest(val transactionId: String) : RoomDetailAction() } 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..9af0e946a0 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 @@ -141,7 +142,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,9 +410,7 @@ 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 { @@ -601,20 +599,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 +683,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 +710,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 @@ -1024,6 +1021,10 @@ class RoomDetailFragment @Inject constructor( .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS") } + override fun onTimelineItemAction(itemAction: RoomDetailAction) { + roomDetailViewModel.handle(itemAction) + } + override fun onRoomCreateLinkClicked(url: String) { permalinkHandler.launch(requireContext(), url, object : NavigateToRoomInterceptor { override fun navToRoom(roomId: String, eventId: String?): Boolean { @@ -1197,9 +1198,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/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index c1d3f4ce4a..3ce27be63a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -48,6 +48,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent +import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationStart import im.vector.matrix.rx.rx import im.vector.matrix.rx.unwrap import im.vector.riotx.R @@ -177,6 +178,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() + is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) + is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) } } @@ -786,6 +789,21 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro }) } + private fun handleAcceptVerification(action: RoomDetailAction.AcceptVerificationRequest) { + session.getSasVerificationService().beginKeyVerificationInDMs( + KeyVerificationStart.VERIF_METHOD_SAS, + action.transactionId, + room.roomId, + action.otherUserId, + action.otherdDeviceId, + null + ) + } + + private fun handleDeclineVerification(action: RoomDetailAction.DeclineVerificationRequest) { + Timber.e("TODO implement $action") + } + private fun observeSyncState() { session.rx() .liveSyncState() 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/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 576b9fa0ba..fe1a681480 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -31,6 +31,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.core.epoxy.LoadingItem_ import im.vector.riotx.core.extensions.localDateTime +import im.vector.riotx.features.home.room.detail.RoomDetailAction import im.vector.riotx.features.home.room.detail.RoomDetailViewState import im.vector.riotx.features.home.room.detail.UnreadState import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory @@ -62,6 +63,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) fun onEditedDecorationClicked(informationData: MessageInformationData) + + // TODO move all callbacks to this? + fun onTimelineItemAction(itemAction: RoomDetailAction) } interface ReactionPillCallback { 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..e8047c2b06 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 @@ -23,10 +23,7 @@ import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.isTextMessage import im.vector.matrix.android.api.session.events.model.toModel -import im.vector.matrix.android.api.session.room.model.message.MessageContent -import im.vector.matrix.android.api.session.room.model.message.MessageImageContent -import im.vector.matrix.android.api.session.room.model.message.MessageTextContent -import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent @@ -172,6 +169,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { eventHtmlRenderer.get().render(messageContent.formattedBody ?: messageContent.body) + } else if (messageContent is MessageVerificationRequestContent) { + stringProvider.getString(R.string.verification_request) } else { messageContent?.body } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt index 3f234fcd3e..29b01120d1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt @@ -17,6 +17,7 @@ package im.vector.riotx.features.home.room.detail.timeline.factory import android.view.View +import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel @@ -34,7 +35,8 @@ import javax.inject.Inject class EncryptionItemFactory @Inject constructor(private val stringProvider: StringProvider, private val avatarRenderer: AvatarRenderer, - private val avatarSizeProvider: AvatarSizeProvider) { + private val avatarSizeProvider: AvatarSizeProvider, + private val session: Session) { fun create(event: TimelineEvent, highlight: Boolean, @@ -46,7 +48,8 @@ class EncryptionItemFactory @Inject constructor(private val stringProvider: Stri sendState = event.root.sendState, avatarUrl = event.senderAvatar, memberName = event.getDisambiguatedDisplayName(), - showInformation = false + showInformation = false, + sentByMe = event.root.senderId == session.myUserId ) val attributes = NoticeItem.Attributes( avatarRenderer = avatarRenderer, 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..93d5ab3789 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -24,6 +24,7 @@ import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.view.View import dagger.Lazy +import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.message.* @@ -64,7 +65,8 @@ class MessageItemFactory @Inject constructor( private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, private val defaultItemFactory: DefaultItemFactory, private val noticeItemFactory: NoticeItemFactory, - private val avatarSizeProvider: AvatarSizeProvider) { + private val avatarSizeProvider: AvatarSizeProvider, + private val session: Session) { fun create(event: TimelineEvent, nextEvent: TimelineEvent?, @@ -97,14 +99,15 @@ class MessageItemFactory @Inject constructor( // val all = event.root.toContent() // val ev = all.toModel() return when (messageContent) { - is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessageTextContent -> buildItemForTextContent(messageContent, informationData, highlight, callback, attributes) - is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes) - else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback) + is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageTextContent -> buildItemForTextContent(messageContent, informationData, highlight, callback, attributes) + is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) + else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback) } } @@ -128,6 +131,51 @@ class MessageItemFactory @Inject constructor( })) } + private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent, + @Suppress("UNUSED_PARAMETER") + informationData: MessageInformationData, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): VerificationRequestItem? { + // If this request is not sent by me or sent to me, we should ignore it in timeline + val myUserId = session.myUserId + if (informationData.senderId != myUserId && messageContent.toUserId != myUserId) { + return null + } + + val otherUserId = if (informationData.sentByMe) messageContent.toUserId else informationData.senderId + val otherUserName = if (informationData.sentByMe) session.getUser(messageContent.toUserId)?.displayName + else informationData.memberName + return VerificationRequestItem_() + .attributes( + VerificationRequestItem.Attributes( + otherUserId, + otherUserName.toString(), + messageContent.fromDevice, + informationData.eventId, + informationData, + attributes.avatarRenderer, + attributes.colorProvider, + attributes.itemLongClickListener, + attributes.itemClickListener, + attributes.reactionPillCallback, + attributes.readReceiptsCallback, + attributes.emojiTypeFace + ) + ) + .callback(callback) +// .izLocalFile(messageContent.getFileUrl().isLocalFile()) +// .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) +// .filename(messageContent.body) +// .iconRes(R.drawable.filetype_audio) +// .clickListener( +// DebouncedClickListener(View.OnClickListener { +// callback?.onAudioMessageClicked(messageContent) +// })) + } + private fun buildFileMessageItem(messageContent: MessageFileContent, informationData: MessageInformationData, highlight: Boolean, @@ -193,7 +241,8 @@ class MessageItemFactory @Inject constructor( val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val thumbnailData = ImageContentRenderer.Data( filename = messageContent.body, - url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl, + url = messageContent.videoInfo?.thumbnailFile?.url + ?: messageContent.videoInfo?.thumbnailUrl, elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), height = messageContent.videoInfo?.height, maxHeight = maxHeight, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index a705576234..6d1ce2cf2a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -28,7 +28,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me private val encryptedItemFactory: EncryptedItemFactory, private val noticeItemFactory: NoticeItemFactory, private val defaultItemFactory: DefaultItemFactory, - private val roomCreateItemFactory: RoomCreateItemFactory) { + private val roomCreateItemFactory: RoomCreateItemFactory, + private val verificationConclusionItemFactory: VerificationItemFactory) { fun create(event: TimelineEvent, nextEvent: TimelineEvent?, @@ -66,13 +67,15 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me } EventType.KEY_VERIFICATION_ACCEPT, EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_MAC -> { // These events are filtered from timeline in normal case // Only visible in developer mode - defaultItemFactory.create(event, highlight, callback) + noticeItemFactory.create(event, highlight, callback) + } + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_DONE -> { + verificationConclusionItemFactory.create(event, highlight, callback) } // Unhandled event types (yet) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt new file mode 100644 index 0000000000..75305518d2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.home.room.detail.timeline.factory + +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.crypto.sas.CancelCode +import im.vector.matrix.android.api.session.crypto.sas.safeValueOf +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.RelationType +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.model.message.MessageRelationContent +import im.vector.matrix.android.api.session.room.model.message.MessageVerificationCancelContent +import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.internal.session.room.VerificationState +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.resources.UserPreferencesProvider +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory +import im.vector.riotx.features.home.room.detail.timeline.item.VerificationRequestConclusionItem +import im.vector.riotx.features.home.room.detail.timeline.item.VerificationRequestConclusionItem_ +import javax.inject.Inject + +/** + * Can creates verification conclusion items + * Notice that not all KEY_VERIFICATION_DONE will be displayed in timeline, + * several checks are made to see if this conclusion is attached to a known request + */ +class VerificationItemFactory @Inject constructor( + private val colorProvider: ColorProvider, + private val messageInformationDataFactory: MessageInformationDataFactory, + private val messageItemAttributesFactory: MessageItemAttributesFactory, + private val avatarSizeProvider: AvatarSizeProvider, + private val noticeItemFactory: NoticeItemFactory, + private val userPreferencesProvider: UserPreferencesProvider, + private val session: Session +) { + + fun create(event: TimelineEvent, + highlight: Boolean, + callback: TimelineEventController.Callback? + ): VectorEpoxyModel<*>? { + if (event.root.eventId == null) return null + + val relContent: MessageRelationContent = event.root.content.toModel() + ?: event.root.getClearContent().toModel() + ?: return ignoredConclusion(event, highlight, callback) + + if (relContent.relatesTo?.type != RelationType.REFERENCE) return ignoredConclusion(event, highlight, callback) + val refEventId = relContent.relatesTo?.eventId + ?: return ignoredConclusion(event, highlight, callback) + + // If we cannot find the referenced request we do not display the done event + val refEvent = session.getRoom(event.root.roomId ?: "")?.getTimeLineEvent(refEventId) + ?: return ignoredConclusion(event, highlight, callback) + + // If it's not a request ignore this event + if (refEvent.root.getClearContent().toModel() == null) return ignoredConclusion(event, highlight, callback) + + val referenceInformationData = messageInformationDataFactory.create(refEvent, null) + + val informationData = messageInformationDataFactory.create(event, null) + val attributes = messageItemAttributesFactory.create(null, informationData, callback) + + when (event.root.getClearType()) { + EventType.KEY_VERIFICATION_CANCEL -> { + // Is the request referenced is actually really cancelled? + val cancelContent = event.root.getClearContent().toModel() + ?: return ignoredConclusion(event, highlight, callback) + + when (safeValueOf(cancelContent.code)) { + CancelCode.MismatchedCommitment, + CancelCode.MismatchedKeys, + CancelCode.MismatchedSas -> { + // We should display these bad conclusions + return VerificationRequestConclusionItem_() + .attributes( + VerificationRequestConclusionItem.Attributes( + toUserId = informationData.senderId, + toUserName = informationData.memberName.toString(), + isPositive = false, + informationData = informationData, + avatarRenderer = attributes.avatarRenderer, + colorProvider = colorProvider, + emojiTypeFace = attributes.emojiTypeFace, + itemClickListener = attributes.itemClickListener, + itemLongClickListener = attributes.itemLongClickListener, + reactionPillCallback = attributes.reactionPillCallback, + readReceiptsCallback = attributes.readReceiptsCallback + ) + ) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } + else -> ignoredConclusion(event, highlight, callback) + } + } + EventType.KEY_VERIFICATION_DONE -> { + // Is the request referenced is actually really completed? + if (referenceInformationData.referencesInfoData?.verificationStatus != VerificationState.DONE) + return ignoredConclusion(event, highlight, callback) + + // We only tale the one sent by me + if (informationData.sentByMe) { + // We only display the done sent by the other user, the done send by me is ignored + return ignoredConclusion(event, highlight, callback) + } + return VerificationRequestConclusionItem_() + .attributes( + VerificationRequestConclusionItem.Attributes( + toUserId = informationData.senderId, + toUserName = informationData.memberName.toString(), + isPositive = true, + informationData = informationData, + avatarRenderer = attributes.avatarRenderer, + colorProvider = colorProvider, + emojiTypeFace = attributes.emojiTypeFace, + itemClickListener = attributes.itemClickListener, + itemLongClickListener = attributes.itemLongClickListener, + reactionPillCallback = attributes.reactionPillCallback, + readReceiptsCallback = attributes.readReceiptsCallback + ) + ) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } + } + return null + } + + private fun ignoredConclusion(event: TimelineEvent, + highlight: Boolean, + callback: TimelineEventController.Callback? + ): VectorEpoxyModel<*>? { + if (userPreferencesProvider.shouldShowHiddenEvents()) return noticeItemFactory.create(event, highlight, callback) + return null + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt new file mode 100644 index 0000000000..ed6bc9df62 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.home.room.detail.timeline.format + +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.isReply +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.room.timeline.getTextEditableContent +import im.vector.riotx.R +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.resources.StringProvider +import me.gujun.android.span.span +import javax.inject.Inject + +class DisplayableEventFormatter @Inject constructor( +// private val sessionHolder: ActiveSessionHolder, + private val stringProvider: StringProvider, + private val colorProvider: ColorProvider, + private val noticeEventFormatter: NoticeEventFormatter +) { + + fun format(timelineEvent: TimelineEvent, appendAuthor: Boolean): CharSequence { + if (timelineEvent.root.isEncrypted() + && timelineEvent.root.mxDecryptionResult == null) { + return stringProvider.getString(R.string.encrypted_message) + } + + val senderName = timelineEvent.getDisambiguatedDisplayName() + + when (timelineEvent.root.getClearType()) { + EventType.MESSAGE -> { + timelineEvent.getLastMessageContent()?.let { messageContent -> + when (messageContent.type) { + MessageType.MSGTYPE_VERIFICATION_REQUEST -> { + return simpleFormat(senderName, stringProvider.getString(R.string.verification_request), appendAuthor) + } + MessageType.MSGTYPE_IMAGE -> { + return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor) + } + MessageType.MSGTYPE_AUDIO -> { + return simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor) + } + MessageType.MSGTYPE_VIDEO -> { + return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor) + } + MessageType.MSGTYPE_FILE -> { + return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_file), appendAuthor) + } + MessageType.MSGTYPE_TEXT -> { + if (messageContent.isReply()) { + // Skip reply prefix, and show important + // TODO add a reply image span ? + return simpleFormat(senderName, timelineEvent.getTextEditableContent() + ?: messageContent.body, appendAuthor) + } else { + return simpleFormat(senderName, messageContent.body, appendAuthor) + } + } + else -> { + return simpleFormat(senderName, messageContent.body, appendAuthor) + } + } + } + } + else -> { + return span { + text = noticeEventFormatter.format(timelineEvent) ?: "" + textStyle = "italic" + } + } + } + + return span { } + } + + private fun simpleFormat(senderName: String, body: CharSequence, appendAuthor: Boolean): CharSequence { + return if (appendAuthor) { + span { + text = senderName + textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_primary) + } + .append(": ") + .append(body) + } else { + body + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 75100e6c03..f5253a9a28 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -44,6 +44,12 @@ class NoticeEventFormatter @Inject constructor(private val sessionHolder: Active EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) EventType.MESSAGE, EventType.REACTION, + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_KEY, EventType.REDACTION -> formatDebug(timelineEvent.root) else -> { Timber.v("Type $type not handled by this formatter") 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..a46d0bb9b9 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 @@ -20,15 +20,19 @@ package im.vector.riotx.features.home.room.detail.timeline.helper import im.vector.matrix.android.api.session.Session 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.ReferencesAggregatedContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited +import im.vector.matrix.android.internal.session.room.VerificationState +import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.core.extensions.localDateTime import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.features.home.getColorFromUserId -import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData +import im.vector.riotx.features.home.room.detail.timeline.item.ReferencesInfoData import me.gujun.android.span.span import javax.inject.Inject @@ -60,7 +64,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( @@ -86,7 +90,15 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses .map { ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) } - .toList() + .toList(), + referencesInfoData = event.annotations?.referencesAggregatedSummary?.let { referencesAggregatedSummary -> + val stateStr = referencesAggregatedSummary.content.toModel()?.verificationSummary + ReferencesInfoData( + VerificationState.values().firstOrNull { stateStr == it.name } + ?: VerificationState.REQUEST + ) + }, + sentByMe = event.root.senderId == session.myUserId ) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 033ff68433..343b5ec74c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -37,7 +37,9 @@ object TimelineDisplayableEvents { EventType.STICKER, EventType.STATE_ROOM_CREATE, EventType.STATE_ROOM_TOMBSTONE, - EventType.STATE_ROOM_JOIN_RULES + EventType.STATE_ROOM_JOIN_RULES, + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_CANCEL ) val DEBUG_DISPLAYABLE_TYPES = DISPLAYABLE_TYPES + listOf( @@ -45,8 +47,6 @@ object TimelineDisplayableEvents { EventType.REACTION, EventType.KEY_VERIFICATION_ACCEPT, EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_MAC, EventType.KEY_VERIFICATION_KEY ) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt new file mode 100644 index 0000000000..6d99bb2650 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.home.room.detail.timeline.item + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.isVisible +import im.vector.matrix.android.api.session.room.send.SendState +import im.vector.riotx.R +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.utils.DebouncedClickListener +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.reactions.widget.ReactionButton +import im.vector.riotx.features.ui.getMessageTextColor + +/** + * Base timeline item with reactions and read receipts. + * Manages associated click listeners and send status. + * Should not be used as this, use a subclass. + */ +abstract class AbsBaseMessageItem : BaseEventItem() { + + abstract val baseAttributes: Attributes + + private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { + baseAttributes.readReceiptsCallback?.onReadReceiptsClicked(baseAttributes.informationData.readReceipts) + }) + + private var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener { + override fun onReacted(reactionButton: ReactionButton) { + baseAttributes.reactionPillCallback?.onClickOnReactionPill(baseAttributes.informationData, reactionButton.reactionString, true) + } + + override fun onUnReacted(reactionButton: ReactionButton) { + baseAttributes.reactionPillCallback?.onClickOnReactionPill(baseAttributes.informationData, reactionButton.reactionString, false) + } + + override fun onLongClick(reactionButton: ReactionButton) { + baseAttributes.reactionPillCallback?.onLongClickOnReactionPill(baseAttributes.informationData, reactionButton.reactionString) + } + } + + open fun shouldShowReactionAtBottom(): Boolean { + return true + } + + override fun getEventIds(): List { + return listOf(baseAttributes.informationData.eventId) + } + + override fun bind(holder: H) { + super.bind(holder) + holder.readReceiptsView.render( + baseAttributes.informationData.readReceipts, + baseAttributes.avatarRenderer, + _readReceiptsClickListener + ) + + val reactions = baseAttributes.informationData.orderedReactionList + if (!shouldShowReactionAtBottom() || reactions.isNullOrEmpty()) { + holder.reactionsContainer.isVisible = false + } else { + holder.reactionsContainer.isVisible = true + holder.reactionsContainer.removeAllViews() + reactions.take(8).forEach { reaction -> + val reactionButton = ReactionButton(holder.view.context) + reactionButton.reactedListener = reactionClickListener + reactionButton.setTag(R.id.reactionsContainer, reaction.key) + reactionButton.reactionString = reaction.key + reactionButton.reactionCount = reaction.count + reactionButton.setChecked(reaction.addedByMe) + reactionButton.isEnabled = reaction.synced + holder.reactionsContainer.addView(reactionButton) + } + holder.reactionsContainer.setOnLongClickListener(baseAttributes.itemLongClickListener) + } + + holder.view.setOnClickListener(baseAttributes.itemClickListener) + holder.view.setOnLongClickListener(baseAttributes.itemLongClickListener) + } + + override fun unbind(holder: H) { + holder.readReceiptsView.unbind() + super.unbind(holder) + } + + protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) { + root.isClickable = baseAttributes.informationData.sendState.isSent() + val state = if (baseAttributes.informationData.hasPendingEdits) SendState.UNSENT else baseAttributes.informationData.sendState + textView?.setTextColor(baseAttributes.colorProvider.getMessageTextColor(state)) + failureIndicator?.isVisible = baseAttributes.informationData.sendState.hasFailed() + } + + abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { + val reactionsContainer by bind(R.id.reactionsContainer) + } + + /** + * This class holds all the common attributes for timeline items. + */ + interface Attributes { + // val avatarSize: Int, + val informationData: MessageInformationData + val avatarRenderer: AvatarRenderer + val colorProvider: ColorProvider + val itemLongClickListener: View.OnLongClickListener? + val itemClickListener: View.OnClickListener? + // val memberClickListener: View.OnClickListener? + val reactionPillCallback: TimelineEventController.ReactionPillCallback? + // val avatarCallback: TimelineEventController.AvatarCallback? + val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? +// val emojiTypeFace: Typeface? + } + +// data class AbsAttributes( +// override val informationData: MessageInformationData, +// override val avatarRenderer: AvatarRenderer, +// override val colorProvider: ColorProvider, +// override val itemLongClickListener: View.OnLongClickListener? = null, +// override val itemClickListener: View.OnClickListener? = null, +// override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, +// override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null +// ) : Attributes +} 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..ae69164951 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 @@ -18,22 +18,24 @@ package im.vector.riotx.features.home.room.detail.timeline.item import android.graphics.Typeface import android.view.View -import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.annotation.IdRes -import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute -import im.vector.matrix.android.api.session.room.send.SendState import im.vector.riotx.R import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.reactions.widget.ReactionButton -import im.vector.riotx.features.ui.getMessageTextColor -abstract class AbsMessageItem : BaseEventItem() { +/** + * Base timeline item that adds an optional information bar with the sender avatar, name and time + * Adds associated click listeners (on avatar, displayname) + */ +abstract class AbsMessageItem : AbsBaseMessageItem() { + + override val baseAttributes: AbsBaseMessageItem.Attributes + get() = attributes @EpoxyAttribute lateinit var attributes: Attributes @@ -45,24 +47,6 @@ abstract class AbsMessageItem : BaseEventItem() { attributes.avatarCallback?.onMemberNameClicked(attributes.informationData) }) - private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { - attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) - }) - - var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener { - override fun onReacted(reactionButton: ReactionButton) { - attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, true) - } - - override fun onUnReacted(reactionButton: ReactionButton) { - attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, false) - } - - override fun onLongClick(reactionButton: ReactionButton) { - attributes.reactionPillCallback?.onLongClickOnReactionPill(attributes.informationData, reactionButton.reactionString) - } - } - override fun bind(holder: H) { super.bind(holder) if (attributes.informationData.showInformation) { @@ -77,12 +61,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 { @@ -94,60 +73,12 @@ abstract class AbsMessageItem : BaseEventItem() { holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null) } - holder.view.setOnClickListener(attributes.itemClickListener) - holder.view.setOnLongClickListener(attributes.itemLongClickListener) - - holder.readReceiptsView.render( - attributes.informationData.readReceipts, - attributes.avatarRenderer, - _readReceiptsClickListener - ) - - val reactions = attributes.informationData.orderedReactionList - if (!shouldShowReactionAtBottom() || reactions.isNullOrEmpty()) { - holder.reactionsContainer.isVisible = false - } else { - holder.reactionsContainer.isVisible = true - holder.reactionsContainer.removeAllViews() - reactions.take(8).forEach { reaction -> - val reactionButton = ReactionButton(holder.view.context) - reactionButton.reactedListener = reactionClickListener - reactionButton.setTag(R.id.reactionsContainer, reaction.key) - reactionButton.reactionString = reaction.key - reactionButton.reactionCount = reaction.count - reactionButton.setChecked(reaction.addedByMe) - reactionButton.isEnabled = reaction.synced - holder.reactionsContainer.addView(reactionButton) - } - holder.reactionsContainer.setOnLongClickListener(attributes.itemLongClickListener) - } } - override fun unbind(holder: H) { - holder.readReceiptsView.unbind() - super.unbind(holder) - } - - open fun shouldShowReactionAtBottom(): Boolean { - return true - } - - override fun getEventIds(): List { - return listOf(attributes.informationData.eventId) - } - - protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) { - root.isClickable = attributes.informationData.sendState.isSent() - val state = if (attributes.informationData.hasPendingEdits) SendState.UNSENT else attributes.informationData.sendState - textView?.setTextColor(attributes.colorProvider.getMessageTextColor(state)) - failureIndicator?.isVisible = attributes.informationData.sendState.hasFailed() - } - - abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) { + abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) { val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) - val reactionsContainer by bind(R.id.reactionsContainer) } /** @@ -155,15 +86,15 @@ abstract class AbsMessageItem : BaseEventItem() { */ data class Attributes( val avatarSize: Int, - val informationData: MessageInformationData, - val avatarRenderer: AvatarRenderer, - val colorProvider: ColorProvider, - val itemLongClickListener: View.OnLongClickListener? = null, - val itemClickListener: View.OnClickListener? = null, + override val informationData: MessageInformationData, + override val avatarRenderer: AvatarRenderer, + override val colorProvider: ColorProvider, + override val itemLongClickListener: View.OnLongClickListener? = null, + override val itemClickListener: View.OnClickListener? = null, val memberClickListener: View.OnClickListener? = null, - val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, + override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, val avatarCallback: TimelineEventController.AvatarCallback? = null, - val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, + override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, val emojiTypeFace: Typeface? = null - ) + ) : AbsBaseMessageItem.Attributes } 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/MessageInformationData.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt index 2dd581ce6f..835a789107 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -18,6 +18,8 @@ package im.vector.riotx.features.home.room.detail.timeline.item import android.os.Parcelable import im.vector.matrix.android.api.session.room.send.SendState +import im.vector.matrix.android.api.util.MatrixItem +import im.vector.matrix.android.internal.session.room.VerificationState import kotlinx.android.parcel.Parcelize @Parcelize @@ -33,7 +35,18 @@ data class MessageInformationData( val orderedReactionList: List? = null, val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, - val readReceipts: List = emptyList() + val readReceipts: List = emptyList(), + val referencesInfoData: ReferencesInfoData? = null, + val sentByMe : Boolean +) : Parcelable { + + val matrixItem: MatrixItem + get() = MatrixItem.UserItem(senderId, memberName?.toString(), avatarUrl) +} + +@Parcelize +data class ReferencesInfoData( + val verificationStatus: VerificationState ) : Parcelable @Parcelize @@ -51,3 +64,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/item/VerificationRequestConclusionItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestConclusionItem.kt new file mode 100644 index 0000000000..036bf2b036 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestConclusionItem.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.home.room.detail.timeline.item + +import android.annotation.SuppressLint +import android.graphics.Typeface +import android.view.View +import android.widget.ImageView +import android.widget.RelativeLayout +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base_state) +abstract class VerificationRequestConclusionItem : AbsBaseMessageItem() { + + override val baseAttributes: AbsBaseMessageItem.Attributes + get() = attributes + + @EpoxyAttribute + lateinit var attributes: Attributes + + override fun getViewType() = STUB_ID + + @SuppressLint("SetTextI18n") + override fun bind(holder: Holder) { + super.bind(holder) + holder.endGuideline.updateLayoutParams { + this.marginEnd = leftGuideline + } + val title = if (attributes.isPositive) R.string.sas_verified else R.string.verification_conclusion_warning + holder.titleView.text = holder.view.context.getString(title) + holder.descriptionView.text = "${attributes.informationData.memberName} (${attributes.informationData.senderId})" + + val startDrawable = if (attributes.isPositive) R.drawable.ic_shield_trusted else R.drawable.ic_shield_warning + holder.titleView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(holder.view.context, startDrawable), + null, null, null + ) + + renderSendState(holder.view, null, holder.failedToSendIndicator) + } + + class Holder : AbsBaseMessageItem.Holder(STUB_ID) { + val titleView by bind(R.id.itemVerificationDoneTitleTextView) + val descriptionView by bind(R.id.itemVerificationDoneDetailTextView) + val endGuideline by bind(R.id.messageEndGuideline) + val failedToSendIndicator by bind(R.id.messageFailToSendIndicator) + } + + companion object { + private const val STUB_ID = R.id.messageVerificationDoneStub + } + + /** + * This class holds all the common attributes for timeline items. + */ + data class Attributes( + val toUserId: String, + val toUserName: String, + val isPositive: Boolean, + override val informationData: MessageInformationData, + override val avatarRenderer: AvatarRenderer, + override val colorProvider: ColorProvider, + override val itemLongClickListener: View.OnLongClickListener? = null, + override val itemClickListener: View.OnClickListener? = null, + override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, + override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, + val emojiTypeFace: Typeface? = null + ) : AbsBaseMessageItem.Attributes +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestItem.kt new file mode 100644 index 0000000000..7964707d3c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestItem.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.home.room.detail.timeline.item + +import android.annotation.SuppressLint +import android.graphics.Typeface +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.matrix.android.internal.session.room.VerificationState +import im.vector.riotx.R +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.utils.DebouncedClickListener +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.RoomDetailAction +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base_state) +abstract class VerificationRequestItem : AbsBaseMessageItem() { + + override val baseAttributes: AbsBaseMessageItem.Attributes + get() = attributes + + @EpoxyAttribute + lateinit var attributes: Attributes + + @EpoxyAttribute + var callback: TimelineEventController.Callback? = null + + override fun getViewType() = STUB_ID + + @SuppressLint("SetTextI18n") + override fun bind(holder: Holder) { + super.bind(holder) + + holder.endGuideline.updateLayoutParams { + this.marginEnd = leftGuideline + } + + holder.titleView.text = if (attributes.informationData.sentByMe) + holder.view.context.getString(R.string.verification_sent) +// + "\n ${attributes.informationData.referencesInfoData?.verificationStatus?.name +// ?: "??"}" + else + holder.view.context.getString(R.string.verification_request) +// + "\n ${attributes.informationData.referencesInfoData?.verificationStatus?.name +// ?: "??"}" + + holder.descriptionView.text = if (!attributes.informationData.sentByMe) + "${attributes.informationData.memberName} (${attributes.informationData.senderId})" + else + "${attributes.otherUserName} (${attributes.otherUserId})" + + when (attributes.informationData.referencesInfoData?.verificationStatus) { + VerificationState.REQUEST, + null -> { + holder.buttonBar.isVisible = !attributes.informationData.sentByMe + holder.statusTextView.text = null + holder.statusTextView.isVisible = false + } + VerificationState.CANCELED_BY_OTHER -> { + holder.buttonBar.isVisible = false + holder.statusTextView.text = holder.view.context.getString(R.string.verification_request_other_cancelled, attributes.informationData.memberName) + holder.statusTextView.isVisible = true + } + VerificationState.CANCELED_BY_ME -> { + holder.buttonBar.isVisible = false + holder.statusTextView.text = holder.view.context.getString(R.string.verification_request_you_cancelled) + holder.statusTextView.isVisible = true + } + VerificationState.WAITING -> { + holder.buttonBar.isVisible = false + holder.statusTextView.text = holder.view.context.getString(R.string.verification_request_waiting) + holder.statusTextView.isVisible = true + } + VerificationState.DONE -> { + holder.buttonBar.isVisible = false + holder.statusTextView.text = if (attributes.informationData.sentByMe) + holder.view.context.getString(R.string.verification_request_other_accepted, attributes.otherUserName) + else + holder.view.context.getString(R.string.verification_request_you_accepted) + holder.statusTextView.isVisible = true + } + else -> { + holder.buttonBar.isVisible = false + holder.statusTextView.text = null + holder.statusTextView.isVisible = false + } + } + + holder.callback = callback + holder.attributes = attributes + + renderSendState(holder.view, null, holder.failedToSendIndicator) + } + + override fun unbind(holder: Holder) { + super.unbind(holder) + holder.callback = null + holder.attributes = null + } + + class Holder : AbsBaseMessageItem.Holder(STUB_ID) { + + var callback: TimelineEventController.Callback? = null + var attributes: Attributes? = null + + private val _clickListener = DebouncedClickListener(View.OnClickListener { + val att = attributes ?: return@OnClickListener + if (it == acceptButton) { + callback?.onTimelineItemAction(RoomDetailAction.AcceptVerificationRequest( + att.referenceId, + att.otherUserId, + att.fromDevide)) + } else if (it == declineButton) { + callback?.onTimelineItemAction(RoomDetailAction.DeclineVerificationRequest(att.referenceId)) + } + }) + + val titleView by bind(R.id.itemVerificationTitleTextView) + val descriptionView by bind(R.id.itemVerificationDetailTextView) + val buttonBar by bind(R.id.itemVerificationButtonBar) + val statusTextView by bind(R.id.itemVerificationStatusText) + val endGuideline by bind(R.id.messageEndGuideline) + private val declineButton by bind