Merge tag 'v1.5.26' into sc

Change-Id: Ie54ce4c15b4b95f7ecb4419f421762d7c57c5c2d

Conflicts:
	dependencies.gradle
	matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
	vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
	vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
	vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
	vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml
	vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml
	vector/src/main/res/drawable/ic_rich_composer_add.xml
	vector/src/main/res/drawable/ic_rich_composer_send.xml
This commit is contained in:
SpiritCroc 2023-02-23 11:45:01 +01:00
commit 38c8e30541
198 changed files with 4539 additions and 803 deletions

View File

@ -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

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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:

View File

@ -4,6 +4,8 @@ on:
pull_request: { }
push:
branches: [ main, develop ]
paths-ignore:
- '.github/**'
# Enrich gradle.properties for CI/CD
env:

View File

@ -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

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
.idea/caches
.idea/libraries
.idea/inspectionProfiles
.idea/sonarlint
.idea/*.xml
.DS_Store
/build

View File

@ -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)
=======================================

View File

@ -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

View File

@ -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",

View File

@ -0,0 +1,2 @@
Main changes in this version: Mainly bugfixing.
Full changelog: https://github.com/vector-im/element-android/releases

View File

@ -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)

View File

@ -1063,6 +1063,9 @@
<string name="settings_discovery_category">Discovery</string>
<string name="settings_discovery_manage">Manage your discovery settings.</string>
<string name="settings_external_account_management_title">Account</string>
<string name="settings_external_account_management">Your account details are managed separately at %1$s.</string>
<!-- analytics -->
<string name="settings_analytics">Analytics</string>
<string name="settings_opt_in_of_analytics">Send analytics data</string>
@ -1820,7 +1823,7 @@
<!-- Terms -->
<string name="terms_of_service">Terms of Service</string>
<string name="terms_description_for_identity_server">Be discoverable by others</string>
<string name="terms_description_for_integration_manager">Use Bots, bridges, widgets and sticker packs</string>
<string name="terms_description_for_integration_manager">Use bots, bridges, widgets and sticker packs</string>
<string name="identity_server">Identity server</string>
<string name="disconnect_identity_server">Disconnect identity server</string>
@ -3208,6 +3211,7 @@
<string name="room_polls_wait_for_display">Displaying polls</string>
<string name="room_polls_load_more">Load more polls</string>
<string name="room_polls_loading_error">Error fetching polls.</string>
<string name="room_poll_details_go_to_timeline">View poll in timeline</string>
<!-- Location -->
<string name="location_activity_title_static_sharing">Share location</string>
@ -3503,7 +3507,11 @@
<string name="rich_text_editor_link">Set link</string>
<string name="rich_text_editor_numbered_list">Toggle numbered list</string>
<string name="rich_text_editor_bullet_list">Toggle bullet list</string>
<string name="rich_text_editor_indent">Indent</string>
<string name="rich_text_editor_unindent">Unindent</string>
<string name="rich_text_editor_quote">Toggle quote</string>
<string name="rich_text_editor_inline_code">Apply inline code format</string>
<string name="rich_text_editor_code_block">Toggle code block</string>
<string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string>
<string name="set_link_text">Text</string>

View File

@ -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

View File

@ -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
}
}
}
}

View File

@ -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.

View File

@ -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;
}

View File

@ -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
* <pre>
* {
* "issuer": "https://id.server.org",
* "account": "https://id.server.org/my-account",
* }
* </pre>
* .
*/
@JsonClass(generateAdapter = true)
data class DelegatedAuthConfig(
@Json(name = "issuer")
val issuer: String,
@Json(name = "account")
val accountManagementUrl: String,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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
)
}
}

View File

@ -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<Byte>()
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()

View File

@ -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,
)

View File

@ -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
)

View File

@ -20,5 +20,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
open class RendezvousTransportDetails(
val type: RendezvousTransportType
val type: String
)

View File

@ -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")
}

View File

@ -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)

View File

