Merge branch 'release/0.1.2' into main

This commit is contained in:
Benoit Marty
2023-08-16 17:09:09 +02:00
974 changed files with 10616 additions and 4188 deletions

1
.gitattributes vendored
View File

@@ -1 +1,2 @@
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text

View File

@@ -2,9 +2,10 @@ name: APK Build
on:
workflow_dispatch:
pull_request: { }
pull_request:
merge_group:
push:
branches: [ main, develop ]
branches: [ develop ]
# Enrich gradle.properties for CI/CD
env:
@@ -13,14 +14,17 @@ env:
jobs:
debug:
name: Build debug APKs
name: Build APKs
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main'
# Skip for `main` and the merge queue if the branch is up to date with `develop`
if: github.ref != 'refs/heads/main' && github.event.merge_group.base_ref != 'refs/heads/develop'
strategy:
matrix:
variant: [debug, release, nightly, samples]
fail-fast: false
# Allow all jobs on develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}', github.sha) || format('build-debug-{0}', github.ref) }}
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}-{1}', matrix.variant, github.sha) || format('build-{0}-{1}', matrix.variant, github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
@@ -34,14 +38,18 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
if: ${{ matrix.variant == 'debug' }}
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload APK APKs
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v3
with:
name: elementx-debug
@@ -53,12 +61,12 @@ jobs:
continue-on-error: true
env:
token: ${{ secrets.DIAWI_TOKEN }}
if: ${{ github.event_name == 'pull_request' && env.token != '' }}
if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && env.token != '' }}
with:
token: ${{ env.token }}
file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
- name: Add or update PR comment with QR Code to download APK.
if: ${{ github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
uses: NejcZdovc/comment-pr@v2
with:
message: |
@@ -70,8 +78,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources
run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES
if: ${{ matrix.variant == 'release' }}
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources
run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES
if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile samples minimal
if: ${{ matrix.variant == 'samples' }}
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES

View File

@@ -1,17 +1,19 @@
name: Danger CI
on: [pull_request]
on: [pull_request, merge_group]
jobs:
build:
runs-on: ubuntu-latest
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
name: Danger main check
steps:
- uses: actions/checkout@v3
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@11.2.6
uses: danger/danger-js@11.2.8
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View File

@@ -1,12 +1,15 @@
name: "Validate Gradle Wrapper"
on:
pull_request: { }
pull_request:
merge_group:
push:
branches: [ main, develop ]
jobs:
validation:
name: "Validation"
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:

View File

@@ -38,16 +38,14 @@ jobs:
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
- name: Upload debug APKs
uses: actions/upload-artifact@v3
with:
name: elementx-debug
path: |
app/build/outputs/apk/debug/*.apk
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-universal-debug.apk
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
# app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK).
app-file: app/build/outputs/apk/debug/app-x86_64-debug.apk
env: |
USERNAME=maestroelement
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}

View File

@@ -1,4 +1,4 @@
name: Build and release nightly APK
name: Build and release nightly application
on:
workflow_dispatch:
@@ -12,7 +12,7 @@ env:
jobs:
nightly:
name: Build and publish nightly APK to Firebase
name: Build and publish nightly bundle to Firebase
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
@@ -31,18 +31,21 @@ jobs:
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
rm towncrier.toml.bak
yes n | towncrier build --version nightly
- name: Build and upload Nightly APK
- name: Build and upload Nightly application
run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
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 }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- name: Additionally upload Nightly APK to browserstack for testing
continue-on-error: true # don't block anything by this upload failing (for now)
run: curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
run: |
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
env:
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}

View File

@@ -62,7 +62,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis

View File

@@ -2,7 +2,8 @@ name: Code Quality Checks
on:
workflow_dispatch:
pull_request: { }
pull_request:
merge_group:
push:
branches: [ main, develop ]
@@ -15,6 +16,8 @@ jobs:
checkScript:
name: Search for forbidden patterns
runs-on: ubuntu-latest
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
steps:
- uses: actions/checkout@v3
- name: Run code quality check suite
@@ -23,6 +26,8 @@ jobs:
check:
name: Project Check Suite
runs-on: ubuntu-latest
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }}
@@ -39,7 +44,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite
@@ -65,7 +70,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@11.2.6
uses: danger/danger-js@11.2.8
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:

View File

@@ -24,7 +24,7 @@ jobs:
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots

40
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Create release App Bundle
on:
workflow_dispatch:
push:
branches: [ main ]
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs:
release:
name: Create App Bundle
runs-on: ubuntu-latest
concurrency:
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.7.0
- name: Create app bundle
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 bundleRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
uses: actions/upload-artifact@v3
with:
name: elementx-app-bundle-unsigned
path: |
app/build/outputs/bundle/release/app-release.aab

51
.github/workflows/sonar.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Code Quality Checks
on:
workflow_dispatch:
pull_request:
merge_group:
push:
branches: [ main, develop ]
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
jobs:
sonar:
name: Project Check Suite
runs-on: ubuntu-latest
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('sonar-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('sonar-develop-{0}', github.sha) || format('sonar-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: 🔊 Publish results to Sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
- name: Prepare Danger
if: always()
run: |
npm install --save-dev @babel/core
npm install --save-dev @babel/plugin-transform-flow-strip-types
yarn add danger-plugin-lint-report --dev

View File

@@ -2,7 +2,8 @@ name: Test
on:
workflow_dispatch:
pull_request: { }
pull_request:
merge_group:
push:
branches: [ main, develop ]
@@ -15,6 +16,8 @@ jobs:
tests:
name: Runs unit tests
runs-on: ubuntu-latest
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
# Allow all jobs on main and develop. Just one per PR.
concurrency:
@@ -33,7 +36,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1
uses: gradle/gradle-build-action@v2.7.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View File

@@ -1,10 +1,12 @@
name: Validate Git LFS
on: [pull_request]
on: [pull_request, merge_group]
jobs:
build:
runs-on: ubuntu-latest
# Don't run in the merge queue again if the branch is up to date with `develop`
if: github.event.merge_group.base_ref != 'refs/heads/develop'
name: Validate
steps:
- uses: nschloe/action-cached-lfs-checkout@v1.2.1

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ captures/
# IntelliJ
*.iml
.idea/.name
.idea/androidTestResultsUserPreferences.xml
.idea/assetWizardSettings.xml
.idea/compiler.xml
.idea/deploymentTargetDropDown.xml

View File

@@ -8,6 +8,7 @@
<w>measurables</w>
<w>onboarding</w>
<w>placeables</w>
<w>posthog</w>
<w>showkase</w>
<w>snackbar</w>
<w>swipeable</w>

View File

@@ -18,7 +18,7 @@ To setup, please refer at [https://maestro.mobile.dev](https://maestro.mobile.de
From root dir of the project
*Note: Since ElementX does not allow account creation nor room creation, we have to use an existing account with an existing room to run maestro test suite. So to run locally, please replace `user` and `123` with your test matrix.org account credentials, and `my room` with one of a room this account has join. Note that the test will send messages to this room.*
*Note: Since Element X does not allow account creation, we have to use an existing account to run maestro test suite. So to run locally, please replace `user` and `123` with your test matrix.org account credentials, and `my room` with one of a room this account has joined. Note that the test will send messages to this room.*
```shell
maestro test \
@@ -39,7 +39,7 @@ Test result will be printed on the console, and screenshots will be generated at
Tests are yaml files. Generally each yaml file should leave the app in the same screen than at the beginning.
Start the ElementX app and run this command to help writing test.
Start the Element X app and run this command to help writing test.
```shell
maestro studio

View File

@@ -1,3 +1,24 @@
Changes in Element X v0.1.2 (2023-08-16)
========================================
Bugfixes 🐛
----------
- Filter push notifications using push rules. ([#640](https://github.com/vector-im/element-x-android/issues/640))
- Use `for` instead of `forEach` in `DefaultDiffCacheInvalidator` to improve performance. ([#1035](https://github.com/vector-im/element-x-android/issues/1035))
In development 🚧
----------------
- [Poll] Render start event in the timeline ([#1031](https://github.com/vector-im/element-x-android/issues/1031))
Other changes
-------------
- Add Button component based on Compound designs ([#1021](https://github.com/vector-im/element-x-android/issues/1021))
- Compound: implement dialogs. ([#1043](https://github.com/vector-im/element-x-android/issues/1043))
- Compound: customise `IconButton` component. ([#1049](https://github.com/vector-im/element-x-android/issues/1049))
- Compound: implement `DropdownMenu` customisations. ([#1050](https://github.com/vector-im/element-x-android/issues/1050))
- Compound: implement Snackbar component. ([#1054](https://github.com/vector-im/element-x-android/issues/1054))
Changes in Element X v0.1.0 (2023-07-19)
========================================

View File

@@ -13,6 +13,7 @@
* [Kotlin](#kotlin)
* [Changelog](#changelog)
* [Code quality](#code-quality)
* [detekt](#detekt)
* [ktlint](#ktlint)
* [knit](#knit)
* [lint](#lint)
@@ -50,7 +51,7 @@ Note: please make sure that the configuration is `app` and not `samples.minimal`
## Strings
The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with ElementX iOS.
The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with Element X iOS.
### I want to add new strings to the project
@@ -60,14 +61,12 @@ Please follow the naming rules for the key. More details in [the dedicated secti
### I want to help translating Element
Please note that the Localazy project is not open yet for external contributions.
To help translating, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
- If you want to fix an issue with an English string, please open an issue on the github project of ElementX (Android or iOS). Only the core team can modify or add English strings.
- If you want to fix an issue with an English string, please open an issue on the github project of Element X (Android or iOS). Only the core team can modify or add English strings.
- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
More informations can be found [in this README.md](./tools/localazy/README.md).
More information can be found [in this README.md](./tools/localazy/README.md).
## I want to submit a PR to fix an issue
@@ -101,11 +100,17 @@ See https://github.com/twisted/towncrier#news-fragments if you need more details
Make sure the following commands execute without any error:
<pre>
./gradlew check
./tools/quality/check.sh
</pre>
Some separate commands can also be run, see below.
#### detekt
<pre>
./gradlew detekt
</pre>
#### ktlint
<pre>
@@ -153,7 +158,7 @@ Make sure the following commands execute without any error:
### Tests
Element X is currently supported on Android Lollipop (API 21+): please test your change on an Android device (or Android emulator) running with API 21. Many issues can happen (including crashes) on older devices.
Element X is currently supported on Android Marshmallow (API 23+): please test your change on an Android device (or Android emulator) running with API 23. Many issues can happen (including crashes) on older devices.
Also, if possible, please test your change on a real device. Testing on Android emulator may not be sufficient.
You should consider adding Unit tests with your PR, and also integration tests (AndroidTest). Please refer to [this document](./docs/integration_tests.md) to install and run the integration test environment.
@@ -166,7 +171,18 @@ For instance, when updating the image `src` of an ImageView, please also conside
### Jetpack Compose
When adding or editing `@Composable`, make sure that you create a `@Preview` function, with suffix `Preview`. This will also create a UI test automatically.
When adding or editing `@Composable`, make sure that you create an internal function annotated with `@DayNightPreviews`, with a name suffixed by `Preview`, and having `ElementPreview` as the root composable.
Example:
```kotlin
@DayNightPreviews
@Composable
internal fun PinIconPreview() = ElementPreview {
PinIcon()
}
```
This will allow to preview the composable in both light and dark mode in Android Studio. This will also automatically add UI tests. The GitHub action [Record screenshots](https://github.com/vector-im/element-x-android/actions/workflows/recordScreenshots.yml) has to be run to record the new screenshots. The PR reviewer can trigger this for you if you're not part of the core team.
### Authors

View File

@@ -3,14 +3,18 @@
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=bugs)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
[![codecov](https://codecov.io/github/vector-im/element-x-android/branch/develop/graph/badge.svg?token=ecwvia7amV)](https://codecov.io/github/vector-im/element-x-android)
[![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org)
[![Weblate](https://translate.element.io/widgets/element-android/-/svg-badge.svg)](https://translate.element.io/engage/element-android/?utm_source=widget)
[![Element X_Android Matrix room #element-x-android:matrix.org](https://img.shields.io/matrix/element-x-android:matrix.org.svg?label=%23element-x-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-x-android:matrix.org)
[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element)
# element-x-android
# Element X Android
ElementX Android is a [Matrix](https://matrix.org/) Android Client provided by [Element](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionality.
Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionalities.
The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using Jetpack compose.
The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/).
## Table of contents
<!--- TOC -->
@@ -28,24 +32,41 @@ The application is a total rewrite of [Element-Android](https://github.com/vecto
Here are some early screenshots of the application:
|<img src=./docs/images/screen1.png width=280 />|<img src=./docs/images/screen2.png width=280 />|<img src=./docs/images/screen3.png width=280 />|<img src=./docs/images/screen4.png width=280 />|
<!--
Commands run before taking the screenshots:
adb shell settings put system time_12_24 24
adb shell am broadcast -a com.android.systemui.demo -e command enter
adb shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 1337
adb shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e level 4
adb shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4
adb shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false
adb shell am broadcast -a com.android.systemui.demo -e command battery -e plugged false -e level 100
And to exit demo mode:
adb shell am broadcast -a com.android.systemui.demo -e command exit
-->
|<img src=./docs/images-lfs/screen_1_light.png width=280 />|<img src=./docs/images-lfs/screen_2_light.png width=280 />|<img src=./docs/images-lfs/screen_3_light.png width=280 />|<img src=./docs/images-lfs/screen_4_light.png width=280 />|
|-|-|-|-|
|<img src=./docs/images-lfs/screen_1_dark.png width=280 />|<img src=./docs/images-lfs/screen_2_dark.png width=280 />|<img src=./docs/images-lfs/screen_3_dark.png width=280 />|<img src=./docs/images-lfs/screen_4_dark.png width=280 />|
## Rust SDK
ElementX leverages the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) through an FFI layer that the final client can directly import and use.
Element X leverages the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) through an FFI layer that the final client can directly import and use.
We're doing this as a way to share code between platforms and while we've seen promising results it's still in the experimental stage and bound to change.
## Status
This project is in work in progress. The app does not cover yet all functionalities we expect.
This project is in work in progress. The app does not cover yet all functionalities we expect. The list of supported features can be found in [this issue](https://github.com/vector-im/element-x-android/issues/911).
## Contributing
Please see our [contribution guide](CONTRIBUTING.md).
Want to get actively involved in the project? You're more than welcome! A good way to start is to check the issues that are labelled with the [good first issue](https://github.com/vector-im/element-x-android/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label. Let us know by commenting the issue that you're starting working on it.
Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/#element-android:matrix.org).
But first make sure to read our [contribution guide](CONTRIBUTING.md) first.
You can also come chat with the community in the Matrix [room](https://matrix.to/#/#element-x-android:matrix.org) dedicated to the project.
## Build instructions
@@ -54,9 +75,9 @@ Makes sure to select the `app` configuration when building (as we also have samp
## Support
When you are experiencing an issue on ElementX Android, please first search in [GitHub issues](https://github.com/vector-im/element-x-android/issues)
and then in [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org).
If after your research you still have a question, ask at [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting (Rageshake) from the Element application by shaking your phone or going to the application settings. This is especially recommended when you encounter a crash.
When you are experiencing an issue on Element X Android, please first search in [GitHub issues](https://github.com/vector-im/element-x-android/issues)
and then in [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org).
If after your research you still have a question, ask at [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting from the application settings. This is especially recommended when you encounter a crash.
## Copyright & License

View File

@@ -129,6 +129,8 @@ android {
// "App Distribution found more than 1 output file for this variant.
// Please contact firebase-support@google.com for help using APK splits with App Distribution."
artifactPath = "$rootDir/app/build/outputs/apk/nightly/app-universal-nightly.apk"
// artifactType = "AAB"
// artifactPath = "$rootDir/app/build/outputs/bundle/nightly/app-nightly.aab"
// This file will be generated by the GitHub action
releaseNotesFile = "CHANGES_NIGHTLY.md"
groups = "external-testers"

View File

@@ -24,8 +24,7 @@ import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.EmojiInitializer
import io.element.android.x.initializer.MatrixInitializer
import io.element.android.x.initializer.TimberInitializer
import io.element.android.x.initializer.TracingInitializer
class ElementXApplication : Application(), DaggerComponentOwner {
@@ -39,8 +38,7 @@ class ElementXApplication : Application(), DaggerComponentOwner {
appComponent = DaggerAppComponent.factory().create(applicationContext)
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
initializeComponent(TimberInitializer::class.java)
initializeComponent(MatrixInitializer::class.java)
initializeComponent(TracingInitializer::class.java)
initializeComponent(EmojiInitializer::class.java)
}
logApplicationInfo()

View File

@@ -17,11 +17,15 @@
package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.tracing.TracingService
@ContributesTo(AppScope::class)
interface AppBindings {
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
fun snackbarDispatcher(): SnackbarDispatcher
fun tracingService(): TracingService
fun bugReporter(): BugReporter
}

View File

@@ -28,7 +28,7 @@ import io.element.android.x.R
@Preview
@Composable
fun IconPreview(
internal fun IconPreview(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
@@ -39,7 +39,7 @@ fun IconPreview(
@Preview
@Composable
fun RoundIconPreview(
internal fun RoundIconPreview(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.clip(shape = CircleShape)) {

View File

@@ -1,36 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.initializer
import android.content.Context
import androidx.startup.Initializer
import io.element.android.libraries.matrix.impl.tracing.setupTracing
import io.element.android.libraries.matrix.api.tracing.TracingConfigurations
import io.element.android.x.BuildConfig
class MatrixInitializer : Initializer<Unit> {
override fun create(context: Context) {
if (BuildConfig.DEBUG) {
setupTracing(TracingConfigurations.debug)
} else {
setupTracing(TracingConfigurations.release)
}
}
override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java)
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.initializer
import android.content.Context
import androidx.startup.Initializer
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration
import io.element.android.x.BuildConfig
import io.element.android.x.di.AppBindings
import timber.log.Timber
class TracingInitializer : Initializer<Unit> {
override fun create(context: Context) {
val appBindings = context.bindings<AppBindings>()
val tracingService = appBindings.tracingService()
val bugReporter = appBindings.bugReporter()
Timber.plant(tracingService.createTimberTree())
val tracingConfiguration = if (BuildConfig.DEBUG) {
TracingConfiguration(
filterConfiguration = TracingFilterConfigurations.debug,
writesToLogcat = true,
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
)
} else {
TracingConfiguration(
filterConfiguration = TracingFilterConfigurations.release,
writesToLogcat = false,
writesToFilesConfiguration = WriteToFilesConfiguration.Enabled(
directory = bugReporter.logDirectory().absolutePath,
filenamePrefix = "logs"
)
)
}
bugReporter.cleanLogDirectoryIfNeeded()
tracingService.setupTracing(tracingConfiguration)
}
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()
}

View File

@@ -65,6 +65,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.services.appnavstate.test)

View File

@@ -58,7 +58,8 @@ class LoggedInEventProcessor @Inject constructor(
.filter { it }
.onEach {
displayMessage(CommonStrings.common_verification_complete)
}.launchIn(this)
}
.launchIn(this)
}
}

View File

@@ -44,14 +44,14 @@ import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -69,10 +69,12 @@ import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@@ -100,13 +102,13 @@ class LoggedInFlowNode @AssistedInject constructor(
) {
interface Callback : Plugin {
fun onOpenBugReport() = Unit
fun onOpenBugReport()
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, client: MatrixClient) = Unit
fun onFlowCreated(identifier: String, client: MatrixClient)
fun onFlowReleased(identifier: String, client: MatrixClient) = Unit
fun onFlowReleased(identifier: String, client: MatrixClient)
}
data class Inputs(
@@ -123,7 +125,6 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
@@ -138,14 +139,12 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Ftue)
}
},
onResume = {
lifecycleScope.launch {
syncService.startSync()
onStop = {
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
coroutineScope.launch {
syncService.stopSync()
}
},
onPause = {
syncService.stopSync()
},
onDestroy = {
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
appNavigationStateService.onLeavingSpace(id)
@@ -153,22 +152,23 @@ class LoggedInFlowNode @AssistedInject constructor(
loggedInFlowProcessor.stopObserving()
}
)
observeSyncStateAndNetworkStatus()
}
@OptIn(FlowPreview::class)
private fun observeSyncStateAndNetworkStatus() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
syncService.syncState,
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
syncService.syncState.debounce(100),
networkMonitor.connectivity
) { syncState, networkStatus ->
syncState == SyncState.Error && networkStatus == NetworkStatus.Online
Pair(syncState, networkStatus)
}
.distinctUntilChanged()
.collect { restartSync ->
if (restartSync) {
.collect { (syncState, networkStatus) ->
Timber.d("Sync state: $syncState, network status: $networkStatus")
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
syncService.startSync()
}
}
@@ -305,7 +305,8 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onFtueFlowFinished() {
backstack.pop()
}
}).build()
})
.build()
}
}
}
@@ -350,3 +351,4 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.InviteList)
}
}

View File

@@ -21,8 +21,6 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import timber.log.Timber
import javax.inject.Inject

View File

@@ -21,16 +21,27 @@ import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.delay
import javax.inject.Inject
private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
) : Presenter<LoggedInState> {
@@ -53,18 +64,25 @@ class LoggedInPresenter @Inject constructor(
pushService.registerWith(matrixClient, pushProvider, distributor)
}
val syncState = matrixClient.syncService().syncState.collectAsState()
val roomListState by matrixClient.roomListService.state.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState()
val permissionsState = postNotificationPermissionsPresenter.present()
// fun handleEvents(event: LoggedInEvents) {
// when (event) {
// }
// }
var showSyncSpinner by remember {
mutableStateOf(false)
}
LaunchedEffect(roomListState, networkStatus) {
showSyncSpinner = when {
networkStatus == NetworkStatus.Offline -> false
roomListState == RoomListService.State.Running -> false
else -> {
delay(DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS)
true
}
}
}
return LoggedInState(
syncState = syncState.value,
showSyncSpinner = showSyncSpinner,
permissionsState = permissionsState,
// eventSink = ::handleEvents
)
}
}

View File

@@ -16,11 +16,9 @@
package io.element.android.appnav.loggedin
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.permissions.api.PermissionsState
data class LoggedInState(
val syncState: SyncState,
val showSyncSpinner: Boolean,
val permissionsState: PermissionsState,
// val eventSink: (LoggedInEvents) -> Unit
)

View File

@@ -17,22 +17,20 @@
package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(),
aLoggedInState(syncState = SyncState.Idle),
aLoggedInState(false),
aLoggedInState(true),
// Add other state here
)
}
fun aLoggedInState(
syncState: SyncState = SyncState.Running,
showSyncSpinner: Boolean = true,
) = LoggedInState(
syncState = syncState,
showSyncSpinner = showSyncSpinner,
permissionsState = createDummyPostNotificationPermissionsState(),
// eventSink = {}
)

View File

@@ -47,7 +47,7 @@ fun LoggedInView(
modifier = Modifier
.padding(top = 8.dp)
.align(Alignment.TopCenter),
syncState = state.syncState,
isVisible = state.showSyncSpinner,
)
PermissionsView(
state = state.permissionsState,
@@ -58,7 +58,7 @@ fun LoggedInView(
@DayNightPreviews
@Composable
fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
LoggedInView(
state = state
)

View File

@@ -38,19 +38,18 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SyncStateView(
syncState: SyncState,
isVisible: Boolean,
modifier: Modifier = Modifier
) {
val animationSpec = spring<Float>(stiffness = 500F)
AnimatedVisibility(
modifier = modifier,
visible = syncState.mustBeVisible(),
visible = isVisible,
enter = fadeIn(animationSpec = animationSpec),
exit = fadeOut(animationSpec = animationSpec),
) {
@@ -60,15 +59,15 @@ fun SyncStateView(
) {
Row(
modifier = Modifier
.background(color = ElementTheme.colors.bgSubtleSecondary)
.padding(horizontal = 24.dp, vertical = 10.dp),
.background(color = ElementTheme.colors.bgSubtleSecondary)
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.size(12.dp),
.progressSemantics()
.size(12.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
@@ -82,20 +81,13 @@ fun SyncStateView(
}
}
private fun SyncState.mustBeVisible() = when (this) {
SyncState.Idle -> true /* Cold start of the app */
SyncState.Running -> false
SyncState.Error -> false /* In this case, the network error banner can be displayed */
SyncState.Terminated -> true /* The app is resumed and the sync is started again */
}
@DayNightPreviews
@Composable
fun SyncStateViewPreview() = ElementPreview {
internal fun SyncStateViewPreview() = ElementPreview {
// Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) {
SyncStateView(
syncState = SyncState.Idle
isVisible = true
)
}
}

View File

@@ -16,19 +16,13 @@
package io.element.android.appnav.room
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -37,9 +31,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -48,7 +41,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
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.designsystem.theme.placeholderBackground
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@@ -103,20 +95,7 @@ private fun LoadingRoomTopBar(
BackButton(onClick = onBackClicked)
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(AvatarSize.TimelineRoom.dp)
.align(Alignment.CenterVertically)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
PlaceholderAtom(width = 20.dp, height = 7.dp)
Spacer(modifier = Modifier.width(7.dp))
PlaceholderAtom(width = 45.dp, height = 7.dp)
}
IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp)
},
windowInsets = WindowInsets(0.dp),
)
@@ -124,12 +103,12 @@ private fun LoadingRoomTopBar(
@Preview
@Composable
fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
internal fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
internal fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable

View File

@@ -96,7 +96,8 @@ class RoomFlowNode @AssistedInject constructor(
} else {
backstack.newRoot(NavTarget.Loading)
}
}.launchIn(lifecycleScope)
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View File

