Merge branch 'develop' into feature/fga/pinned_messages_list

This commit is contained in:
ganfra 2024-09-04 14:11:53 +02:00 committed by GitHub
commit 12e7e05551
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
289 changed files with 3558 additions and 1883 deletions

View file

@ -79,7 +79,7 @@ jobs:
uses: actions/download-artifact@v4
with:
name: elementx-apk-maestro
- uses: mobile-dev-inc/action-maestro-cloud@v1.8.1
- uses: mobile-dev-inc/action-maestro-cloud@v1.9.1
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}

View file

@ -44,7 +44,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
concurrency:
group: ${{ format('build-release-main-gplay-{0}', github.sha) }}
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4

View file

@ -1,5 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible: "Confirm that it's you"
visible: "Confirm your identity"
timeout: 20000

View file

@ -1,3 +1,92 @@
Changes in Element X v0.5.1 (2024-08-28)
=========================================
### ✨ Features
* Add simplified sliding sync toggle to developer options by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3222
* Feature: identity reset by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3298
* Timeline UI | MessageShield Support by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3240
* Suggestion for room alias (disabled for now) by @bmarty in https://github.com/element-hq/element-x-android/pull/3322
* Allow `PictureInPicture` mode for Element Call. by @bmarty in https://github.com/element-hq/element-x-android/pull/3345
### 🙌 Improvements
* Join Room : allow to join by alias (and getPreview) by @ganfra in https://github.com/element-hq/element-x-android/pull/3241
* [Feature] Pinned message : render m.room.pinned events in timeline by @ganfra in https://github.com/element-hq/element-x-android/pull/3276
* Enable sync on push feature flag to partially sync when notifications arrive by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3290
* Improve the text for mentions and replies in notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3328
* Use new functions exposed by Element Call about PiP by @bmarty in https://github.com/element-hq/element-x-android/pull/3334
### 🐛 Bugfixes
* Ensure sessionPath is not reused for different homeserver. Fixes not loading media issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/3299
* Fix reset identity with password stuck in loading state. by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3317
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3252
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3267
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3297
* Sync Strings - New language: Dutch. by @ElementBot in https://github.com/element-hq/element-x-android/pull/3308
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3339
### 🧱 Build
* Update sonarcloud project key by @guillaumevillemont in https://github.com/element-hq/element-x-android/pull/3264
* Fix `build_rust_sdk.sh` script to work on linux by @erikjohnston in https://github.com/element-hq/element-x-android/pull/3291
* Fix proguard config for nightly and release builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3294
* Gradle update action: Use JDK 17 and skip early in forks. by @bmarty in https://github.com/element-hq/element-x-android/pull/3311
* Gradle update action: add label and use other token. by @bmarty in https://github.com/element-hq/element-x-android/pull/3313
* Update Gradle Wrapper from 8.9 to 8.10 by @ElementBot in https://github.com/element-hq/element-x-android/pull/3314
### 🚧 In development 🚧
* WIP Pinned events : add feature flag and pin/unpin actions by @ganfra in https://github.com/element-hq/element-x-android/pull/3255
* WIP Pinned events : start creating the banner ui, no logic. by @ganfra in https://github.com/element-hq/element-x-android/pull/3259
* WIP Pinned events : banner logic by @ganfra in https://github.com/element-hq/element-x-android/pull/3275
### Dependency upgrades
* Update dependency org.maplibre.gl:android-sdk to v11.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3244
* Update activity to v1.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3242
* Update media3 to v1.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3247
* Update dependency androidx.annotation:annotation-jvm to v1.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3243
* Update dependencyAnalysis to v1.33.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3250
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.35 by @renovate in https://github.com/element-hq/element-x-android/pull/3249
* Update dependency io.sentry:sentry-android to v7.12.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3246
* Update dependency io.nlopez.compose.rules:detekt to v0.4.8 by @renovate in https://github.com/element-hq/element-x-android/pull/3254
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.36 by @renovate in https://github.com/element-hq/element-x-android/pull/3269
* Update wysiwyg to v2.37.8 by @renovate in https://github.com/element-hq/element-x-android/pull/3263
* Update dependency io.sentry:sentry-android to v7.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3258
* Update dependency io.nlopez.compose.rules:detekt to v0.4.9 by @renovate in https://github.com/element-hq/element-x-android/pull/3277
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.38 by @renovate in https://github.com/element-hq/element-x-android/pull/3280
* Update dependency androidx.annotation:annotation-jvm to v1.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3282
* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/2990
* Update dependency io.nlopez.compose.rules:detekt to v0.4.10 by @renovate in https://github.com/element-hq/element-x-android/pull/3281
* Update dependency com.posthog:posthog-android to v3.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3287
* Update wysiwyg to v2.37.8 by @renovate in https://github.com/element-hq/element-x-android/pull/3284
* Update the SDK bindings to `v0.2.39` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3288
* Update gradle/actions action to v4 by @renovate in https://github.com/element-hq/element-x-android/pull/3265
* Update android.gradle.plugin to v8.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3004
* Update dependency io.sentry:sentry-android to v7.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3285
* Update dependency io.sentry:sentry-android to v7.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3302
* Update dependency androidx.test:runner to v1.6.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3304
* Update dependency com.otaliastudios:transcoder to v0.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3306
* Update lifecycle to v2.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/2848
* Update lifecycle to v2.8.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3315
* Update dagger to v2.52 by @renovate in https://github.com/element-hq/element-x-android/pull/3270
* Update telephoto to v0.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3325
* Update dependency androidx.compose:compose-bom to v2024.08.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3323
* Update dependency com.google.firebase:firebase-bom to v33.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3331
* Update dependency com.posthog:posthog-android to v3.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3340
* Update dependency com.android.tools:desugar_jdk_libs to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3341
* Update dependencyAnalysis to v2 (major) by @renovate in https://github.com/element-hq/element-x-android/pull/3346
* Update dependency org.maplibre.gl:android-sdk to v11.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3347
* Update media3 to v1.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3344
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.40 by @renovate in https://github.com/element-hq/element-x-android/pull/3343
### Others
* Feature/fga/push subscribe to room by @ganfra in https://github.com/element-hq/element-x-android/pull/3257
* Feature/fga/start sync on push by @ganfra in https://github.com/element-hq/element-x-android/pull/3260
* Cleanup and add unit test for DefaultPinnedMessagesBannerFormatter by @bmarty in https://github.com/element-hq/element-x-android/pull/3307
* Add test on function name which may start or end with spaces by @bmarty in https://github.com/element-hq/element-x-android/pull/3318
* Fix broken direct room member for rooms with old users that left by @networkException in https://github.com/element-hq/element-x-android/pull/3324
* Add unit test on MatrixRoom extension by @bmarty in https://github.com/element-hq/element-x-android/pull/3327
* Fix login navigation getting stuck when the app was compiled with no-op analytics provider by @SpiritCroc in https://github.com/element-hq/element-x-android/pull/3337
Changes in Element X v0.5.0 (2024-07-24)
=========================================

View file