@ -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 {

View File

@ -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"

View File

@ -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
}
/**

View File

@ -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
}

View File

@ -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<MessageContent>()?.newContent
val lastContent = annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>()?.newContent
return if (isReply()) {
val previousFormattedBody = root.getClearContent().toModel<MessageTextContent>()?.formattedBody
if (previousFormattedBody?.isNotEmpty() == true) {
val lastMessageContent = lastContent.toModel<MessageTextContent>()
lastMessageContent?.let { ensureCorrectFormattedBodyInTextReply(it, previousFormattedBody) }?.toContent() ?: lastContent
} else {
lastContent
}
} else {
lastContent
}
}
private const val MX_REPLY_END_TAG = "</mx-reply>"
/**
* 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? {

View File

@ -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(),
)

View File

@ -43,6 +43,13 @@ internal data class LoginFlow(
* See MSC #2858
*/
@Json(name = "identity_providers")
val ssoIdentityProvider: List<SsoIdentityProvider>? = null
val ssoIdentityProvider: List<SsoIdentityProvider>? = 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
)

View File

@ -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()

View File

@ -48,6 +48,7 @@ internal object HomeServerCapabilitiesMapper {
canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications,
canRemotelyTogglePushNotificationsOfDevices = entity.canRemotelyTogglePushNotificationsOfDevices,
canRedactEventWithRelations = entity.canRedactEventWithRelations,
externalAccountManagementUrl = entity.externalAccountManagementUrl,
)
}

View File

@ -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()
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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
}

View File

@ -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(

View File

@ -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<Room>()
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 {

View File

@ -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

View File

@ -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

View File

@ -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<ActiveSessionHolder>()
private val fakeDimensionConverter = mockk<DimensionConverter>()
private val renderer = EventHtmlRenderer(
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences),
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences, fakeDimensionConverter),
context,
fakeVectorPreferences,
fakeSessionHolder,

View File

@ -332,6 +332,7 @@
<activity android:name=".features.settings.devices.v2.details.SessionDetailsActivity" />
<activity android:name=".features.settings.devices.v2.rename.RenameSessionActivity" />
<activity android:name=".features.login.qr.QrCodeLoginActivity" />
<activity android:name=".features.roomprofile.polls.detail.ui.RoomPollDetailActivity" />
<!-- Services -->

View File

@ -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<*, *>
}

View File

@ -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<TimelineEvent> {
return activeSessionHolder.getActiveSession().getRoom(roomId)
?.timelineService()
?.getTimelineEventLive(eventId)
?.asFlow()
?.unwrap()
?: emptyFlow()
}
}

View File

@ -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()
}
}
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(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) {

View File

@ -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 {

View File

@ -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") }
}
}

View File

@ -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<MessagePollContent>() ?: return null
val pollContent = pollStartEvent?.root?.getClearContent()?.toModel<MessagePollContent>()
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)

View File

@ -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,

View File

@ -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<PollItem.Holder>() {
@EpoxyAttribute
var pollQuestion: EpoxyCharSequence? = null
var pollTitle: EpoxyCharSequence? = null
@EpoxyAttribute
var callback: TimelineEventController.Callback? = null
@ -54,6 +56,9 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@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<PollItem.Holder>() {
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<PollItem.Holder>() {
}
}
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<PollItem.Holder>() {
val optionsContainer by bind<LinearLayout>(R.id.optionsContainer)
val votesStatusTextView by bind<TextView>(R.id.optionsVotesStatusTextView)
val endedPollTextView by bind<TextView>(R.id.endedPollTextView)
val pollIcon by bind<ImageView>(R.id.timelinePollIcon)
}
companion object {

View File

@ -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))

View File

@ -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()
}
}

View File

@ -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()
}

View File