@@ -20,6 +20,7 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
@@ -74,8 +75,8 @@ class RoomLoadedFlowNode @AssistedInject constructor(
}
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
fun onFlowCreated(identifier: String, room: MatrixRoom)
fun onFlowReleased(identifier: String, room: MatrixRoom)
}
data class Inputs(
@@ -114,7 +115,8 @@ class RoomLoadedFlowNode @AssistedInject constructor(
room.updateMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess {
}
.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
}
}
@@ -161,13 +163,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
// Rely on the View Lifecycle instead of the Node Lifecycle,
// Rely on the View Lifecycle in addition to the Node Lifecycle,
// because this node enters 'onDestroy' before his children, so it can leads to
// using the room in a child node where it's already closed.
DisposableEffect(Unit) {
inputs.room.open()
inputs.room.subscribeToSync()
onDispose {
inputs.room.close()
inputs.room.unsubscribeFromSync()
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
inputs.room.destroy()
}
}
}
Children(

View File

@@ -16,7 +16,7 @@
package io.element.android.appnav
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -38,7 +38,7 @@ class RootPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -54,7 +54,7 @@ class RootPresenterTest {
showError("Bad news", "Something bad happened")
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)

View File

@@ -16,17 +16,22 @@
package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionClock
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.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -34,7 +39,7 @@ class LoggedInPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -42,14 +47,33 @@ class LoggedInPresenterTest {
}
}
private fun createPresenter(): LoggedInPresenter {
@Test
fun `present - show sync spinner`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createPresenter(roomListService, NetworkStatus.Online)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
consumeItemsUntilPredicate { it.showSyncSpinner }
roomListService.postState(RoomListService.State.Running)
consumeItemsUntilPredicate { !it.showSyncSpinner }
}
}
private fun createPresenter(
roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(),
matrixClient = FakeMatrixClient(roomListService = roomListService),
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return NoopPermissionsPresenter()
}
},
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = object : PushService {
override fun notificationStyleChanged() {
}

View File

@@ -18,12 +18,12 @@ package io.element.android.appnav.room
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.roomlist.RoomList
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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -47,29 +47,29 @@ class LoadingRoomStateFlowFactoryTest {
@Test
fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID)
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource)
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(this, A_ROOM_ID)
.test {
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room)
roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1))
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
}
}
@Test
fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource)
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(this, A_ROOM_ID)
.test {
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1))
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
}
}

