mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-21 17:05:39 +03:00
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:
commit
38c8e30541
198 changed files with 4539 additions and 803 deletions
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
@ -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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
47
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
47
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
|
@ -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
|
2
.github/ISSUE_TEMPLATE/matrix-sdk.yml
vendored
2
.github/ISSUE_TEMPLATE/matrix-sdk.yml
vendored
|
@ -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]
|
||||
|
||||
|
|
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
|
@ -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:
|
||||
|
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
@ -4,6 +4,8 @@ on:
|
|||
pull_request: { }
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
|
|
25
.github/workflows/triage-labelled.yml
vendored
25
.github/workflows/triage-labelled.yml
vendored
|
@ -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
1
.gitignore
vendored
|
@ -5,6 +5,7 @@
|
|||
.idea/caches
|
||||
.idea/libraries
|
||||
.idea/inspectionProfiles
|
||||
.idea/sonarlint
|
||||
.idea/*.xml
|
||||
.DS_Store
|
||||
/build
|
||||
|
|
29
CHANGES.md
29
CHANGES.md
|
@ -1,3 +1,32 @@
|
|||
Changes in Element v1.5.26 (2023-02-22)
|
||||
=======================================
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- Adds MSC3824 OIDC-awareness when talking to an OIDC-enabled homeservers ([#6367](https://github.com/vector-im/element-android/issues/6367))
|
||||
- [Poll] Synchronize polls push rules with message push rules ([#8007](https://github.com/vector-im/element-android/issues/8007))
|
||||
- [Rich text editor] Add code block, quote and indentation actions ([#8045](https://github.com/vector-im/element-android/issues/8045))
|
||||
- [Poll] History list: details screen of a poll
|
||||
- [Poll] History list: enable the new settings entry in release mode ([#8056](https://github.com/vector-im/element-android/issues/8056))
|
||||
- [Location sharing] Show own location in map views ([#8110](https://github.com/vector-im/element-android/issues/8110))
|
||||
- Updates to protocol used for Sign in with QR code ([#8123](https://github.com/vector-im/element-android/issues/8123))
|
||||
- [Poll] Synchronize polls and message push rules ([#8130](https://github.com/vector-im/element-android/issues/8130))
|
||||
|
||||
Bugfixes 🐛
|
||||
----------
|
||||
- Android app does not show correct poll data ([#6121](https://github.com/vector-im/element-android/issues/6121))
|
||||
- Fix timeline always jumps to the bottom when screen goes back to foreground. ([#8090](https://github.com/vector-im/element-android/issues/8090))
|
||||
- [Poll] Improve rendering of poll end message when poll start event isn't available ([#8129](https://github.com/vector-im/element-android/issues/8129))
|
||||
- Replace hardcoded colors by theming colors on send button. ([#8142](https://github.com/vector-im/element-android/issues/8142))
|
||||
- [Timeline]: Editing a reply from iOS breaks the "in reply to" rendering ([#8150](https://github.com/vector-im/element-android/issues/8150))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Build unmerged APKs on pull request ([#8044](https://github.com/vector-im/element-android/issues/8044))
|
||||
- Replace 'Bots' with 'bots' inside terms_description_for_integration_manager ([#8115](https://github.com/vector-im/element-android/issues/8115))
|
||||
- Fix ktlint issue with fields and a new line. ([#8139](https://github.com/vector-im/element-android/issues/8139))
|
||||
|
||||
|
||||
Changes in Element v1.5.25 (2023-02-15)
|
||||
=======================================
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
2
fastlane/metadata/android/en-US/changelogs/40105260.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40105260.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
Main changes in this version: Mainly bugfixing.
|
||||
Full changelog: https://github.com/vector-im/element-android/releases
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -20,5 +20,5 @@ import com.squareup.moshi.JsonClass
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
open class RendezvousTransportDetails(
|
||||
val type: RendezvousTransportType
|
||||
val type: String
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -48,6 +48,7 @@ internal object HomeServerCapabilitiesMapper {
|
|||
canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications,
|
||||
canRemotelyTogglePushNotificationsOfDevices = entity.canRemotelyTogglePushNotificationsOfDevices,
|
||||
canRedactEventWithRelations = entity.canRedactEventWithRelations,
|
||||
externalAccountManagementUrl = entity.externalAccountManagementUrl,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 -->
|
||||
|
||||
|
|
|
@ -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<*, *>
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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") }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -23,4 +23,5 @@ sealed class LiveLocationMapAction : VectorViewModelAction {
|
|||
data class RemoveMapSymbol(val key: String) : LiveLocationMapAction()
|
||||
object StopSharing : LiveLocationMapAction()
|
||||
object ShowMapLoadingError : LiveLocationMapAction()
|
||||
object ZoomToUserLocation : LiveLocationMapAction()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
|
|||
|
||||
sealed class LocationPreviewAction : VectorViewModelAction {
|
||||
object ShowMapLoadingError : LocationPreviewAction()
|
||||
object ZoomToUserLocation : LocationPreviewAction()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
|
@ -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),
|
||||
|
|
|
@ -64,7 +64,7 @@ import javax.inject.Inject
|
|||
|
||||
@Parcelize
|
||||
data class RoomProfileArgs(
|
||||
val roomId: String
|
||||
val roomId: String,
|
||||
) : Parcelable
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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
Loading…
Reference in a new issue