@ -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() {

View File

@ -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

View File

@ -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<Location>(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
}
/**

View File

@ -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,
)

View File

@ -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) {

View File

@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction {
data class RemoveMapSymbol(val key: String) : LiveLocationMapAction()
object StopSharing : LiveLocationMapAction()
object ShowMapLoadingError : LiveLocationMapAction()
object ZoomToUserLocation : LiveLocationMapAction()
}

View File

@ -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
}

View File

@ -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<UserLiveLocationViewState>()
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<UserLiveLocationViewState>) {
@ -236,7 +285,24 @@ class LiveLocationMapViewFragment :
}
}
private fun updateMap(userLiveLocations: List<UserLiveLocationViewState>) {
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<UserLiveLocationViewState>,
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<UserLiveLocationViewState>, 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<UserLiveLocationViewState>) {
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 ->

View File

@ -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<LiveLocationMapViewState, LiveLocationMapAction, LiveLocationMapViewEvents>(initialState), LocationSharingServiceConnection.Callback {
private val locationTracker: LocationTracker,
) :
VectorViewModel<LiveLocationMapViewState, LiveLocationMapAction, LiveLocationMapViewEvents>(initialState),
LocationSharingServiceConnection.Callback,
LocationTracker.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LiveLocationMapViewModel, LiveLocationMapViewState> {
@ -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<String>) {
// 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)
}
}

View File

@ -29,6 +29,9 @@ data class LiveLocationMapViewState(
*/
val mapSymbolIds: Map<String, Long> = 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

View File

@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class LocationPreviewAction : VectorViewModelAction {
object ShowMapLoadingError : LocationPreviewAction()
object ZoomToUserLocation : LocationPreviewAction()
}

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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<LocationPreviewViewState, LocationPreviewAction, EmptyViewEvents>(initialState) {
private val session: Session,
private val locationPinProvider: LocationPinProvider,
private val locationTracker: LocationTracker,
) : VectorViewModel<LocationPreviewViewState, LocationPreviewAction, LocationPreviewViewEvents>(initialState), LocationTracker.Callback {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> {
@ -36,13 +45,68 @@ class LocationPreviewViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<LocationPreviewViewModel, LocationPreviewViewState> 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))
}
}
}

View File

@ -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,
)
}

View File

@ -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<VB : ViewBinding> : AbstractLoginFragment<VB>() {
@ -90,7 +91,8 @@ abstract class AbstractSSOLoginFragment<VB : ViewBinding> : 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) }
}

View File

@ -69,7 +69,8 @@ sealed class LoginAction : VectorViewModelAction {
data class SetupSsoForSessionRecovery(
val homeServerUrl: String,
val deviceId: String,
val ssoIdentityProviders: List<SsoIdentityProvider>?
val ssoIdentityProviders: List<SsoIdentityProvider>?,
val hasOidcCompatibilityFlow: Boolean
) : LoginAction()
data class PostViewEvent(val viewEvent: LoginViewEvents) : LoginAction()

View File

@ -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<ActivityLoginBinding>(), UnlockedA
redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL,
deviceId = state.deviceId,
providerId = null,
action = SSOAction.LOGIN
)?.let { ssoUrl ->
openUrlInChromeCustomTab(this, null, ssoUrl)
}

View File

@ -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) }
}

View File

@ -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()
}

View File

@ -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 {

View File

@ -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? {

View File

@ -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) }
}

View File

@ -67,7 +67,7 @@ class NotifiableEventResolver @Inject constructor(
) {
private val nonEncryptedNotifiableEventTypes: List<String> =
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

View File

@ -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? {

View File

@ -75,6 +75,7 @@ data class SelectedHomeserverState(
val upstreamUrl: String? = null,
val preferredLoginMode: LoginMode = LoginMode.Unknown,
val supportedLoginTypes: List<String> = emptyList(),
val hasOidcCompatibilityFlow: Boolean = false,
val isLogoutDevicesSupported: Boolean = false,
val isLoginWithQrSupported: Boolean = false,
) : Parcelable

View File

@ -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
}

View File

@ -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<VB : ViewBinding> : AbstractFtueAuthFragment<VB>() {
@ -93,7 +95,8 @@ abstract class AbstractSSOFtueAuthFragment<VB : ViewBinding> : 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) }
}

View File

@ -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) }
}
}

View File

@ -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) }
}
}

View File

@ -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) }
}

View File

@ -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()
}
}

View File

@ -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,

View File

@ -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),

View File

@ -64,7 +64,7 @@ import javax.inject.Inject
@Parcelize
data class RoomProfileArgs(
val roomId: String
val roomId: String,
) : Parcelable
@AndroidEntryPoint

View File

@ -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()
}
}

View File

@ -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,
)

View File

@ -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
}

Some files were not shown because too many files have changed in this diff Show More