View File

@@ -56,7 +56,7 @@ allprojects {
// activate all available (even unstable) rules.
allRules = true
// point to your custom config defining rules to run, overwriting default behavior
config = files("$rootDir/tools/detekt/detekt.yml")
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.1.12")
@@ -90,6 +90,14 @@ allprojects {
apply {
plugin("org.owasp.dependencycheck")
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
// Warnings are potential errors, so stop ignoring them
// This is disabled by default, but the CI will enforce this.
// You can override by passing `-PallWarningsAsErrors=true` in the command line
// Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file
kotlinOptions.allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true"
}
}
// To run a sonar analysis:

View File

@@ -145,7 +145,7 @@ Then you can launch the build script from the matrix-rust-components-kotlin repo
- `-m` Option to select the gradle module to build. Default is sdk.
- `-t` Option to to select an android target to build against. Default will build for all targets.
So for example to build the sdk against aarch64-linux-android target and copy the generated aar to ElementX project:
So for example to build the sdk against aarch64-linux-android target and copy the generated aar to Element X project:
```shell
./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar
@@ -313,7 +313,7 @@ suffix `Presenter`,states MUST have a suffix `State`, etc. Also we want to have
### Push
**Note** Firebase Push is not yet implemented on the project.
**Note** Firebase is implemented, but Unified Push is not yet fully implemented on the project, so this is not possible to choose this push provider in the app at the moment.
Please see the dedicated [documentation](notifications.md) for more details.
@@ -342,8 +342,7 @@ We have 3 tests frameworks in place, and this should be sufficient to guarantee
file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewProvider to provide
different states. See for instance the
file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt)
- Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the
class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt).
- Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt).
**Note** For now we want to avoid using class mocking (with library such as *mockk*), because this should be not necessary. We prefer to create Fake
implementation of our interfaces. Mocking can be used to mock Android framework classes though, such as `Bitmap` for instance.

BIN
docs/images-lfs/screen_1_dark.png LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
docs/images-lfs/screen_2_dark.png LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
docs/images-lfs/screen_3_dark.png LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
docs/images-lfs/screen_4_dark.png LFS Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

View File

@@ -27,16 +27,21 @@ Place your API key in `local.properties` with the key
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
```
Optionally you can also place your custom MapTyler style ids for light and dark maps
in the `local.properties` with the keys `services.maptiler.lightMapId` and
`services.maptiler.darkMapId`. If you don't specify these, the default MapTiler "basic-v2"
styles will be used.
## Making releasable builds with MapTiler
To insert the MapTiler API key when building an APK, set the
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
environment.
environment.
If you've added custom styles also set the `ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID`
and `ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID` environment variables accordingly.
## Using other map sources or MapTiler styles
If you wish to use an alternative map provider, or custom MapTiler styles,
you can customise the functions in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.
If you wish to use an alternative map provider, you can provide your own implementations of
`TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`.

View File

@@ -10,11 +10,11 @@
## Configuration
The nightly build will contain what's on develop, in release mode, for the main variant. It is signed using a dedicated signature, and has a dedicated appId (`io.element.android.x.nightly`), so it can be installed along with the production version of ElementX Android. The only other difference compared to ElementX Android is a different app name. We do not want to change the app name since it will also affect some strings in the app, and we do want to do that. (TODO today, the app name is changed.)
The nightly build will contain what's on develop, in release mode, for the main variant. It is signed using a dedicated signature, and has a dedicated appId (`io.element.android.x.nightly`), so it can be installed along with the production version of Element X Android. The only other difference compared to ElementX Android is a different app name. We do not want to change the app name since it will also affect some strings in the app, and we do want to do that. (TODO today, the app name is changed.)
Nightly builds are built and released to Firebase every days, and automatically.
This is recommended to exclusively use this app, with your main account, instead of ElementX Android, and fallback to ElementX Android just in case of regression, to discover as soon as possible any regression, and report it to the team. To avoid double notification, you may want to disable the notification from the Element Android production version. Just open Element Android, navigate to `Settings/Notifications` and uncheck `Enable notifications for this session` (TODO Not supported yet).
This is recommended to exclusively use this app, with your main account, instead of Element X Android, and fallback to ElementX Android just in case of regression, to discover as soon as possible any regression, and report it to the team. To avoid double notification, you may want to disable the notification from the Element Android production version. Just open Element Android, navigate to `Settings/Notifications` and uncheck `Enable notifications for this session` (TODO Not supported yet).
*Note:* Due to a limitation of Firebase, the nightly build is the universal build, which means that the size of the APK is a bit bigger, but this should not have any other side effect.

View File

@@ -13,7 +13,7 @@
## Overview
- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently.
- ElementX uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase).
- Element X uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase).
- The screenshot verification occurs on every pull request as part of the `tests.yml` workflow.
## Setup
@@ -30,6 +30,14 @@ If installed correctly, `git push` and `git pull` will now include LFS content.
## Recording
Recording of screenshots is done by triggering the GitHub action [Record screenshots](https://github.com/vector-im/element-x-android/actions/workflows/recordScreenshots.yml), to avoid differences of generated binary files (png images) depending on developers' environment.
So basically, you will create a branch, do some commits with your work on it, then push your branch, trigger the GitHub action to record the screenshots (only if you think preview may have changed), and finally create a pull request. The GitHub action will record the screenshots and commit the changes to the branch.
You can still record the screenshots locally, but please do not commit the changes.
To record the screenshot locally, run the following command:
```shell
./gradlew recordPaparazziDebug
```

View File

@@ -0,0 +1,2 @@
First release of Element X 🚀!
Full changelog: https://github.com/vector-im/element-x-android/releases

View File

@@ -81,12 +81,12 @@ fun buildAnnotatedStringWithColoredPart(
@Preview
@Composable
fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
internal fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable

View File

@@ -52,6 +52,4 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl)
androidTestImplementation(libs.test.junitext)
}