@ -17,15 +17,19 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
import com.android.build.gradle.internal.tasks.factory.dependsOn
import com.android.build.gradle.tasks.GenerateBuildConfig
import extension.AssetCopyTask
import extension.GitBranchNameValueSource
import extension.GitRevisionValueSource
import extension.allEnterpriseImpl
import extension.allFeaturesImpl
import extension.allLibrariesImpl
import extension.allServicesImpl
import extension.gitBranchName
import extension.gitRevision
import extension.koverDependencies
import extension.locales
import extension.setupKover
import java.util.Locale
plugins {
id("io.element.android-compose-application")
@ -36,7 +40,8 @@ plugins {
id(libs.plugins.firebaseAppDistribution.get().pluginId)
alias(libs.plugins.knit)
id("kotlin-parcelize")
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.licensee)
alias(libs.plugins.kotlin.serialization)
// To be able to update the firebase.xml files, uncomment and build the project
// id("com.google.gms.google-services")
}
@ -61,9 +66,6 @@ android {
abiFilters += listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
}
buildConfigField("String", "GIT_REVISION", "\"${gitRevision()}\"")
buildConfigField("String", "GIT_BRANCH_NAME", "\"${gitBranchName()}\"")
// Ref: https://developer.android.com/studio/build/configure-apk-splits.html#configure-abi-split
splits {
// Configures multiple APKs based on ABI.
@ -215,6 +217,9 @@ androidComponents {
output.versionCode.set((output.versionCode.orNull ?: 0) * 10 + abiCode)
}
}
val reportingExtension: ReportingExtension = project.extensions.getByType(ReportingExtension::class.java)
configureLicensesTasks(reportingExtension)
}
// Knit
@ -259,8 +264,6 @@ dependencies {
// Comment to not include unified push in the project
implementation(projects.libraries.pushproviders.unifiedpush)
"gplayImplementation"(libs.play.services.oss.licenses)
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
@ -291,3 +294,51 @@ dependencies {
koverDependencies()
}
tasks.withType<GenerateBuildConfig>().configureEach {
outputs.upToDateWhen { false }
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
android.defaultConfig.buildConfigField("String", "GIT_REVISION", "\"$gitRevision\"")
android.defaultConfig.buildConfigField("String", "GIT_BRANCH_NAME", "\"$gitBranchName\"")
}
licensee {
allow("Apache-2.0")
allow("MIT")
allow("GPL-2.0-with-classpath-exception")
allow("BSD-2-Clause")
allowUrl("https://opensource.org/licenses/MIT")
allowUrl("https://developer.android.com/studio/terms.html")
allowUrl("http://openjdk.java.net/legal/gplv2+ce.html")
allowUrl("https://www.zetetic.net/sqlcipher/license/")
allowUrl("https://jsoup.org/license")
allowUrl("https://asm.ow2.io/license.html")
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
}
fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) {
androidComponents {
onVariants { variant ->
val capitalizedVariantName = variant.name.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(Locale.getDefault())
} else {
it.toString()
}
}
val artifactsFile = reportingExtension.file("licensee/android$capitalizedVariantName/artifacts.json")
val copyArtifactsTask =
project.tasks.register<AssetCopyTask>("copy${capitalizedVariantName}LicenseeReportToAssets") {
inputFile.set(artifactsFile)
targetFileName.set("licensee-artifacts.json")
}
variant.sources.assets?.addGeneratedSourceDirectory(
copyArtifactsTask,
AssetCopyTask::outputDirectory,
)
copyArtifactsTask.dependsOn("licenseeAndroid$capitalizedVariantName")
}
}
}

View file

@ -1,12 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesMenuActivity"
android:theme="@style/Theme.OssLicenses" />
<activity
android:name="com.google.android.gms.oss.licenses.OssLicensesActivity"
android:theme="@style/Theme.OssLicenses" />
</application>
</manifest>

View file

@ -1,37 +0,0 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.x.licenses
import android.app.Activity
import android.content.Intent
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.ui.strings.CommonStrings
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class OssOpenSourcesLicensesProvider @Inject constructor() : OpenSourceLicensesProvider {
override val hasOpenSourceLicenses: Boolean = true
override fun navigateToOpenSourceLicenses(activity: Activity) {
val title = activity.getString(CommonStrings.common_open_source_licenses)
OssLicensesMenuActivity.setActivityTitle(title)
activity.startActivity(Intent(activity, OssLicensesMenuActivity::class.java))
}
}

View file

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024 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
~
~ https://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.
-->
<resources>
<!-- Use a few colors from compoundColorsDark -->
<!-- DarkColorTokens.colorThemeBg -->
<color name="colorThemeBg">#FF101317</color>
<!-- DarkColorTokens.colorGray1400 -->
<color name="textPrimary">#FFEBEEF2</color>
<!-- DarkColorTokens.colorGray900 -->
<color name="textSecondary">#ff808994</color>
<!-- DarkColorTokens.colorBlue900 -->
<color name="textLinkExternal">#FF4187EB</color>
<bool name="windowLightStatusBar">false</bool>
<bool name="windowLightNavigationBar">false</bool>
</resources>

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024 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
~
~ https://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.
-->
<resources>
<style name="Theme.OssLicenses.Light.v27" parent="Base.Theme.OssLicenses">
<item name="android:windowLightNavigationBar">@bool/windowLightNavigationBar</item>
</style>
<style name="Theme.OssLicenses" parent="Theme.OssLicenses.Light.v27"/>
</resources>

View file

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024 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
~
~ https://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.
-->
<resources>
<!-- Use a few colors from compoundColorsLight -->
<!-- LightColorTokens.colorThemeBg -->
<color name="colorThemeBg">#FFFFFFFF</color>
<!-- LightColorTokens.colorGray1400 -->
<color name="textPrimary">#FF1B1D22</color>
<!-- LightColorTokens.colorGray900 -->
<color name="textSecondary">#FF656D77</color>
<!-- LightColorTokens.colorBlue900 -->
<color name="textLinkExternal">#FF0467DD</color>
<bool name="windowLightStatusBar">true</bool>
<bool name="windowLightNavigationBar">true</bool>
</resources>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024 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
~
~ https://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.
-->
<resources>
<style name="NoElevationToolbar" parent="Widget.MaterialComponents.Toolbar">
<item name="android:elevation">0dp</item>
</style>
</resources>

View file

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2024 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
~
~ https://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.
-->
<resources>
<style name="Base.Theme.OssLicenses" parent="Theme.MaterialComponents.DayNight">
<!-- Background of title bar -->
<item name="colorPrimary">@color/colorThemeBg</item>
<!-- Background of the screen -->
<item name="android:colorBackground">@color/colorThemeBg</item>
<!-- Text of the licenses -->
<item name="android:textColor">@color/textSecondary</item>
<!-- Link text color -->
<item name="android:textColorLink">@color/textLinkExternal</item>
<!-- Title, back button and license item text color -->
<item name="android:textColorPrimary">@color/textPrimary</item>
<!-- Background of status bar -->
<item name="android:statusBarColor">@color/colorThemeBg</item>
<item name="android:windowLightStatusBar">@bool/windowLightStatusBar</item>
<!-- Background of navigation bar -->
<item name="android:navigationBarColor">@color/colorThemeBg</item>
<!-- Try to remove Toolbar elevation, but it does not work :/ -->
<item name="toolbarStyle">@style/NoElevationToolbar</item>
</style>
<style name="Theme.OssLicenses" parent="Base.Theme.OssLicenses" />
</resources>

View file

@ -261,6 +261,10 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.CreateRoom)
}
override fun onSetUpRecoveryClick() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery))
}
override fun onSessionConfirmRecoveryKeyClick() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}

View file

@ -1,11 +1,7 @@
import com.google.devtools.ksp.gradle.KspTask
import org.apache.tools.ant.taskdefs.optional.ReplaceRegExp
buildscript {
dependencies {
classpath(libs.kotlin.gradle.plugin)
classpath(libs.gms.google.services)
classpath(libs.oss.licenses.plugin)
}
}
@ -61,7 +57,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.10")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.11")
}
// KtLint
@ -202,24 +198,6 @@ subprojects {
tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask)
}
// Workaround for https://github.com/airbnb/Showkase/issues/335
subprojects {
tasks.withType<KspTask> {
doLast {
fileTree(layout.buildDirectory).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file ->
ReplaceRegExp().apply {
setMatch("^public fun Showkase.getMetadata")
setReplace("@Suppress(\"DEPRECATION\") public fun Showkase.getMetadata")
setFlags("g")
setByLine(true)
setFile(file)
execute()
}
}
}
}
}
subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {

View file

@ -3,5 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Chamada em curso"</string>
<string name="call_foreground_service_message_android">"Toca para voltar à chamada"</string>
<string name="call_foreground_service_title_android">"☎️ Chamada em curso"</string>
<string name="screen_incoming_call_subtitle_android">"A receber chamada da Element "</string>
<string name="screen_incoming_call_subtitle_android">"A receber chamada da Element"</string>
</resources>

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.licenses.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}

View file

