diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 1ab5d835b2..d25a322161 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -1,5 +1,5 @@
name: Bug report for the Element Android app
-description: Report any issues that you have found with the Element app. Please [check open issues](https://github.com/vector-im/element-android/issues) first, in case it has already been reported.
+description: Report any issues that you have found with the Element app. Please check open issues first, in case it has already been reported.
labels: [T-Defect]
body:
- type: markdown
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..09848e9f88
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Enhancement or feature request
+ url: https://github.com/vector-im/element-meta/discussions/categories/ideas
+ about: Do you have a suggestion or feature request?
+ - name: Element Android Community Support
+ url: https://matrix.to/#/#element-android:matrix.org
+ about: General Element Android support questions can be asked in the app Matrix room
diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml
deleted file mode 100644
index 0e51d5155e..0000000000
--- a/.github/ISSUE_TEMPLATE/enhancement.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-name: Enhancement request
-description: Do you have a suggestion or feature request?
-labels: [T-Enhancement]
-body:
- - type: markdown
- attributes:
- value: |
- Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/vector-im/element-meta/discussions/new?category=ideas).
- - type: textarea
- id: usecase
- attributes:
- label: Your use case
- description: Please feel welcome to include screenshots or mock ups.
- placeholder: Tell us what you would like to do!
- value: |
- #### What would you like to do?
-
- #### Why would you like to do it?
-
- #### How would you like to achieve it?
- validations:
- required: true
- - type: textarea
- id: alternative
- attributes:
- label: Have you considered any alternatives?
- placeholder: A clear and concise description of any alternative solutions or features you've considered.
- validations:
- required: false
- - type: textarea
- id: additional-context
- attributes:
- label: Additional context
- placeholder: Is there anything else you'd like to add?
- validations:
- required: false
- - type: dropdown
- id: pr
- attributes:
- label: Are you willing to provide a PR?
- description: |
- Don't worry, it's still OK to answer 'No' :).
- options:
- - 'Yes'
- - 'No'
- validations:
- required: true
diff --git a/.github/ISSUE_TEMPLATE/matrix-sdk.yml b/.github/ISSUE_TEMPLATE/matrix-sdk.yml
index 4033423dd5..213a6696f7 100644
--- a/.github/ISSUE_TEMPLATE/matrix-sdk.yml
+++ b/.github/ISSUE_TEMPLATE/matrix-sdk.yml
@@ -1,5 +1,5 @@
name: Matrix SDK bug or enhancement
-description: Report issue or ask for a feature in the [Android Matrix SDK](https://github.com/matrix-org/matrix-android-sdk2)
+description: "Report issue or ask for a feature in the Android Matrix SDK: https://github.com/matrix-org/matrix-android-sdk2"
title: "[SDK] "
labels: [matrix-sdk]
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8832499c43..e529692dd7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -25,6 +25,9 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
+ with:
+ # https://github.com/actions/checkout/issues/881
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Configure gradle
uses: gradle/gradle-build-action@v2
with:
@@ -46,6 +49,9 @@ jobs:
cancel-in-progress: ${{ github.ref != 'refs/head/main' }}
steps:
- uses: actions/checkout@v3
+ with:
+ # https://github.com/actions/checkout/issues/881
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Configure gradle
uses: gradle/gradle-build-action@v2
with:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d9ae49a5f0..111648a2d4 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,6 +4,8 @@ on:
pull_request: { }
push:
branches: [ main, develop ]
+ paths-ignore:
+ - '.github/**'
# Enrich gradle.properties for CI/CD
env:
diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml
index 036bc069ac..5abd284dcd 100644
--- a/.github/workflows/triage-labelled.yml
+++ b/.github/workflows/triage-labelled.yml
@@ -271,6 +271,31 @@ jobs:
PROJECT_ID: "PVT_kwDOAM0swc4ABTXY"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ ex_plorers:
+ name: Add labelled issues to X-Plorer project
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
+ steps:
+ - uses: octokit/graphql-action@v2.x
+ id: add_to_project
+ with:
+ headers: '{"GraphQL-Features": "projects_next_graphql"}'
+ query: |
+ mutation add_to_project($projectid:ID!,$contentid:ID!) {
+ addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) {
+ item {
+ id
+ }
+ }
+ }
+ projectid: ${{ env.PROJECT_ID }}
+ contentid: ${{ github.event.issue.node_id }}
+ env:
+ PROJECT_ID: "PVT_kwDOAM0swc4ALoFY"
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
ps_features1:
name: Add labelled issues to PS features team 1
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index 068b169d7c..7c8d1e5549 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
.idea/caches
.idea/libraries
.idea/inspectionProfiles
+.idea/sonarlint
.idea/*.xml
.DS_Store
/build
diff --git a/CHANGES.md b/CHANGES.md
index 3fc21c7d07..ae1f10f6c0 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,32 @@
+Changes in Element v1.5.26 (2023-02-22)
+=======================================
+
+Features ✨
+----------
+ - Adds MSC3824 OIDC-awareness when talking to an OIDC-enabled homeservers ([#6367](https://github.com/vector-im/element-android/issues/6367))
+ - [Poll] Synchronize polls push rules with message push rules ([#8007](https://github.com/vector-im/element-android/issues/8007))
+ - [Rich text editor] Add code block, quote and indentation actions ([#8045](https://github.com/vector-im/element-android/issues/8045))
+ - [Poll] History list: details screen of a poll
+ - [Poll] History list: enable the new settings entry in release mode ([#8056](https://github.com/vector-im/element-android/issues/8056))
+ - [Location sharing] Show own location in map views ([#8110](https://github.com/vector-im/element-android/issues/8110))
+ - Updates to protocol used for Sign in with QR code ([#8123](https://github.com/vector-im/element-android/issues/8123))
+ - [Poll] Synchronize polls and message push rules ([#8130](https://github.com/vector-im/element-android/issues/8130))
+
+Bugfixes 🐛
+----------
+ - Android app does not show correct poll data ([#6121](https://github.com/vector-im/element-android/issues/6121))
+ - Fix timeline always jumps to the bottom when screen goes back to foreground. ([#8090](https://github.com/vector-im/element-android/issues/8090))
+ - [Poll] Improve rendering of poll end message when poll start event isn't available ([#8129](https://github.com/vector-im/element-android/issues/8129))
+ - Replace hardcoded colors by theming colors on send button. ([#8142](https://github.com/vector-im/element-android/issues/8142))
+ - [Timeline]: Editing a reply from iOS breaks the "in reply to" rendering ([#8150](https://github.com/vector-im/element-android/issues/8150))
+
+Other changes
+-------------
+ - Build unmerged APKs on pull request ([#8044](https://github.com/vector-im/element-android/issues/8044))
+ - Replace 'Bots' with 'bots' inside terms_description_for_integration_manager ([#8115](https://github.com/vector-im/element-android/issues/8115))
+ - Fix ktlint issue with fields and a new line. ([#8139](https://github.com/vector-im/element-android/issues/8139))
+
+
Changes in Element v1.5.25 (2023-02-15)
=======================================
diff --git a/build.gradle b/build.gradle
index 46144940fc..ecd1cd557b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -29,7 +29,7 @@ buildscript {
classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730'
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6'
classpath "com.likethesalad.android:stem-plugin:2.3.0"
- classpath 'org.owasp:dependency-check-gradle:8.0.2'
+ classpath 'org.owasp:dependency-check-gradle:8.1.0'
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.20"
classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0"
classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3'
@@ -41,7 +41,7 @@ buildscript {
plugins {
// ktlint Plugin
- id "org.jlleitschuh.gradle.ktlint" version "11.1.0"
+ id "org.jlleitschuh.gradle.ktlint" version "11.2.0"
// Detekt
id "io.gitlab.arturbosch.detekt" version "1.22.0"
// Ksp
diff --git a/dependencies.gradle b/dependencies.gradle
index 1dceeac424..699cf82196 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -10,8 +10,8 @@ def gradle = "7.4.1"
// Ref: https://kotlinlang.org/releases.html
def kotlin = "1.8.10"
def kotlinCoroutines = "1.6.4"
-def dagger = "2.44.2"
-def firebaseBom = "31.2.0"
+def dagger = "2.45"
+def firebaseBom = "31.2.1"
def appDistribution = "16.0.0-beta05"
def retrofit = "2.9.0"
def markwon = "4.6.2"
@@ -27,7 +27,7 @@ def jjwt = "0.11.5"
// Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert
// the whole commit which set version 0.16.0-SNAPSHOT
def vanniktechEmoji = "0.16.0-SNAPSHOT"
-def sentry = "6.13.0"
+def sentry = "6.14.0"
// Use 1.6.0 alpha to fix issue with test
def fragment = "1.6.0-alpha04"
// Testing
@@ -51,11 +51,11 @@ ext.libs = [
],
androidx : [
'activity' : "androidx.activity:activity-ktx:1.6.1",
- 'appCompat' : "androidx.appcompat:appcompat:1.6.0",
+ 'appCompat' : "androidx.appcompat:appcompat:1.6.1",
'biometric' : "androidx.biometric:biometric:1.1.0",
'core' : "androidx.core:core-ktx:1.9.0",
'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1",
- 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.5",
+ 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.6",
'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment",
'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment",
'fragmentTestingManifest' : "androidx.fragment:fragment-testing-manifest:$fragment",
@@ -89,7 +89,7 @@ ext.libs = [
//'appdistributionApi' : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution",
//'appdistribution' : "com.google.firebase:firebase-appdistribution:$appDistribution",
// Phone number https://github.com/google/libphonenumber
- 'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.13.5"
+ 'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.13.6"
],
dagger : [
'dagger' : "com.google.dagger:dagger:$dagger",
diff --git a/fastlane/metadata/android/en-US/changelogs/40105260.txt b/fastlane/metadata/android/en-US/changelogs/40105260.txt
new file mode 100644
index 0000000000..df2e163506
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105260.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Mainly bugfixing.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt
index 17af1c3190..369e96682a 100644
--- a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt
+++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt
@@ -33,6 +33,7 @@ class CountUpTimer(
private val lastTime: AtomicLong = AtomicLong(clock.epochMillis())
private val elapsedTime: AtomicLong = AtomicLong(0)
+
// To ensure that the regular tick value is an exact multiple of `intervalInMs`
private val specialRound = SpecialRound(intervalInMs)
diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index de3fa20916..e06355a173 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -1063,6 +1063,9 @@
Discovery
Manage your discovery settings.
+ Account
+ Your account details are managed separately at %1$s.
+
Analytics
Send analytics data
@@ -1820,7 +1823,7 @@
Terms of Service
Be discoverable by others
- Use Bots, bridges, widgets and sticker packs
+ Use bots, bridges, widgets and sticker packs
Identity server
Disconnect identity server
@@ -3208,6 +3211,7 @@
Displaying polls
Load more polls
Error fetching polls.
+ View poll in timeline
Share location
@@ -3503,7 +3507,11 @@
Set link
Toggle numbered list
Toggle bullet list
+ Indent
+ Unindent
+ Toggle quote
Apply inline code format
+ Toggle code block
Toggle full screen mode
Text
diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle
index ed15d0c8f9..133067cea4 100644
--- a/matrix-sdk-android/build.gradle
+++ b/matrix-sdk-android/build.gradle
@@ -62,7 +62,7 @@ android {
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
- buildConfigField "String", "SDK_VERSION", "\"1.5.25\""
+ buildConfigField "String", "SDK_VERSION", "\"1.5.26\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""
@@ -196,7 +196,7 @@ dependencies {
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
// Video compression
- implementation 'com.otaliastudios:transcoder:0.10.4'
+ implementation 'com.otaliastudios:transcoder:0.10.5'
// Exif data handling
implementation libs.apache.commonsImaging
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/rendezvous/RendezvousTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/rendezvous/RendezvousTest.kt
new file mode 100644
index 0000000000..5b5aad4c51
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/rendezvous/RendezvousTest.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.rendezvous
+
+import org.amshove.kluent.invoking
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldBeInstanceOf
+import org.amshove.kluent.shouldThrow
+import org.amshove.kluent.with
+import org.junit.Test
+import org.matrix.android.sdk.InstrumentedTest
+import org.matrix.android.sdk.api.rendezvous.channels.ECDHRendezvousChannel
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
+import org.matrix.android.sdk.common.CommonTestHelper
+
+class RendezvousTest : InstrumentedTest {
+
+ @Test
+ fun shouldSuccessfullyBuildChannels() = CommonTestHelper.runCryptoTest(context()) { _, _ ->
+ val cases = listOf(
+ // v1:
+ "{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256\"," +
+ "\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
+ "{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
+ "\"intent\":\"login.reciprocate\"}",
+ // v2:
+ "{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256\"," +
+ "\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
+ "{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
+ "\"intent\":\"login.reciprocate\"}",
+ )
+
+ cases.forEach { input ->
+ Rendezvous.buildChannelFromCode(input).channel shouldBeInstanceOf ECDHRendezvousChannel::class
+ }
+ }
+
+ @Test
+ fun shouldFailToBuildChannelAsUnsupportedAlgorithm() {
+ invoking {
+ Rendezvous.buildChannelFromCode(
+ "{\"rendezvous\":{\"algorithm\":\"bad algo\"," +
+ "\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
+ "{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
+ "\"intent\":\"login.reciprocate\"}"
+ )
+ } shouldThrow RendezvousError::class with {
+ this.reason shouldBeEqualTo RendezvousFailureReason.UnsupportedAlgorithm
+ }
+ }
+
+ @Test
+ fun shouldFailToBuildChannelAsUnsupportedTransport() {
+ invoking {
+ Rendezvous.buildChannelFromCode(
+ "{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256\"," +
+ "\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
+ "{\"type\":\"bad transport\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
+ "\"intent\":\"login.reciprocate\"}"
+ )
+ } shouldThrow RendezvousError::class with {
+ this.reason shouldBeEqualTo RendezvousFailureReason.UnsupportedTransport
+ }
+ }
+
+ @Test
+ fun shouldFailToBuildChannelWithInvalidIntent() {
+ invoking {
+ Rendezvous.buildChannelFromCode(
+ "{\"rendezvous\":{\"algorithm\":\"org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256\"," +
+ "\"key\":\"aeSGwYTV1IUhikUyCapzC6p2xG5NpJ4Lwj2UgUMlcTk\",\"transport\":" +
+ "{\"type\":\"org.matrix.msc3886.http.v1\",\"uri\":\"https://rendezvous.lab.element.dev/bcab62cd-3e34-48b4-bc39-90895da8f6fe\"}}," +
+ "\"intent\":\"foo\"}"
+ )
+ } shouldThrow RendezvousError::class with {
+ this.reason shouldBeEqualTo RendezvousFailureReason.InvalidCode
+ }
+ }
+
+ @Test
+ fun shouldFailToBuildChannelAsInvalidCode() {
+ val cases = listOf(
+ "{}",
+ "rubbish",
+ ""
+ )
+
+ cases.forEach { input ->
+ invoking {
+ Rendezvous.buildChannelFromCode(input)
+ } shouldThrow RendezvousError::class with {
+ this.reason shouldBeEqualTo RendezvousFailureReason.InvalidCode
+ }
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
index e490311b91..c6fab7762f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
@@ -44,7 +44,7 @@ interface AuthenticationService {
/**
* Get a SSO url.
*/
- fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?): String?
+ fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?, action: SSOAction): String?
/**
* Get the sign in or sign up fallback URL.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/SSOAction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/SSOAction.kt
new file mode 100644
index 0000000000..db2dd870d5
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/SSOAction.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.auth
+
+/**
+ * See https://github.com/matrix-org/matrix-spec-proposals/pull/3824
+ */
+enum class SSOAction {
+ LOGIN,
+ REGISTER;
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/DelegatedAuthConfig.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/DelegatedAuthConfig.kt
new file mode 100644
index 0000000000..b57472ab7c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/DelegatedAuthConfig.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.auth.data
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/2965
+ *
+ * {
+ * "issuer": "https://id.server.org",
+ * "account": "https://id.server.org/my-account",
+ * }
+ *
+ * .
+ */
+
+@JsonClass(generateAdapter = true)
+data class DelegatedAuthConfig(
+ @Json(name = "issuer")
+ val issuer: String,
+
+ @Json(name = "account")
+ val accountManagementUrl: String,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt
index 5de83033e1..5d737b716b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt
@@ -22,6 +22,7 @@ data class LoginFlowResult(
val isLoginAndRegistrationSupported: Boolean,
val homeServerUrl: String,
val isOutdatedHomeserver: Boolean,
+ val hasOidcCompatibilityFlow: Boolean,
val isLogoutDevicesSupported: Boolean,
val isLoginWithQrSupported: Boolean,
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/WellKnown.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/WellKnown.kt
index 10c7d51392..95488bd682 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/WellKnown.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/WellKnown.kt
@@ -54,5 +54,11 @@ data class WellKnown(
val identityServer: WellKnownBaseConfig? = null,
@Json(name = "m.integrations")
- val integrations: JsonDict? = null
+ val integrations: JsonDict? = null,
+
+ /**
+ * For delegation of auth via OIDC as per [MSC2965](https://github.com/matrix-org/matrix-spec-proposals/pull/2965).
+ */
+ @Json(name = "org.matrix.msc2965.authentication")
+ val unstableDelegatedAuthConfig: DelegatedAuthConfig? = null,
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
index e5b2d6bf12..28d8230be8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
@@ -26,8 +26,11 @@ import org.matrix.android.sdk.api.rendezvous.model.Outcome
import org.matrix.android.sdk.api.rendezvous.model.Payload
import org.matrix.android.sdk.api.rendezvous.model.PayloadType
import org.matrix.android.sdk.api.rendezvous.model.Protocol
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousCode
import org.matrix.android.sdk.api.rendezvous.model.RendezvousError
import org.matrix.android.sdk.api.rendezvous.model.RendezvousIntent
+import org.matrix.android.sdk.api.rendezvous.model.RendezvousTransportType
+import org.matrix.android.sdk.api.rendezvous.model.SecureRendezvousChannelAlgorithm
import org.matrix.android.sdk.api.rendezvous.transports.SimpleHttpRendezvousTransport
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel
@@ -53,18 +56,37 @@ class Rendezvous(
@Throws(RendezvousError::class)
fun buildChannelFromCode(code: String): Rendezvous {
- val parsed = try {
- // we rely on moshi validating the code and throwing exception if invalid JSON or doesn't
+ // we first check that the code is valid JSON and has right high-level structure
+ val genericParsed = try {
+ // we rely on moshi validating the code and throwing exception if invalid JSON or algorithm doesn't match
+ MatrixJsonParser.getMoshi().adapter(RendezvousCode::class.java).fromJson(code)
+ } catch (a: Throwable) {
+ throw RendezvousError("Malformed code", RendezvousFailureReason.InvalidCode)
+ } ?: throw RendezvousError("Code is null", RendezvousFailureReason.InvalidCode)
+
+ // then we check that algorithm is supported
+ if (!SecureRendezvousChannelAlgorithm.values().map { it.value }.contains(genericParsed.rendezvous.algorithm)) {
+ throw RendezvousError("Unsupported algorithm", RendezvousFailureReason.UnsupportedAlgorithm)
+ }
+
+ // and, that the transport is supported
+ if (!RendezvousTransportType.values().map { it.value }.contains(genericParsed.rendezvous.transport.type)) {
+ throw RendezvousError("Unsupported transport", RendezvousFailureReason.UnsupportedTransport)
+ }
+
+ // now that we know the overall structure looks sensible, we rely on moshi validating the code and
+ // throwing exception if other parts are invalid
+ val supportedParsed = try {
MatrixJsonParser.getMoshi().adapter(ECDHRendezvousCode::class.java).fromJson(code)
} catch (a: Throwable) {
- throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
- } ?: throw RendezvousError("Invalid code", RendezvousFailureReason.InvalidCode)
+ throw RendezvousError("Malformed ECDH rendezvous code", RendezvousFailureReason.InvalidCode)
+ } ?: throw RendezvousError("ECDH rendezvous code is null", RendezvousFailureReason.InvalidCode)
- val transport = SimpleHttpRendezvousTransport(parsed.rendezvous.transport.uri)
+ val transport = SimpleHttpRendezvousTransport(supportedParsed.rendezvous.transport.uri)
return Rendezvous(
- ECDHRendezvousChannel(transport, parsed.rendezvous.key),
- parsed.intent
+ ECDHRendezvousChannel(transport, supportedParsed.rendezvous.algorithm, supportedParsed.rendezvous.key),
+ supportedParsed.intent
)
}
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt
index c1d6b1b70e..71b22da338 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/channels/ECDHRendezvousChannel.kt
@@ -41,7 +41,11 @@ import javax.crypto.spec.SecretKeySpec
* Implements X25519 ECDH key agreement and AES-256-GCM encryption channel as per MSC3903:
* https://github.com/matrix-org/matrix-spec-proposals/pull/3903
*/
-class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPublicKeyBase64: String?) : RendezvousChannel {
+class ECDHRendezvousChannel(
+ override var transport: RendezvousTransport,
+ private val algorithm: SecureRendezvousChannelAlgorithm,
+ theirPublicKeyBase64: String?,
+) : RendezvousChannel {
companion object {
private const val ALGORITHM_SPEC = "AES/GCM/NoPadding"
private const val KEY_SPEC = "AES"
@@ -53,7 +57,7 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
val algorithm: SecureRendezvousChannelAlgorithm? = null,
val key: String? = null,
val ciphertext: String? = null,
- val iv: String? = null
+ val iv: String? = null,
)
private val olmSASMutex = Mutex()
@@ -65,10 +69,22 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
init {
theirPublicKeyBase64?.let {
- theirPublicKey = Base64.decode(it, Base64.NO_WRAP)
+ theirPublicKey = decodeBase64(it)
}
olmSAS = OlmSAS()
- ourPublicKey = Base64.decode(olmSAS!!.publicKey, Base64.NO_WRAP)
+ ourPublicKey = decodeBase64(olmSAS!!.publicKey)
+ }
+
+ fun encodeBase64(input: ByteArray?): String? {
+ if (algorithm == SecureRendezvousChannelAlgorithm.ECDH_V2) {
+ return Base64.encodeToString(input, Base64.NO_WRAP or Base64.NO_PADDING)
+ }
+ return Base64.encodeToString(input, Base64.NO_WRAP)
+ }
+
+ fun decodeBase64(input: String?): ByteArray {
+ // for decoding we aren't concerned about padding
+ return Base64.decode(input, Base64.NO_WRAP)
}
@Throws(RendezvousError::class)
@@ -86,25 +102,25 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
RendezvousFailureReason.UnsupportedAlgorithm,
)
}
- theirPublicKey = Base64.decode(res.key, Base64.NO_WRAP)
+ theirPublicKey = decodeBase64(res.key)
} else {
// send our public key unencrypted
Timber.tag(TAG).i("Sending public key")
send(
ECDHPayload(
- algorithm = SecureRendezvousChannelAlgorithm.ECDH_V1,
- key = Base64.encodeToString(ourPublicKey, Base64.NO_WRAP)
+ algorithm = algorithm,
+ key = encodeBase64(ourPublicKey)
)
)
}
olmSASMutex.withLock {
- sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
- sas.setTheirPublicKey(Base64.encodeToString(theirPublicKey, Base64.NO_WRAP))
+ sas.setTheirPublicKey(encodeBase64(theirPublicKey))
+ sas.setTheirPublicKey(encodeBase64(theirPublicKey))
- val initiatorKey = Base64.encodeToString(if (isInitiator) ourPublicKey else theirPublicKey, Base64.NO_WRAP)
- val recipientKey = Base64.encodeToString(if (isInitiator) theirPublicKey else ourPublicKey, Base64.NO_WRAP)
- val aesInfo = "${SecureRendezvousChannelAlgorithm.ECDH_V1.value}|$initiatorKey|$recipientKey"
+ val initiatorKey = encodeBase64(if (isInitiator) ourPublicKey else theirPublicKey)
+ val recipientKey = encodeBase64(if (isInitiator) theirPublicKey else ourPublicKey)
+ val aesInfo = "${algorithm.value}|$initiatorKey|$recipientKey"
aesKey = sas.generateShortCode(aesInfo, 32)
@@ -162,20 +178,20 @@ class ECDHRendezvousChannel(override var transport: RendezvousTransport, theirPu
cipherText.addAll(encryptCipher.doFinal().toList())
return ECDHPayload(
- ciphertext = Base64.encodeToString(cipherText.toByteArray(), Base64.NO_WRAP),
- iv = Base64.encodeToString(iv, Base64.NO_WRAP)
+ ciphertext = encodeBase64(cipherText.toByteArray()),
+ iv = encodeBase64(iv)
)
}
private fun decrypt(payload: ECDHPayload): ByteArray {
- val iv = Base64.decode(payload.iv, Base64.NO_WRAP)
+ val iv = decodeBase64(payload.iv)
val encryptCipher = Cipher.getInstance(ALGORITHM_SPEC)
val secretKeySpec = SecretKeySpec(aesKey, KEY_SPEC)
val ivParameterSpec = IvParameterSpec(iv)
encryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
val plainText = LinkedList()
- plainText.addAll(encryptCipher.update(Base64.decode(payload.ciphertext, Base64.NO_WRAP)).toList())
+ plainText.addAll(encryptCipher.update(decodeBase64(payload.ciphertext)).toList())
plainText.addAll(encryptCipher.doFinal().toList())
return plainText.toByteArray()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Rendezvous.kt
new file mode 100644
index 0000000000..f424f8cab0
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/Rendezvous.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.rendezvous.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+open class Rendezvous(
+ val transport: RendezvousTransportDetails,
+ val algorithm: String,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousCode.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousCode.kt
new file mode 100644
index 0000000000..ffa8bf6661
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousCode.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.rendezvous.model
+
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+open class RendezvousCode(
+ open val intent: RendezvousIntent,
+ open val rendezvous: Rendezvous
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt
index 1bde43ab7e..34d96ac64a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/RendezvousTransportDetails.kt
@@ -20,5 +20,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
open class RendezvousTransportDetails(
- val type: RendezvousTransportType
+ val type: String
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt
index 75f0024fda..123e41a5d7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SecureRendezvousChannelAlgorithm.kt
@@ -22,5 +22,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class SecureRendezvousChannelAlgorithm(val value: String) {
@Json(name = "org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256")
- ECDH_V1("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256")
+ ECDH_V1("org.matrix.msc3903.rendezvous.v1.curve25519-aes-sha256"),
+ @Json(name = "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256")
+ ECDH_V2("org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256")
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt
index 049aa8b756..d2342bb9d5 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/model/SimpleHttpRendezvousTransportDetails.kt
@@ -21,4 +21,4 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SimpleHttpRendezvousTransportDetails(
val uri: String
-) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1)
+) : RendezvousTransportDetails(type = RendezvousTransportType.MSC3886_SIMPLE_HTTP_V1.name)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
index 96e52469c3..4968df775a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
@@ -80,6 +80,11 @@ data class HomeServerCapabilities(
* True if the home server supports event redaction with relations.
*/
var canRedactEventWithRelations: Boolean = false,
+
+ /**
+ * External account management url for use with MSC3824 delegated OIDC, provided in Wellknown.
+ */
+ val externalAccountManagementUrl: String? = null,
) {
enum class RoomCapabilitySupport {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/RuleIds.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/RuleIds.kt
index 4f35fb79c3..34581b613a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/RuleIds.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/RuleIds.kt
@@ -47,6 +47,16 @@ object RuleIds {
const val RULE_ID_ALL_OTHER_MESSAGES_ROOMS = ".m.rule.message"
const val RULE_ID_ENCRYPTED = ".m.rule.encrypted"
+ const val RULE_ID_POLL_START_ONE_TO_ONE = ".m.rule.poll_start_one_to_one"
+ const val RULE_ID_POLL_START_ONE_TO_ONE_UNSTABLE = ".org.matrix.msc3930.rule.poll_start_one_to_one"
+ const val RULE_ID_POLL_END_ONE_TO_ONE = ".m.rule.poll_end_one_to_one"
+ const val RULE_ID_POLL_END_ONE_TO_ONE_UNSTABLE = ".org.matrix.msc3930.rule.poll_end_one_to_one"
+
+ const val RULE_ID_POLL_START = ".m.rule.poll_start"
+ const val RULE_ID_POLL_START_UNSTABLE = ".org.matrix.msc3930.rule.poll_start"
+ const val RULE_ID_POLL_END = ".m.rule.poll_end"
+ const val RULE_ID_POLL_END_UNSTABLE = ".org.matrix.msc3930.rule.poll_end"
+
// Not documented
const val RULE_ID_FALLBACK = ".m.rule.fallback"
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/rest/RuleSet.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/rest/RuleSet.kt
index 9498ed002c..9287a7828d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/rest/RuleSet.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/pushrules/rest/RuleSet.kt
@@ -47,21 +47,14 @@ data class RuleSet(
* @param ruleId a RULE_ID_XX value
* @return the matched bing rule or null it doesn't exist.
*/
- fun findDefaultRule(ruleId: String?): PushRuleAndKind? {
- var result: PushRuleAndKind? = null
- // sanity check
- if (null != ruleId) {
- if (RuleIds.RULE_ID_CONTAIN_USER_NAME == ruleId) {
- result = findRule(content, ruleId)?.let { PushRuleAndKind(it, RuleSetKey.CONTENT) }
- } else {
- // assume that the ruleId is unique.
- result = findRule(override, ruleId)?.let { PushRuleAndKind(it, RuleSetKey.OVERRIDE) }
- if (null == result) {
- result = findRule(underride, ruleId)?.let { PushRuleAndKind(it, RuleSetKey.UNDERRIDE) }
- }
- }
+ fun findDefaultRule(ruleId: String): PushRuleAndKind? {
+ return if (RuleIds.RULE_ID_CONTAIN_USER_NAME == ruleId) {
+ findRule(content, ruleId)?.let { PushRuleAndKind(it, RuleSetKey.CONTENT) }
+ } else {
+ // assume that the ruleId is unique.
+ findRule(override, ruleId)?.let { PushRuleAndKind(it, RuleSetKey.OVERRIDE) }
+ ?: findRule(underride, ruleId)?.let { PushRuleAndKind(it, RuleSetKey.UNDERRIDE) }
}
- return result
}
/**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt
index 6e31320b13..9c894ebe28 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt
@@ -33,5 +33,9 @@ data class MessageEndPollContent(
override val msgType: String = MessageType.MSGTYPE_POLL_END,
@Json(name = "body") override val body: String = "",
@Json(name = "m.new_content") override val newContent: Content? = null,
- @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null
-) : MessageContent
+ @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
+ @Json(name = "org.matrix.msc1767.text") val unstableText: String? = null,
+ @Json(name = "m.text") val text: String? = null,
+) : MessageContent {
+ fun getBestText() = text ?: unstableText
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
index 78323d2b8f..dc17a678d3 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
@@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.events.model.isLiveLocation
import org.matrix.android.sdk.api.session.events.model.isPoll
import org.matrix.android.sdk.api.session.events.model.isReply
import org.matrix.android.sdk.api.session.events.model.isSticker
+import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.ReadReceipt
@@ -36,6 +37,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocati
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent
+import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@@ -158,7 +160,39 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
}
fun TimelineEvent.getLastEditNewContent(): Content? {
- return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel()?.newContent
+ val lastContent = annotations?.editSummary?.latestEdit?.getClearContent()?.toModel()?.newContent
+ return if (isReply()) {
+ val previousFormattedBody = root.getClearContent().toModel()?.formattedBody
+ if (previousFormattedBody?.isNotEmpty() == true) {
+ val lastMessageContent = lastContent.toModel()
+ lastMessageContent?.let { ensureCorrectFormattedBodyInTextReply(it, previousFormattedBody) }?.toContent() ?: lastContent
+ } else {
+ lastContent
+ }
+ } else {
+ lastContent
+ }
+}
+
+private const val MX_REPLY_END_TAG = ""
+
+/**
+ * Not every client sends a formatted body in the last edited event since this is not required in the
+ * [Matrix specification](https://spec.matrix.org/v1.4/client-server-api/#applying-mnew_content).
+ * We must ensure there is one so that it is still considered as a reply when rendering the message.
+ */
+private fun ensureCorrectFormattedBodyInTextReply(messageTextContent: MessageTextContent, previousFormattedBody: String): MessageTextContent {
+ return when {
+ messageTextContent.formattedBody.isNullOrEmpty() && previousFormattedBody.contains(MX_REPLY_END_TAG) -> {
+ // take previous formatted body with the new body content
+ val newFormattedBody = previousFormattedBody.replaceAfterLast(MX_REPLY_END_TAG, messageTextContent.body)
+ messageTextContent.copy(
+ formattedBody = newFormattedBody,
+ format = MessageFormat.FORMAT_MATRIX_HTML,
+ )
+ }
+ else -> messageTextContent
+ }
}
private fun TimelineEvent.getLastPollEditNewContent(): Content? {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
index d9c2afcb40..d1dd0238ba 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
@@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.LoginType
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.auth.data.Credentials
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowResult
@@ -88,7 +89,7 @@ internal class DefaultAuthenticationService @Inject constructor(
return getLoginFlow(homeServerConnectionConfig)
}
- override fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?): String? {
+ override fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?, action: SSOAction): String? {
val homeServerUrlBase = getHomeServerUrlBase() ?: return null
return buildString {
@@ -103,6 +104,9 @@ internal class DefaultAuthenticationService @Inject constructor(
// But https://github.com/matrix-org/synapse/issues/5755
appendParamToUrl("device_id", it)
}
+
+ // unstable MSC3824 action param
+ appendParamToUrl("org.matrix.msc3824.action", action.toString())
}
}
@@ -292,12 +296,18 @@ internal class DefaultAuthenticationService @Inject constructor(
val loginFlowResponse = executeRequest(null) {
authAPI.getLoginFlows()
}
+
+ // If an m.login.sso flow is present that is flagged as being for MSC3824 OIDC compatibility then we only return that flow
+ val oidcCompatibilityFlow = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == "m.login.sso" && it.delegatedOidcCompatibilty == true }
+ val flows = if (oidcCompatibilityFlow != null) listOf(oidcCompatibilityFlow) else loginFlowResponse.flows
+
return LoginFlowResult(
- supportedLoginTypes = loginFlowResponse.flows.orEmpty().mapNotNull { it.type },
- ssoIdentityProviders = loginFlowResponse.flows.orEmpty().firstOrNull { it.type == LoginFlowTypes.SSO }?.ssoIdentityProvider,
+ supportedLoginTypes = flows.orEmpty().mapNotNull { it.type },
+ ssoIdentityProviders = flows.orEmpty().firstOrNull { it.type == LoginFlowTypes.SSO }?.ssoIdentityProvider,
isLoginAndRegistrationSupported = versions.isLoginAndRegistrationSupportedBySdk(),
homeServerUrl = homeServerUrl,
isOutdatedHomeserver = !versions.isSupportedBySdk(),
+ hasOidcCompatibilityFlow = oidcCompatibilityFlow != null,
isLogoutDevicesSupported = versions.doesServerSupportLogoutDevices(),
isLoginWithQrSupported = versions.doesServerSupportQrCodeLogin(),
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt
index df10e110d1..971407388c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/LoginFlowResponse.kt
@@ -43,6 +43,13 @@ internal data class LoginFlow(
* See MSC #2858
*/
@Json(name = "identity_providers")
- val ssoIdentityProvider: List? = null
+ val ssoIdentityProvider: List? = null,
+ /**
+ * Whether this login flow is preferred for OIDC-aware clients.
+ *
+ * See [MSC3824](https://github.com/matrix-org/matrix-spec-proposals/pull/3824)
+ */
+ @Json(name = "org.matrix.msc3824.delegated_oidc_compatibility")
+ val delegatedOidcCompatibilty: Boolean? = null
)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
index 9e097d9397..e3d081c01d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
@@ -74,6 +74,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo049
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo050
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo051
import org.matrix.android.sdk.internal.util.Normalizer
import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
import timber.log.Timber
@@ -97,7 +98,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val scSchemaVersion = 7L
private val scSchemaVersionOffset = (1L shl 12)
- val schemaVersion = 50L +
+ val schemaVersion = 51L +
scSchemaVersion * scSchemaVersionOffset
}
@@ -164,6 +165,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 48) MigrateSessionTo048(realm).perform()
if (oldVersion < 49) MigrateSessionTo049(realm).perform()
if (oldVersion < 50) MigrateSessionTo050(realm).perform()
+ if (oldVersion < 51) MigrateSessionTo051(realm).perform()
if (oldScVersion <= 0) MigrateScSessionTo001(realm).perform()
if (oldScVersion <= 1) MigrateScSessionTo002(realm).perform()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
index 83f3e87d05..1c7a0591a1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
@@ -48,6 +48,7 @@ internal object HomeServerCapabilitiesMapper {
canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications,
canRemotelyTogglePushNotificationsOfDevices = entity.canRemotelyTogglePushNotificationsOfDevices,
canRedactEventWithRelations = entity.canRedactEventWithRelations,
+ externalAccountManagementUrl = entity.externalAccountManagementUrl,
)
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo051.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo051.kt
new file mode 100644
index 0000000000..3bed97073d
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo051.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2023 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields
+import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo051(realm: DynamicRealm) : RealmMigrator(realm, 51) {
+
+ override fun doMigrate(realm: DynamicRealm) {
+ realm.schema.get("HomeServerCapabilitiesEntity")
+ ?.addField(HomeServerCapabilitiesEntityFields.EXTERNAL_ACCOUNT_MANAGEMENT_URL, String::class.java)
+ ?.forceRefreshOfHomeServerCapabilities()
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
index 9acdcde7e5..35a5c654de 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
@@ -35,6 +35,7 @@ internal open class HomeServerCapabilitiesEntity(
var canUseThreadReadReceiptsAndNotifications: Boolean = false,
var canRemotelyTogglePushNotificationsOfDevices: Boolean = false,
var canRedactEventWithRelations: Boolean = false,
+ var externalAccountManagementUrl: String? = null,
) : RealmObject() {
companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
index 5a6107821d..ec12695ecd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
@@ -167,6 +167,7 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
Timber.v("Extracted integration config : $config")
realm.insertOrUpdate(config)
}
+ homeServerCapabilitiesEntity.externalAccountManagementUrl = getWellknownResult.wellKnown.unstableDelegatedAuthConfig?.accountManagementUrl
}
homeServerCapabilitiesEntity.lastUpdatedTimestamp = Date().time
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
index 9fe93d8262..3dfac694ed 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
@@ -57,6 +57,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) {
in EventType.POLL_START.values,
+ in EventType.POLL_END.values,
in EventType.STATE_ROOM_BEACON_INFO.values,
EventType.MESSAGE,
EventType.REDACTION,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt
index 2ff43d6812..ca224cd543 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt
@@ -84,7 +84,6 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
val roomId = event.roomId ?: return false
val senderId = event.senderId ?: return false
val targetEventId = event.getRelationContent()?.eventId ?: return false
- val targetPollContent = getPollContent(session, roomId, targetEventId) ?: return false
val annotationsSummaryEntity = getAnnotationsSummaryEntity(realm, roomId, targetEventId)
val aggregatedPollSummaryEntity = getAggregatedPollSummaryEntity(realm, annotationsSummaryEntity)
@@ -108,7 +107,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
}
val vote = content.getBestResponse()?.answers?.first() ?: return false
- if (!targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(vote).orFalse()) {
+ val targetPollContent = getPollContent(session, roomId, targetEventId)
+ if (targetPollContent != null && !targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(vote).orFalse()) {
return false
}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
index af2fb072af..b24ffa1f91 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
@@ -243,7 +243,8 @@ internal class LocalEchoEventFactory @Inject constructor(
relatesTo = RelationDefaultContent(
type = RelationType.REFERENCE,
eventId = eventId
- )
+ ),
+ unstableText = "Ended poll",
)
val localId = LocalEcho.createLocalEchoId()
return Event(
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt
index 766e51a8e5..248c4b322d 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt
@@ -147,6 +147,19 @@ class DefaultPollAggregationProcessorTest {
pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, AN_INVALID_POLL_RESPONSE_EVENT).shouldBeFalse()
}
+ @Test
+ fun `given a poll response event and no existing poll start event, when processing, then is processed and returns true`() {
+ // Given
+ mockRoom(roomId = A_ROOM_ID, eventId = AN_EVENT_ID, hasExistingTimelineEvent = false)
+ every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity()
+
+ // When
+ val result = pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT)
+
+ // Then
+ result.shouldBeTrue()
+ }
+
@Test
fun `given a poll end event, when processing, then is processed and return true`() = runTest {
// Given
@@ -234,11 +247,12 @@ class DefaultPollAggregationProcessorTest {
private fun mockRoom(
roomId: String,
- eventId: String
+ eventId: String,
+ hasExistingTimelineEvent: Boolean = true,
) {
val room = mockk()
every { session.getRoom(roomId) } returns room
- every { room.getTimelineEvent(eventId) } returns A_TIMELINE_EVENT
+ every { room.getTimelineEvent(eventId) } returns if (hasExistingTimelineEvent) A_TIMELINE_EVENT else null
}
private fun mockRedactionPowerLevels(userId: String, isAbleToRedact: Boolean): PowerLevelsHelper {
diff --git a/vector-app/build.gradle b/vector-app/build.gradle
index 00b99144e9..f1fa042208 100644
--- a/vector-app/build.gradle
+++ b/vector-app/build.gradle
@@ -37,7 +37,7 @@ ext.versionMinor = 5
// Note: even values are reserved for regular release, odd values for hotfix release.
// When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release.
-ext.versionPatch = 25
+ext.versionPatch = 26
ext.scVersion = 63
diff --git a/vector/build.gradle b/vector/build.gradle
index 4a88db3bf4..bdbe067de8 100644
--- a/vector/build.gradle
+++ b/vector/build.gradle
@@ -236,7 +236,7 @@ dependencies {
kapt libs.dagger.hiltCompiler
// Analytics
- implementation('com.posthog.android:posthog:2.0.1') {
+ implementation('com.posthog.android:posthog:2.0.2') {
exclude group: 'com.android.support', module: 'support-annotations'
}
implementation libs.sentry.sentryAndroid
diff --git a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt
index 7f3293e7d1..c095b33b44 100644
--- a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt
+++ b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt
@@ -21,6 +21,7 @@ import androidx.core.text.toSpanned
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.resources.ColorProvider
+import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.toTestSpan
import im.vector.app.features.settings.VectorPreferences
import io.mockk.every
@@ -40,9 +41,10 @@ class EventHtmlRendererTest {
every { it.isRichTextEditorEnabled() } returns false
}
private val fakeSessionHolder = mockk()
+ private val fakeDimensionConverter = mockk()
private val renderer = EventHtmlRenderer(
- MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences),
+ MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences, fakeDimensionConverter),
context,
fakeVectorPreferences,
fakeSessionHolder,
diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml
index 66361c1ca1..9b82818aea 100644
--- a/vector/src/main/AndroidManifest.xml
+++ b/vector/src/main/AndroidManifest.xml
@@ -332,6 +332,7 @@
+
diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt
index 911bbfa4a3..35d8d0e896 100644
--- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt
+++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt
@@ -85,6 +85,7 @@ import im.vector.app.features.roomprofile.members.RoomMemberListViewModel
import im.vector.app.features.roomprofile.notifications.RoomNotificationSettingsViewModel
import im.vector.app.features.roomprofile.permissions.RoomPermissionsViewModel
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
+import im.vector.app.features.roomprofile.polls.detail.ui.RoomPollDetailViewModel
import im.vector.app.features.roomprofile.settings.RoomSettingsViewModel
import im.vector.app.features.roomprofile.settings.joinrule.advanced.RoomJoinRuleChooseRestrictedViewModel
import im.vector.app.features.roomprofile.uploads.RoomUploadsViewModel
@@ -107,7 +108,8 @@ import im.vector.app.features.settings.ignored.IgnoredUsersViewModel
import im.vector.app.features.settings.labs.VectorSettingsLabsViewModel
import im.vector.app.features.settings.legals.LegalsViewModel
import im.vector.app.features.settings.locale.LocalePickerViewModel
-import im.vector.app.features.settings.notifications.VectorSettingsNotificationPreferenceViewModel
+import im.vector.app.features.settings.notifications.VectorSettingsNotificationViewModel
+import im.vector.app.features.settings.notifications.VectorSettingsPushRuleNotificationViewModel
import im.vector.app.features.settings.push.PushGatewaysViewModel
import im.vector.app.features.settings.threepids.ThreePidsSettingsViewModel
import im.vector.app.features.share.IncomingShareViewModel
@@ -689,9 +691,16 @@ interface MavericksViewModelModule {
@Binds
@IntoMap
- @MavericksViewModelKey(VectorSettingsNotificationPreferenceViewModel::class)
+ @MavericksViewModelKey(VectorSettingsNotificationViewModel::class)
fun vectorSettingsNotificationPreferenceViewModelFactory(
- factory: VectorSettingsNotificationPreferenceViewModel.Factory
+ factory: VectorSettingsNotificationViewModel.Factory
+ ): MavericksAssistedViewModelFactory<*, *>
+
+ @Binds
+ @IntoMap
+ @MavericksViewModelKey(VectorSettingsPushRuleNotificationViewModel::class)
+ fun vectorSettingsPushRuleNotificationPreferenceViewModelFactory(
+ factory: VectorSettingsPushRuleNotificationViewModel.Factory
): MavericksAssistedViewModelFactory<*, *>
@Binds
@@ -703,4 +712,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(RoomPollsViewModel::class)
fun roomPollsViewModelFactory(factory: RoomPollsViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
+
+ @Binds
+ @IntoMap
+ @MavericksViewModelKey(RoomPollDetailViewModel::class)
+ fun roomPollDetailViewModelFactory(factory: RoomPollDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}
diff --git a/vector/src/main/java/im/vector/app/core/event/GetTimelineEventUseCase.kt b/vector/src/main/java/im/vector/app/core/event/GetTimelineEventUseCase.kt
new file mode 100644
index 0000000000..4265aac53e
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/event/GetTimelineEventUseCase.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.core.event
+
+import androidx.lifecycle.asFlow
+import im.vector.app.core.di.ActiveSessionHolder
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import org.matrix.android.sdk.api.session.getRoom
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import org.matrix.android.sdk.flow.unwrap
+import javax.inject.Inject
+
+class GetTimelineEventUseCase @Inject constructor(
+ private val activeSessionHolder: ActiveSessionHolder,
+) {
+
+ fun execute(roomId: String, eventId: String): Flow {
+ return activeSessionHolder.getActiveSession().getRoom(roomId)
+ ?.timelineService()
+ ?.getTimelineEventLive(eventId)
+ ?.asFlow()
+ ?.unwrap()
+ ?: emptyFlow()
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/core/notification/PushRulesUpdater.kt b/vector/src/main/java/im/vector/app/core/notification/PushRulesUpdater.kt
new file mode 100644
index 0000000000..4925941f92
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/notification/PushRulesUpdater.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.core.notification
+
+import im.vector.app.features.session.coroutineScope
+import im.vector.app.features.settings.notifications.usecase.UpdatePushRulesIfNeededUseCase
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes
+import org.matrix.android.sdk.flow.flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Listen changes in Account Data to update the push rules if needed.
+ */
+@Singleton
+class PushRulesUpdater @Inject constructor(
+ private val updatePushRulesIfNeededUseCase: UpdatePushRulesIfNeededUseCase,
+) {
+
+ private var job: Job? = null
+
+ fun onSessionStarted(session: Session) {
+ updatePushRulesOnChange(session)
+ }
+
+ private fun updatePushRulesOnChange(session: Session) {
+ job?.cancel()
+ job = session.coroutineScope.launch {
+ session.flow()
+ .liveUserAccountData(UserAccountDataTypes.TYPE_PUSH_RULES)
+ .onEach { updatePushRulesIfNeededUseCase.execute(session) }
+ .collect()
+ }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt
index c6a2635e6c..b9573e9292 100644
--- a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt
+++ b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt
@@ -20,6 +20,7 @@ import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import im.vector.app.core.extensions.startSyncing
import im.vector.app.core.notification.NotificationsSettingUpdater
+import im.vector.app.core.notification.PushRulesUpdater
import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.session.coroutineScope
@@ -37,6 +38,7 @@ class ConfigureAndStartSessionUseCase @Inject constructor(
private val vectorPreferences: VectorPreferences,
private val notificationsSettingUpdater: NotificationsSettingUpdater,
private val updateNotificationSettingsAccountDataUseCase: UpdateNotificationSettingsAccountDataUseCase,
+ private val pushRulesUpdater: PushRulesUpdater,
) {
fun execute(session: Session, startSyncing: Boolean = true) {
@@ -50,6 +52,7 @@ class ConfigureAndStartSessionUseCase @Inject constructor(
updateMatrixClientInfoIfNeeded(session)
createNotificationSettingsAccountDataIfNeeded(session)
notificationsSettingUpdater.onSessionStarted(session)
+ pushRulesUpdater.onSessionStarted(session)
}
private fun updateMatrixClientInfoIfNeeded(session: Session) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index 550e1b496a..04e39cb000 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -57,6 +57,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyViewHolder
+import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.epoxy.OnModelBuildFinishedListener
import com.airbnb.epoxy.addGlidePreloader
import com.airbnb.epoxy.glidePreloader
@@ -89,7 +90,6 @@ import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.extensions.restart
import im.vector.app.core.extensions.setTextOrHide
-import im.vector.app.core.extensions.trackItemsVisibilityChange
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.glide.GlideRequests
import im.vector.app.core.intent.getFilenameFromUri
@@ -296,6 +296,7 @@ class TimelineFragment :
private val timelineViewModel: TimelineViewModel by fragmentViewModel()
private val messageComposerViewModel: MessageComposerViewModel by fragmentViewModel()
private val debouncer = Debouncer(createUIHandler())
+ private val itemVisibilityTracker = EpoxyVisibilityTracker()
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback
@@ -1162,11 +1163,11 @@ class TimelineFragment :
override fun onResume() {
super.onResume()
+ itemVisibilityTracker.attach(views.timelineRecyclerView)
notificationDrawerManager.setCurrentRoom(timelineArgs.roomId)
notificationDrawerManager.setCurrentThread(timelineArgs.threadTimelineArgs?.rootThreadEventId)
roomDetailPendingActionStore.data?.let { handlePendingAction(it) }
roomDetailPendingActionStore.data = null
- views.timelineRecyclerView.adapter = timelineEventController.adapter
}
private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) {
@@ -1183,9 +1184,9 @@ class TimelineFragment :
override fun onPause() {
super.onPause()
+ itemVisibilityTracker.detach(views.timelineRecyclerView)
notificationDrawerManager.setCurrentRoom(null)
notificationDrawerManager.setCurrentThread(null)
- views.timelineRecyclerView.adapter = null
}
private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult ->
@@ -1298,7 +1299,6 @@ class TimelineFragment :
)
}
- views.timelineRecyclerView.trackItemsVisibilityChange()
layoutManager = object : BetterLinearLayoutManager(requireContext(), RecyclerView.VERTICAL, true) {
override fun onLayoutCompleted(state: RecyclerView.State) {
super.onLayoutCompleted(state)
@@ -1337,6 +1337,7 @@ class TimelineFragment :
super.onScrolled(recyclerView, dx, dy)
}
})
+ views.timelineRecyclerView.adapter = timelineEventController.adapter
if (vectorPreferences.swipeToReplyIsEnabled()) {
val quickReplyHandler = object : RoomMessageTouchHelperCallback.QuickReplayHandler {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index 350e2a9d6c..25a62cc6d8 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
@@ -57,6 +57,7 @@ import im.vector.app.features.crypto.verification.SupportedVerificationMethodsPr
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.error.RoomNotFound
import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase
+import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
@@ -103,7 +104,6 @@ import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.EventType
-import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode
import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage
@@ -176,6 +176,7 @@ class TimelineViewModel @AssistedInject constructor(
htmlRenderer: EventHtmlRenderer,
spanUtils: SpanUtils,
imageContentRenderer: ImageContentRenderer,
+ private val voteToPollUseCase: VoteToPollUseCase,
) : VectorViewModel(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback,
ReplyPreviewRetriever.PowerLevelProvider, ReplyPreviewRetriever.PreviewReplyRetrieverCallback {
@@ -1346,15 +1347,11 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleVoteToPoll(action: RoomDetailAction.VoteToPoll) {
if (room == null) return
- // Do not allow to vote unsent local echo of the poll event
- if (LocalEcho.isLocalEchoId(action.eventId)) return
- // Do not allow to vote the same option twice
- room.getTimelineEvent(action.eventId)?.let { pollTimelineEvent ->
- val currentVote = pollTimelineEvent.annotations?.pollResponseSummary?.aggregatedContent?.myVote
- if (currentVote != action.optionKey) {
- room.sendService().voteToPoll(action.eventId, action.optionKey)
- }
- }
+ voteToPollUseCase.execute(
+ roomId = room.roomId,
+ pollEventId = action.eventId,
+ optionId = action.optionKey,
+ )
}
private fun handleEndPoll(eventId: String) {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
index 347be1c520..73063ea49e 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
@@ -233,6 +233,27 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.STRIKE_THROUGH) {
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough)
}
+ addRichTextMenuItem(R.drawable.ic_composer_bullet_list, R.string.rich_text_editor_bullet_list, ComposerAction.UNORDERED_LIST) {
+ views.richTextComposerEditText.toggleList(ordered = false)
+ }
+ addRichTextMenuItem(R.drawable.ic_composer_numbered_list, R.string.rich_text_editor_numbered_list, ComposerAction.ORDERED_LIST) {
+ views.richTextComposerEditText.toggleList(ordered = true)
+ }
+ addRichTextMenuItem(R.drawable.ic_composer_indent, R.string.rich_text_editor_indent, ComposerAction.INDENT) {
+ views.richTextComposerEditText.indent()
+ }
+ addRichTextMenuItem(R.drawable.ic_composer_unindent, R.string.rich_text_editor_unindent, ComposerAction.UNINDENT) {
+ views.richTextComposerEditText.unindent()
+ }
+ addRichTextMenuItem(R.drawable.ic_composer_quote, R.string.rich_text_editor_quote, ComposerAction.QUOTE) {
+ views.richTextComposerEditText.toggleQuote()
+ }
+ addRichTextMenuItem(R.drawable.ic_composer_inline_code, R.string.rich_text_editor_inline_code, ComposerAction.INLINE_CODE) {
+ views.richTextComposerEditText.toggleInlineFormat(InlineFormat.InlineCode)
+ }
+ addRichTextMenuItem(R.drawable.ic_composer_code_block, R.string.rich_text_editor_code_block, ComposerAction.CODE_BLOCK) {
+ views.richTextComposerEditText.toggleCodeBlock()
+ }
addRichTextMenuItem(R.drawable.ic_composer_link, R.string.rich_text_editor_link, ComposerAction.LINK) {
views.richTextComposerEditText.getLinkAction()?.let {
when (it) {
@@ -241,15 +262,6 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
}
}
}
- addRichTextMenuItem(R.drawable.ic_composer_bullet_list, R.string.rich_text_editor_bullet_list, ComposerAction.UNORDERED_LIST) {
- views.richTextComposerEditText.toggleList(ordered = false)
- }
- addRichTextMenuItem(R.drawable.ic_composer_numbered_list, R.string.rich_text_editor_numbered_list, ComposerAction.ORDERED_LIST) {
- views.richTextComposerEditText.toggleList(ordered = true)
- }
- addRichTextMenuItem(R.drawable.ic_composer_inline_code, R.string.rich_text_editor_inline_code, ComposerAction.INLINE_CODE) {
- views.richTextComposerEditText.toggleInlineFormat(InlineFormat.InlineCode)
- }
}
fun setLink(link: String?) =
@@ -332,11 +344,11 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
* Updates the non-active input with the contents of the active input.
*/
private fun syncEditTexts() =
- if (isTextFormattingEnabled) {
- views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown())
- } else {
- views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString())
- }
+ if (isTextFormattingEnabled) {
+ views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown())
+ } else {
+ views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString())
+ }
private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) {
val inflater = LayoutInflater.from(context)
@@ -356,6 +368,13 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
val stateForAction = menuState[action]
button.isEnabled = stateForAction != ActionState.DISABLED
button.isSelected = stateForAction == ActionState.REVERSED
+
+ if (action == ComposerAction.INDENT || action == ComposerAction.UNINDENT) {
+ val indentationButtonIsVisible =
+ menuState[ComposerAction.ORDERED_LIST] == ActionState.REVERSED ||
+ menuState[ComposerAction.UNORDERED_LIST] == ActionState.REVERSED
+ button.isVisible = indentationButtonIsVisible
+ }
}
fun estimateCollapsedHeight(): Int {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/poll/VoteToPollUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/poll/VoteToPollUseCase.kt
new file mode 100644
index 0000000000..62f8006988
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/poll/VoteToPollUseCase.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.home.room.detail.poll
+
+import im.vector.app.core.di.ActiveSessionHolder
+import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.getRoom
+import org.matrix.android.sdk.api.session.room.getTimelineEvent
+import timber.log.Timber
+import javax.inject.Inject
+
+class VoteToPollUseCase @Inject constructor(
+ private val activeSessionHolder: ActiveSessionHolder,
+) {
+
+ fun execute(roomId: String, pollEventId: String, optionId: String) {
+ // Do not allow to vote unsent local echo of the poll event
+ if (LocalEcho.isLocalEchoId(pollEventId)) return
+
+ runCatching {
+ val room = activeSessionHolder.getActiveSession().getRoom(roomId)
+ room?.getTimelineEvent(pollEventId)?.let { pollTimelineEvent ->
+ val currentVote = pollTimelineEvent
+ .annotations
+ ?.pollResponseSummary
+ ?.aggregatedContent
+ ?.myVote
+ if (currentVote != optionId) {
+ room.sendService().voteToPoll(
+ pollEventId = pollEventId,
+ answerId = optionId
+ )
+ }
+ }
+ }.onFailure { Timber.w("Failed to vote in poll with id $pollEventId in room with id $roomId") }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
index 36b6f82eae..960fdf2e4a 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
@@ -166,8 +166,8 @@ class MessageItemFactory @Inject constructor(
textRendererFactory.create(roomId)
}
- private val useRichTextEditorStyle: Boolean get() =
- vectorPreferences.isRichTextEditorEnabled()
+ private val useRichTextEditorStyle: Boolean
+ get() = vectorPreferences.isRichTextEditorEnabled()
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
val event = params.event
@@ -265,12 +265,16 @@ class MessageItemFactory @Inject constructor(
attributes: AbsMessageItem.Attributes,
isEnded: Boolean,
): PollItem {
- val pollViewState = pollItemViewStateFactory.create(pollContent, informationData)
+ val pollViewState = pollItemViewStateFactory.create(
+ pollContent = pollContent,
+ pollResponseData = informationData.pollResponseAggregatedSummary,
+ isSent = informationData.sendState.isSent(),
+ )
return PollItem_()
.attributes(attributes)
.eventId(informationData.eventId)
- .pollQuestion(createPollQuestion(informationData, pollViewState.question, callback))
+ .pollTitle(createPollQuestion(informationData, pollViewState.question, callback))
.canVote(pollViewState.canVote)
.votesStatus(pollViewState.votesStatus)
.optionViewStates(pollViewState.optionViewStates.orEmpty())
@@ -290,21 +294,37 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
- ): PollItem? {
- pollStartEventId ?: return null.also {
- Timber.e("### buildEndedPollItem. Cannot render poll end event because poll start event id is null")
+ ): PollItem {
+ val pollStartEvent = if (pollStartEventId?.isNotEmpty() == true) {
+ session.roomService().getRoom(roomId)?.getTimelineEvent(pollStartEventId)
+ } else {
+ null
}
- val pollStartEvent = session.roomService().getRoom(roomId)?.getTimelineEvent(pollStartEventId)
- val pollContent = pollStartEvent?.root?.getClearContent()?.toModel() ?: return null
+ val pollContent = pollStartEvent?.root?.getClearContent()?.toModel()
- return buildPollItem(
- pollContent,
- informationData,
- highlight,
- callback,
- attributes,
- isEnded = true
- )
+ return if (pollContent == null) {
+ val title = stringProvider.getString(R.string.message_reply_to_ended_poll_preview).toEpoxyCharSequence()
+ PollItem_()
+ .attributes(attributes)
+ .eventId(informationData.eventId)
+ .pollTitle(title)
+ .optionViewStates(emptyList())
+ .edited(informationData.hasBeenEdited)
+ .ended(true)
+ .hasContent(false)
+ .highlighted(highlight)
+ .leftGuideline(avatarSizeProvider.leftGuideline)
+ .callback(callback)
+ } else {
+ buildPollItem(
+ pollContent,
+ informationData,
+ highlight,
+ callback,
+ attributes,
+ isEnded = true,
+ )
+ }
}
private fun createPollQuestion(
@@ -511,7 +531,6 @@ class MessageItemFactory @Inject constructor(
highlight,
callback,
attributes,
- useRichTextEditorStyle = vectorPreferences.isRichTextEditorEnabled(),
)
}
@@ -636,7 +655,7 @@ class MessageItemFactory @Inject constructor(
val replyToContent = messageContent.relatesTo?.inReplyTo
buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent)
} else {
- buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes, useRichTextEditorStyle)
+ buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}
}
@@ -664,7 +683,6 @@ class MessageItemFactory @Inject constructor(
highlight,
callback,
attributes,
- useRichTextEditorStyle,
pseudoEmojiBody,
)
}
@@ -676,7 +694,6 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
- useRichTextEditorStyle: Boolean,
emojiCheckCharSequence: CharSequence? = null,
): MessageTextItem? {
val renderedBody = textRenderer.render(body)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt
index 3c1a1cfd85..b630a514e4 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt
@@ -18,9 +18,8 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
-import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
-import im.vector.app.features.poll.PollViewState
+import im.vector.app.features.poll.PollItemViewState
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
@@ -33,27 +32,25 @@ class PollItemViewStateFactory @Inject constructor(
fun create(
pollContent: MessagePollContent,
- informationData: MessageInformationData,
- ): PollViewState {
+ pollResponseData: PollResponseData?,
+ isSent: Boolean,
+ ): PollItemViewState {
val pollCreationInfo = pollContent.getBestPollCreationInfo()
-
val question = pollCreationInfo?.question?.getBestQuestion().orEmpty()
-
- val pollResponseSummary = informationData.pollResponseAggregatedSummary
- val totalVotes = pollResponseSummary?.totalVotes ?: 0
+ val totalVotes = pollResponseData?.totalVotes ?: 0
return when {
- !informationData.sendState.isSent() -> {
+ !isSent -> {
createSendingPollViewState(question, pollCreationInfo)
}
- informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> {
- createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
+ pollResponseData?.isClosed.orFalse() -> {
+ createEndedPollViewState(question, pollCreationInfo, pollResponseData, totalVotes)
}
pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> {
- createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary)
+ createUndisclosedPollViewState(question, pollCreationInfo, pollResponseData)
}
- informationData.pollResponseAggregatedSummary?.myVote?.isNotEmpty().orFalse() -> {
- createVotedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
+ pollResponseData?.myVote?.isNotEmpty().orFalse() -> {
+ createVotedPollViewState(question, pollCreationInfo, pollResponseData, totalVotes)
}
else -> {
createReadyPollViewState(question, pollCreationInfo, totalVotes)
@@ -61,8 +58,8 @@ class PollItemViewStateFactory @Inject constructor(
}
}
- private fun createSendingPollViewState(question: String, pollCreationInfo: PollCreationInfo?): PollViewState {
- return PollViewState(
+ private fun createSendingPollViewState(question: String, pollCreationInfo: PollCreationInfo?): PollItemViewState {
+ return PollItemViewState(
question = question,
votesStatus = stringProvider.getString(R.string.poll_no_votes_cast),
canVote = false,
@@ -73,51 +70,51 @@ class PollItemViewStateFactory @Inject constructor(
private fun createEndedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
- pollResponseSummary: PollResponseData?,
+ pollResponseData: PollResponseData?,
totalVotes: Int,
- ): PollViewState {
- val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
+ ): PollItemViewState {
+ val totalVotesText = if (pollResponseData?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes)
}
- return PollViewState(
+ return PollItemViewState(
question = question,
votesStatus = totalVotesText,
canVote = false,
- optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseSummary),
+ optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseData),
)
}
private fun createUndisclosedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
- pollResponseSummary: PollResponseData?
- ): PollViewState {
- return PollViewState(
+ pollResponseData: PollResponseData?
+ ): PollItemViewState {
+ return PollItemViewState(
question = question,
votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended),
canVote = true,
- optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseSummary),
+ optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseData),
)
}
private fun createVotedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
- pollResponseSummary: PollResponseData?,
+ pollResponseData: PollResponseData?,
totalVotes: Int
- ): PollViewState {
- val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
+ ): PollItemViewState {
+ val totalVotesText = if (pollResponseData?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes)
}
- return PollViewState(
+ return PollItemViewState(
question = question,
votesStatus = totalVotesText,
canVote = true,
- optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseSummary),
+ optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseData),
)
}
@@ -125,13 +122,13 @@ class PollItemViewStateFactory @Inject constructor(
question: String,
pollCreationInfo: PollCreationInfo?,
totalVotes: Int
- ): PollViewState {
+ ): PollItemViewState {
val totalVotesText = if (totalVotes == 0) {
stringProvider.getString(R.string.poll_no_votes_cast)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, totalVotes, totalVotes)
}
- return PollViewState(
+ return PollItemViewState(
question = question,
votesStatus = totalVotesText,
canVote = true,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt
index 6fe19e9762..220e422365 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt
@@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.item
+import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.children
@@ -23,6 +24,7 @@ import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
+import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
@@ -31,7 +33,7 @@ import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
abstract class PollItem : AbsMessageItem() {
@EpoxyAttribute
- var pollQuestion: EpoxyCharSequence? = null
+ var pollTitle: EpoxyCharSequence? = null
@EpoxyAttribute
var callback: TimelineEventController.Callback? = null
@@ -54,6 +56,9 @@ abstract class PollItem : AbsMessageItem() {
@EpoxyAttribute
var ended: Boolean = false
+ @EpoxyAttribute
+ var hasContent: Boolean = true
+
override fun getViewStubId() = STUB_ID
override fun bind(holder: Holder) {
@@ -61,8 +66,8 @@ abstract class PollItem : AbsMessageItem() {
renderSendState(holder.view, holder.questionTextView)
- holder.questionTextView.text = pollQuestion?.charSequence
- holder.votesStatusTextView.text = votesStatus
+ holder.questionTextView.text = pollTitle?.charSequence
+ holder.votesStatusTextView.setTextOrHide(votesStatus)
while (holder.optionsContainer.childCount < optionViewStates.size) {
holder.optionsContainer.addView(PollOptionView(holder.view.context))
@@ -80,7 +85,8 @@ abstract class PollItem : AbsMessageItem() {
}
}
- holder.endedPollTextView.isVisible = ended
+ holder.endedPollTextView.isVisible = ended && hasContent
+ holder.pollIcon.isVisible = ended && hasContent.not()
}
private fun onPollItemClick(optionViewState: PollOptionViewState) {
@@ -96,6 +102,7 @@ abstract class PollItem : AbsMessageItem() {
val optionsContainer by bind(R.id.optionsContainer)
val votesStatusTextView by bind(R.id.optionsVotesStatusTextView)
val endedPollTextView by bind(R.id.endedPollTextView)
+ val pollIcon by bind(R.id.timelinePollIcon)
}
companion object {
diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt
index 08a74aa3ac..a678af8bb0 100644
--- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt
+++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt
@@ -394,6 +394,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(
private val colorProvider: ColorProvider,
private val resources: Resources,
private val vectorPreferences: VectorPreferences,
+ private val dimensionConverter: DimensionConverter,
) : HtmlPlugin.HtmlConfigure {
override fun configureHtml(plugin: HtmlPlugin) {
@@ -404,7 +405,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(
.addHandler(ParagraphHandler(DimensionConverter(resources)))
// Note: only for fallback replies, which we should have removed by now
.addHandler(MxReplyTagHandler())
- .addHandler(CodePostProcessorTagHandler(vectorPreferences))
+ .addHandler(CodePostProcessorTagHandler(vectorPreferences, dimensionConverter))
.addHandler(CodePreTagHandler())
.addHandler(CodeTagHandler())
.addHandler(SpanHandler(colorProvider))
diff --git a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt
index 295b74c7a9..3175996ba1 100644
--- a/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt
+++ b/vector/src/main/java/im/vector/app/features/html/HtmlCodeHandlers.kt
@@ -16,7 +16,9 @@
package im.vector.app.features.html
+import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.settings.VectorPreferences
+import io.element.android.wysiwyg.spans.CodeBlockSpan
import io.element.android.wysiwyg.spans.InlineCodeSpan
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.SpannableBuilder
@@ -68,6 +70,7 @@ internal class CodePreTagHandler : TagHandler() {
internal class CodePostProcessorTagHandler(
private val vectorPreferences: VectorPreferences,
+ private val dimensionConverter: DimensionConverter,
) : TagHandler() {
override fun supportedTags() = listOf(HtmlRootTagPlugin.ROOT_TAG_NAME)
@@ -90,6 +93,7 @@ internal class CodePostProcessorTagHandler(
val intermediateCodeSpan = code.what as IntermediateCodeSpan
val theme = visitor.configuration().theme()
val span = intermediateCodeSpan.toFinalCodeSpan(theme)
+
SpannableBuilder.setSpans(
visitor.builder(), span, code.start, code.end
)
@@ -98,9 +102,15 @@ internal class CodePostProcessorTagHandler(
private fun IntermediateCodeSpan.toFinalCodeSpan(
markwonTheme: MarkwonTheme
- ): Any = if (vectorPreferences.isRichTextEditorEnabled() && !isBlock) {
- InlineCodeSpan()
+ ): Any = if (vectorPreferences.isRichTextEditorEnabled()) {
+ toRichTextEditorSpan()
} else {
HtmlCodeSpan(markwonTheme, isBlock)
}
+
+ private fun IntermediateCodeSpan.toRichTextEditorSpan() = if (isBlock) {
+ CodeBlockSpan(dimensionConverter.dpToPx(10), dimensionConverter.dpToPx(4))
+ } else {
+ InlineCodeSpan()
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationDialog.kt b/vector/src/main/java/im/vector/app/features/location/LocationDialog.kt
new file mode 100644
index 0000000000..81ce75e57d
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/LocationDialog.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.location
+
+import androidx.fragment.app.Fragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import im.vector.app.R
+
+fun Fragment.showUserLocationNotAvailableErrorDialog(onConfirmListener: () -> Unit) {
+ MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(R.string.location_not_available_dialog_title)
+ .setMessage(R.string.location_not_available_dialog_content)
+ .setPositiveButton(R.string.ok) { _, _ ->
+ onConfirmListener()
+ }
+ .setCancelable(false)
+ .show()
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
index 779818b3d6..0fdf9d04cd 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt
@@ -176,14 +176,7 @@ class LocationSharingFragment :
}
private fun handleLocationNotAvailableError() {
- MaterialAlertDialogBuilder(requireActivity())
- .setTitle(R.string.location_not_available_dialog_title)
- .setMessage(R.string.location_not_available_dialog_content)
- .setPositiveButton(R.string.ok) { _, _ ->
- locationSharingNavigator.quit()
- }
- .setCancelable(false)
- .show()
+ showUserLocationNotAvailableErrorDialog { locationSharingNavigator.quit() }
}
private fun handleLiveLocationSharingNotEnoughPermission() {
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
index c7a2349afa..e11bfbf16e 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt
@@ -47,7 +47,7 @@ data class LocationSharingViewState(
fun LocationSharingViewState.toMapState() = MapState(
zoomOnlyOnce = true,
- userLocationData = lastKnownUserLocation,
+ pinLocationData = lastKnownUserLocation,
pinId = DEFAULT_PIN_ID,
pinDrawable = null,
// show the map pin only when target location and user location are not equal
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt
index c617277f3f..f78b5e4311 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt
@@ -66,6 +66,8 @@ class LocationTracker @Inject constructor(
@VisibleForTesting
var hasLocationFromGPSProvider = false
+ private var isStarted = false
+ private var isStarting = false
private var firstLocationHandled = false
private val _locations = MutableSharedFlow(replay = 1)
@@ -90,43 +92,48 @@ class LocationTracker @Inject constructor(
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
- Timber.d("start()")
+ if (!isStarting && !isStarted) {
+ isStarting = true
+ Timber.d("start()")
- if (locationManager == null) {
- Timber.v("LocationManager is not available")
- onNoLocationProviderAvailable()
- return
- }
+ if (locationManager == null) {
+ Timber.v("LocationManager is not available")
+ onNoLocationProviderAvailable()
+ return
+ }
- val providers = locationManager.allProviders
+ val providers = locationManager.allProviders
- if (providers.isEmpty()) {
- Timber.v("There is no location provider available")
- onNoLocationProviderAvailable()
- } else {
- // Take GPS first
- providers.sortedByDescending(::getProviderPriority)
- .mapNotNull { provider ->
- Timber.d("track location using $provider")
+ if (providers.isEmpty()) {
+ Timber.v("There is no location provider available")
+ onNoLocationProviderAvailable()
+ } else {
+ // Take GPS first
+ providers.sortedByDescending(::getProviderPriority)
+ .mapNotNull { provider ->
+ Timber.d("track location using $provider")
- locationManager.requestLocationUpdates(
- provider,
- minDurationToUpdateLocationMillis,
- MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
- this
- )
+ locationManager.requestLocationUpdates(
+ provider,
+ minDurationToUpdateLocationMillis,
+ MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
+ this
+ )
- locationManager.getLastKnownLocation(provider)
- }
- .maxByOrNull { location -> location.time }
- ?.let { latestKnownLocation ->
- if (buildMeta.lowPrivacyLoggingEnabled) {
- Timber.d("lastKnownLocation: $latestKnownLocation")
- } else {
- Timber.d("lastKnownLocation: ${latestKnownLocation.provider}")
+ locationManager.getLastKnownLocation(provider)
}
- notifyLocation(latestKnownLocation)
- }
+ .maxByOrNull { location -> location.time }
+ ?.let { latestKnownLocation ->
+ if (buildMeta.lowPrivacyLoggingEnabled) {
+ Timber.d("lastKnownLocation: $latestKnownLocation")
+ } else {
+ Timber.d("lastKnownLocation: ${latestKnownLocation.provider}")
+ }
+ notifyLocation(latestKnownLocation)
+ }
+ }
+ isStarted = true
+ isStarting = false
}
}
@@ -148,6 +155,8 @@ class LocationTracker @Inject constructor(
callbacks.clear()
hasLocationFromGPSProvider = false
hasLocationFromFusedProvider = false
+ isStarting = false
+ isStarted = false
}
/**
diff --git a/vector/src/main/java/im/vector/app/features/location/MapState.kt b/vector/src/main/java/im/vector/app/features/location/MapState.kt
index c4325291a8..2224317b02 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapState.kt
@@ -21,9 +21,10 @@ import androidx.annotation.Px
data class MapState(
val zoomOnlyOnce: Boolean,
- val userLocationData: LocationData? = null,
+ val pinLocationData: LocationData? = null,
val pinId: String,
val pinDrawable: Drawable? = null,
val showPin: Boolean = true,
- @Px val logoMarginBottom: Int = 0
+ val userLocationData: LocationData? = null,
+ @Px val logoMarginBottom: Int = 0,
)
diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
index b001621bf4..d7e3463a5c 100644
--- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
+++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt
@@ -18,6 +18,7 @@ package im.vector.app.features.location
import android.content.Context
import android.content.res.TypedArray
+import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.Gravity
import android.widget.ImageView
@@ -38,6 +39,8 @@ import im.vector.app.R
import im.vector.app.core.utils.DimensionConverter
import timber.log.Timber
+private const val USER_PIN_ID = "user-pin-id"
+
class MapTilerMapView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -101,9 +104,11 @@ class MapTilerMapView @JvmOverloads constructor(
private fun initMapStyle(map: MapboxMap, url: String) {
map.setStyle(url) { style ->
+ val symbolManager = SymbolManager(this, map, style)
+ symbolManager.iconAllowOverlap = true
mapRefs = MapRefs(
map,
- SymbolManager(this, map, style),
+ symbolManager,
style
)
pendingState?.let { render(it) }
@@ -166,29 +171,43 @@ class MapTilerMapView @JvmOverloads constructor(
}
val pinDrawable = state.pinDrawable ?: userLocationDrawable
- pinDrawable?.let { drawable ->
- if (!safeMapRefs.style.isFullyLoaded ||
- safeMapRefs.style.getImage(state.pinId) == null) {
- safeMapRefs.style.addImage(state.pinId, drawable.toBitmap())
- }
- }
+ addImageToMapStyle(pinDrawable, state.pinId, safeMapRefs)
- state.userLocationData?.let { locationData ->
+ safeMapRefs.symbolManager.deleteAll()
+ state.pinLocationData?.let { locationData ->
if (!initZoomDone || !state.zoomOnlyOnce) {
zoomToLocation(locationData)
initZoomDone = true
}
- safeMapRefs.symbolManager.deleteAll()
if (pinDrawable != null && state.showPin) {
- safeMapRefs.symbolManager.create(
- SymbolOptions()
- .withLatLng(LatLng(locationData.latitude, locationData.longitude))
- .withIconImage(state.pinId)
- .withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
- )
+ createSymbol(locationData, state.pinId, safeMapRefs)
}
}
+
+ state.userLocationData?.let { locationData ->
+ addImageToMapStyle(userLocationDrawable, USER_PIN_ID, safeMapRefs)
+ if (userLocationDrawable != null) {
+ createSymbol(locationData, USER_PIN_ID, safeMapRefs)
+ }
+ }
+ }
+
+ private fun addImageToMapStyle(image: Drawable?, imageId: String, mapRefs: MapRefs) {
+ image?.let { drawable ->
+ if (!mapRefs.style.isFullyLoaded || mapRefs.style.getImage(imageId) == null) {
+ mapRefs.style.addImage(imageId, drawable.toBitmap())
+ }
+ }
+ }
+
+ private fun createSymbol(locationData: LocationData, imageId: String, mapRefs: MapRefs) {
+ mapRefs.symbolManager.create(
+ SymbolOptions()
+ .withLatLng(LatLng(locationData.latitude, locationData.longitude))
+ .withIconImage(imageId)
+ .withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
+ )
}
fun zoomToLocation(locationData: LocationData) {
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt
index 295d6b5d41..4bb86c8f53 100644
--- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapAction.kt
@@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction {
data class RemoveMapSymbol(val key: String) : LiveLocationMapAction()
object StopSharing : LiveLocationMapAction()
object ShowMapLoadingError : LiveLocationMapAction()
+ object ZoomToUserLocation : LiveLocationMapAction()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt
index 2c4f34dce0..89a300a2e2 100644
--- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewEvents.kt
@@ -17,7 +17,10 @@
package im.vector.app.features.location.live.map
import im.vector.app.core.platform.VectorViewEvents
+import im.vector.app.features.location.LocationData
sealed interface LiveLocationMapViewEvents : VectorViewEvents {
- data class Error(val error: Throwable) : LiveLocationMapViewEvents
+ data class LiveLocationError(val error: Throwable) : LiveLocationMapViewEvents
+ data class ZoomToUserLocation(val userLocation: LocationData) : LiveLocationMapViewEvents
+ object UserLocationNotAvailableError : LiveLocationMapViewEvents
}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt
index 942021dd64..3c02d5d87d 100644
--- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewFragment.kt
@@ -24,6 +24,8 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isGone
import androidx.core.view.isVisible
+import androidx.core.view.marginBottom
+import androidx.core.view.marginTop
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
@@ -46,11 +48,17 @@ import im.vector.app.core.extensions.addChildFragment
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
+import im.vector.app.core.resources.DrawableProvider
import im.vector.app.core.utils.DimensionConverter
+import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
+import im.vector.app.core.utils.checkPermissions
+import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.openLocation
+import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentLiveLocationMapViewBinding
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.UrlMapProvider
+import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog
import im.vector.app.features.location.zoomToBounds
import im.vector.app.features.location.zoomToLocation
import kotlinx.coroutines.launch
@@ -58,6 +66,8 @@ import timber.log.Timber
import java.lang.ref.WeakReference
import javax.inject.Inject
+private const val USER_LOCATION_PIN_ID = "user-location-pin-id"
+
/**
* Screen showing a map with all the current users sharing their live location in a room.
*/
@@ -68,6 +78,7 @@ class LiveLocationMapViewFragment :
@Inject lateinit var urlMapProvider: UrlMapProvider
@Inject lateinit var bottomSheetController: LiveLocationBottomSheetController
@Inject lateinit var dimensionConverter: DimensionConverter
+ @Inject lateinit var drawableProvider: DrawableProvider
private val viewModel: LiveLocationMapViewModel by fragmentViewModel()
@@ -75,7 +86,7 @@ class LiveLocationMapViewFragment :
private var mapView: MapView? = null
private var symbolManager: SymbolManager? = null
private var mapStyle: Style? = null
- private val pendingLiveLocations = mutableListOf()
+ private val userLocationDrawable by lazy { drawableProvider.getDrawable(R.drawable.ic_location_user) }
private var isMapFirstUpdate = true
private var onSymbolClickListener: OnSymbolClickListener? = null
private var mapLoadingErrorListener: MapView.OnDidFailLoadingMapListener? = null
@@ -88,6 +99,7 @@ class LiveLocationMapViewFragment :
super.onViewCreated(view, savedInstanceState)
observeViewEvents()
setupMap()
+ initLocateButton()
views.liveLocationBottomSheetRecyclerView.configureWith(bottomSheetController, hasFixedSize = false, disableItemAnimation = true)
@@ -105,11 +117,23 @@ class LiveLocationMapViewFragment :
private fun observeViewEvents() {
viewModel.observeViewEvents { viewEvent ->
when (viewEvent) {
- is LiveLocationMapViewEvents.Error -> displayErrorDialog(viewEvent.error)
+ is LiveLocationMapViewEvents.LiveLocationError -> displayErrorDialog(viewEvent.error)
+ is LiveLocationMapViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(viewEvent)
+ LiveLocationMapViewEvents.UserLocationNotAvailableError -> handleUserLocationNotAvailableError()
}
}
}
+ private fun handleZoomToUserLocationEvent(event: LiveLocationMapViewEvents.ZoomToUserLocation) {
+ mapboxMap?.get().zoomToLocation(event.userLocation)
+ }
+
+ private fun handleUserLocationNotAvailableError() {
+ showUserLocationNotAvailableErrorDialog {
+ // do nothing
+ }
+ }
+
override fun onDestroyView() {
onSymbolClickListener?.let { symbolManager?.removeClickListener(it) }
symbolManager?.onDestroy()
@@ -139,14 +163,33 @@ class LiveLocationMapViewFragment :
true
}.also { addClickListener(it) }
}
- pendingLiveLocations
- .takeUnless { it.isEmpty() }
- ?.let { updateMap(it) }
+ // force refresh of the map using the last viewState
+ invalidate()
}
}
}
}
+ private fun initLocateButton() {
+ views.liveLocationMapLocateButton.setOnClickListener {
+ if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) {
+ zoomToUserLocation()
+ }
+ }
+ }
+
+ private fun zoomToUserLocation() {
+ viewModel.handle(LiveLocationMapAction.ZoomToUserLocation)
+ }
+
+ private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
+ if (allGranted) {
+ zoomToUserLocation()
+ } else if (deniedPermanently) {
+ activity?.onPermissionDeniedDialog(R.string.denied_permission_generic)
+ }
+ }
+
private fun listenMapLoadingError(mapView: MapView) {
mapLoadingErrorListener = MapView.OnDidFailLoadingMapListener {
viewModel.handle(LiveLocationMapAction.ShowMapLoadingError)
@@ -189,9 +232,15 @@ class LiveLocationMapViewFragment :
views.mapPreviewLoadingError.isVisible = true
} else {
views.mapPreviewLoadingError.isGone = true
- updateMap(viewState.userLocations)
+ updateMap(userLiveLocations = viewState.userLocations, userLocation = viewState.lastKnownUserLocation)
+ }
+ if (viewState.isLoadingUserLocation) {
+ showLoadingDialog()
+ } else {
+ dismissLoadingDialog()
}
updateUserListBottomSheet(viewState.userLocations)
+ updateLocateButton(showLocateButton = viewState.showLocateUserButton)
}
private fun updateUserListBottomSheet(userLocations: List) {
@@ -236,7 +285,24 @@ class LiveLocationMapViewFragment :
}
}
- private fun updateMap(userLiveLocations: List) {
+ private fun updateLocateButton(showLocateButton: Boolean) {
+ views.liveLocationMapLocateButton.isVisible = showLocateButton
+ adjustCompassButton()
+ }
+
+ private fun adjustCompassButton() {
+ val locateButton = views.liveLocationMapLocateButton
+ locateButton.post {
+ val marginTop = locateButton.height + locateButton.marginTop + locateButton.marginBottom
+ val marginRight = locateButton.context.resources.getDimensionPixelOffset(R.dimen.location_sharing_compass_button_margin_horizontal)
+ mapboxMap?.get()?.uiSettings?.setCompassMargins(0, marginTop, marginRight, 0)
+ }
+ }
+
+ private fun updateMap(
+ userLiveLocations: List,
+ userLocation: LocationData?,
+ ) {
symbolManager?.let { sManager ->
val latLngBoundsBuilder = LatLngBounds.Builder()
userLiveLocations.forEach { userLocation ->
@@ -249,28 +315,60 @@ class LiveLocationMapViewFragment :
removeOutdatedSymbols(userLiveLocations, sManager)
updateMapZoomWhenNeeded(userLiveLocations, latLngBoundsBuilder)
- } ?: postponeUpdateOfMap(userLiveLocations)
- }
-
- private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) = withState(viewModel) { state ->
- val symbolId = state.mapSymbolIds[userLocation.matrixItem.id]
-
- if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
- createSymbol(userLocation, symbolManager)
- } else {
- updateSymbol(symbolId, userLocation, symbolManager)
+ if (userLocation == null) {
+ removeUserSymbol(sManager)
+ } else {
+ createOrUpdateUserSymbol(userLocation, sManager)
+ }
}
}
- private fun createSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
- addUserPinToMapStyle(userLocation.matrixItem.id, userLocation.pinDrawable)
- val symbolOptions = buildSymbolOptions(userLocation)
- val symbol = symbolManager.create(symbolOptions)
- viewModel.handle(LiveLocationMapAction.AddMapSymbol(userLocation.matrixItem.id, symbol.id))
+ private fun createOrUpdateSymbol(userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
+ val pinId = userLocation.matrixItem.id
+ val pinDrawable = userLocation.pinDrawable
+ createOrUpdateSymbol(pinId, pinDrawable, userLocation.locationData, symbolManager)
}
- private fun updateSymbol(symbolId: Long, userLocation: UserLiveLocationViewState, symbolManager: SymbolManager) {
- val newLocation = LatLng(userLocation.locationData.latitude, userLocation.locationData.longitude)
+ private fun createOrUpdateUserSymbol(locationData: LocationData, symbolManager: SymbolManager) {
+ userLocationDrawable?.let { pinDrawable -> createOrUpdateSymbol(USER_LOCATION_PIN_ID, pinDrawable, locationData, symbolManager) }
+ }
+
+ private fun removeUserSymbol(symbolManager: SymbolManager) = withState(viewModel) { state ->
+ val pinId = USER_LOCATION_PIN_ID
+ state.mapSymbolIds[pinId]?.let { symbolId ->
+ removeSymbol(pinId, symbolId, symbolManager)
+ }
+ }
+
+ private fun createOrUpdateSymbol(
+ pinId: String,
+ pinDrawable: Drawable,
+ locationData: LocationData,
+ symbolManager: SymbolManager
+ ) = withState(viewModel) { state ->
+ val symbolId = state.mapSymbolIds[pinId]
+
+ if (symbolId == null || symbolManager.annotations.get(symbolId) == null) {
+ createSymbol(pinId, pinDrawable, locationData, symbolManager)
+ } else {
+ updateSymbol(symbolId, locationData, symbolManager)
+ }
+ }
+
+ private fun createSymbol(
+ pinId: String,
+ pinDrawable: Drawable,
+ locationData: LocationData,
+ symbolManager: SymbolManager
+ ) {
+ addPinToMapStyle(pinId, pinDrawable)
+ val symbolOptions = buildSymbolOptions(locationData, pinId)
+ val symbol = symbolManager.create(symbolOptions)
+ viewModel.handle(LiveLocationMapAction.AddMapSymbol(pinId, symbol.id))
+ }
+
+ private fun updateSymbol(symbolId: Long, locationData: LocationData, symbolManager: SymbolManager) {
+ val newLocation = LatLng(locationData.latitude, locationData.longitude)
val symbol = symbolManager.annotations.get(symbolId)
symbol?.let {
it.latLng = newLocation
@@ -279,17 +377,11 @@ class LiveLocationMapViewFragment :
}
private fun removeOutdatedSymbols(userLiveLocations: List, symbolManager: SymbolManager) = withState(viewModel) { state ->
- val userIdsToRemove = state.mapSymbolIds.keys.subtract(userLiveLocations.map { it.matrixItem.id }.toSet())
- userIdsToRemove.forEach { userId ->
- removeUserPinFromMapStyle(userId)
- viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(userId))
-
- state.mapSymbolIds[userId]?.let { symbolId ->
- Timber.d("trying to delete symbol with id: $symbolId")
- symbolManager.annotations.get(symbolId)?.let {
- symbolManager.delete(it)
- }
- }
+ val pinIdsToKeep = userLiveLocations.map { it.matrixItem.id } + USER_LOCATION_PIN_ID
+ val pinIdsToRemove = state.mapSymbolIds.keys.subtract(pinIdsToKeep.toSet())
+ pinIdsToRemove.forEach { pinId ->
+ val symbolId = state.mapSymbolIds[pinId]
+ removeSymbol(pinId, symbolId, symbolManager)
}
}
@@ -304,27 +396,35 @@ class LiveLocationMapViewFragment :
}
}
- private fun postponeUpdateOfMap(userLiveLocations: List) {
- pendingLiveLocations.clear()
- pendingLiveLocations.addAll(userLiveLocations)
- }
-
- private fun addUserPinToMapStyle(userId: String, userPinDrawable: Drawable) {
+ private fun addPinToMapStyle(pinId: String, pinDrawable: Drawable) {
mapStyle?.let { style ->
- if (style.getImage(userId) == null) {
- style.addImage(userId, userPinDrawable.toBitmap())
+ if (style.getImage(pinId) == null) {
+ style.addImage(pinId, pinDrawable.toBitmap())
}
}
}
- private fun removeUserPinFromMapStyle(userId: String) {
- mapStyle?.removeImage(userId)
+ private fun removeSymbol(pinId: String, symbolId: Long?, symbolManager: SymbolManager) {
+ removeUserPinFromMapStyle(pinId)
+
+ symbolId?.let { id ->
+ Timber.d("trying to delete symbol with id: $id")
+ symbolManager.annotations.get(id)?.let {
+ symbolManager.delete(it)
+ }
+ }
+
+ viewModel.handle(LiveLocationMapAction.RemoveMapSymbol(pinId))
}
- private fun buildSymbolOptions(userLiveLocation: UserLiveLocationViewState) =
+ private fun removeUserPinFromMapStyle(pinId: String) {
+ mapStyle?.removeImage(pinId)
+ }
+
+ private fun buildSymbolOptions(locationData: LocationData, pinId: String) =
SymbolOptions()
- .withLatLng(LatLng(userLiveLocation.locationData.latitude, userLiveLocation.locationData.longitude))
- .withIconImage(userLiveLocation.matrixItem.id)
+ .withLatLng(LatLng(locationData.latitude, locationData.longitude))
+ .withIconImage(pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
private fun handleBottomSheetUserSelected(userId: String) = withState(viewModel) { state ->
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt
index 33c584ff85..15e41470e0 100644
--- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewModel.kt
@@ -23,19 +23,27 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
+import im.vector.app.features.location.LocationData
+import im.vector.app.features.location.LocationTracker
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
class LiveLocationMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LiveLocationMapViewState,
+ private val session: Session,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
-) : VectorViewModel(initialState), LocationSharingServiceConnection.Callback {
+ private val locationTracker: LocationTracker,
+) :
+ VectorViewModel(initialState),
+ LocationSharingServiceConnection.Callback,
+ LocationTracker.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory {
@@ -46,12 +54,37 @@ class LiveLocationMapViewModel @AssistedInject constructor(
init {
getListOfUserLiveLocationUseCase.execute(initialState.roomId)
- .onEach { setState { copy(userLocations = it) } }
+ .onEach { setState { copy(userLocations = it, showLocateUserButton = it.none { it.matrixItem.id == session.myUserId }) } }
.launchIn(viewModelScope)
locationSharingServiceConnection.bind(this)
+ initLocationTracking()
+ }
+
+ private fun initLocationTracking() {
+ locationTracker.addCallback(this)
+ locationTracker.locations
+ .onEach(::onLocationUpdate)
+ .launchIn(viewModelScope)
+ }
+
+ private fun onLocationUpdate(locationData: LocationData) = withState { state ->
+ val zoomToUserLocation = state.isLoadingUserLocation
+ val showLocateButton = state.showLocateUserButton
+
+ setState {
+ copy(
+ lastKnownUserLocation = if (showLocateButton) locationData else null,
+ isLoadingUserLocation = false,
+ )
+ }
+
+ if (zoomToUserLocation) {
+ _viewEvents.post(LiveLocationMapViewEvents.ZoomToUserLocation(locationData))
+ }
}
override fun onCleared() {
+ locationTracker.removeCallback(this)
locationSharingServiceConnection.unbind(this)
super.onCleared()
}
@@ -62,6 +95,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
is LiveLocationMapAction.RemoveMapSymbol -> handleRemoveMapSymbol(action)
LiveLocationMapAction.StopSharing -> handleStopSharing()
LiveLocationMapAction.ShowMapLoadingError -> handleShowMapLoadingError()
+ LiveLocationMapAction.ZoomToUserLocation -> handleZoomToUserLocation()
}
}
@@ -83,7 +117,7 @@ class LiveLocationMapViewModel @AssistedInject constructor(
viewModelScope.launch {
val result = stopLiveLocationShareUseCase.execute(initialState.roomId)
if (result is UpdateLiveLocationShareResult.Failure) {
- _viewEvents.post(LiveLocationMapViewEvents.Error(result.error))
+ _viewEvents.post(LiveLocationMapViewEvents.LiveLocationError(result.error))
}
}
}
@@ -92,6 +126,18 @@ class LiveLocationMapViewModel @AssistedInject constructor(
setState { copy(loadingMapHasFailed = true) }
}
+ private fun handleZoomToUserLocation() = withState { state ->
+ if (!state.isLoadingUserLocation) {
+ setState {
+ copy(isLoadingUserLocation = true)
+ }
+ viewModelScope.launch(session.coroutineDispatchers.main) {
+ locationTracker.start()
+ locationTracker.requestLastKnownLocation()
+ }
+ }
+ }
+
override fun onLocationServiceRunning(roomIds: Set) {
// NOOP
}
@@ -101,6 +147,10 @@ class LiveLocationMapViewModel @AssistedInject constructor(
}
override fun onLocationServiceError(error: Throwable) {
- _viewEvents.post(LiveLocationMapViewEvents.Error(error))
+ _viewEvents.post(LiveLocationMapViewEvents.LiveLocationError(error))
+ }
+
+ override fun onNoLocationProviderAvailable() {
+ _viewEvents.post(LiveLocationMapViewEvents.UserLocationNotAvailableError)
}
}
diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt
index ddd1cd2369..74b0023a08 100644
--- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationMapViewState.kt
@@ -29,6 +29,9 @@ data class LiveLocationMapViewState(
*/
val mapSymbolIds: Map = emptyMap(),
val loadingMapHasFailed: Boolean = false,
+ val showLocateUserButton: Boolean = false,
+ val isLoadingUserLocation: Boolean = false,
+ val lastKnownUserLocation: LocationData? = null,
) : MavericksState {
constructor(liveLocationMapViewArgs: LiveLocationMapViewArgs) : this(
roomId = liveLocationMapViewArgs.roomId
diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt
index 38f6952f67..094c2206fa 100644
--- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt
+++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewAction.kt
@@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationPreviewAction : VectorViewModelAction {
object ShowMapLoadingError : LocationPreviewAction()
+ object ZoomToUserLocation : LocationPreviewAction()
}
diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt
index 082cee02f0..1d816ddc83 100644
--- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewFragment.kt
@@ -31,18 +31,22 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider
+import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING
+import im.vector.app.core.utils.checkPermissions
+import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.openLocation
+import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentLocationPreviewBinding
-import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.location.DEFAULT_PIN_ID
import im.vector.app.features.location.LocationSharingArgs
import im.vector.app.features.location.MapState
import im.vector.app.features.location.UrlMapProvider
+import im.vector.app.features.location.showUserLocationNotAvailableErrorDialog
import java.lang.ref.WeakReference
import javax.inject.Inject
-/*
- * TODO Move locationPinProvider to a ViewModel
+/**
+ * Screen displaying the expanded map of a static location share.
*/
@AndroidEntryPoint
class LocationPreviewFragment :
@@ -50,7 +54,6 @@ class LocationPreviewFragment :
VectorMenuProvider {
@Inject lateinit var urlMapProvider: UrlMapProvider
- @Inject lateinit var locationPinProvider: LocationPinProvider
private val args: LocationSharingArgs by args()
@@ -76,8 +79,29 @@ class LocationPreviewFragment :
lifecycleScope.launchWhenCreated {
views.mapView.initialize(urlMapProvider.getMapUrl())
- loadPinDrawable()
}
+
+ observeViewEvents()
+ initLocateButton()
+ }
+
+ private fun observeViewEvents() {
+ viewModel.observeViewEvents {
+ when (it) {
+ LocationPreviewViewEvents.UserLocationNotAvailableError -> handleUserLocationNotAvailableError()
+ is LocationPreviewViewEvents.ZoomToUserLocation -> handleZoomToUserLocationEvent(it)
+ }
+ }
+ }
+
+ private fun handleUserLocationNotAvailableError() {
+ showUserLocationNotAvailableErrorDialog {
+ // do nothing
+ }
+ }
+
+ private fun handleZoomToUserLocationEvent(event: LocationPreviewViewEvents.ZoomToUserLocation) {
+ views.mapView.zoomToLocation(event.userLocation)
}
override fun onDestroyView() {
@@ -124,6 +148,24 @@ class LocationPreviewFragment :
override fun invalidate() = withState(viewModel) { state ->
views.mapPreviewLoadingError.isVisible = state.loadingMapHasFailed
+ if (state.isLoadingUserLocation) {
+ showLoadingDialog()
+ } else {
+ dismissLoadingDialog()
+ }
+ updateMap(state)
+ }
+
+ private fun updateMap(viewState: LocationPreviewViewState) {
+ views.mapView.render(
+ MapState(
+ zoomOnlyOnce = true,
+ pinLocationData = viewState.pinLocationData,
+ pinId = viewState.pinUserId ?: DEFAULT_PIN_ID,
+ pinDrawable = viewState.pinDrawable,
+ userLocationData = viewState.lastKnownUserLocation,
+ )
+ )
}
override fun getMenuRes() = R.menu.menu_location_preview
@@ -143,21 +185,23 @@ class LocationPreviewFragment :
openLocation(requireActivity(), location.latitude, location.longitude)
}
- private fun loadPinDrawable() {
- val location = args.initialLocationData ?: return
- val userId = args.locationOwnerId
-
- locationPinProvider.create(userId) { pinDrawable ->
- lifecycleScope.launchWhenResumed {
- views.mapView.render(
- MapState(
- zoomOnlyOnce = true,
- userLocationData = location,
- pinId = args.locationOwnerId ?: DEFAULT_PIN_ID,
- pinDrawable = pinDrawable
- )
- )
+ private fun initLocateButton() {
+ views.mapView.locateButton.setOnClickListener {
+ if (checkPermissions(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, requireActivity(), foregroundLocationResultLauncher)) {
+ zoomToUserLocation()
}
}
}
+
+ private fun zoomToUserLocation() {
+ viewModel.handle(LocationPreviewAction.ZoomToUserLocation)
+ }
+
+ private val foregroundLocationResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
+ if (allGranted) {
+ zoomToUserLocation()
+ } else if (deniedPermanently) {
+ activity?.onPermissionDeniedDialog(R.string.denied_permission_generic)
+ }
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewEvents.kt
new file mode 100644
index 0000000000..605c240d06
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewEvents.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.location.preview
+
+import im.vector.app.core.platform.VectorViewEvents
+import im.vector.app.features.location.LocationData
+
+sealed class LocationPreviewViewEvents : VectorViewEvents {
+ data class ZoomToUserLocation(val userLocation: LocationData) : LocationPreviewViewEvents()
+ object UserLocationNotAvailableError : LocationPreviewViewEvents()
+}
diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt
index f0698249ce..a1544ac2af 100644
--- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewModel.kt
@@ -22,12 +22,21 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
-import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
+import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
+import im.vector.app.features.location.LocationData
+import im.vector.app.features.location.LocationTracker
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.matrix.android.sdk.api.session.Session
class LocationPreviewViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationPreviewViewState,
-) : VectorViewModel(initialState) {
+ private val session: Session,
+ private val locationPinProvider: LocationPinProvider,
+ private val locationTracker: LocationTracker,
+) : VectorViewModel(initialState), LocationTracker.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory {
@@ -36,13 +45,68 @@ class LocationPreviewViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory()
+ init {
+ initPin(initialState.pinUserId)
+ initLocationTracking()
+ }
+
+ private fun initPin(userId: String?) {
+ locationPinProvider.create(userId) { pinDrawable ->
+ setState { copy(pinDrawable = pinDrawable) }
+ }
+ }
+
+ private fun initLocationTracking() {
+ locationTracker.addCallback(this)
+ locationTracker.locations
+ .onEach(::onLocationUpdate)
+ .launchIn(viewModelScope)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ locationTracker.removeCallback(this)
+ }
+
override fun handle(action: LocationPreviewAction) {
when (action) {
LocationPreviewAction.ShowMapLoadingError -> handleShowMapLoadingError()
+ LocationPreviewAction.ZoomToUserLocation -> handleZoomToUserLocationAction()
}
}
private fun handleShowMapLoadingError() {
setState { copy(loadingMapHasFailed = true) }
}
+
+ private fun handleZoomToUserLocationAction() = withState { state ->
+ if (!state.isLoadingUserLocation) {
+ setState {
+ copy(isLoadingUserLocation = true)
+ }
+ viewModelScope.launch(session.coroutineDispatchers.main) {
+ locationTracker.start()
+ locationTracker.requestLastKnownLocation()
+ }
+ }
+ }
+
+ override fun onNoLocationProviderAvailable() {
+ _viewEvents.post(LocationPreviewViewEvents.UserLocationNotAvailableError)
+ }
+
+ private fun onLocationUpdate(locationData: LocationData) = withState { state ->
+ val zoomToUserLocation = state.isLoadingUserLocation
+
+ setState {
+ copy(
+ lastKnownUserLocation = locationData,
+ isLoadingUserLocation = false,
+ )
+ }
+
+ if (zoomToUserLocation) {
+ _viewEvents.post(LocationPreviewViewEvents.ZoomToUserLocation(locationData))
+ }
+ }
}
diff --git a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt
index 96e8316323..23f8d4d7dc 100644
--- a/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/location/preview/LocationPreviewViewState.kt
@@ -16,8 +16,22 @@
package im.vector.app.features.location.preview
+import android.graphics.drawable.Drawable
import com.airbnb.mvrx.MavericksState
+import im.vector.app.features.location.LocationData
+import im.vector.app.features.location.LocationSharingArgs
data class LocationPreviewViewState(
- val loadingMapHasFailed: Boolean = false
-) : MavericksState
+ val pinLocationData: LocationData? = null,
+ val pinUserId: String? = null,
+ val pinDrawable: Drawable? = null,
+ val loadingMapHasFailed: Boolean = false,
+ val isLoadingUserLocation: Boolean = false,
+ val lastKnownUserLocation: LocationData? = null,
+) : MavericksState {
+
+ constructor(args: LocationSharingArgs) : this(
+ pinLocationData = args.initialLocationData,
+ pinUserId = args.locationOwnerId,
+ )
+}
diff --git a/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt b/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt
index ddab65d981..77bcaed3fb 100644
--- a/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/login/AbstractSSOLoginFragment.kt
@@ -24,6 +24,7 @@ import androidx.browser.customtabs.CustomTabsSession
import androidx.viewbinding.ViewBinding
import com.airbnb.mvrx.withState
import im.vector.app.core.utils.openUrlInChromeCustomTab
+import org.matrix.android.sdk.api.auth.SSOAction
abstract class AbstractSSOLoginFragment : AbstractLoginFragment() {
@@ -90,7 +91,8 @@ abstract class AbstractSSOLoginFragment : AbstractLoginFragmen
loginViewModel.getSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- providerId = null
+ providerId = null,
+ action = if (state.signMode == SignMode.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { prefetchUrl(it) }
}
diff --git a/vector/src/main/java/im/vector/app/features/login/LoginAction.kt b/vector/src/main/java/im/vector/app/features/login/LoginAction.kt
index 5947fa0cb5..984c3694e8 100644
--- a/vector/src/main/java/im/vector/app/features/login/LoginAction.kt
+++ b/vector/src/main/java/im/vector/app/features/login/LoginAction.kt
@@ -69,7 +69,8 @@ sealed class LoginAction : VectorViewModelAction {
data class SetupSsoForSessionRecovery(
val homeServerUrl: String,
val deviceId: String,
- val ssoIdentityProviders: List?
+ val ssoIdentityProviders: List?,
+ val hasOidcCompatibilityFlow: Boolean
) : LoginAction()
data class PostViewEvent(val viewEvent: LoginViewEvents) : LoginAction()
diff --git a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt
index 4e4df5d1aa..9dfae7ff5f 100644
--- a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt
+++ b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt
@@ -46,6 +46,7 @@ import im.vector.app.features.login.terms.LoginTermsFragmentArgument
import im.vector.app.features.onboarding.AuthenticationDescription
import im.vector.app.features.pin.UnlockedActivity
import im.vector.lib.core.utils.compat.getParcelableExtraCompat
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.auth.registration.FlowResult
import org.matrix.android.sdk.api.auth.registration.Stage
import org.matrix.android.sdk.api.auth.toLocalizedLoginTerms
@@ -300,6 +301,7 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
providerId = null,
+ action = SSOAction.LOGIN
)?.let { ssoUrl ->
openUrlInChromeCustomTab(this, null, ssoUrl)
}
diff --git a/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt
index 2362c8ee75..00c1e47231 100644
--- a/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/login/LoginFragment.kt
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.failure.isInvalidPassword
@@ -200,11 +201,12 @@ class LoginFragment :
if (state.loginMode is LoginMode.SsoAndPassword) {
views.loginSocialLoginContainer.isVisible = true
- views.loginSocialLoginButtons.render(state.loginMode.ssoState, ssoMode(state)) { provider ->
+ views.loginSocialLoginButtons.render(state.loginMode, ssoMode(state)) { provider ->
loginViewModel.getSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- providerId = provider?.id
+ providerId = provider?.id,
+ action = if (state.signMode == SignMode.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { openInCustomTab(it) }
}
diff --git a/vector/src/main/java/im/vector/app/features/login/LoginMode.kt b/vector/src/main/java/im/vector/app/features/login/LoginMode.kt
index 944b159441..384108e6a8 100644
--- a/vector/src/main/java/im/vector/app/features/login/LoginMode.kt
+++ b/vector/src/main/java/im/vector/app/features/login/LoginMode.kt
@@ -23,8 +23,8 @@ sealed class LoginMode : Parcelable { // Parcelable because persist state
@Parcelize object Unknown : LoginMode()
@Parcelize object Password : LoginMode()
- @Parcelize data class Sso(val ssoState: SsoState) : LoginMode()
- @Parcelize data class SsoAndPassword(val ssoState: SsoState) : LoginMode()
+ @Parcelize data class Sso(val ssoState: SsoState, val hasOidcCompatibilityFlow: Boolean) : LoginMode()
+ @Parcelize data class SsoAndPassword(val ssoState: SsoState, val hasOidcCompatibilityFlow: Boolean) : LoginMode()
@Parcelize object Unsupported : LoginMode()
}
diff --git a/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt
index dbcf674847..5ed806622f 100644
--- a/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/login/LoginSignUpSignInSelectionFragment.kt
@@ -27,6 +27,7 @@ import im.vector.app.R
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.databinding.FragmentLoginSignupSigninSelectionBinding
import im.vector.app.features.login.SocialLoginButtonsView.Mode
+import org.matrix.android.sdk.api.auth.SSOAction
/**
* In this screen, the user is asked to sign up or to sign in to the homeserver.
@@ -75,11 +76,12 @@ class LoginSignUpSignInSelectionFragment :
when (state.loginMode) {
is LoginMode.SsoAndPassword -> {
views.loginSignupSigninSignInSocialLoginContainer.isVisible = true
- views.loginSignupSigninSocialLoginButtons.render(state.loginMode.ssoState(), Mode.MODE_CONTINUE) { provider ->
+ views.loginSignupSigninSocialLoginButtons.render(state.loginMode, Mode.MODE_CONTINUE) { provider ->
loginViewModel.getSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- providerId = provider?.id
+ providerId = provider?.id,
+ action = if (state.signMode == SignMode.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { openInCustomTab(it) }
}
@@ -111,7 +113,8 @@ class LoginSignUpSignInSelectionFragment :
loginViewModel.getSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- providerId = null
+ providerId = null,
+ action = if (state.signMode == SignMode.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { openInCustomTab(it) }
} else {
diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt
index 8d520628f0..4da022d4bb 100644
--- a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt
@@ -39,6 +39,7 @@ import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.login.LoginWizard
@@ -224,7 +225,7 @@ class LoginViewModel @AssistedInject constructor(
setState {
copy(
signMode = SignMode.SignIn,
- loginMode = LoginMode.Sso(action.ssoIdentityProviders.toSsoState()),
+ loginMode = LoginMode.Sso(action.ssoIdentityProviders.toSsoState(), action.hasOidcCompatibilityFlow),
homeServerUrlFromUser = action.homeServerUrl,
homeServerUrl = action.homeServerUrl,
deviceId = action.deviceId
@@ -817,8 +818,11 @@ class LoginViewModel @AssistedInject constructor(
val loginMode = when {
// SSO login is taken first
data.supportedLoginTypes.contains(LoginFlowTypes.SSO) &&
- data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(data.ssoIdentityProviders.toSsoState())
- data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders.toSsoState())
+ data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(
+ data.ssoIdentityProviders.toSsoState(),
+ data.hasOidcCompatibilityFlow
+ )
+ data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders.toSsoState(), data.hasOidcCompatibilityFlow)
data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password
else -> LoginMode.Unsupported
}
@@ -845,8 +849,8 @@ class LoginViewModel @AssistedInject constructor(
return loginConfig?.homeServerUrl
}
- fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?): String? {
- return authenticationService.getSsoUrl(redirectUrl, deviceId, providerId)
+ fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?, action: SSOAction): String? {
+ return authenticationService.getSsoUrl(redirectUrl, deviceId, providerId, action)
}
fun getFallbackUrl(forSignIn: Boolean, deviceId: String?): String? {
diff --git a/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt b/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt
index 816050420e..4ac98d6f2d 100644
--- a/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt
+++ b/vector/src/main/java/im/vector/app/features/login/SocialLoginButtonsView.kt
@@ -56,6 +56,14 @@ class SocialLoginButtonsView @JvmOverloads constructor(context: Context, attrs:
}
}
+ var hasOidcCompatibilityFlow: Boolean = false
+ set(value) {
+ if (value != hasOidcCompatibilityFlow) {
+ field = value
+ update()
+ }
+ }
+
var listener: InteractionListener? = null
private fun update() {
@@ -70,7 +78,8 @@ class SocialLoginButtonsView @JvmOverloads constructor(context: Context, attrs:
transformationMethod = null
textAlignment = View.TEXT_ALIGNMENT_CENTER
}.let {
- it.text = getButtonTitle(context.getString(R.string.login_social_sso))
+ it.text = if (hasOidcCompatibilityFlow) context.getString(R.string.login_continue)
+ else getButtonTitle(context.getString(R.string.login_social_sso))
it.textAlignment = View.TEXT_ALIGNMENT_CENTER
it.setOnClickListener {
listener?.onProviderSelected(null)
@@ -160,11 +169,14 @@ class SocialLoginButtonsView @JvmOverloads constructor(context: Context, attrs:
}
}
-fun SocialLoginButtonsView.render(state: SsoState, mode: SocialLoginButtonsView.Mode, listener: (SsoIdentityProvider?) -> Unit) {
+fun SocialLoginButtonsView.render(loginMode: LoginMode, mode: SocialLoginButtonsView.Mode, listener: (SsoIdentityProvider?) -> Unit) {
this.mode = mode
+ val state = loginMode.ssoState()
this.ssoIdentityProviders = when (state) {
SsoState.Fallback -> null
is SsoState.IdentityProviders -> state.providers.sorted()
}
+ this.hasOidcCompatibilityFlow = (loginMode is LoginMode.Sso && loginMode.hasOidcCompatibilityFlow) ||
+ (loginMode is LoginMode.SsoAndPassword && loginMode.hasOidcCompatibilityFlow)
this.listener = SocialLoginButtonsView.InteractionListener { listener(it) }
}
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
index a69958ef25..14a36d9922 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
@@ -67,7 +67,7 @@ class NotifiableEventResolver @Inject constructor(
) {
private val nonEncryptedNotifiableEventTypes: List =
- listOf(EventType.MESSAGE) + EventType.POLL_START.values + EventType.STATE_ROOM_BEACON_INFO.values
+ listOf(EventType.MESSAGE) + EventType.POLL_START.values + EventType.POLL_END.values + EventType.STATE_ROOM_BEACON_INFO.values
suspend fun resolveEvent(event: Event, session: Session, isNoisy: Boolean): NotifiableEvent? {
val roomID = event.roomId ?: return null
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
index 04487c6198..9c64b5ed87 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt
@@ -55,6 +55,7 @@ import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.MatrixPatterns.getServerName
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.auth.login.LoginWizard
@@ -841,12 +842,12 @@ class OnboardingViewModel @AssistedInject constructor(
fun getDefaultHomeserverUrl() = defaultHomeserverUrl
- fun fetchSsoUrl(redirectUrl: String, deviceId: String?, provider: SsoIdentityProvider?): String? {
+ fun fetchSsoUrl(redirectUrl: String, deviceId: String?, provider: SsoIdentityProvider?, action: SSOAction): String? {
setState {
val authDescription = AuthenticationDescription.Register(provider.toAuthenticationType())
copy(selectedAuthenticationState = SelectedAuthenticationState(authDescription))
}
- return authenticationService.getSsoUrl(redirectUrl, deviceId, provider?.id)
+ return authenticationService.getSsoUrl(redirectUrl, deviceId, provider?.id, action)
}
fun getFallbackUrl(forSignIn: Boolean, deviceId: String?): String? {
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
index ea0d940952..58b28ac4e4 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt
@@ -75,6 +75,7 @@ data class SelectedHomeserverState(
val upstreamUrl: String? = null,
val preferredLoginMode: LoginMode = LoginMode.Unknown,
val supportedLoginTypes: List = emptyList(),
+ val hasOidcCompatibilityFlow: Boolean = false,
val isLogoutDevicesSupported: Boolean = false,
val isLoginWithQrSupported: Boolean = false,
) : Parcelable
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt b/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt
index 9b8f0a1cc4..14a3a9bfd0 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt
@@ -47,13 +47,17 @@ class StartAuthenticationFlowUseCase @Inject constructor(
upstreamUrl = authFlow.homeServerUrl,
preferredLoginMode = preferredLoginMode,
supportedLoginTypes = authFlow.supportedLoginTypes,
+ hasOidcCompatibilityFlow = authFlow.hasOidcCompatibilityFlow,
isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported,
- isLoginWithQrSupported = authFlow.isLoginWithQrSupported,
+ isLoginWithQrSupported = authFlow.isLoginWithQrSupported
)
private fun LoginFlowResult.findPreferredLoginMode() = when {
- supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(ssoIdentityProviders.toSsoState())
- supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(ssoIdentityProviders.toSsoState())
+ supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(
+ ssoIdentityProviders.toSsoState(),
+ hasOidcCompatibilityFlow
+ )
+ supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(ssoIdentityProviders.toSsoState(), hasOidcCompatibilityFlow)
supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password
else -> LoginMode.Unsupported
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt
index b1352db0cc..211c630320 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt
@@ -27,6 +27,8 @@ import im.vector.app.core.utils.openUrlInChromeCustomTab
import im.vector.app.features.login.SSORedirectRouterActivity
import im.vector.app.features.login.hasSso
import im.vector.app.features.login.ssoState
+import im.vector.app.features.onboarding.OnboardingFlow
+import org.matrix.android.sdk.api.auth.SSOAction
abstract class AbstractSSOFtueAuthFragment : AbstractFtueAuthFragment() {
@@ -93,7 +95,8 @@ abstract class AbstractSSOFtueAuthFragment : AbstractFtueAuthF
viewModel.fetchSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- provider = null
+ provider = null,
+ action = if (state.onboardingFlow == OnboardingFlow.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { prefetchUrl(it) }
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt
index 2c016f7077..69090172ea 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedLoginFragment.kt
@@ -41,7 +41,6 @@ import im.vector.app.features.VectorFeatures
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.SSORedirectRouterActivity
import im.vector.app.features.login.SocialLoginButtonsView
-import im.vector.app.features.login.SsoState
import im.vector.app.features.login.qr.QrCodeLoginArgs
import im.vector.app.features.login.qr.QrCodeLoginType
import im.vector.app.features.login.render
@@ -50,6 +49,7 @@ import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
+import org.matrix.android.sdk.api.auth.SSOAction
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
@@ -153,11 +153,11 @@ class FtueAuthCombinedLoginFragment :
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> {
showUsernamePassword()
- renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoState)
+ renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode)
}
is LoginMode.Sso -> {
hideUsernamePassword()
- renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoState)
+ renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode)
}
else -> {
showUsernamePassword()
@@ -166,14 +166,15 @@ class FtueAuthCombinedLoginFragment :
}
}
- private fun renderSsoProviders(deviceId: String?, ssoState: SsoState) {
+ private fun renderSsoProviders(deviceId: String?, loginMode: LoginMode) {
views.ssoGroup.isVisible = true
views.ssoButtonsHeader.isVisible = isUsernameAndPasswordVisible()
- views.ssoButtons.render(ssoState, SocialLoginButtonsView.Mode.MODE_CONTINUE) { id ->
+ views.ssoButtons.render(loginMode, SocialLoginButtonsView.Mode.MODE_CONTINUE) { id ->
viewModel.fetchSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = deviceId,
- provider = id
+ provider = id,
+ action = SSOAction.LOGIN
)?.let { openInCustomTab(it) }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt
index 66668f5303..83a9a9c00b 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt
@@ -45,7 +45,6 @@ import im.vector.app.databinding.FragmentFtueCombinedRegisterBinding
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.SSORedirectRouterActivity
import im.vector.app.features.login.SocialLoginButtonsView
-import im.vector.app.features.login.SsoState
import im.vector.app.features.login.render
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction
@@ -53,6 +52,7 @@ import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.failure.isInvalidPassword
import org.matrix.android.sdk.api.failure.isInvalidUsername
@@ -207,18 +207,19 @@ class FtueAuthCombinedRegisterFragment :
}
when (state.selectedHomeserver.preferredLoginMode) {
- is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoState)
+ is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode)
else -> hideSsoProviders()
}
}
- private fun renderSsoProviders(deviceId: String?, ssoState: SsoState) {
+ private fun renderSsoProviders(deviceId: String?, loginMode: LoginMode) {
views.ssoGroup.isVisible = true
- views.ssoButtons.render(ssoState, SocialLoginButtonsView.Mode.MODE_CONTINUE) { provider ->
+ views.ssoButtons.render(loginMode, SocialLoginButtonsView.Mode.MODE_CONTINUE) { provider ->
viewModel.fetchSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = deviceId,
- provider = provider
+ provider = provider,
+ action = SSOAction.REGISTER
)?.let { openInCustomTab(it) }
}
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt
index 3fd8df6bb9..8cf8dffaf3 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt
@@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
+import org.matrix.android.sdk.api.auth.SSOAction
import org.matrix.android.sdk.api.failure.isInvalidPassword
import org.matrix.android.sdk.api.failure.isInvalidUsername
import org.matrix.android.sdk.api.failure.isLoginEmailUnknown
@@ -215,11 +216,12 @@ class FtueAuthLoginFragment :
if (state.selectedHomeserver.preferredLoginMode is LoginMode.SsoAndPassword) {
views.loginSocialLoginContainer.isVisible = true
- views.loginSocialLoginButtons.render(state.selectedHomeserver.preferredLoginMode.ssoState, ssoMode(state)) { provider ->
+ views.loginSocialLoginButtons.render(state.selectedHomeserver.preferredLoginMode, ssoMode(state)) { provider ->
viewModel.fetchSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- provider = provider
+ provider = provider,
+ action = if (state.signMode == SignMode.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { openInCustomTab(it) }
}
diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt
index b2f2eeb167..cd387f5f6b 100644
--- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt
@@ -34,7 +34,9 @@ import im.vector.app.features.login.SignMode
import im.vector.app.features.login.SocialLoginButtonsView.Mode
import im.vector.app.features.login.render
import im.vector.app.features.onboarding.OnboardingAction
+import im.vector.app.features.onboarding.OnboardingFlow
import im.vector.app.features.onboarding.OnboardingViewState
+import org.matrix.android.sdk.api.auth.SSOAction
/**
* In this screen, the user is asked to sign up or to sign in to the homeserver.
@@ -81,11 +83,12 @@ class FtueAuthSignUpSignInSelectionFragment :
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> {
views.loginSignupSigninSignInSocialLoginContainer.isVisible = true
- views.loginSignupSigninSocialLoginButtons.render(state.selectedHomeserver.preferredLoginMode.ssoState, Mode.MODE_CONTINUE) { provider ->
+ views.loginSignupSigninSocialLoginButtons.render(state.selectedHomeserver.preferredLoginMode, Mode.MODE_CONTINUE) { provider ->
viewModel.fetchSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- provider = provider
+ provider = provider,
+ action = if (state.signMode == SignMode.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { openInCustomTab(it) }
}
@@ -110,7 +113,8 @@ class FtueAuthSignUpSignInSelectionFragment :
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.Sso -> {
// change to only one button that is sign in with sso
- views.loginSignupSigninSubmit.text = getString(R.string.login_signin_sso)
+ views.loginSignupSigninSubmit.text =
+ if (state.selectedHomeserver.hasOidcCompatibilityFlow) getString(R.string.login_continue) else getString(R.string.login_signin_sso)
views.loginSignupSigninSignIn.isVisible = false
}
else -> {
@@ -125,7 +129,8 @@ class FtueAuthSignUpSignInSelectionFragment :
viewModel.fetchSsoUrl(
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
- provider = null
+ provider = null,
+ action = if (state.onboardingFlow == OnboardingFlow.SignUp) SSOAction.REGISTER else SSOAction.LOGIN
)
?.let { openInCustomTab(it) }
} else {
@@ -144,5 +149,7 @@ class FtueAuthSignUpSignInSelectionFragment :
override fun updateWithState(state: OnboardingViewState) {
render(state)
setupButtons(state)
+ // if talking to OIDC enabled homeserver in compatibility mode then immediately start SSO
+ if (state.selectedHomeserver.hasOidcCompatibilityFlow) submit()
}
}
diff --git a/vector/src/main/java/im/vector/app/features/poll/PollViewState.kt b/vector/src/main/java/im/vector/app/features/poll/PollItemViewState.kt
similarity index 96%
rename from vector/src/main/java/im/vector/app/features/poll/PollViewState.kt
rename to vector/src/main/java/im/vector/app/features/poll/PollItemViewState.kt
index ecbee7438a..e5b4f71f1d 100644
--- a/vector/src/main/java/im/vector/app/features/poll/PollViewState.kt
+++ b/vector/src/main/java/im/vector/app/features/poll/PollItemViewState.kt
@@ -18,7 +18,7 @@ package im.vector.app.features.poll
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
-data class PollViewState(
+data class PollItemViewState(
val question: String,
val votesStatus: String,
val canVote: Boolean,
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt
index f873dfbdde..f96506db87 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileController.kt
@@ -18,7 +18,6 @@
package im.vector.app.features.roomprofile
import com.airbnb.epoxy.TypedEpoxyController
-import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.epoxy.expandableTextItem
import im.vector.app.core.epoxy.profiles.buildProfileAction
@@ -269,15 +268,14 @@ class RoomProfileController @Inject constructor(
action = { callback?.onBannedMemberListClicked() }
)
}
- if (BuildConfig.DEBUG) {
- // WIP, will be in release when related screens will be finished
- buildProfileAction(
- id = "poll_history",
- title = stringProvider.getString(R.string.room_profile_section_more_polls),
- icon = R.drawable.ic_attachment_poll,
- action = { callback?.onPollHistoryClicked() }
- )
- }
+
+ buildProfileAction(
+ id = "poll_history",
+ title = stringProvider.getString(R.string.room_profile_section_more_polls),
+ icon = R.drawable.ic_attachment_poll,
+ action = { callback?.onPollHistoryClicked() }
+ )
+
buildProfileAction(
id = "uploads",
title = stringProvider.getString(R.string.room_profile_section_more_uploads),
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt
index 91f57d33e9..9436bafc03 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt
@@ -64,7 +64,7 @@ import javax.inject.Inject
@Parcelize
data class RoomProfileArgs(
- val roomId: String
+ val roomId: String,
) : Parcelable
@AndroidEntryPoint
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/domain/GetEndedPollEventIdUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/domain/GetEndedPollEventIdUseCase.kt
new file mode 100644
index 0000000000..aa1ba1b274
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/domain/GetEndedPollEventIdUseCase.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.domain
+
+import im.vector.app.core.di.ActiveSessionHolder
+import org.matrix.android.sdk.api.session.events.model.RelationType
+import org.matrix.android.sdk.api.session.events.model.isPollEnd
+import timber.log.Timber
+import javax.inject.Inject
+
+class GetEndedPollEventIdUseCase @Inject constructor(
+ private val activeSessionHolder: ActiveSessionHolder,
+) {
+
+ fun execute(roomId: String, startPollEventId: String): String? {
+ val result = runCatching {
+ activeSessionHolder.getActiveSession().roomService().getRoom(roomId)
+ ?.timelineService()
+ ?.getTimelineEventsRelatedTo(RelationType.REFERENCE, startPollEventId)
+ ?.find { it.root.isPollEnd() }
+ ?.eventId
+ }.onFailure { Timber.w("failed to retrieve the ended poll event id for eventId:$startPollEventId") }
+ return result.getOrNull()
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetail.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetail.kt
new file mode 100644
index 0000000000..7857a30eeb
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetail.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import im.vector.app.features.poll.PollItemViewState
+
+data class RoomPollDetail(
+ val creationTimestamp: Long,
+ val isEnded: Boolean,
+ val endedPollEventId: String?,
+ val pollItemViewState: PollItemViewState,
+)
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailAction.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailAction.kt
new file mode 100644
index 0000000000..dbf8436399
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailAction.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import im.vector.app.core.platform.VectorViewModelAction
+
+sealed interface RoomPollDetailAction : VectorViewModelAction {
+ data class Vote(val pollEventId: String, val optionId: String) : RoomPollDetailAction
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailActivity.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailActivity.kt
new file mode 100644
index 0000000000..cf29d5618a
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailActivity.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import com.airbnb.mvrx.Mavericks
+import dagger.hilt.android.AndroidEntryPoint
+import im.vector.app.core.extensions.addFragment
+import im.vector.app.core.platform.VectorBaseActivity
+import im.vector.app.databinding.ActivitySimpleBinding
+import im.vector.lib.core.utils.compat.getParcelableExtraCompat
+
+/**
+ * Display the details of a given poll.
+ */
+@AndroidEntryPoint
+class RoomPollDetailActivity : VectorBaseActivity() {
+
+ override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (isFirstCreation()) {
+ addFragment(
+ container = views.simpleFragmentContainer,
+ fragmentClass = RoomPollDetailFragment::class.java,
+ params = intent.getParcelableExtraCompat(Mavericks.KEY_ARG)
+ )
+ }
+ }
+
+ companion object {
+ fun newIntent(context: Context, pollId: String, roomId: String, isEnded: Boolean): Intent {
+ return Intent(context, RoomPollDetailActivity::class.java).apply {
+ val args = RoomPollDetailArgs(
+ pollId = pollId,
+ roomId = roomId,
+ isEnded = isEnded,
+ )
+ putExtra(Mavericks.KEY_ARG, args)
+ }
+ }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt
new file mode 100644
index 0000000000..7a246f812b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailController.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import com.airbnb.epoxy.TypedEpoxyController
+import im.vector.app.core.date.DateFormatKind
+import im.vector.app.core.date.VectorDateFormatter
+import java.util.UUID
+import javax.inject.Inject
+
+class RoomPollDetailController @Inject constructor(
+ val dateFormatter: VectorDateFormatter,
+) : TypedEpoxyController() {
+
+ interface Callback {
+ fun vote(pollEventId: String, optionId: String)
+ fun goToTimelineEvent(eventId: String)
+ }
+
+ var callback: Callback? = null
+
+ override fun buildModels(viewState: RoomPollDetailViewState?) {
+ val pollDetail = viewState?.pollDetail ?: return
+ val pollItemViewState = pollDetail.pollItemViewState
+ val host = this
+
+ roomPollDetailItem {
+ id(viewState.pollId)
+ eventId(viewState.pollId)
+ formattedDate(host.dateFormatter.format(pollDetail.creationTimestamp, DateFormatKind.TIMELINE_DAY_DIVIDER))
+ question(pollItemViewState.question)
+ canVote(pollItemViewState.canVote)
+ votesStatus(pollItemViewState.votesStatus)
+ optionViewStates(pollItemViewState.optionViewStates.orEmpty())
+ callback(host.callback)
+ }
+
+ buildGoToTimelineItem(targetEventId = pollDetail.endedPollEventId ?: viewState.pollId)
+ }
+
+ private fun buildGoToTimelineItem(targetEventId: String) {
+ val host = this
+ roomPollGoToTimelineItem {
+ id(UUID.randomUUID().toString())
+ clickListener {
+ host.callback?.goToTimelineEvent(targetEventId)
+ }
+ }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt
new file mode 100644
index 0000000000..9c118bb897
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailFragment.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import android.os.Bundle
+import android.os.Parcelable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.airbnb.mvrx.args
+import com.airbnb.mvrx.fragmentViewModel
+import com.airbnb.mvrx.withState
+import dagger.hilt.android.AndroidEntryPoint
+import im.vector.app.R
+import im.vector.app.core.extensions.cleanup
+import im.vector.app.core.extensions.configureWith
+import im.vector.app.core.platform.VectorBaseFragment
+import im.vector.app.databinding.FragmentRoomPollDetailBinding
+import kotlinx.parcelize.Parcelize
+import javax.inject.Inject
+
+@Parcelize
+data class RoomPollDetailArgs(
+ val pollId: String,
+ val roomId: String,
+ val isEnded: Boolean,
+) : Parcelable
+
+@AndroidEntryPoint
+class RoomPollDetailFragment :
+ VectorBaseFragment(),
+ RoomPollDetailController.Callback {
+
+ @Inject lateinit var viewNavigator: RoomPollDetailNavigator
+ @Inject lateinit var roomPollDetailController: RoomPollDetailController
+
+ private val viewModel: RoomPollDetailViewModel by fragmentViewModel()
+ private val roomPollDetailArgs: RoomPollDetailArgs by args()
+
+ override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollDetailBinding {
+ return FragmentRoomPollDetailBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setupToolbar(isEnded = roomPollDetailArgs.isEnded)
+ setupDetailView()
+ }
+
+ override fun onDestroyView() {
+ roomPollDetailController.callback = null
+ views.pollDetailRecyclerView.cleanup()
+ super.onDestroyView()
+ }
+
+ private fun setupDetailView() {
+ roomPollDetailController.callback = this
+ views.pollDetailRecyclerView.configureWith(
+ roomPollDetailController,
+ hasFixedSize = true,
+ )
+ }
+
+ private fun setupToolbar(isEnded: Boolean) {
+ val title = when (isEnded) {
+ true -> getString(R.string.room_polls_ended)
+ false -> getString(R.string.room_polls_active)
+ }
+
+ setupToolbar(views.roomPollDetailToolbar)
+ .setTitle(title)
+ .allowBack(useCross = true)
+ }
+
+ override fun invalidate() = withState(viewModel) { state ->
+ roomPollDetailController.setData(state)
+ }
+
+ override fun vote(pollEventId: String, optionId: String) {
+ viewModel.handle(RoomPollDetailAction.Vote(pollEventId = pollEventId, optionId = optionId))
+ }
+
+ override fun goToTimelineEvent(eventId: String) = withState(viewModel) { state ->
+ viewNavigator.goToTimelineEvent(
+ context = requireContext(),
+ roomId = state.roomId,
+ eventId = eventId,
+ )
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt
new file mode 100644
index 0000000000..b3f905e661
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailItem.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.isVisible
+import com.airbnb.epoxy.EpoxyAttribute
+import com.airbnb.epoxy.EpoxyModelClass
+import im.vector.app.R
+import im.vector.app.core.epoxy.VectorEpoxyHolder
+import im.vector.app.core.epoxy.VectorEpoxyModel
+import im.vector.app.features.home.room.detail.timeline.item.PollOptionView
+import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
+
+@EpoxyModelClass
+abstract class RoomPollDetailItem : VectorEpoxyModel(R.layout.item_poll_detail) {
+
+ @EpoxyAttribute
+ lateinit var formattedDate: String
+
+ @EpoxyAttribute
+ var question: String? = null
+
+ @EpoxyAttribute
+ var callback: RoomPollDetailController.Callback? = null
+
+ @EpoxyAttribute
+ var eventId: String? = null
+
+ @EpoxyAttribute
+ var canVote: Boolean = false
+
+ @EpoxyAttribute
+ var votesStatus: String? = null
+
+ @EpoxyAttribute
+ lateinit var optionViewStates: List
+
+ @EpoxyAttribute
+ var ended: Boolean = false
+
+ override fun bind(holder: Holder) {
+ super.bind(holder)
+ holder.date.text = formattedDate
+ holder.questionTextView.text = question
+ holder.votesStatusTextView.text = votesStatus
+ holder.optionsContainer.removeAllViews()
+ holder.optionsContainer.isVisible = optionViewStates.isNotEmpty()
+ for (option in optionViewStates) {
+ val optionView = PollOptionView(holder.view.context)
+ holder.optionsContainer.addView(optionView)
+ optionView.render(option)
+ optionView.setOnClickListener { onOptionClicked(option) }
+ }
+
+ holder.endedPollTextView.isVisible = false
+ }
+
+ private fun onOptionClicked(optionViewState: PollOptionViewState) {
+ val relatedEventId = eventId
+
+ if (canVote && relatedEventId != null) {
+ callback?.vote(pollEventId = relatedEventId, optionId = optionViewState.optionId)
+ }
+ }
+
+ class Holder : VectorEpoxyHolder() {
+ val date by bind(R.id.pollDetailDate)
+ val questionTextView by bind(R.id.questionTextView)
+ val optionsContainer by bind(R.id.optionsContainer)
+ val votesStatusTextView by bind(R.id.optionsVotesStatusTextView)
+ val endedPollTextView by bind(R.id.endedPollTextView)
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailMapper.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailMapper.kt
new file mode 100644
index 0000000000..8f14118d43
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailMapper.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import im.vector.app.core.extensions.getVectorLastMessageContent
+import im.vector.app.features.home.room.detail.timeline.factory.PollItemViewStateFactory
+import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory
+import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
+import im.vector.app.features.roomprofile.polls.detail.domain.GetEndedPollEventIdUseCase
+import org.matrix.android.sdk.api.extensions.orFalse
+import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
+import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
+import timber.log.Timber
+import javax.inject.Inject
+
+class RoomPollDetailMapper @Inject constructor(
+ private val pollResponseDataFactory: PollResponseDataFactory,
+ private val pollItemViewStateFactory: PollItemViewStateFactory,
+ private val getEndedPollEventIdUseCase: GetEndedPollEventIdUseCase,
+) {
+
+ fun map(timelineEvent: TimelineEvent): RoomPollDetail? {
+ val eventId = timelineEvent.root.eventId.orEmpty()
+ val result = runCatching {
+ val content = timelineEvent.getVectorLastMessageContent()
+ val pollResponseData = pollResponseDataFactory.create(timelineEvent)
+ val creationTimestamp = timelineEvent.root.originServerTs ?: 0
+ return if (eventId.isNotEmpty() && creationTimestamp > 0 && content is MessagePollContent) {
+ val isPollEnded = pollResponseData?.isClosed.orFalse()
+ val endedPollEventId = getEndedPollEventId(
+ isPollEnded,
+ startPollEventId = eventId,
+ roomId = timelineEvent.roomId,
+ )
+ convertToRoomPollDetail(
+ creationTimestamp = creationTimestamp,
+ content = content,
+ pollResponseData = pollResponseData,
+ isPollEnded = isPollEnded,
+ endedPollEventId = endedPollEventId,
+ )
+ } else {
+ Timber.w("missing mandatory info about poll event with id=$eventId")
+ null
+ }
+ }
+
+ if (result.isFailure) {
+ Timber.w("failed to map event with id $eventId")
+ }
+ return result.getOrNull()
+ }
+
+ private fun convertToRoomPollDetail(
+ creationTimestamp: Long,
+ content: MessagePollContent,
+ pollResponseData: PollResponseData?,
+ isPollEnded: Boolean,
+ endedPollEventId: String?,
+ ): RoomPollDetail {
+ // we assume the poll has been sent
+ val pollItemViewState = pollItemViewStateFactory.create(
+ pollContent = content,
+ pollResponseData = pollResponseData,
+ isSent = true,
+ )
+ return RoomPollDetail(
+ creationTimestamp = creationTimestamp,
+ isEnded = isPollEnded,
+ pollItemViewState = pollItemViewState,
+ endedPollEventId = endedPollEventId,
+ )
+ }
+
+ private fun getEndedPollEventId(
+ isPollEnded: Boolean,
+ startPollEventId: String,
+ roomId: String,
+ ): String? {
+ return if (isPollEnded) {
+ getEndedPollEventIdUseCase.execute(startPollEventId = startPollEventId, roomId = roomId)
+ } else {
+ null
+ }
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailNavigator.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailNavigator.kt
new file mode 100644
index 0000000000..a19bb87d9e
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailNavigator.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import android.content.Context
+import im.vector.app.features.navigation.Navigator
+import javax.inject.Inject
+
+class RoomPollDetailNavigator @Inject constructor(
+ private val navigator: Navigator,
+) {
+
+ fun goToTimelineEvent(context: Context, roomId: String, eventId: String) {
+ navigator.openRoom(
+ context = context,
+ roomId = roomId,
+ eventId = eventId,
+ buildTask = true,
+ )
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt
new file mode 100644
index 0000000000..487595d20b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewModel.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import com.airbnb.mvrx.MavericksViewModelFactory
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import im.vector.app.core.di.MavericksAssistedViewModelFactory
+import im.vector.app.core.di.hiltMavericksViewModelFactory
+import im.vector.app.core.event.GetTimelineEventUseCase
+import im.vector.app.core.platform.EmptyViewEvents
+import im.vector.app.core.platform.VectorViewModel
+import im.vector.app.features.home.room.detail.poll.VoteToPollUseCase
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+
+class RoomPollDetailViewModel @AssistedInject constructor(
+ @Assisted initialState: RoomPollDetailViewState,
+ private val getTimelineEventUseCase: GetTimelineEventUseCase,
+ private val roomPollDetailMapper: RoomPollDetailMapper,
+ private val voteToPollUseCase: VoteToPollUseCase,
+) : VectorViewModel(initialState) {
+
+ @AssistedFactory
+ interface Factory : MavericksAssistedViewModelFactory {
+ override fun create(initialState: RoomPollDetailViewState): RoomPollDetailViewModel
+ }
+
+ companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory()
+
+ init {
+ observePollDetails(
+ pollId = initialState.pollId,
+ roomId = initialState.roomId,
+ )
+ }
+
+ private fun observePollDetails(pollId: String, roomId: String) {
+ getTimelineEventUseCase.execute(roomId = roomId, eventId = pollId)
+ .map { roomPollDetailMapper.map(it) }
+ .onEach { setState { copy(pollDetail = it) } }
+ .launchIn(viewModelScope)
+ }
+
+ override fun handle(action: RoomPollDetailAction) {
+ when (action) {
+ is RoomPollDetailAction.Vote -> handleVote(action)
+ }
+ }
+
+ private fun handleVote(vote: RoomPollDetailAction.Vote) = withState { state ->
+ voteToPollUseCase.execute(
+ roomId = state.roomId,
+ pollEventId = vote.pollEventId,
+ optionId = vote.optionId,
+ )
+ }
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewState.kt
new file mode 100644
index 0000000000..a2906dc88f
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollDetailViewState.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import com.airbnb.mvrx.MavericksState
+
+data class RoomPollDetailViewState(
+ val pollId: String,
+ val roomId: String,
+ val pollDetail: RoomPollDetail? = null,
+) : MavericksState {
+
+ constructor(roomPollDetailArgs: RoomPollDetailArgs) : this(
+ pollId = roomPollDetailArgs.pollId,
+ roomId = roomPollDetailArgs.roomId,
+ )
+}
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollGoToTimelineItem.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollGoToTimelineItem.kt
new file mode 100644
index 0000000000..59a5539a4f
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/detail/ui/RoomPollGoToTimelineItem.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.roomprofile.polls.detail.ui
+
+import android.widget.Button
+import com.airbnb.epoxy.EpoxyAttribute
+import com.airbnb.epoxy.EpoxyModelClass
+import im.vector.app.R
+import im.vector.app.core.epoxy.ClickListener
+import im.vector.app.core.epoxy.VectorEpoxyHolder
+import im.vector.app.core.epoxy.VectorEpoxyModel
+import im.vector.app.core.epoxy.onClick
+
+@EpoxyModelClass
+abstract class RoomPollGoToTimelineItem : VectorEpoxyModel(R.layout.item_poll_go_to_timeline) {
+
+ @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
+ var clickListener: ClickListener? = null
+
+ override fun bind(holder: Holder) {
+ super.bind(holder)
+ holder.goToTimelineButton.onClick(clickListener)
+ }
+
+ class Holder : VectorEpoxyHolder() {
+ val goToTimelineButton by bind