View File

@@ -54,6 +54,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
@@ -188,29 +189,28 @@ private fun AnalyticsOptInFooter(
modifier = modifier,
) {
Button(
text = stringResource(id = CommonStrings.action_ok),
onClick = onTermsAccepted,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = CommonStrings.action_ok))
}
)
TextButton(
text = stringResource(id = CommonStrings.action_not_now),
size = ButtonSize.Medium,
onClick = onTermsDeclined,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = CommonStrings.action_not_now))
}
)
}
}
@Preview
@Composable
fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight {
internal fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight {
ContentToPreview(state)
}
@Preview
@Composable
fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark {
internal fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark {
ContentToPreview(state)
}

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Wir erfassen und analysieren "<b>"keine"</b>" Account-Daten"</string>
<string name="screen_analytics_prompt_data_usage">"Wir werden keine personenbezogenen Daten aufzeichnen oder auswerten"</string>
<string name="screen_analytics_prompt_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string>
<string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Nutzerbedingungen %1$s lesen."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Sie können die Analyse jederzeit in den Einstellungen deaktivieren"</string>
<string name="screen_analytics_prompt_settings">"Du kannst dies jederzeit deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string>
<string name="screen_analytics_prompt_title">"Helfen Sie %1$s zu verbessern"</string>
<string name="screen_analytics_prompt_title">"Hilf uns, %1$s zu verbessern"</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Мы не будем записывать или профилировать какие-либо персональные данные"</string>
<string name="screen_analytics_prompt_help_us_improve">"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы."</string>
<string name="screen_analytics_prompt_read_terms">"Вы можете ознакомиться со всеми нашими условиями %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"здесь"</string>
<string name="screen_analytics_prompt_settings">"Вы можете отключить эту функцию в любое время"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Мы не будем передавать ваши данные третьим лицам"</string>
<string name="screen_analytics_prompt_title">"Помогите улучшить %1$s"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_settings">"您可以在任何時候關閉它"</string>
</resources>

View File

@@ -16,7 +16,7 @@
package io.element.android.features.analytics.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -35,7 +35,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(),
analyticsService
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -53,7 +53,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(),
analyticsService
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View File

@@ -16,7 +16,7 @@
package io.element.android.features.analytics.impl.preferences
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -33,7 +33,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
aBuildMeta()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -48,7 +48,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = false, didAskUserConsent = false),
aBuildMeta()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -62,7 +62,7 @@ class AnalyticsPreferencesPresenterTest {
FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
aBuildMeta()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)

View File

@@ -33,7 +33,7 @@ class FakeAnalyticsService(
private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
val capturedEvents = mutableListOf<VectorAnalyticsEvent>()
override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList()
override fun getAvailableAnalyticsProviders(): Set<AnalyticsProvider> = emptySet()
override fun getUserConsent(): Flow<Boolean> = isEnabledFlow

View File

@@ -66,7 +66,5 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)
}