@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.features.preferences.api
package io.element.android.features.licenses.api
import android.app.Activity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
interface OpenSourceLicensesProvider {
val hasOpenSourceLicenses: Boolean
fun navigateToOpenSourceLicenses(activity: Activity)
interface OpenSourceLicensesEntryPoint {
fun getNode(node: Node, buildContext: BuildContext): Node
}

View file

@ -0,0 +1,49 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.anvil)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.licenses.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(libs.serialization.json)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
api(projects.features.licenses.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -14,19 +14,19 @@
* limitations under the License.
*/
package io.element.android.x.licenses
package io.element.android.features.licenses.impl
import android.app.Activity
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class FdroidOpenSourceLicensesProvider @Inject constructor() : OpenSourceLicensesProvider {
override val hasOpenSourceLicenses: Boolean = false
override fun navigateToOpenSourceLicenses(activity: Activity) {
error("Not supported, please ensure that hasOpenSourcesLicenses is true before calling this method")
class DefaultOpenSourcesLicensesEntryPoint @Inject constructor() : OpenSourceLicensesEntryPoint {
override fun getNode(node: Node, buildContext: BuildContext): Node {
return node.createNode<DependenciesFlowNode>(buildContext)
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.licenses.impl.details.DependenciesDetailsNode
import io.element.android.features.licenses.impl.list.DependencyLicensesListNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class DependenciesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<DependenciesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.LicensesList,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object LicensesList : NavTarget
@Parcelize
data class LicenseDetails(val license: DependencyLicenseItem) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LicensesList -> {
val callback = object : DependencyLicensesListNode.Callback {
override fun onOpenLicense(license: DependencyLicenseItem) {
backstack.push(NavTarget.LicenseDetails(license))
}
}
createNode<DependencyLicensesListNode>(buildContext, listOf(callback))
}
is NavTarget.LicenseDetails -> {
createNode<DependenciesDetailsNode>(buildContext, listOf(DependenciesDetailsNode.Inputs(navTarget.license)))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(modifier)
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
interface LicensesProvider {
suspend fun provides(): List<DependencyLicenseItem>
}
@ContributesBinding(AppScope::class)
class AssetLicensesProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatchers: CoroutineDispatchers,
) : LicensesProvider {
@OptIn(ExperimentalSerializationApi::class)
override suspend fun provides(): List<DependencyLicenseItem> {
return withContext(dispatchers.io) {
context.assets.open("licensee-artifacts.json").use { inputStream ->
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
json.decodeFromStream<List<DependencyLicenseItem>>(inputStream)
.sortedBy { it.safeName.lowercase() }
}
}
}
}

View file

@ -5,7 +5,7 @@
* 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
* https://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,
@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.createkey
package io.element.android.features.licenses.impl.details
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -24,21 +24,31 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(SessionScope::class)
class CreateNewRecoveryKeyNode @AssistedInject constructor(
@ContributesNode(AppScope::class)
class DependenciesDetailsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val buildMeta: BuildMeta,
) : Node(buildContext, plugins = plugins) {
) : Node(
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(
val licenseItem: DependencyLicenseItem,
) : NodeInputs
private val licenseItem = inputs<Inputs>().licenseItem
@Composable
override fun View(modifier: Modifier) {
CreateNewRecoveryKeyView(
desktopApplicationName = buildMeta.desktopApplicationName,
DependenciesDetailsView(
modifier = modifier,
onBackClick = ::navigateUp,
licenseItem = licenseItem,
onBack = ::navigateUp
)
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.details
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import io.element.android.features.licenses.impl.list.aDependencyLicenseItem
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DependenciesDetailsView(
licenseItem: DependencyLicenseItem,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = licenseItem.safeName) },
navigationIcon = { BackButton(onClick = onBack) },
)
},
) { contentPadding ->
LazyColumn(
modifier = Modifier.padding(contentPadding),
) {
val licenses = licenseItem.licenses.orEmpty() +
licenseItem.unknownLicenses.orEmpty()
items(licenses) { license ->
val text = buildString {
if (license.name != null) {
append(license.name)
append("\n")
append("\n")
}
if (license.url != null) {
append(license.url)
}
}
ListItem(
headlineContent = {
ClickableLinkText(
text = text,
interactionSource = remember { MutableInteractionSource() },
)
}
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun DependenciesDetailsViewPreview() = ElementPreview {
DependenciesDetailsView(
licenseItem = aDependencyLicenseItem(),
onBack = {}
)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.list
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class DependencyLicensesListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: DependencyLicensesListPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun onOpenLicense(license: DependencyLicenseItem)
}
private fun onOpenLicense(license: DependencyLicenseItem) {
plugins<Callback>()
.forEach { it.onOpenLicense(license) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
DependencyLicensesListView(
state = state,
onBackClick = ::navigateUp,
onOpenLicense = ::onOpenLicense,
)
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.licenses.impl.LicensesProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import javax.inject.Inject
class DependencyLicensesListPresenter @Inject constructor(
private val licensesProvider: LicensesProvider,
) : Presenter<DependencyLicensesListState> {
@Composable
override fun present(): DependencyLicensesListState {
var licenses by remember {
mutableStateOf<AsyncData<ImmutableList<DependencyLicenseItem>>>(AsyncData.Loading())
}
LaunchedEffect(Unit) {
runCatching {
licenses = AsyncData.Success(licensesProvider.provides().toPersistentList())
}.onFailure {
licenses = AsyncData.Failure(it)
}
}
return DependencyLicensesListState(licenses = licenses)
}
}

View file

@ -14,13 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.preferences.impl.about
package io.element.android.features.licenses.impl.list
import android.app.Activity
import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
class FakeOpenSourceLicensesProvider(
override val hasOpenSourceLicenses: Boolean,
) : OpenSourceLicensesProvider {
override fun navigateToOpenSourceLicenses(activity: Activity) = Unit
}
data class DependencyLicensesListState(
val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
)

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.features.licenses.impl.model.License
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.persistentListOf
open class DependencyLicensesListStateProvider : PreviewParameterProvider<DependencyLicensesListState> {
override val values: Sequence<DependencyLicensesListState>
get() = sequenceOf(
DependencyLicensesListState(
licenses = AsyncData.Loading()
),
DependencyLicensesListState(
licenses = AsyncData.Failure(Exception("Failed to load licenses"))
),
DependencyLicensesListState(
licenses = AsyncData.Success(
persistentListOf(
aDependencyLicenseItem(),
aDependencyLicenseItem(name = null),
)
)
)
)
}
internal fun aDependencyLicenseItem(
name: String? = "A dependency",
) = DependencyLicenseItem(
groupId = "org.some.group",
artifactId = "a-dependency",
version = "1.0.0",
name = name,
licenses = listOf(
License(
identifier = "Apache 2.0",
name = "Apache 2.0",
url = "https://www.apache.org/licenses/LICENSE-2.0"
)
),
unknownLicenses = listOf(),
scm = null,
)

View file

@ -0,0 +1,118 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DependencyLicensesListView(
state: DependencyLicensesListState,
onBackClick: () -> Unit,
onOpenLicense: (DependencyLicenseItem) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(text = stringResource(CommonStrings.common_open_source_licenses)) },
navigationIcon = { BackButton(onClick = onBackClick) },
)
},
) { contentPadding ->
LazyColumn(
modifier = Modifier
.padding(contentPadding)
.padding(horizontal = 16.dp)
) {
when (state.licenses) {
is AsyncData.Failure -> item {
Text(
text = stringResource(CommonStrings.common_error),
modifier = Modifier.padding(16.dp)
)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 64.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
append(license.groupId)
append(":")
append(license.artifactId)
append(":")
append(license.version)
}
)
},
onClick = {
onOpenLicense(license)
}
)
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun DependencyLicensesListViewPreview(
@PreviewParameter(DependencyLicensesListStateProvider::class) state: DependencyLicensesListState
) = ElementPreview {
DependencyLicensesListView(
state = state,
onBackClick = {},
onOpenLicense = {},
)
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.model
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class DependencyLicenseItem(
val groupId: String,
val artifactId: String,
val version: String,
@SerialName("spdxLicenses")
val licenses: List<License>?,
val unknownLicenses: List<License>?,
val name: String?,
val scm: Scm?,
) : Parcelable {
@IgnoredOnParcel
val safeName = name?.takeIf { name -> name != "null" } ?: "$groupId:$artifactId"
}
@Serializable
@Parcelize
data class License(
val identifier: String?,
val name: String?,
val url: String?,
) : Parcelable
@Serializable
@Parcelize
data class Scm(
val url: String,
) : Parcelable

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.list
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DependencyLicensesListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state, no licenses`() = runTest {
val presenter = createPresenter { emptyList() }
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.licenses.isSuccess()).isTrue()
assertThat(finalState.licenses.dataOrNull()).isEmpty()
}
}
@Test
fun `present - initial state, one license`() = runTest {
val anItem = aDependencyLicenseItem()
val presenter = createPresenter {
listOf(anItem)
}
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.licenses.isSuccess()).isTrue()
assertThat(finalState.licenses.dataOrNull()!!.size).isEqualTo(1)
assertThat(finalState.licenses.dataOrNull()!!.get(0)).isEqualTo(anItem)
}
}
private fun createPresenter(
provideResult: () -> List<DependencyLicenseItem>
) = DependencyLicensesListPresenter(
licensesProvider = FakeLicensesProvider(provideResult),
)
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.licenses.impl.list
import io.element.android.features.licenses.impl.LicensesProvider
import io.element.android.features.licenses.impl.model.DependencyLicenseItem
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLicensesProvider(
private val provideResult: () -> List<DependencyLicenseItem> = { lambdaError() }
) : LicensesProvider {
override suspend fun provides(): List<DependencyLicenseItem> {
return provideResult()
}
}

View file

@ -16,9 +16,11 @@
package io.element.android.features.lockscreen.impl.unlock
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -26,6 +28,8 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.util.onSuccessLogout
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@ -47,6 +51,8 @@ class PinUnlockNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val isDark = ElementTheme.isLightTheme.not()
LaunchedEffect(state.isUnlocked) {
if (state.isUnlocked) {
onUnlock()
@ -57,6 +63,7 @@ class PinUnlockNode @AssistedInject constructor(
// UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true.
// It's set to false in PinUnlockActivity.
isInAppUnlock = true,
onSuccessLogout = { onSuccessLogout(activity, isDark, it) },
modifier = modifier
)
}

View file

@ -30,6 +30,7 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -61,7 +62,7 @@ class PinUnlockPresenter @Inject constructor(
mutableStateOf(false)
}
val signOutAction = remember {
mutableStateOf<AsyncData<String?>>(AsyncData.Uninitialized)
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
}
var biometricUnlockResult by remember {
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
@ -177,7 +178,7 @@ class PinUnlockPresenter @Inject constructor(
}
}
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncData<String?>>) = launch {
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch {
suspend {
logoutUseCase.logout(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction)

View file

@ -19,6 +19,7 @@ package io.element.android.features.lockscreen.impl.unlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
data class PinUnlockState(
@ -26,7 +27,7 @@ data class PinUnlockState(
val showWrongPinTitle: Boolean,
val remainingAttempts: AsyncData<Int>,
val showSignOutPrompt: Boolean,
val signOutAction: AsyncData<String?>,
val signOutAction: AsyncAction<String?>,
val showBiometricUnlock: Boolean,
val isUnlocked: Boolean,
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,

View file

@ -19,6 +19,7 @@ package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
@ -30,7 +31,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
aPinUnlockState(showSignOutPrompt = true),
aPinUnlockState(showBiometricUnlock = false),
aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
aPinUnlockState(signOutAction = AsyncData.Loading()),
aPinUnlockState(signOutAction = AsyncAction.Loading),
)
}
@ -42,7 +43,7 @@ fun aPinUnlockState(
showBiometricUnlock: Boolean = true,
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
isUnlocked: Boolean = false,
signOutAction: AsyncData<String?> = AsyncData.Uninitialized,
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
) = PinUnlockState(
pinEntry = AsyncData.Success(pinEntry),
showWrongPinTitle = showWrongPinTitle,

View file

@ -39,7 +39,9 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -56,6 +58,7 @@ import io.element.android.features.lockscreen.impl.components.PinEntryTextField
import io.element.android.features.lockscreen.impl.pin.model.PinDigit
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.components.ProgressDialog
@ -74,6 +77,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PinUnlockView(
state: PinUnlockState,
isInAppUnlock: Boolean,
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
@ -91,9 +95,21 @@ fun PinUnlockView(
onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
)
}
if (state.signOutAction is AsyncData.Loading) {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
when (state.signOutAction) {
AsyncAction.Loading -> {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
is AsyncAction.Success -> {
val latestOnSuccessLogout by rememberUpdatedState(onSuccessLogout)
LaunchedEffect(state) {
latestOnSuccessLogout(state.signOutAction.data)
}
}
AsyncAction.Confirming,
is AsyncAction.Failure,
AsyncAction.Uninitialized -> Unit
}
if (state.showBiometricUnlockError) {
ErrorDialog(
content = state.biometricUnlockErrorMessage ?: "",
@ -363,6 +379,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
PinUnlockView(
state = state,
isInAppUnlock = true,
onSuccessLogout = {},
)
}
}
@ -374,6 +391,7 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
PinUnlockView(
state = state,
isInAppUnlock = false,
onSuccessLogout = {},
)
}
}

View file

@ -24,11 +24,13 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter
import io.element.android.features.lockscreen.impl.unlock.PinUnlockView
import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings
import io.element.android.features.logout.api.util.onSuccessLogout
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -53,7 +55,12 @@ class PinUnlockActivity : AppCompatActivity() {
setContent {
ElementThemeApp(appPreferencesStore) {
val state = presenter.present()
PinUnlockView(state = state, isInAppUnlock = false)
val isDark = ElementTheme.isLightTheme.not()
PinUnlockView(
state = state,
isInAppUnlock = false,
onSuccessLogout = { onSuccessLogout(this, isDark, it) },
)
}
}
lifecycleScope.launch {

View file

@ -18,9 +18,9 @@ package io.element.android.features.lockscreen.impl.unlock.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.AppScope
@ContributesTo(SessionScope::class)
@ContributesTo(AppScope::class)
interface PinUnlockBindings {
fun inject(activity: PinUnlockActivity)
}

View file

@ -29,6 +29,7 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.pin.model.assertText
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -51,7 +52,7 @@ class PinUnlockPresenterTest {
assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.isUnlocked).isFalse()
assertThat(state.signOutAction).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(state.signOutAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Uninitialized::class.java)
}
awaitItem().also { state ->
@ -106,7 +107,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - forgot pin flow`() = runTest {
val signOutLambda = lambdaRecorder<Boolean, String> { "" }
val signOutLambda = lambdaRecorder<Boolean, String?> { "" }
val signOut = FakeLogoutUseCase(signOutLambda)
val presenter = createPinUnlockPresenter(this, logoutUseCase = signOut)
moleculeFlow(RecompositionMode.Immediate) {
@ -133,7 +134,7 @@ class PinUnlockPresenterTest {
}
skipItems(2)
awaitItem().also { state ->
assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java)
assertThat(state.signOutAction).isInstanceOf(AsyncAction.Success::class.java)
}
assert(signOutLambda).isCalledOnce()
}

View file

@ -31,7 +31,7 @@ sealed class ChangeServerError : Throwable() {
companion object {
fun from(error: Throwable): ChangeServerError = when (error) {
is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert
is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert
else -> Error(R.string.screen_change_server_error_invalid_homeserver)
}
}

View file

@ -32,7 +32,7 @@ class ErrorFormatterTest {
@Test
fun `loginError - invalid auth error returns unknown error message`() {
val error = AuthenticationException.SlidingSyncNotAvailable("Some message. Also contains M_FORBIDDEN, but won't be parsed")
val error = AuthenticationException.SlidingSyncVersion("Some message. Also contains M_FORBIDDEN, but won't be parsed")
assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
}

View file

@ -22,6 +22,7 @@ android {
}
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)

View file

@ -23,9 +23,10 @@ interface LogoutUseCase {
/**
* Log out the current user and then perform any needed cleanup tasks.
* @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway.
* @return the session id of the logged out user.
* @return an optional URL. When the URL is there, it should be presented to the user after logout for
* Relying Party (RP) initiated logout on their account page.
*/
suspend fun logout(ignoreSdkError: Boolean): String
suspend fun logout(ignoreSdkError: Boolean): String?
interface Factory {
fun create(sessionId: String): LogoutUseCase

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.logout.api.util
import android.app.Activity
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import timber.log.Timber
fun onSuccessLogout(
activity: Activity,
darkTheme: Boolean,
url: String?,
) {
Timber.d("Success logout with result url: $url")
url?.let {
activity.openUrlInChromeCustomTab(null, darkTheme, it)
}
}

View file

@ -17,27 +17,25 @@
package io.element.android.features.logout.impl
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import javax.inject.Inject
class DefaultLogoutUseCase @AssistedInject constructor(
@Assisted private val sessionId: String,
@ContributesBinding(AppScope::class)
class DefaultLogoutUseCase @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val matrixClientProvider: MatrixClientProvider,
) : LogoutUseCase {
@ContributesBinding(AppScope::class)
@AssistedFactory
interface Factory : LogoutUseCase.Factory {
override fun create(sessionId: String): DefaultLogoutUseCase
}
override suspend fun logout(ignoreSdkError: Boolean): String {
val matrixClient = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrThrow()
matrixClient.logout(ignoreSdkError = ignoreSdkError)
return sessionId
override suspend fun logout(ignoreSdkError: Boolean): String? {
val currentSession = authenticationService.getLatestSessionId()
return if (currentSession != null) {
matrixClientProvider.getOrRestore(currentSession)
.getOrThrow()
.logout(userInitiated = true, ignoreSdkError = true)
} else {
error("No session to sign out")
}
}
}

View file

@ -27,10 +27,10 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.features.logout.api.util.onSuccessLogout
import io.element.android.libraries.di.SessionScope
import timber.log.Timber
@ContributesNode(SessionScope::class)
class LogoutNode @AssistedInject constructor(
@ -42,21 +42,15 @@ class LogoutNode @AssistedInject constructor(
plugins<LogoutEntryPoint.Callback>().forEach { it.onChangeRecoveryKeyClick() }
}
private fun onSuccessLogout(activity: Activity, url: String?) {
Timber.d("Success logout with result url: $url")
url?.let {
activity.openUrlInChromeCustomTab(null, false, it)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val isDark = ElementTheme.isLightTheme.not()
LogoutView(
state = state,
onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick,
onSuccessLogout = { onSuccessLogout(activity, it) },
onSuccessLogout = { onSuccessLogout(activity, isDark, it) },
onBackClick = ::navigateUp,
modifier = modifier,
)

View file

@ -104,7 +104,7 @@ class LogoutPresenter @Inject constructor(
ignoreSdkError: Boolean,
) = launch {
suspend {
matrixClient.logout(ignoreSdkError)
matrixClient.logout(userInitiated = true, ignoreSdkError)
}.runCatchingUpdatingState(logoutAction)
}
}

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.logout.impl
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@Module
@ContributesTo(SessionScope::class)
object SessionLogoutModule {
@Provides
fun provideLogoutUseCase(
currentSessionIdHolder: CurrentSessionIdHolder,
factory: DefaultLogoutUseCase.Factory,
): LogoutUseCase {
return factory.create(currentSessionIdHolder.current.value)
}
}

View file

@ -86,7 +86,7 @@ class DefaultDirectLogoutPresenter @Inject constructor(
ignoreSdkError: Boolean,
) = launch {
suspend {
matrixClient.logout(ignoreSdkError)
matrixClient.logout(userInitiated = true, ignoreSdkError)
}.runCatchingUpdatingState(logoutAction)
}
}

View file

@ -144,7 +144,7 @@ class LogoutPresenterTest {
@Test
fun `present - logout with error then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { _ ->
logoutLambda = { _, _ ->
throw A_THROWABLE
}
}
@ -172,7 +172,7 @@ class LogoutPresenterTest {
@Test
fun `present - logout with error then force`() = runTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { ignoreSdkError ->
logoutLambda = { ignoreSdkError, _ ->
if (!ignoreSdkError) {
throw A_THROWABLE
} else {

View file

@ -125,7 +125,7 @@ class DefaultDirectLogoutPresenterTest {
@Test
fun `present - logout with error then cancel`() = runTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { _ ->
logoutLambda = { _, _ ->
throw A_THROWABLE
}
}
@ -153,7 +153,7 @@ class DefaultDirectLogoutPresenterTest {
@Test
fun `present - logout with error then force`() = runTest {
val matrixClient = FakeMatrixClient().apply {
logoutLambda = { ignoreSdkError ->
logoutLambda = { ignoreSdkError, _ ->
if (!ignoreSdkError) {
throw A_THROWABLE
} else {

View file

@ -20,9 +20,9 @@ import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.tests.testutils.lambda.lambdaError
class FakeLogoutUseCase(
var logoutLambda: (Boolean) -> String = lambdaError()
var logoutLambda: (Boolean) -> String? = { lambdaError() }
) : LogoutUseCase {
override suspend fun logout(ignoreSdkError: Boolean): String {
override suspend fun logout(ignoreSdkError: Boolean): String? {
return logoutLambda(ignoreSdkError)
}
}

View file

@ -18,11 +18,11 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
data class ToggleReaction(val emoji: String, val uniqueId: UniqueId) : MessagesEvents
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data object Dismiss : MessagesEvents
}

View file

@ -70,7 +70,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
@ -194,7 +194,7 @@ class MessagesPresenter @AssistedInject constructor(
)
}
is MessagesEvents.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventId)
localCoroutineScope.toggleReaction(event.emoji, event.uniqueId)
}
is MessagesEvents.InviteDialogDismissed -> {
hasDismissedInviteDialog = true
@ -316,10 +316,10 @@ class MessagesPresenter @AssistedInject constructor(
private fun CoroutineScope.toggleReaction(
emoji: String,
eventId: EventId,
uniqueId: UniqueId,
) = launch(dispatchers.io) {
timelineController.invokeOnCurrentTimeline {
toggleReaction(emoji, eventId)
toggleReaction(emoji, uniqueId)
.onFailure { Timber.e(it) }
}
}

View file

@ -175,8 +175,7 @@ fun MessagesView(
}
fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.id))
}
fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) {
@ -248,7 +247,6 @@ fun MessagesView(
state = state.actionListState,
onSelectAction = ::onActionSelected,
onCustomReactionClick = { event ->
if (event.eventId == null) return@ActionListView
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
},
onEmojiReactionClick = ::onEmojiReactionClick,
@ -256,8 +254,8 @@ fun MessagesView(
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, uniqueId))
}
)

View file

@ -126,7 +126,6 @@ class DefaultActionListPresenter @AssistedInject constructor(
)
val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.isRemote &&
timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) {
target.value = ActionListState.Target.Success(

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
@ -97,7 +98,7 @@ class TimelinePresenter @AssistedInject constructor(
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val prevMostRecentItemId = rememberSaveable { mutableStateOf<UniqueId?>(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
@ -244,7 +245,7 @@ class TimelinePresenter @AssistedInject constructor(
*/
private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>,
prevMostRecentItemId: MutableState<UniqueId?>,
newEventState: MutableState<NewEventState>
) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@ -123,7 +124,10 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
}
fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
return TimelineItem.Virtual(UUID.randomUUID().toString(), aTimelineItemDaySeparatorModel("Today"))
return TimelineItem.Virtual(
id = UniqueId(UUID.randomUUID().toString()),
model = aTimelineItemDaySeparatorModel("Today"),
)
}
internal fun aTimelineItemEvent(
@ -145,7 +149,7 @@ internal fun aTimelineItemEvent(
messageShield: MessageShield? = null,
): TimelineItem.Event {
return TimelineItem.Event(
id = UUID.randomUUID().toString(),
id = UniqueId(UUID.randomUUID().toString()),
eventId = eventId,
transactionId = transactionId,
senderId = UserId("@senderId:domain"),
@ -211,7 +215,7 @@ internal fun aTimelineItemReadReceipts(
}
internal fun aGroupedEvents(
id: Long = 0,
id: UniqueId = UniqueId("0"),
withReadReceipts: Boolean = false,
): TimelineItem.GroupedEvents {
val event1 = aTimelineItemEvent(
@ -232,7 +236,7 @@ internal fun aGroupedEvents(
)
val events = listOf(event1, event2)
return TimelineItem.GroupedEvents(
id = id.toString(),
id = id,
events = events.toImmutableList(),
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
)

View file

@ -69,7 +69,7 @@ fun TimelineItemGroupedEventsRow(
)
},
) {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) }
val isExpanded = rememberSaveable(key = timelineItem.identifier().value) { mutableStateOf(false) }
fun onExpandGroupClick() {
isExpanded.value = !isExpanded.value

View file

@ -25,13 +25,13 @@ import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomReactionBottomSheet(
state: CustomReactionState,
onSelectEmoji: (EventId, Emoji) -> Unit,
onSelectEmoji: (UniqueId, Emoji) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState()
@ -43,10 +43,10 @@ fun CustomReactionBottomSheet(
}
fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event?.eventId == null) return
if (target?.event == null) return
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onSelectEmoji(target.event.eventId, emoji)
onSelectEmoji(target.event.id, emoji)
}
}

View file

@ -20,6 +20,7 @@ import androidx.annotation.VisibleForTesting
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UniqueId
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
@ -71,7 +72,7 @@ private fun MutableList<TimelineItem>.addGroup(
val groupId = groupIds.getOrPutGroupId(groupOfItems)
add(
TimelineItem.GroupedEvents(
id = groupId,
id = UniqueId(groupId),
events = groupOfItems.toImmutableList(),
aggregatedReadReceipts = groupOfItems.flatMap { it.readReceiptState.receipts }.toImmutableList()
)
@ -83,15 +84,15 @@ private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<Timel
assert(timelineItems.isNotEmpty())
for (item in timelineItems) {
val itemIdentifier = item.identifier()
if (this.contains(itemIdentifier)) {
return this[itemIdentifier]!!
if (this.contains(itemIdentifier.value)) {
return this[itemIdentifier.value]!!
}
}
val timelineItem = timelineItems.first()
return computeGroupIdWith(timelineItem).also { groupId ->
this[timelineItem.identifier()] = groupId
return computeGroupIdWith(timelineItem).value.also { groupId ->
this[timelineItem.identifier().value] = groupId
}
}
@VisibleForTesting
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"
internal fun computeGroupIdWith(timelineItem: TimelineItem): UniqueId = UniqueId("${timelineItem.identifier()}_group")

View file

@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
@ -36,7 +37,7 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface TimelineItem {
fun identifier(): String = when (this) {
fun identifier(): UniqueId = when (this) {
is Event -> id
is Virtual -> id
is GroupedEvents -> id
@ -58,13 +59,13 @@ sealed interface TimelineItem {
@Immutable
data class Virtual(
val id: String,
val id: UniqueId,
val model: TimelineItemVirtualModel
) : TimelineItem
@Immutable
data class Event(
val id: String,
val id: UniqueId,
// Note: eventId can be null when the event is a local echo
val eventId: EventId? = null,
val transactionId: TransactionId? = null,
@ -101,7 +102,7 @@ sealed interface TimelineItem {
@Immutable
data class GroupedEvents(
val id: String,
val id: UniqueId,
val events: ImmutableList<Event>,
val aggregatedReadReceipts: ImmutableList<ReadReceiptData>,
) : TimelineItem

View file

@ -73,6 +73,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@ -81,11 +82,11 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -201,8 +202,8 @@ class MessagesPresenterTest {
@Test
fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
val toggleReactionFailure = lambdaRecorder { _: String, _: EventId -> Result.failure<Unit>(IllegalStateException("Failed to send reaction")) }
val toggleReactionSuccess = lambdaRecorder { _: String, _: UniqueId -> Result.success(Unit) }
val toggleReactionFailure = lambdaRecorder { _: String, _: UniqueId -> Result.failure<Unit>(IllegalStateException("Failed to send reaction")) }
val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess
@ -222,25 +223,25 @@ class MessagesPresenterTest {
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", A_UNIQUE_ID))
// No crashes when sending a reaction failed
timeline.apply { toggleReactionLambda = toggleReactionFailure }
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", A_UNIQUE_ID))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assert(toggleReactionSuccess)
.isCalledOnce()
.with(value("👍"), value(AN_EVENT_ID))
.with(value("👍"), value(A_UNIQUE_ID))
assert(toggleReactionFailure)
.isCalledOnce()
.with(value("👍"), value(AN_EVENT_ID))
.with(value("👍"), value(A_UNIQUE_ID))
}
}
@Test
fun `present - handle toggling a reaction twice`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) }
val toggleReactionSuccess = lambdaRecorder { _: String, _: UniqueId -> Result.success(Unit) }
val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess
@ -259,13 +260,13 @@ class MessagesPresenterTest {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID))
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", A_UNIQUE_ID))
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", A_UNIQUE_ID))
assert(toggleReactionSuccess)
.isCalledExactly(2)
.withSequence(
listOf(value("👍"), value(AN_EVENT_ID)),
listOf(value("👍"), value(AN_EVENT_ID)),
listOf(value("👍"), value(A_UNIQUE_ID)),
listOf(value("👍"), value(A_UNIQUE_ID)),
)
}
}

View file

@ -361,7 +361,7 @@ class MessagesViewTest {
state = state,
)
rule.onAllNodesWithText("👍️").onFirst().performClick()
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventId!!))
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.id))
}
@Test
@ -463,7 +463,7 @@ class MessagesViewTest {
// Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000)
customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventId!!))
eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.id))
}
@Test

View file

@ -758,7 +758,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
displayEmojiReactions = false,
displayEmojiReactions = true,
actions = persistentListOf(
TimelineItemAction.Edit,
TimelineItemAction.Copy,

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -51,7 +52,7 @@ internal fun aMessageEvent(
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
messageShield: MessageShield? = null,
) = TimelineItem.Event(
id = eventId?.value.orEmpty(),
id = UniqueId(eventId?.value.orEmpty()),
eventId = eventId,
transactionId = transactionId,
senderId = A_USER_ID,

View file

@ -27,6 +27,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@ -87,7 +89,7 @@ class PinnedMessagesBannerPresenterTest {
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID",
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
@ -119,14 +121,14 @@ class PinnedMessagesBannerPresenterTest {
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID",
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent1,
),
),
MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID_2",
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = messageContent2,

View file

@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import org.junit.Test
@ -34,7 +35,7 @@ class TimelineItemIndexerTest {
groupedEvents.events.forEach { eventIds.add(it.eventId!!) }
},
TimelineItem.Virtual(
id = "dummy",
id = UniqueId("dummy"),
model = TimelineItemReadMarkerModel
),
)

View file

@ -36,6 +36,7 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -46,6 +47,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
@ -79,9 +82,6 @@ import java.util.Date
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
@OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@ -131,7 +131,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem())
)
)
)
@ -164,9 +164,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
@ -201,9 +201,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
@ -243,9 +243,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
@ -279,8 +279,8 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
MatrixTimelineItem.Virtual(A_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Virtual(A_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
)
)
).apply {
@ -310,13 +310,13 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
timelineItems.emit(
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent())))
listOf(MatrixTimelineItem.Event(UniqueId("0"), anEventTimelineItem(content = aMessageContent())))
)
consumeItemsUntilPredicate { it.timelineItems.size == 1 }
// Mimics sending a message, and assert newEventState is FromMe
timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true)
items + listOf(MatrixTimelineItem.Event("1", event))
items + listOf(MatrixTimelineItem.Event(UniqueId("1"), event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 2 }
awaitLastSequentialItem().also { state ->
@ -325,7 +325,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
// Mimics receiving a message without clearing the previous FromMe
timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event("2", event))
items + listOf(MatrixTimelineItem.Event(UniqueId("2"), event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 3 }
@ -337,7 +337,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
// Mimics receiving a message and assert newEventState is FromOther
timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event("3", event))
items + listOf(MatrixTimelineItem.Event(UniqueId("3"), event))
}
consumeItemsUntilPredicate { it.timelineItems.size == 4 }
awaitLastSequentialItem().also { state ->
@ -381,7 +381,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
),
)
timelineItems.emit(
listOf(MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
listOf(MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction)))
)
val item = awaitItem().timelineItems.first()
assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
@ -476,7 +476,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID,
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(),
)
)
@ -532,7 +532,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID,
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(eventId = AN_EVENT_ID),
)
)
@ -618,7 +618,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
FAKE_UNIQUE_ID,
A_UNIQUE_ID,
anEventTimelineItem(
sender = A_USER_ID,
receipts = persistentListOf(

View file

@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -54,7 +55,7 @@ class TimelineViewTest {
state = aTimelineState(
timelineItems = persistentListOf<TimelineItem>(
TimelineItem.Virtual(
id = "backward_pagination",
id = UniqueId("backward_pagination"),
model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0)
),
),

View file

@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemRead
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@ -37,7 +38,7 @@ class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event(
id = "0",
id = UniqueId("0"),
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
senderProfile = aProfileTimelineDetailsReady(displayName = ""),
@ -54,7 +55,7 @@ class TimelineItemGrouperTest {
messageShield = null,
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
private val aNonGroupableItemNoEvent = TimelineItem.Virtual(UniqueId("virtual"), aTimelineItemDaySeparatorModel("Today"))
@Test
fun `test empty`() {
@ -82,8 +83,8 @@ class TimelineItemGrouperTest {
fun `test groupables and ensure reordering`() {
val result = sut.group(
listOf(
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "0"),
aGroupableItem.copy(id = UniqueId("1")),
aGroupableItem.copy(id = UniqueId("0")),
),
)
assertThat(result).isEqualTo(
@ -91,8 +92,8 @@ class TimelineItemGrouperTest {
TimelineItem.GroupedEvents(
id = computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem.copy("0"),
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = UniqueId("0")),
aGroupableItem.copy(id = UniqueId("1")),
).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().toImmutableList(),
),
@ -161,13 +162,13 @@ class TimelineItemGrouperTest {
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
// When
val groupableItems = mutableListOf(
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "2")
aGroupableItem.copy(id = UniqueId("1")),
aGroupableItem.copy(id = UniqueId("2"))
)
val expectedGroupId = sut.group(groupableItems).first().identifier()
groupableItems.add(0, aGroupableItem.copy("3"))
groupableItems.add(2, aGroupableItem.copy("4"))
groupableItems.add(aGroupableItem.copy("5"))
groupableItems.add(0, aGroupableItem.copy(UniqueId("3")))
groupableItems.add(2, aGroupableItem.copy(UniqueId("4")))
groupableItems.add(aGroupableItem.copy(UniqueId("5")))
val actualGroupId = sut.group(groupableItems).first().identifier()
// Then
assertThat(actualGroupId).isEqualTo(expectedGroupId)

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -80,7 +81,7 @@ fun TestScope.aDefaultRedactedVoiceMessageManager(
fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
MatrixTimelineItem.Event(
uniqueId = "0",
uniqueId = UniqueId("0"),
event = EventTimelineItem(
eventId = eventId,
transactionId = null,

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2024 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 io.element.android.features.migration.impl.migrations
import com.squareup.anvil.annotations.ContributesMultibinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.sessionstorage.api.SessionStore
import java.io.File
import javax.inject.Inject
/**
* Create the cache directory for the existing sessions.
*/
@ContributesMultibinding(AppScope::class)
class AppMigration06 @Inject constructor(
private val sessionStore: SessionStore,
@CacheDirectory private val cacheDirectory: File,
) : AppMigration {
override val order: Int = 6
override suspend fun migrate() {
val allSessions = sessionStore.getAllSessions()
for (session in allSessions) {
if (session.cachePath.isEmpty()) {
val sessionFile = File(session.sessionPath)
val sessionFolder = sessionFile.name
val cachePath = File(cacheDirectory, sessionFolder)
sessionStore.updateData(session.copy(cachePath = cachePath.absolutePath))
// Move existing cache files
listOf(
"matrix-sdk-event-cache.sqlite3",
"matrix-sdk-event-cache.sqlite3-shm",
"matrix-sdk-event-cache.sqlite3-wal",
).map { fileName ->
File(sessionFile, fileName)
}.takeIf { files ->
files.all { it.exists() }
}?.forEach { cacheFile ->
val targetFile = File(cachePath, cacheFile.name)
cacheFile.copyTo(targetFile)
cacheFile.delete()
}
}
}
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.poll.impl
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@ -32,8 +33,8 @@ fun aPollTimelineItems(
return flowOf(
polls.map { entry ->
MatrixTimelineItem.Event(
entry.key.value,
anEventTimelineItem(
uniqueId = UniqueId(entry.key.value),
event = anEventTimelineItem(
eventId = entry.key,
content = entry.value,
)

View file

@ -62,6 +62,7 @@ dependencies {
implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.features.licenses.api)
implementation(projects.features.logout.api)
implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api)

View file

@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
@ -59,6 +60,7 @@ class PreferencesFlowNode @AssistedInject constructor(
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
private val logoutEntryPoint: LogoutEntryPoint,
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
) : BaseFlowNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -106,6 +108,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object SignOut : NavTarget
@Parcelize
data object OssLicenses : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -170,7 +175,12 @@ class PreferencesFlowNode @AssistedInject constructor(
createNode<ConfigureTracingNode>(buildContext)
}
NavTarget.About -> {
createNode<AboutNode>(buildContext)
val callback = object : AboutNode.Callback {
override fun openOssLicenses() {
backstack.push(NavTarget.OssLicenses)
}
}
createNode<AboutNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsSettings -> {
createNode<AnalyticsSettingsNode>(buildContext)
@ -232,6 +242,9 @@ class PreferencesFlowNode @AssistedInject constructor(
.callback(callBack)
.build()
}
is NavTarget.OssLicenses -> {
openSourceLicensesEntryPoint.getNode(this, buildContext)
}
}
}

View file

@ -27,7 +27,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
@ -36,8 +35,11 @@ class AboutNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AboutPresenter,
private val openSourceLicensesProvider: OpenSourceLicensesProvider,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openOssLicenses()
}
private fun onElementLegalClick(
activity: Activity,
darkTheme: Boolean,
@ -58,7 +60,7 @@ class AboutNode @AssistedInject constructor(
onElementLegalClick(activity, isDark, elementLegal)
},
onOpenSourceLicensesClick = {
openSourceLicensesProvider.navigateToOpenSourceLicenses(activity)
plugins.filterIsInstance<Callback>().forEach { it.openOssLicenses() }
},
modifier = modifier
)

View file

@ -17,18 +17,14 @@
package io.element.android.features.preferences.impl.about
import androidx.compose.runtime.Composable
import io.element.android.features.preferences.api.OpenSourceLicensesProvider
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class AboutPresenter @Inject constructor(
private val openSourceLicensesProvider: OpenSourceLicensesProvider,
) : Presenter<AboutState> {
class AboutPresenter @Inject constructor() : Presenter<AboutState> {
@Composable
override fun present(): AboutState {
return AboutState(
elementLegals = getAllLegals(),
hasOpenSourcesLicenses = openSourceLicensesProvider.hasOpenSourceLicenses,
)
}
}

View file

@ -18,5 +18,4 @@ package io.element.android.features.preferences.impl.about
data class AboutState(
val elementLegals: List<ElementLegal>,
val hasOpenSourcesLicenses: Boolean,
)

View file

@ -22,13 +22,11 @@ open class AboutStateProvider : PreviewParameterProvider<AboutState> {
override val values: Sequence<AboutState>
get() = sequenceOf(
anAboutState(),
anAboutState(hasOpenSourcesLicenses = true),
)
}
fun anAboutState(
hasOpenSourcesLicenses: Boolean = false,
elementLegals: List<ElementLegal> = getAllLegals(),
) = AboutState(
elementLegals = getAllLegals(),
hasOpenSourcesLicenses = hasOpenSourcesLicenses,
elementLegals = elementLegals,
)

View file

@ -45,12 +45,10 @@ fun AboutView(
onClick = { onElementLegalClick(elementLegal) }
)
}
if (state.hasOpenSourcesLicenses) {
PreferenceText(
title = stringResource(id = CommonStrings.common_open_source_licenses),
onClick = onOpenSourceLicensesClick,
)
}
PreferenceText(
title = stringResource(id = CommonStrings.common_open_source_licenses),
onClick = onOpenSourceLicensesClick,
)
}
}

View file

@ -30,10 +30,10 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.logout.api.util.onSuccessLogout
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import timber.log.Timber
@ContributesNode(SessionScope::class)
class PreferencesRootNode @AssistedInject constructor(
@ -94,13 +94,6 @@ class PreferencesRootNode @AssistedInject constructor(
}
}
private fun onSuccessLogout(activity: Activity, url: String?) {
Timber.d("Success (direct) logout with result url: $url")
url?.let {
activity.openUrlInChromeCustomTab(null, false, it)
}
}
private fun onOpenNotificationSettings() {
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
}
@ -153,7 +146,7 @@ class PreferencesRootNode @AssistedInject constructor(
directLogoutView.Render(
state = state.directLogoutState,
onSuccessLogout = {
onSuccessLogout(activity, it)
onSuccessLogout(activity, isDark, it)
}
)
}

View file

@ -37,7 +37,7 @@ Wenn du fortfährst, können sich einige deiner Einstellungen ändern."</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>
<string name="screen_notification_settings_edit_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_notification_settings_edit_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"Benachrichtige mich bei Direktnachrichten über "</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"Benachrichtige mich bei Direktnachrichten über"</string>
<string name="screen_notification_settings_edit_screen_group_section_header">"Bei Gruppenchats benachrichtige mich bei"</string>
<string name="screen_notification_settings_enable_notifications">"Benachrichtigungen auf diesem Gerät aktivieren"</string>
<string name="screen_notification_settings_failed_fixing_configuration">"Die Konfiguration wurde nicht korrigiert, bitte versuche es erneut."</string>

View file

@ -31,25 +31,12 @@ class AboutPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = AboutPresenter(FakeOpenSourceLicensesProvider(hasOpenSourceLicenses = true))
val presenter = AboutPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.elementLegals).isEqualTo(getAllLegals())
assertThat(initialState.hasOpenSourcesLicenses).isTrue()
}
}
@Test
fun `present - initial state, no open source licenses`() = runTest {
val presenter = AboutPresenter(FakeOpenSourceLicensesProvider(hasOpenSourceLicenses = false))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.elementLegals).isEqualTo(getAllLegals())
assertThat(initialState.hasOpenSourcesLicenses).isFalse()
}
}
}

View file

@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.about
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@ -61,21 +60,10 @@ class AboutViewTest {
}
@Test
fun `if open source licenses are not available, the entry is not displayed`() {
rule.setAboutView(
anAboutState(),
)
val text = rule.activity.getString(CommonStrings.common_open_source_licenses)
rule.onNodeWithText(text).assertDoesNotExist()
}
@Test
fun `if open source licenses are available, clicking on the entry invokes the expected callback`() {
fun `clicking on the open source licenses invokes the expected callback`() {
ensureCalledOnce { callback ->
rule.setAboutView(
anAboutState(
hasOpenSourcesLicenses = true,
),
anAboutState(),
onOpenSourceLicensesClick = callback,
)
rule.clickOn(CommonStrings.common_open_source_licenses)

View file

@ -166,7 +166,7 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - toggling simplified sliding sync changes the preferences and logs out the user`() = runTest {
val logoutCallRecorder = lambdaRecorder<Boolean, String> { "" }
val logoutCallRecorder = lambdaRecorder<Boolean, String?> { "" }
val logoutUseCase = FakeLogoutUseCase(logoutLambda = logoutCallRecorder)
val preferences = InMemoryAppPreferencesStore()
val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences, logoutUseCase = logoutUseCase)

View file

@ -9,7 +9,7 @@
<string name="screen_bug_report_editor_supporting">"Iloji bo\'lsa, tavsifni ingliz tilida yozing."</string>
<string name="screen_bug_report_include_crash_logs">"Buzilish jurnallarini yuboring"</string>
<string name="screen_bug_report_include_logs">"Jurnallarga ruxsat bering"</string>
<string name="screen_bug_report_include_screenshot">"Ekran tasvirini yuboring "</string>
<string name="screen_bug_report_include_screenshot">"Ekran tasvirini yuboring"</string>
<string name="screen_bug_report_logs_description">"Har bir narsa to\'ri ishlayotganiga ishonch hosil qilish uchun xabaringizga jurnallar kiritiladi. Xabarni jurnallarsiz yuborish uchun ushbu sozlamani oʻchiring."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?"</string>
</resources>

View file

@ -289,6 +289,7 @@ class DefaultBugReporterTest {
slidingSyncProxy = null,
passphrase = null,
sessionPath = "session",
cachePath = "cache",
)
@Test
fun `test sendBugReport error`() = runTest {

View file

@ -14,7 +14,7 @@
<string name="screen_room_details_error_muting">"Bu xona ovozini ochirib bolmadi, qayta urinib koring."</string>
<string name="screen_room_details_error_unmuting">"Bu xonaning ovozi yoqilmadi, qayta urinib koring."</string>
<string name="screen_room_details_invite_people_title">"Odamlarni taklif qiling"</string>
<string name="screen_room_details_leave_room_title">"Xonani tark etish "</string>
<string name="screen_room_details_leave_room_title">"Xonani tark etish"</string>
<string name="screen_room_details_notification_mode_custom">"Maxsus"</string>
<string name="screen_room_details_notification_mode_default">"Standart"</string>
<string name="screen_room_details_notification_title">"Bildirishnomalar"</string>

View file

@ -33,6 +33,7 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onRoomClick(roomId: RoomId)
fun onCreateRoomClick()
fun onSettingsClick()
fun onSetUpRecoveryClick()
fun onSessionConfirmRecoveryKeyClick()
fun onRoomSettingsClick(roomId: RoomId)
fun onReportBugClick()

View file

@ -66,6 +66,10 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClick() }
}
private fun onSetUpRecoveryClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSetUpRecoveryClick() }
}
private fun onSessionConfirmRecoveryKeyClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClick() }
}
@ -98,6 +102,7 @@ class RoomListNode @AssistedInject constructor(
onRoomClick = this::onRoomClick,
onSettingsClick = this::onOpenSettings,
onCreateRoomClick = this::onCreateRoomClick,
onSetUpRecoveryClick = this::onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick,
onRoomSettingsClick = this::onRoomSettingsClick,
onMenuActionClick = { onMenuActionClick(activity, it) },

View file

@ -187,8 +187,15 @@ class RoomListPresenter @Inject constructor(
derivedStateOf {
when {
currentSecurityBannerDismissed -> SecurityBannerState.None
recoveryState == RecoveryState.INCOMPLETE &&
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
syncState == SyncState.Running -> {
when (recoveryState) {
RecoveryState.UNKNOWN,
RecoveryState.DISABLED -> SecurityBannerState.SetUpRecovery
RecoveryState.INCOMPLETE -> SecurityBannerState.RecoveryKeyConfirmation
RecoveryState.WAITING_FOR_SYNC,
RecoveryState.ENABLED -> SecurityBannerState.None
}
}
else -> SecurityBannerState.None
}
}

View file

@ -66,6 +66,7 @@ enum class InvitesState {
enum class SecurityBannerState {
None,
SetUpRecovery,
RecoveryKeyConfirmation,
}

View file

@ -52,6 +52,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = MatrixUser(userId = UserId("@id:domain")), contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
)
}

View file

@ -53,6 +53,7 @@ fun RoomListView(
state: RoomListState,
onRoomClick: (RoomId) -> Unit,
onSettingsClick: () -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onCreateRoomClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
@ -78,6 +79,7 @@ fun RoomListView(
RoomListScaffold(
state = state,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
onOpenSettings = onSettingsClick,
@ -106,6 +108,7 @@ fun RoomListView(
@Composable
private fun RoomListScaffold(
state: RoomListState,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomId) -> Unit,
onOpenSettings: () -> Unit,
@ -142,6 +145,7 @@ private fun RoomListScaffold(
contentState = state.contentState,
filtersState = state.filtersState,
eventSink = state.eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = ::onRoomClick,
onCreateRoomClick = onCreateRoomClick,
@ -178,6 +182,7 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
state = state,
onRoomClick = {},
onSettingsClick = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onCreateRoomClick = {},
onRoomSettingsClick = {},

View file

@ -70,6 +70,7 @@ fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
onCreateRoomClick: () -> Unit,
@ -95,6 +96,7 @@ fun RoomListContentView(
state = contentState,
filtersState = filtersState,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
)
@ -141,6 +143,7 @@ private fun RoomsView(
state: RoomListContentState.Rooms,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
@ -154,6 +157,7 @@ private fun RoomsView(
RoomsViewList(
state = state,
eventSink = eventSink,
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onRoomClick = onRoomClick,
modifier = modifier.fillMaxSize(),
@ -165,6 +169,7 @@ private fun RoomsView(
private fun RoomsViewList(
state: RoomListContentState.Rooms,
eventSink: (RoomListEvents) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onRoomClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
@ -188,21 +193,27 @@ private fun RoomsViewList(
// FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80
contentPadding = PaddingValues(bottom = 80.dp)
) {
if (state.securityBannerState != SecurityBannerState.None) {
when (state.securityBannerState) {
SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
when (state.securityBannerState) {
SecurityBannerState.SetUpRecovery -> {
item {
SetUpRecoveryKeyBanner(
onContinueClick = onSetUpRecoveryClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
else -> Unit
}
} else if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
onContinueClick = onConfirmRecoveryKeyClick,
onDismissClick = { updatedEventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) {
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
}
}
}
@ -276,6 +287,7 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
eventSink = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onRoomClick = {},
onCreateRoomClick = {},

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2024 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
*
* https://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 io.element.android.features.roomlist.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
internal fun SetUpRecoveryKeyBanner(
onContinueClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
) {
DialogLikeBannerMolecule(
modifier = modifier,
title = stringResource(R.string.banner_set_up_recovery_title),
content = stringResource(R.string.banner_set_up_recovery_content),
onSubmitClick = onContinueClick,
onDismissClick = onDismissClick,
)
}
@PreviewsDayNight
@Composable
internal fun SetUpRecoveryKeyBannerPreview() = ElementPreview {
SetUpRecoveryKeyBanner(
onContinueClick = {},
onDismissClick = {},
)
}

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Vygenerujte nový klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup ke svým zařízením."</string>
<string name="banner_set_up_recovery_title">"Nastavení obnovy"</string>
<string name="confirm_recovery_key_banner_message">"Vaše záloha chatu není aktuálně synchronizována. Abyste si zachovali přístup k záloze chatu, musíte potvrdit klíč pro obnovení."</string>
<string name="confirm_recovery_key_banner_title">"Potvrďte klíč pro obnovení"</string>
<string name="full_screen_intent_banner_message">"Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."</string>
<string name="banner_set_up_recovery_title">"Seadista taastamine"</string>
<string name="confirm_recovery_key_banner_message">"Sinu vestluste varukoopia pole hetkel sünkroonis. Säilitamaks ligipääsu vestluse varukoopiale palun sisesta oma taastevõti."</string>
<string name="confirm_recovery_key_banner_title">"Sisesta oma taastevõti"</string>
<string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_set_up_recovery_content">"Hozzon létre egy új helyreállítási kulcsot, amellyel visszaállíthatja a titkosított üzenetek előzményeit, ha elveszíti az eszközökhöz való hozzáférést."</string>
<string name="banner_set_up_recovery_title">"Helyreállítás beállítása"</string>
<string name="confirm_recovery_key_banner_message">"A csevegés biztonsági mentése nincs szinkronban. Meg kell erősítenie a helyreállítási kulcsát, hogy továbbra is hozzáférjen a csevegés biztonsági mentéséhez."</string>
<string name="confirm_recovery_key_banner_title">"Helyreállítási kulcs megerősítése"</string>
<string name="full_screen_intent_banner_message">"Annak érdekében, hogy soha ne maradjon le egyetlen fontos hívásról sem, módosítsa a beállításokat, hogy engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."</string>

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