Merge branch 'develop' into renovate/org.owasp.dependencycheck-10.x

This commit is contained in:
ganfra 2024-09-06 12:00:23 +02:00 committed by GitHub
commit ec87d3f51e
281 changed files with 3290 additions and 1976 deletions

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
jobs: jobs:
@ -39,20 +39,13 @@ jobs:
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay APK - name: Assemble debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Assemble debug Fdroid APK
if: ${{ matrix.variant == 'debug' }}
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs - name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
jobs: jobs:
@ -61,7 +61,7 @@ jobs:
name: elementx-enterprise-debug name: elementx-enterprise-debug
path: | path: |
app/build/outputs/apk/gplay/debug/*-universal-debug.apk app/build/outputs/apk/gplay/debug/*-universal-debug.apk
- name: Compile release sources - name: Compile nightly and release sources
if: ${{ matrix.variant == 'release' }} if: ${{ matrix.variant == 'release' }}
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources - name: Compile nightly sources

View file

@ -8,7 +8,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
jobs: jobs:
@ -79,7 +79,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: elementx-apk-maestro 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' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}

View file

@ -7,7 +7,7 @@ on:
- cron: "0 4 * * *" - cron: "0 4 * * *"
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
jobs: jobs:

View file

@ -8,7 +8,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
jobs: jobs:

View file

@ -7,7 +7,7 @@ on:
- cron: "0 4 * * *" - cron: "0 4 * * *"
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
jobs: jobs:

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
jobs: jobs:

View file

@ -7,7 +7,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx5g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC -Dsonar.gradle.skipCompile=true GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false -Dsonar.gradle.skipCompile=true
jobs: jobs:
record: record:

View file

@ -7,7 +7,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true
jobs: jobs:

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true
GROUP: ${{ format('sonar-{0}', github.ref) }} GROUP: ${{ format('sonar-{0}', github.ref) }}
@ -36,16 +36,8 @@ jobs:
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - name: Build debug code and test fixtures
run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew assembleDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
- name: Build Fdroid Debug
run: ./gradlew :app:assembleFdroidDebug $CI_GRADLE_ARG_PROPERTIES
- name: Build Sample
run: ./gradlew :samples:minimal:assembleDebug $CI_GRADLE_ARG_PROPERTIES
- name: Build library fixtures
run: ./gradlew assembleDebug createFullJarDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
- name: Build app fixtures
run: ./gradlew :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
- name: 🔊 Publish results to Sonar - name: 🔊 Publish results to Sonar
env: env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseG1GC GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true
jobs: jobs:

View file

@ -1,3 +1,49 @@
Changes in Element X v0.5.2 (2024-09-05)
=========================================
### 🙌 Improvements
* [Identity reset] Remove instruction to reset identity on another client. by @bmarty in https://github.com/element-hq/element-x-android/pull/3355
* Redact message on displayed notification by @bmarty in https://github.com/element-hq/element-x-android/pull/3320
* Add a way to sign out when the user is asked to verify the session. by @bmarty in https://github.com/element-hq/element-x-android/pull/3359
* Add banner entry point to set up recovery by @bmarty in https://github.com/element-hq/element-x-android/pull/3360
* Replace OSS licenses plugin with Licensee and some manually done UI. by @bmarty in https://github.com/element-hq/element-x-android/pull/3381
### 🐛 Bugfixes
* Small fixes around logging out. by @bmarty in https://github.com/element-hq/element-x-android/pull/3356
* Ensure starting PinUnlockActivity does not crash the application. by @bmarty in https://github.com/element-hq/element-x-android/pull/3369
* Use the right colors for `@room` mention pills by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3376
* Fix avatar sometimes not loading by @bmarty in https://github.com/element-hq/element-x-android/pull/3366
* Make pinned events required state in SlidingSync by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3385
* Make sure to save the tokens the Client might return when its session is restored by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3378
* Fix Element Call closing automatically on API 34 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3402
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3373
### 🧱 Build
* Try adding a memory limit for the kotlin compiler by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3377
### Dependency upgrades
* Update dependency com.google.testparameterinjector:test-parameter-injector to v1.17 by @renovate in https://github.com/element-hq/element-x-android/pull/3357
* Update dependencyAnalysis to v2.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3362
* Update android.gradle.plugin to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3363
* Update dependency io.nlopez.compose.rules:detekt to v0.4.11 by @renovate in https://github.com/element-hq/element-x-android/pull/3364
* Update dependency com.posthog:posthog-android to v3.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3358
* Update mobile-dev-inc/action-maestro-cloud action to v1.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3367
* Update dependency com.posthog:posthog-android to v3.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3368
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.41 by @renovate in https://github.com/element-hq/element-x-android/pull/3384
* Rust sdk : update to 0.2.42 by @ganfra in https://github.com/element-hq/element-x-android/pull/3393
* Update dependency com.android.tools:desugar_jdk_libs to v2.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3350
* Update dependency com.sigpwned:emoji4j-core to v15.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3396
### Others
* Release : use a different concurrency group for enterprise build by @ganfra in https://github.com/element-hq/element-x-android/pull/3351
* Provide distinct cache directory to the Rust SDK. by @bmarty in https://github.com/element-hq/element-x-android/pull/3370
* Remove the migration screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3389
* Unified push endpoint: do not fallback to default endpoint in case of failure and add troubleshoot test. by @bmarty in https://github.com/element-hq/element-x-android/pull/3388
* Skip device verification screen when creating a new account using OIDC by @bmarty in https://github.com/element-hq/element-x-android/pull/3395
* Big emoji-only messages by @frebib in https://github.com/element-hq/element-x-android/pull/3295
Changes in Element X v0.5.1 (2024-08-28) Changes in Element X v0.5.1 (2024-08-28)
========================================= =========================================

View file

@ -17,15 +17,19 @@
@file:Suppress("UnstableApiUsage") @file:Suppress("UnstableApiUsage")
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI 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.allEnterpriseImpl
import extension.allFeaturesImpl import extension.allFeaturesImpl
import extension.allLibrariesImpl import extension.allLibrariesImpl
import extension.allServicesImpl import extension.allServicesImpl
import extension.gitBranchName
import extension.gitRevision
import extension.koverDependencies import extension.koverDependencies
import extension.locales import extension.locales
import extension.setupKover import extension.setupKover
import java.util.Locale
plugins { plugins {
id("io.element.android-compose-application") id("io.element.android-compose-application")
@ -36,7 +40,8 @@ plugins {
id(libs.plugins.firebaseAppDistribution.get().pluginId) id(libs.plugins.firebaseAppDistribution.get().pluginId)
alias(libs.plugins.knit) alias(libs.plugins.knit)
id("kotlin-parcelize") 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 // To be able to update the firebase.xml files, uncomment and build the project
// id("com.google.gms.google-services") // id("com.google.gms.google-services")
} }
@ -61,9 +66,6 @@ android {
abiFilters += listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64") 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 // Ref: https://developer.android.com/studio/build/configure-apk-splits.html#configure-abi-split
splits { splits {
// Configures multiple APKs based on ABI. // Configures multiple APKs based on ABI.
@ -215,6 +217,9 @@ androidComponents {
output.versionCode.set((output.versionCode.orNull ?: 0) * 10 + abiCode) output.versionCode.set((output.versionCode.orNull ?: 0) * 10 + abiCode)
} }
} }
val reportingExtension: ReportingExtension = project.extensions.getByType(ReportingExtension::class.java)
configureLicensesTasks(reportingExtension)
} }
// Knit // Knit
@ -259,8 +264,6 @@ dependencies {
// Comment to not include unified push in the project // Comment to not include unified push in the project
implementation(projects.libraries.pushproviders.unifiedpush) implementation(projects.libraries.pushproviders.unifiedpush)
"gplayImplementation"(libs.play.services.oss.licenses)
implementation(libs.appyx.core) implementation(libs.appyx.core)
implementation(libs.androidx.splash) implementation(libs.androidx.splash)
implementation(libs.androidx.core) implementation(libs.androidx.core)
@ -291,3 +294,51 @@ dependencies {
koverDependencies() 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

@ -1,11 +1,7 @@
import com.google.devtools.ksp.gradle.KspTask
import org.apache.tools.ant.taskdefs.optional.ReplaceRegExp
buildscript { buildscript {
dependencies { dependencies {
classpath(libs.kotlin.gradle.plugin) classpath(libs.kotlin.gradle.plugin)
classpath(libs.gms.google.services) classpath(libs.gms.google.services)
classpath(libs.oss.licenses.plugin)
} }
} }
@ -202,24 +198,6 @@ subprojects {
tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) 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 { subprojects {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions { kotlinOptions {

View file

@ -0,0 +1,2 @@
Main changes in this version: mainly bug fixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -42,6 +42,7 @@ import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings import io.element.android.features.call.impl.di.CallBindings
@ -54,6 +55,7 @@ import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -128,12 +130,14 @@ class ElementCallActivity :
private fun ListenToAndroidEvents(pipState: PictureInPictureState) { private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
val pipEventSink by rememberUpdatedState(pipState.eventSink) val pipEventSink by rememberUpdatedState(pipState.eventSink)
DisposableEffect(Unit) { DisposableEffect(Unit) {
val onUserLeaveHintListener = Runnable { val listener = Runnable {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture) pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
} }
addOnUserLeaveHintListener(onUserLeaveHintListener) lifecycleScope.launch {
addOnUserLeaveHintListener(listener)
}
onDispose { onDispose {
removeOnUserLeaveHintListener(onUserLeaveHintListener) removeOnUserLeaveHintListener(listener)
} }
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {

View file

@ -3,5 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Chamada em curso"</string> <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_message_android">"Toca para voltar à chamada"</string>
<string name="call_foreground_service_title_android">"☎️ Chamada em curso"</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> </resources>

View file

@ -46,7 +46,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>( ) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root(showDeviceVerifiedScreen = false),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -54,7 +54,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
) { ) {
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Root : NavTarget data class Root(val showDeviceVerifiedScreen: Boolean) : NavTarget
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@ -71,7 +71,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
override fun onDone() { override fun onDone() {
lifecycleScope.launch { lifecycleScope.launch {
// Move to the completed state view in the verification flow // Move to the completed state view in the verification flow
backstack.newRoot(NavTarget.Root) backstack.newRoot(NavTarget.Root(showDeviceVerifiedScreen = true))
} }
} }
} }
@ -80,6 +80,7 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
is NavTarget.Root -> { is NavTarget.Root -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext) verifySessionEntryPoint.nodeBuilder(this, buildContext)
.params(VerifySessionEntryPoint.Params(navTarget.showDeviceVerifiedScreen))
.callback(object : VerifySessionEntryPoint.Callback { .callback(object : VerifySessionEntryPoint.Callback {
override fun onEnterRecoveryKey() { override fun onEnterRecoveryKey() {
backstack.push(NavTarget.EnterRecoveryKey) backstack.push(NavTarget.EnterRecoveryKey)

View file

@ -14,15 +14,15 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {
namespace = "io.element.android.features.roomlist.test" namespace = "io.element.android.features.licenses.api"
} }
dependencies { dependencies {
implementation(projects.features.roomlist.api) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem)
implementation(projects.tests.testutils) implementation(projects.libraries.uiStrings)
} }

View file

@ -14,11 +14,11 @@
* limitations under the License. * 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 { interface OpenSourceLicensesEntryPoint {
val hasOpenSourceLicenses: Boolean fun getNode(node: Node, buildContext: BuildContext): Node
fun navigateToOpenSourceLicenses(activity: Activity)
} }

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. * 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 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 io.element.android.libraries.di.AppScope
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class FdroidOpenSourceLicensesProvider @Inject constructor() : OpenSourceLicensesProvider { class DefaultOpenSourcesLicensesEntryPoint @Inject constructor() : OpenSourceLicensesEntryPoint {
override val hasOpenSourceLicenses: Boolean = false override fun getNode(node: Node, buildContext: BuildContext): Node {
return node.createNode<DependenciesFlowNode>(buildContext)
override fun navigateToOpenSourceLicenses(activity: Activity) {
error("Not supported, please ensure that hasOpenSourcesLicenses is true before calling this method")
} }
} }

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

@ -0,0 +1,54 @@
/*
* 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.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 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.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class DependenciesDetailsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(
val licenseItem: DependencyLicenseItem,
) : NodeInputs
private val licenseItem = inputs<Inputs>().licenseItem
@Composable
override fun View(modifier: Modifier) {
DependenciesDetailsView(
modifier = modifier,
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. * 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.licenses.impl.model.DependencyLicenseItem
import io.element.android.features.preferences.api.OpenSourceLicensesProvider import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
class FakeOpenSourceLicensesProvider( data class DependencyLicensesListState(
override val hasOpenSourceLicenses: Boolean, val licenses: AsyncData<ImmutableList<DependencyLicenseItem>>,
) : OpenSourceLicensesProvider { )
override fun navigateToOpenSourceLicenses(activity: Activity) = Unit
}

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

@ -18,9 +18,9 @@ package io.element.android.features.lockscreen.impl.unlock.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity 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 { interface PinUnlockBindings {
fun inject(activity: PinUnlockActivity) fun inject(activity: PinUnlockActivity)
} }

View file

@ -31,7 +31,7 @@ sealed class ChangeServerError : Throwable() {
companion object { companion object {
fun from(error: Throwable): ChangeServerError = when (error) { 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) else -> Error(R.string.screen_change_server_error_invalid_homeserver)
} }
} }

View file

@ -32,7 +32,7 @@ class ErrorFormatterTest {
@Test @Test
fun `loginError - invalid auth error returns unknown error message`() { 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) assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown)
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -72,6 +72,7 @@ dependencies {
implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
implementation(libs.vanniktech.blurhash) implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage) implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings) implementation(libs.matrix.emojibase.bindings)

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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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 { sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : 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 class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data object Dismiss : MessagesEvents data object Dismiss : MessagesEvents
} }

View file

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

View file

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

View file

@ -106,7 +106,6 @@ class ActionListPresenter @Inject constructor(
isEventPinned = pinnedEventIds.contains(timelineItem.eventId), isEventPinned = pinnedEventIds.contains(timelineItem.eventId),
) )
val displayEmojiReactions = usersEventPermissions.canSendReaction && val displayEmojiReactions = usersEventPermissions.canSendReaction &&
timelineItem.isRemote &&
timelineItem.content.canReact() timelineItem.content.canReact()
if (actions.isNotEmpty() || displayEmojiReactions) { if (actions.isNotEmpty() || displayEmojiReactions) {
target.value = ActionListState.Target.Success( 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.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.isDm
@ -95,7 +96,7 @@ class TimelinePresenter @AssistedInject constructor(
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.REACTION, 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 newEventState = remember { mutableStateOf(NewEventState.None) }
val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) } val messageShield: MutableState<MessageShield?> = remember { mutableStateOf(null) }
@ -242,7 +243,7 @@ class TimelinePresenter @AssistedInject constructor(
*/ */
private suspend fun computeNewItemState( private suspend fun computeNewItemState(
timelineItems: ImmutableList<TimelineItem>, timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>, prevMostRecentItemId: MutableState<UniqueId?>,
newEventState: MutableState<NewEventState> newEventState: MutableState<NewEventState>
) = withContext(dispatchers.computation) { ) = withContext(dispatchers.computation) {
// FromMe is prioritized over FromOther, so skip if we already have a FromMe // 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.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId 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.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.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo 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.LocalEventSendState
@ -122,7 +123,10 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
} }
fun aTimelineItemDaySeparator(): TimelineItem.Virtual { 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( internal fun aTimelineItemEvent(
@ -144,7 +148,7 @@ internal fun aTimelineItemEvent(
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
): TimelineItem.Event { ): TimelineItem.Event {
return TimelineItem.Event( return TimelineItem.Event(
id = UUID.randomUUID().toString(), id = UniqueId(UUID.randomUUID().toString()),
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
senderId = UserId("@senderId:domain"), senderId = UserId("@senderId:domain"),
@ -210,7 +214,7 @@ internal fun aTimelineItemReadReceipts(
} }
internal fun aGroupedEvents( internal fun aGroupedEvents(
id: Long = 0, id: UniqueId = UniqueId("0"),
withReadReceipts: Boolean = false, withReadReceipts: Boolean = false,
): TimelineItem.GroupedEvents { ): TimelineItem.GroupedEvents {
val event1 = aTimelineItemEvent( val event1 = aTimelineItemEvent(
@ -231,7 +235,7 @@ internal fun aGroupedEvents(
) )
val events = listOf(event1, event2) val events = listOf(event1, event2)
return TimelineItem.GroupedEvents( return TimelineItem.GroupedEvents(
id = id.toString(), id = id,
events = events.toImmutableList(), events = events.toImmutableList(),
aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(), aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(),
) )

View file

@ -58,7 +58,7 @@ fun TimelineItemGroupedEventsRow(
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) } val isExpanded = rememberSaveable(key = timelineItem.identifier().value) { mutableStateOf(false) }
fun onExpandGroupClick() { fun onExpandGroupClick() {
isExpanded.value = !isExpanded.value 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.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomReactionBottomSheet( fun CustomReactionBottomSheet(
state: CustomReactionState, state: CustomReactionState,
onSelectEmoji: (EventId, Emoji) -> Unit, onSelectEmoji: (UniqueId, Emoji) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
@ -43,10 +43,10 @@ fun CustomReactionBottomSheet(
} }
fun onEmojiSelectedDismiss(emoji: Emoji) { fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event?.eventId == null) return if (target?.event == null) return
sheetState.hide(coroutineScope) { sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onSelectEmoji(target.event.eventId, emoji) onSelectEmoji(target.event.id, emoji)
} }
} }

View file

@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.features.messages.impl.utils.containsOnlyEmojis
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -54,9 +55,15 @@ fun TimelineItemTextView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
) { ) {
val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) &&
content.body.replace(" ", "").containsOnlyEmojis()
val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
else -> ElementTheme.typography.fontBodyLgRegular
}
CompositionLocalProvider( CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary, LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular LocalTextStyle provides textStyle
) { ) {
val body = getTextWithResolvedMentions(content) val body = getTextWithResolvedMentions(content)
Box(modifier.semantics { contentDescription = content.plainText }) { Box(modifier.semantics { contentDescription = content.plainText }) {

View file

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

View file

@ -38,10 +38,10 @@ fun anAggregatedReaction(
count: Int = 1, count: Int = 1,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
): AggregatedReaction { ): AggregatedReaction {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US)
val date = Date(1_689_061_264L)
val senders = buildList { val senders = buildList {
repeat(count) { index -> repeat(count) { index ->
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
val date = Date(1_689_061_264L)
add( add(
AggregatedReactionSender( AggregatedReactionSender(
senderId = if (isHighlighted && index == 0) userId else UserId("@user$index:server.org"), senderId = if (isHighlighted && index == 0) userId else UserId("@user$index:server.org"),

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

View file

@ -42,6 +42,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemTextContent(), aTimelineItemTextContent(),
aTimelineItemUnknownContent(), aTimelineItemUnknownContent(),
aTimelineItemTextContent().copy(isEdited = true), aTimelineItemTextContent().copy(isEdited = true),
aTimelineItemTextContent(body = "😁")
) )
} }

View file

@ -0,0 +1,44 @@
/*
* 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.messages.impl.utils
import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
import com.sigpwned.emoji4j.core.GraphemeMatchResult
import com.sigpwned.emoji4j.core.GraphemeMatcher
/**
* Returns true if the string consists exclusively of "emoji or pictographic graphemes".
*/
fun String.containsOnlyEmojis(): Boolean {
if (isEmpty()) return false
val matcher = GraphemeMatcher(this)
var m: GraphemeMatchResult? = null
var contiguous = true
var previous = 0
while (contiguous && matcher.find()) {
m = matcher.toMatchResult()
// Many non-"emoji" characters are pictographics. We only want to identify this specific range
// https://en.wikipedia.org/wiki/Miscellaneous_Symbols_and_Pictographs
val isEmoji = m!!.grapheme().type == EMOJI || m.grapheme().type == PICTOGRAPHIC && m.group() in "🌍".."🗺"
contiguous = isEmoji and (m.start() == previous)
previous = m.end()
}
return contiguous and (m?.end() == length)
}

View file

@ -69,6 +69,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.EventId
import io.element.android.libraries.matrix.api.core.TransactionId 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.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
@ -77,11 +78,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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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_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_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_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_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THROWABLE 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.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -197,8 +198,8 @@ class MessagesPresenterTest {
@Test @Test
fun `present - handle toggling a reaction`() = runTest { fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
val toggleReactionSuccess = lambdaRecorder { _: String, _: EventId -> Result.success(Unit) } val toggleReactionSuccess = lambdaRecorder { _: String, _: UniqueId -> Result.success(Unit) }
val toggleReactionFailure = lambdaRecorder { _: String, _: EventId -> Result.failure<Unit>(IllegalStateException("Failed to send reaction")) } val toggleReactionFailure = lambdaRecorder { _: String, _: UniqueId -> Result.failure<Unit>(IllegalStateException("Failed to send reaction")) }
val timeline = FakeTimeline().apply { val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess this.toggleReactionLambda = toggleReactionSuccess
@ -218,25 +219,25 @@ class MessagesPresenterTest {
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() 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 // No crashes when sending a reaction failed
timeline.apply { toggleReactionLambda = toggleReactionFailure } 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) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assert(toggleReactionSuccess) assert(toggleReactionSuccess)
.isCalledOnce() .isCalledOnce()
.with(value("👍"), value(AN_EVENT_ID)) .with(value("👍"), value(A_UNIQUE_ID))
assert(toggleReactionFailure) assert(toggleReactionFailure)
.isCalledOnce() .isCalledOnce()
.with(value("👍"), value(AN_EVENT_ID)) .with(value("👍"), value(A_UNIQUE_ID))
} }
} }
@Test @Test
fun `present - handle toggling a reaction twice`() = runTest { fun `present - handle toggling a reaction twice`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) 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 { val timeline = FakeTimeline().apply {
this.toggleReactionLambda = toggleReactionSuccess this.toggleReactionLambda = toggleReactionSuccess
@ -255,13 +256,13 @@ class MessagesPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", A_UNIQUE_ID))
initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", A_UNIQUE_ID))
assert(toggleReactionSuccess) assert(toggleReactionSuccess)
.isCalledExactly(2) .isCalledExactly(2)
.withSequence( .withSequence(
listOf(value("👍"), value(AN_EVENT_ID)), listOf(value("👍"), value(A_UNIQUE_ID)),
listOf(value("👍"), value(AN_EVENT_ID)), listOf(value("👍"), value(A_UNIQUE_ID)),
) )
} }
} }

View file

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

View file

@ -757,7 +757,7 @@ class ActionListPresenterTest {
assertThat(successState.target).isEqualTo( assertThat(successState.target).isEqualTo(
ActionListState.Target.Success( ActionListState.Target.Success(
event = messageEvent, event = messageEvent,
displayEmojiReactions = false, displayEmojiReactions = true,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, 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.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId 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.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.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield 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), sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
messageShield: MessageShield? = null, messageShield: MessageShield? = null,
) = TimelineItem.Event( ) = TimelineItem.Event(
id = eventId?.value.orEmpty(), id = UniqueId(eventId?.value.orEmpty()),
eventId = eventId, eventId = eventId,
transactionId = transactionId, transactionId = transactionId,
senderId = A_USER_ID, senderId = A_USER_ID,

View file

@ -24,6 +24,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.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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.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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@ -80,7 +82,7 @@ class PinnedMessagesBannerPresenterTest {
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID", uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem( event = anEventTimelineItem(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
content = messageContent, content = messageContent,
@ -112,14 +114,14 @@ class PinnedMessagesBannerPresenterTest {
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID", uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem( event = anEventTimelineItem(
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
content = messageContent1, content = messageContent1,
), ),
), ),
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = "FAKE_UNIQUE_ID_2", uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem( event = anEventTimelineItem(
eventId = AN_EVENT_ID_2, eventId = AN_EVENT_ID_2,
content = messageContent2, 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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel 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.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import org.junit.Test import org.junit.Test
@ -34,7 +35,7 @@ class TimelineItemIndexerTest {
groupedEvents.events.forEach { eventIds.add(it.eventId!!) } groupedEvents.events.forEach { eventIds.add(it.eventId!!) }
}, },
TimelineItem.Virtual( TimelineItem.Virtual(
id = "dummy", id = UniqueId("dummy"),
model = TimelineItemReadMarkerModel 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.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction 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.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.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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.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
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 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.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember 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
import kotlin.time.Duration.Companion.seconds 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 { @OptIn(ExperimentalCoroutinesApi::class) class TimelinePresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -131,7 +131,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline( val timeline = FakeTimeline(
timelineItems = flowOf( timelineItems = flowOf(
listOf( 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( val timeline = FakeTimeline(
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2, uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem( event = anEventTimelineItem(
eventId = AN_EVENT_ID_2, eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message") content = aMessageContent("Test message")
@ -201,9 +201,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline( val timeline = FakeTimeline(
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2, uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem( event = anEventTimelineItem(
eventId = AN_EVENT_ID_2, eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message") content = aMessageContent("Test message")
@ -243,9 +243,9 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline( val timeline = FakeTimeline(
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()), MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2, uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem( event = anEventTimelineItem(
eventId = AN_EVENT_ID_2, eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message") content = aMessageContent("Test message")
@ -279,8 +279,8 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
val timeline = FakeTimeline( val timeline = FakeTimeline(
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker), MatrixTimelineItem.Virtual(A_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker) MatrixTimelineItem.Virtual(A_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
) )
) )
).apply { ).apply {
@ -310,13 +310,13 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
assertThat(initialState.newEventState).isEqualTo(NewEventState.None) assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0) assertThat(initialState.timelineItems.size).isEqualTo(0)
timelineItems.emit( timelineItems.emit(
listOf(MatrixTimelineItem.Event("0", anEventTimelineItem(content = aMessageContent()))) listOf(MatrixTimelineItem.Event(UniqueId("0"), anEventTimelineItem(content = aMessageContent())))
) )
consumeItemsUntilPredicate { it.timelineItems.size == 1 } consumeItemsUntilPredicate { it.timelineItems.size == 1 }
// Mimics sending a message, and assert newEventState is FromMe // Mimics sending a message, and assert newEventState is FromMe
timelineItems.getAndUpdate { items -> timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent(), isOwn = true) 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 } consumeItemsUntilPredicate { it.timelineItems.size == 2 }
awaitLastSequentialItem().also { state -> 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 // Mimics receiving a message without clearing the previous FromMe
timelineItems.getAndUpdate { items -> timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent()) val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event("2", event)) items + listOf(MatrixTimelineItem.Event(UniqueId("2"), event))
} }
consumeItemsUntilPredicate { it.timelineItems.size == 3 } 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 // Mimics receiving a message and assert newEventState is FromOther
timelineItems.getAndUpdate { items -> timelineItems.getAndUpdate { items ->
val event = anEventTimelineItem(content = aMessageContent()) val event = anEventTimelineItem(content = aMessageContent())
items + listOf(MatrixTimelineItem.Event("3", event)) items + listOf(MatrixTimelineItem.Event(UniqueId("3"), event))
} }
consumeItemsUntilPredicate { it.timelineItems.size == 4 } consumeItemsUntilPredicate { it.timelineItems.size == 4 }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
@ -381,7 +381,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
), ),
) )
timelineItems.emit( 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() val item = awaitItem().timelineItems.first()
assertThat(item).isInstanceOf(TimelineItem.Event::class.java) assertThat(item).isInstanceOf(TimelineItem.Event::class.java)
@ -476,7 +476,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID, uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(), event = anEventTimelineItem(),
) )
) )
@ -532,7 +532,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID, uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(eventId = AN_EVENT_ID), event = anEventTimelineItem(eventId = AN_EVENT_ID),
) )
) )
@ -618,7 +618,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
timelineItems = flowOf( timelineItems = flowOf(
listOf( listOf(
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
FAKE_UNIQUE_ID, A_UNIQUE_ID,
anEventTimelineItem( anEventTimelineItem(
sender = A_USER_ID, sender = A_USER_ID,
receipts = persistentListOf( 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.timeline.model.virtual.TimelineItemLoadingIndicatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState 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.core.UserId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -54,7 +55,7 @@ class TimelineViewTest {
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf<TimelineItem>( timelineItems = persistentListOf<TimelineItem>(
TimelineItem.Virtual( TimelineItem.Virtual(
id = "backward_pagination", id = UniqueId("backward_pagination"),
model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0) 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.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel 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.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.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -37,7 +38,7 @@ class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper() private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event( private val aGroupableItem = TimelineItem.Event(
id = "0", id = UniqueId("0"),
senderId = A_USER_ID, senderId = A_USER_ID,
senderAvatar = anAvatarData(), senderAvatar = anAvatarData(),
senderProfile = aProfileTimelineDetailsReady(displayName = ""), senderProfile = aProfileTimelineDetailsReady(displayName = ""),
@ -54,7 +55,7 @@ class TimelineItemGrouperTest {
messageShield = null, messageShield = null,
) )
private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) private val aNonGroupableItemNoEvent = TimelineItem.Virtual(UniqueId("virtual"), aTimelineItemDaySeparatorModel("Today"))
@Test @Test
fun `test empty`() { fun `test empty`() {
@ -82,8 +83,8 @@ class TimelineItemGrouperTest {
fun `test groupables and ensure reordering`() { fun `test groupables and ensure reordering`() {
val result = sut.group( val result = sut.group(
listOf( listOf(
aGroupableItem.copy(id = "1"), aGroupableItem.copy(id = UniqueId("1")),
aGroupableItem.copy(id = "0"), aGroupableItem.copy(id = UniqueId("0")),
), ),
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
@ -91,8 +92,8 @@ class TimelineItemGrouperTest {
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
id = computeGroupIdWith(aGroupableItem), id = computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem.copy("0"), aGroupableItem.copy(id = UniqueId("0")),
aGroupableItem.copy(id = "1"), aGroupableItem.copy(id = UniqueId("1")),
).toImmutableList(), ).toImmutableList(),
aggregatedReadReceipts = emptyList<ReadReceiptData>().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`() { fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
// When // When
val groupableItems = mutableListOf( val groupableItems = mutableListOf(
aGroupableItem.copy(id = "1"), aGroupableItem.copy(id = UniqueId("1")),
aGroupableItem.copy(id = "2") aGroupableItem.copy(id = UniqueId("2"))
) )
val expectedGroupId = sut.group(groupableItems).first().identifier() val expectedGroupId = sut.group(groupableItems).first().identifier()
groupableItems.add(0, aGroupableItem.copy("3")) groupableItems.add(0, aGroupableItem.copy(UniqueId("3")))
groupableItems.add(2, aGroupableItem.copy("4")) groupableItems.add(2, aGroupableItem.copy(UniqueId("4")))
groupableItems.add(aGroupableItem.copy("5")) groupableItems.add(aGroupableItem.copy(UniqueId("5")))
val actualGroupId = sut.group(groupableItems).first().identifier() val actualGroupId = sut.group(groupableItems).first().identifier()
// Then // Then
assertThat(actualGroupId).isEqualTo(expectedGroupId) assertThat(actualGroupId).isEqualTo(expectedGroupId)

View file

@ -0,0 +1,45 @@
/*
* 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.messages.impl.utils
import org.junit.Assert
import org.junit.Test
class EmojiTest {
@Test
fun validEmojis() {
// Simple single/multiple single-codepoint emojis per string
Assert.assertTrue("👍".containsOnlyEmojis())
Assert.assertTrue("😀".containsOnlyEmojis())
Assert.assertTrue("🙂🙁".containsOnlyEmojis())
Assert.assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
Assert.assertTrue("👨👩👦1⃣🚀👳🏾🪩".containsOnlyEmojis())
Assert.assertTrue("🌍🌎🌏".containsOnlyEmojis())
// Awkward multi-codepoint graphemes
Assert.assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojis())
Assert.assertTrue("🏴‍☠".containsOnlyEmojis())
Assert.assertTrue("👩🏿‍🔧".containsOnlyEmojis())
Assert.assertFalse("".containsOnlyEmojis())
Assert.assertFalse(" ".containsOnlyEmojis())
Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
Assert.assertFalse("Hello".containsOnlyEmojis())
Assert.assertFalse("Hello 👋".containsOnlyEmojis())
}
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.timeline
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes 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.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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -80,7 +81,7 @@ fun TestScope.aDefaultRedactedVoiceMessageManager(
fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>( fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
uniqueId = "0", uniqueId = UniqueId("0"),
event = EventTimelineItem( event = EventTimelineItem(
eventId = eventId, eventId = eventId,
transactionId = null, 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 package io.element.android.features.poll.impl
import io.element.android.libraries.matrix.api.core.EventId 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.poll.PollAnswer
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
@ -32,8 +33,8 @@ fun aPollTimelineItems(
return flowOf( return flowOf(
polls.map { entry -> polls.map { entry ->
MatrixTimelineItem.Event( MatrixTimelineItem.Event(
entry.key.value, uniqueId = UniqueId(entry.key.value),
anEventTimelineItem( event = anEventTimelineItem(
eventId = entry.key, eventId = entry.key,
content = entry.value, content = entry.value,
) )

View file

@ -62,6 +62,7 @@ dependencies {
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
implementation(projects.features.licenses.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.features.roomlist.api) implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
@ -91,7 +92,6 @@ dependencies {
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl) testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)
testImplementation(projects.features.roomlist.test)
testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.fullscreenintent.test) testImplementation(projects.libraries.fullscreenintent.test)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,7 +24,6 @@ import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.features.roomlist.api.migration.MigrationScreenStore
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ -47,7 +46,6 @@ class DefaultClearCacheUseCase @Inject constructor(
private val defaultCacheService: DefaultCacheService, private val defaultCacheService: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>, private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService, private val ftueService: FtueService,
private val migrationScreenStore: MigrationScreenStore,
private val pushService: PushService, private val pushService: PushService,
) : ClearCacheUseCase { ) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) { override suspend fun invoke() = withContext(coroutineDispatchers.io) {
@ -64,8 +62,6 @@ class DefaultClearCacheUseCase @Inject constructor(
context.cacheDir.deleteRecursively() context.cacheDir.deleteRecursively()
// Clear some settings // Clear some settings
ftueService.reset() ftueService.reset()
// Clear migration screen store
migrationScreenStore.reset()
// Ensure any error will be displayed again // Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false) pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted // Ensure the app is restarted

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_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_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_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_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_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> <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 @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = AboutPresenter(FakeOpenSourceLicensesProvider(hasOpenSourceLicenses = true)) val presenter = AboutPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.elementLegals).isEqualTo(getAllLegals()) 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.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -61,21 +60,10 @@ class AboutViewTest {
} }
@Test @Test
fun `if open source licenses are not available, the entry is not displayed`() { fun `clicking on the open source licenses invokes the expected callback`() {
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`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setAboutView( rule.setAboutView(
anAboutState( anAboutState(),
hasOpenSourcesLicenses = true,
),
onOpenSourceLicensesClick = callback, onOpenSourceLicensesClick = callback,
) )
rule.clickOn(CommonStrings.common_open_source_licenses) rule.clickOn(CommonStrings.common_open_source_licenses)

View file

@ -21,7 +21,6 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.test.FakeFtueService import io.element.android.features.ftue.test.FakeFtueService
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.features.roomlist.impl.migration.InMemoryMigrationScreenStore
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
@ -47,10 +46,6 @@ class DefaultClearCacheUseCaseTest {
val ftueService = FakeFtueService( val ftueService = FakeFtueService(
resetLambda = resetFtueLambda, resetLambda = resetFtueLambda,
) )
val resetMigrationLambda = lambdaRecorder<Unit> { }
val migrationScreenStore = InMemoryMigrationScreenStore(
resetLambda = resetMigrationLambda,
)
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> } val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val pushService = FakePushService( val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
@ -62,14 +57,12 @@ class DefaultClearCacheUseCaseTest {
defaultCacheService = defaultCacheService, defaultCacheService = defaultCacheService,
okHttpClient = { OkHttpClient.Builder().build() }, okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService, ftueService = ftueService,
migrationScreenStore = migrationScreenStore,
pushService = pushService, pushService = pushService,
) )
defaultCacheService.clearedCacheEventFlow.test { defaultCacheService.clearedCacheEventFlow.test {
sut.invoke() sut.invoke()
clearCacheLambda.assertions().isCalledOnce() clearCacheLambda.assertions().isCalledOnce()
resetFtueLambda.assertions().isCalledOnce() resetFtueLambda.assertions().isCalledOnce()
resetMigrationLambda.assertions().isCalledOnce()
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce() setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false)) .with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId) assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)

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_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_crash_logs">"Buzilish jurnallarini yuboring"</string>
<string name="screen_bug_report_include_logs">"Jurnallarga ruxsat bering"</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_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> <string name="screen_bug_report_rash_logs_alert_title">"%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?"</string>
</resources> </resources>

View file

@ -289,6 +289,7 @@ class DefaultBugReporterTest {
slidingSyncProxy = null, slidingSyncProxy = null,
passphrase = null, passphrase = null,
sessionPath = "session", sessionPath = "session",
cachePath = "cache",
) )
@Test @Test
fun `test sendBugReport error`() = runTest { 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_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_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_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_custom">"Maxsus"</string>
<string name="screen_room_details_notification_mode_default">"Standart"</string> <string name="screen_room_details_notification_mode_default">"Standart"</string>
<string name="screen_room_details_notification_title">"Bildirishnomalar"</string> <string name="screen_room_details_notification_title">"Bildirishnomalar"</string>

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.api.migration
import io.element.android.libraries.matrix.api.core.SessionId
interface MigrationScreenStore {
fun isMigrationScreenNeeded(sessionId: SessionId): Boolean
fun setMigrationScreenShown(sessionId: SessionId)
fun reset()
}

View file

@ -84,7 +84,6 @@ dependencies {
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.roomlist.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.features.leaveroom.test) testImplementation(projects.features.leaveroom.test)
} }

View file

@ -29,7 +29,6 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
aRoomsContentState(summaries = persistentListOf()), aRoomsContentState(summaries = persistentListOf()),
aSkeletonContentState(), aSkeletonContentState(),
anEmptyContentState(), anEmptyContentState(),
aMigrationContentState(),
) )
} }
@ -43,8 +42,6 @@ internal fun aRoomsContentState(
summaries = summaries, summaries = summaries,
) )
internal fun aMigrationContentState() = RoomListContentState.Migration
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16) internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState() = RoomListContentState.Empty internal fun anEmptyContentState() = RoomListContentState.Empty

View file

@ -42,7 +42,6 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.RoomListSearchState
@ -93,7 +92,6 @@ class RoomListPresenter @Inject constructor(
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val filtersPresenter: Presenter<RoomListFiltersState>, private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>, private val searchPresenter: Presenter<RoomListSearchState>,
private val migrationScreenPresenter: Presenter<MigrationScreenState>,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@ -210,7 +208,6 @@ class RoomListPresenter @Inject constructor(
roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } roomListDataSource.allRooms.collect { value = AsyncData.Success(it) }
} }
val loadingState by roomListDataSource.loadingState.collectAsState() val loadingState by roomListDataSource.loadingState.collectAsState()
val showMigration = migrationScreenPresenter.present().isMigrating
val showEmpty by remember { val showEmpty by remember {
derivedStateOf { derivedStateOf {
(loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0 (loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0
@ -222,7 +219,6 @@ class RoomListPresenter @Inject constructor(
} }
} }
return when { return when {
showMigration -> RoomListContentState.Migration
showEmpty -> RoomListContentState.Empty showEmpty -> RoomListContentState.Empty
showSkeleton -> RoomListContentState.Skeleton(count = 16) showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> { else -> {

View file

@ -43,7 +43,7 @@ data class RoomListState(
val eventSink: (RoomListEvents) -> Unit, val eventSink: (RoomListEvents) -> Unit,
) { ) {
val displayFilters = contentState is RoomListContentState.Rooms val displayFilters = contentState is RoomListContentState.Rooms
val displayActions = contentState !is RoomListContentState.Migration val displayActions = true
sealed interface ContextMenu { sealed interface ContextMenu {
data object Hidden : ContextMenu data object Hidden : ContextMenu
@ -72,7 +72,6 @@ enum class SecurityBannerState {
@Immutable @Immutable
sealed interface RoomListContentState { sealed interface RoomListContentState {
data object Migration : RoomListContentState
data class Skeleton(val count: Int) : RoomListContentState data class Skeleton(val count: Int) : RoomListContentState
data object Empty : RoomListContentState data object Empty : RoomListContentState
data class Rooms( data class Rooms(

View file

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

View file

@ -54,7 +54,6 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersEmptySta
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.features.roomlist.impl.migration.MigrationScreenView
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -78,9 +77,6 @@ fun RoomListContentView(
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
when (contentState) { when (contentState) {
is RoomListContentState.Migration -> {
MigrationScreenView(isMigrating = true)
}
is RoomListContentState.Skeleton -> { is RoomListContentState.Skeleton -> {
SkeletonView( SkeletonView(
count = contentState.count, count = contentState.count,

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