View File

@@ -28,7 +28,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListEvents
@@ -36,7 +35,6 @@ import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasButtonText
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
@@ -103,16 +101,11 @@ fun AddPeopleViewTopBar(
},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
TextButton(
modifier = Modifier.padding(horizontal = 8.dp),
text = stringResource(id = textActionResId),
onClick = onNextPressed,
) {
val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
Text(
text = stringResource(id = textActionResId),
style = ElementTheme.typography.aliasButtonText,
)
}
)
}
)
}

View File

@@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -94,11 +93,11 @@ fun RoomPrivacyOption(
@Preview
@Composable
fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() }
internal fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() }
internal fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue
@@ -43,6 +44,7 @@ import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -55,7 +57,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasButtonText
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
@@ -193,15 +194,10 @@ fun ConfigureRoomToolbar(
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
TextButton(
modifier = Modifier.padding(horizontal = 8.dp),
text = stringResource(CommonStrings.action_create),
enabled = isNextActionEnabled,
onClick = onNextPressed,
) {
Text(
text = stringResource(CommonStrings.action_create),
style = ElementTheme.typography.aliasButtonText,
)
}
)
}
)
}
@@ -247,6 +243,9 @@ fun RoomTopic(
placeholder = stringResource(CommonStrings.common_topic_placeholder),
onValueChange = onTopicChanged,
maxLines = 3,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
)
}
@@ -277,12 +276,12 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
@Preview
@Composable
fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable

