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