View File

@@ -41,7 +41,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
}
),
aCreateRoomRootState().copy(
startDmAction = Async.Failure(Throwable()),
startDmAction = Async.Failure(Throwable("error")),
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -55,7 +56,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DrawableR
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -162,12 +162,12 @@ fun CreateRoomActionButtonsList(
) {
Column(modifier = modifier) {
CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_groups,
iconRes = VectorIcons.Groups,
text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClicked,
)
CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_share,
iconRes = VectorIcons.Share,
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked,
)
@@ -205,12 +205,12 @@ fun CreateRoomActionButton(
@Preview
@Composable
fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
internal fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun CreateRoomRootViewDarkPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
internal fun CreateRoomRootViewDarkPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable

View File

@@ -4,7 +4,7 @@
<string name="screen_create_room_action_invite_people">"Inviter des amis sur Element"</string>
<string name="screen_create_room_add_people_title">"Inviter des personnes"</string>
<string name="screen_create_room_error_creating_room">"Une erreur s\'est produite lors de la création du salon"</string>
<string name="screen_create_room_private_option_description">"Les messages dans ce salon sont chiffrés. Une fopis activé, le chiffrement ne peut pas être désactivé."</string>
<string name="screen_create_room_private_option_description">"Les messages dans ce salon sont chiffrés. Une fois activé, le chiffrement ne peut pas être désactivé."</string>
<string name="screen_create_room_private_option_title">"Salon privé (sur invitation uniquement)"</string>
<string name="screen_create_room_public_option_description">"Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."</string>
<string name="screen_create_room_public_option_title">"Salon public (nimporte qui)"</string>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Новая комната"</string>
<string name="screen_create_room_action_invite_people">"Пригласите друзей в Element"</string>
<string name="screen_create_room_add_people_title">"Пригласить людей"</string>
<string name="screen_create_room_error_creating_room">"Произошла ошибка при создании комнаты"</string>
<string name="screen_create_room_private_option_description">"Сообщения в этой комнате зашифрованы. Отключить шифрование впоследствии невозможно."</string>
<string name="screen_create_room_private_option_title">"Приватная комната (только по приглашению)"</string>
<string name="screen_create_room_public_option_description">"Сообщения не зашифрованы, и каждый может их прочитать. Вы можете включить шифрование позже."</string>
<string name="screen_create_room_public_option_title">"Публичная комната (любой)"</string>
<string name="screen_create_room_room_name_label">"Название комнаты"</string>
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при попытке открытия комнаты"</string>
<string name="screen_create_room_title">"Создать комнату"</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_invite_people">"邀請朋友使用 Element"</string>
<string name="screen_create_room_room_name_label">"聊天室名稱"</string>
<string name="screen_create_room_topic_label">"主題(非必填)"</string>
<string name="screen_create_room_title">"建立聊天室"</string>
</resources>

View File

@@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.addpeople
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -43,7 +43,7 @@ class AddPeoplePresenterTests {
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// TODO This doesn't actually test anything...

View File

@@ -17,7 +17,7 @@
package io.element.android.features.createroom.impl.configureroom
import android.net.Uri
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -93,7 +93,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -108,7 +108,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - create room button is enabled only if the required fields are completed`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -133,7 +133,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - state is updated when fields are changed`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -203,7 +203,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - trigger create room action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -221,7 +221,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - record analytics when creating room`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -240,7 +240,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -265,7 +265,7 @@ class ConfigureRoomPresenterTests {
@Test
fun `present - trigger retry and cancel actions`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View File

@@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -27,8 +27,6 @@ import io.element.android.features.createroom.impl.userlist.FakeUserListPresente
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -68,7 +66,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -82,7 +80,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -102,7 +100,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - creating a DM records analytics event`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -123,7 +121,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -142,7 +140,7 @@ class CreateRoomRootPresenterTests {
@Test
fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View File

@@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.userlist
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -41,7 +41,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -62,7 +62,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -83,7 +83,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -119,7 +119,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -158,7 +158,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
@@ -183,7 +183,7 @@ class DefaultUserListPresenterTests {
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)

View File

@@ -97,9 +97,11 @@ fun WelcomeView(
}
},
footer = {
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
Text(text = stringResource(CommonStrings.action_continue))
}
Button(
text = stringResource(CommonStrings.action_continue),
modifier = Modifier.fillMaxWidth(),
onClick = onContinueClicked
)
Spacer(modifier = Modifier.height(32.dp))
}
)

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
<string name="screen_welcome_button">"Los geht\'s!"</string>
<string name="screen_welcome_subtitle">"Folgendes musst du wissen:"</string>
<string name="screen_welcome_title">"Willkommen bei %1$s!"</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
<string name="screen_welcome_bullet_3">"Nous serions ravis davoir votre avis, nhésitez pas à nous le partager via la page des paramètres."</string>
<string name="screen_welcome_button">"Cest parti !"</string>
<string name="screen_welcome_subtitle">"Voici ce quil faut savoir :"</string>
<string name="screen_welcome_title">"Bienvenue sur %1$s !"</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>
<string name="screen_welcome_button">"Поехали!"</string>
<string name="screen_welcome_subtitle">"Вот что вам необходимо знать:"</string>
<string name="screen_welcome_title">"Добро пожаловать в %1$s!"</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
<string name="screen_welcome_button">"Poďme na to!"</string>
<string name="screen_welcome_subtitle">"Tu je to, čo potrebujete vedieť:"</string>
<string name="screen_welcome_title">"Vitajte v %1$s!"</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_button">"開始吧!"</string>
</resources>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string>

View File

@@ -25,7 +25,6 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test

View File

@@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom
@@ -56,8 +56,9 @@ class InviteListPresenter @Inject constructor(
@Composable
override fun present(): InviteListState {
val invites by client
.roomSummaryDataSource
.inviteRooms()
.roomListService
.invites()
.summaries
.collectAsState()
var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
@@ -152,8 +153,7 @@ class InviteListPresenter @Inject constructor(
client.getRoom(roomId)?.use {
it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
}
Unit
}.let { }
}.runCatchingUpdatingState(declinedAction)
}

View File

@@ -86,7 +86,6 @@ fun InviteListView(
title = stringResource(titleResource),
submitText = stringResource(CommonStrings.action_decline),
cancelText = stringResource(CommonStrings.action_cancel),
emphasizeSubmitButton = true,
onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) },
onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) }
)

View File

@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -49,8 +48,8 @@ import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAto
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.aliasButtonText
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@@ -133,23 +132,19 @@ internal fun DefaultInviteSummaryRow(
// CTAs
Row(Modifier.padding(top = 12.dp)) {
OutlinedButton(
content = { Text(stringResource(CommonStrings.action_decline), style = ElementTheme.typography.aliasButtonText) },
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClicked,
modifier = Modifier
.weight(1f)
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
modifier = Modifier.weight(1f),
size = ButtonSize.Medium,
)
Spacer(modifier = Modifier.width(12.dp))
Button(
content = { Text(stringResource(CommonStrings.action_accept), style = ElementTheme.typography.aliasButtonText) },
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClicked,
modifier = Modifier
.weight(1f)
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
modifier = Modifier.weight(1f),
size = ButtonSize.Medium,
)
}
}

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Möchten Sie den Beitritt zu %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_chat_message">"Möchtest du den Beitritt zu %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_message">"Möchtest du den privaten Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Вы уверены, что хотите отклонить приглашение в %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Отклонить приглашение"</string>
<string name="screen_invites_decline_direct_chat_message">"Вы уверены, что хотите отказаться от приватного общения с %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Отклонить чат"</string>
<string name="screen_invites_empty_list">"Нет приглашений"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) пригласил вас"</string>
</resources>

View File

@@ -16,7 +16,7 @@
package io.element.android.features.invitelist.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
@@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
@@ -40,7 +40,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
@@ -51,17 +51,17 @@ class InviteListPresenterTests {
@Test
fun `present - starts empty, adds invites when received`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val roomListService = FakeRoomListService()
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.inviteList).isEmpty()
roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary()))
roomListService.postInviteRooms(listOf(aRoomSummary()))
val withInviteState = awaitItem()
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
@@ -72,11 +72,11 @@ class InviteListPresenterTests {
@Test
fun `present - uses user ID and avatar for direct invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
val roomListService = FakeRoomListService().withDirectChatInvitation()
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val withInviteState = awaitItem()
@@ -98,11 +98,11 @@ class InviteListPresenterTests {
@Test
fun `present - includes sender details for room invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val withInviteState = awaitItem()
@@ -122,16 +122,16 @@ class InviteListPresenterTests {
@Test
fun `present - shows confirm dialog for declining direct chat invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
val roomListService = FakeRoomListService().withDirectChatInvitation()
val presenter = InviteListPresenter(
FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
),
FakeSeenInvitesStore(),
FakeAnalyticsService(),
FakeNotificationDrawerManager()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -148,11 +148,11 @@ class InviteListPresenterTests {
@Test
fun `present - shows confirm dialog for declining room invites`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -169,11 +169,11 @@ class InviteListPresenterTests {
@Test
fun `present - hides confirm dialog when cancelling`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val presenter = createPresenter(
FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -190,16 +190,16 @@ class InviteListPresenterTests {
@Test
fun `present - declines invite after confirming`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
)
val room = FakeMatrixRoom()
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -217,9 +217,9 @@ class InviteListPresenterTests {
@Test
fun `present - declines invite after confirming and sets state on error`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
)
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
@@ -227,7 +227,7 @@ class InviteListPresenterTests {
room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -247,9 +247,9 @@ class InviteListPresenterTests {
@Test
fun `present - dismisses declining error state`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
)
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
@@ -257,7 +257,7 @@ class InviteListPresenterTests {
room.givenLeaveRoomError(ex)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -279,16 +279,16 @@ class InviteListPresenterTests {
@Test
fun `present - accepts invites and sets state on success`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
)
val room = FakeMatrixRoom()
val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -303,9 +303,9 @@ class InviteListPresenterTests {
@Test
fun `present - accepts invites and sets state on error`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
)
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
@@ -313,7 +313,7 @@ class InviteListPresenterTests {
room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -325,9 +325,9 @@ class InviteListPresenterTests {
@Test
fun `present - dismisses accepting error state`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val roomListService = FakeRoomListService().withRoomInvitation()
val client = FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
)
val room = FakeMatrixRoom()
val presenter = createPresenter(client)
@@ -335,7 +335,7 @@ class InviteListPresenterTests {
room.givenJoinRoomResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val originalState = awaitItem()
@@ -352,35 +352,35 @@ class InviteListPresenterTests {
@Test
fun `present - stores seen invites when received`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val roomListService = FakeRoomListService()
val store = FakeSeenInvitesStore()
val presenter = InviteListPresenter(
FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
),
store,
FakeAnalyticsService(),
FakeNotificationDrawerManager()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem()
// When one invite is received, that ID is saved
roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary()))
roomListService.postInviteRooms(listOf(aRoomSummary()))
awaitItem()
Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
// When a second is added, both are saved
roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
awaitItem()
Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
// When they're both dismissed, an empty set is saved
roomSummaryDataSource.postInviteRooms(listOf())
roomListService.postInviteRooms(listOf())
awaitItem()
Truth.assertThat(store.getProvidedRoomIds()).isEmpty()
@@ -389,23 +389,23 @@ class InviteListPresenterTests {
@Test
fun `present - marks invite as new if they're unseen`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val roomListService = FakeRoomListService()
val store = FakeSeenInvitesStore()
store.publishRoomIds(setOf(A_ROOM_ID))
val presenter = InviteListPresenter(
FakeMatrixClient(
roomSummaryDataSource = roomSummaryDataSource,
roomListService = roomListService,
),
store,
FakeAnalyticsService(),
FakeNotificationDrawerManager()
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem()
roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
skipItems(1)
val withInviteState = awaitItem()
@@ -417,7 +417,7 @@ class InviteListPresenterTests {
}
}
private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource {
private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService {
postInviteRooms(
listOf(
RoomSummary.Filled(
@@ -446,7 +446,7 @@ class InviteListPresenterTests {
return this
}
private suspend fun FakeRoomSummaryDataSource.withDirectChatInvitation(): FakeRoomSummaryDataSource {
private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService {
postInviteRooms(
listOf(
RoomSummary.Filled(

View File

@@ -16,7 +16,7 @@
package io.element.android.features.leaveroom.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -40,7 +40,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -60,7 +60,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -80,7 +80,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -100,7 +100,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -122,7 +122,7 @@ class LeaveRoomPresenterImplTest {
},
roomMembershipObserver = roomMembershipObserver
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -145,7 +145,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -167,7 +167,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
@@ -191,7 +191,7 @@ class LeaveRoomPresenterImplTest {
)
}
)
moleculeFlow(RecompositionClock.Immediate) {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()

View File

@@ -22,12 +22,12 @@ plugins {
id("kotlin-parcelize")
}
fun readLocalProperty(name: String) = Properties().apply {
fun readLocalProperty(name: String): String? = Properties().apply {
try {
load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) {
}
}[name]
}.getProperty(name)
android {
namespace = "io.element.android.features.location.api"
@@ -37,9 +37,23 @@ android {
type = "string",
name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey") as? String
?: readLocalProperty("services.maptiler.apikey")
?: ""
)
resValue(
type = "string",
name = "maptiler_light_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
?: "basic-v2" // fall back to maptiler's default light map.
)
resValue(
type = "string",
name = "maptiler_dark_map_id",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
?: "basic-v2-dark" // fall back to maptiler's default dark map.
)
}
}

View File

@@ -29,16 +29,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.staticMapUrl
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
@@ -65,23 +65,22 @@ fun StaticMapView(
) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null
} else {
ImageRequest.Builder(LocalContext.current)
ImageRequest.Builder(context)
.data(
staticMapUrl(
context = context,
builder.build(
lat = lat,
lon = lon,
zoom = zoom,
darkMode = darkMode,
// Size the map based on DP rather than pixels, as otherwise the features and attribution
// end up being illegibly tiny on high density displays.
width = constraints.maxWidth.toDp().value.toInt(),
height = constraints.maxHeight.toDp().value.toInt(),
width = constraints.maxWidth,
height = constraints.maxHeight,
density = LocalDensity.current.density,
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
@@ -106,13 +105,7 @@ fun StaticMapView(
resourceId = DesignSystemR.drawable.pin,
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.align { size, space, _ ->
// Center bottom edge of pin (i.e. its arrow) to center of screen
IntOffset(
x = (space.width - size.width) / 2,
y = (space.height / 2) - size.height,
)
}
modifier = Modifier.centerBottomEdge(this),
)
} else {
StaticMapPlaceholder(
@@ -127,7 +120,7 @@ fun StaticMapView(
@DayNightPreviews
@Composable
fun StaticMapViewPreview() = ElementPreview {
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
lat = 0.0,
lon = 0.0,

View File

@@ -0,0 +1,30 @@
/*
* 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.location.api.internal
import android.content.Context
import io.element.android.features.location.api.R
internal const val MAPTILER_BASE_URL = "https://api.maptiler.com/maps"
internal fun Context.mapId(darkMode: Boolean) = when (darkMode) {
true -> getString(R.string.maptiler_dark_map_id)
false -> getString(R.string.maptiler_light_map_id)
}
internal val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

View File

@@ -0,0 +1,93 @@
/*
* 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.location.api.internal
import android.content.Context
import kotlin.math.roundToInt
/**
* Builds an URL for MapTiler's Static Maps API.
*
* https://docs.maptiler.com/cloud/api/static-maps/
*/
internal class MapTilerStaticMapUrlBuilder(
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
)
override fun build(
lat: Double,
lon: Double,
zoom: Double,
darkMode: Boolean,
width: Int,
height: Int,
density: Float
): String {
val mapId = if (darkMode) darkMapId else lightMapId
val finalZoom = zoom.coerceIn(zoomRange)
// Request @2x density for xhdpi and above (xhdpi == 320dpi == 2x density).
val is2x = density >= 2
// Scale requested width/height according to the reported display density.
val (finalWidth, finalHeight) = coerceWidthAndHeight(
width = (width / density).roundToInt(),
height = (height / density).roundToInt(),
is2x = is2x,
)
val scale = if (is2x) "@2x" else ""
// Since Maptiler doesn't support arbitrary dpi scaling, we stick to 2x sized
// images even on displays with density higher than 2x, thereby yielding an
// image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness.
return "$MAPTILER_BASE_URL/${mapId}/static/${lon},${lat},${finalZoom}/${finalWidth}x${finalHeight}${scale}.webp?key=${apiKey}&attribution=bottomleft"
}
}
private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair<Int, Int> {
if (width <= 0 || height <= 0) {
// This effectively yields an URL which asks for a 0x0 image which will result in an HTTP error,
// but it's better than e.g. asking for a 1x1 image which would be unreadable and increase usage costs.
return 0 to 0
}
val aspectRatio = width.toDouble() / height.toDouble()
val range = if (is2x) widthHeightRange2x else widthHeightRange
return if (width >= height) {
width.coerceIn(range).let { coercedWidth ->
coercedWidth to (coercedWidth / aspectRatio).roundToInt()
}
} else {
height.coerceIn(range).let { coercedHeight ->
(coercedHeight * aspectRatio).roundToInt() to coercedHeight
}
}
}
private val widthHeightRange = 1..2048 // API will error if outside 1-2048 range @1x.
private val widthHeightRange2x = 1..1024 // API will error if outside 1-1024 range @2x.
private val zoomRange = 0.0..22.0 // API will error if outside 0-22 range.

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:JvmName("TileServerStyleUriBuilderKt")
package io.element.android.features.location.api.internal
import android.content.Context
internal class MapTilerTileServerStyleUriBuilder(
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
)
override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId
return "${MAPTILER_BASE_URL}/${mapId}/style.json?key=${apiKey}"
}
}

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