Merge branch 'release/0.1.2' into main

This commit is contained in:
Benoit Marty 2023-08-16 17:09:09 +02:00
commit c2cd7532ce
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 **/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: on:
workflow_dispatch: workflow_dispatch:
pull_request: { } pull_request:
merge_group:
push: push:
branches: [ main, develop ] branches: [ develop ]
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
@ -13,14 +14,17 @@ env:
jobs: jobs:
debug: debug:
name: Build debug APKs name: Build APKs
runs-on: ubuntu-latest 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: strategy:
matrix:
variant: [debug, release, nightly, samples]
fail-fast: false fail-fast: false
# Allow all jobs on develop. Just one per PR. # Allow all jobs on develop. Just one per PR.
concurrency: 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -34,14 +38,18 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1 uses: gradle/gradle-build-action@v2.7.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK
if: ${{ matrix.variant == 'debug' }}
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
- name: Upload debug APKs 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 uses: actions/upload-artifact@v3
with: with:
name: elementx-debug name: elementx-debug
@ -53,12 +61,12 @@ jobs:
continue-on-error: true continue-on-error: true
env: env:
token: ${{ secrets.DIAWI_TOKEN }} token: ${{ secrets.DIAWI_TOKEN }}
if: ${{ github.event_name == 'pull_request' && env.token != '' }} if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && env.token != '' }}
with: with:
token: ${{ env.token }} token: ${{ env.token }}
file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
- name: Add or update PR comment with QR Code to download 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 uses: NejcZdovc/comment-pr@v2
with: with:
message: | message: |
@ -70,8 +78,11 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources - 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 - 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 - name: Compile samples minimal
if: ${{ matrix.variant == 'samples' }}
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES

View file

@ -1,17 +1,19 @@
name: Danger CI name: Danger CI
on: [pull_request] on: [pull_request, merge_group]
jobs: jobs:
build: build:
runs-on: ubuntu-latest 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 name: Danger main check
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@11.2.6 uses: danger/danger-js@11.2.8
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:

View file

@ -1,12 +1,15 @@
name: "Validate Gradle Wrapper" name: "Validate Gradle Wrapper"
on: on:
pull_request: { } pull_request:
merge_group:
push: push:
branches: [ main, develop ] branches: [ main, develop ]
jobs: jobs:
validation: validation:
name: "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 runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time. # No concurrency required, this is a prerequisite to other actions and should run every time.
steps: steps:

View file

@ -38,16 +38,14 @@ jobs:
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
- name: Upload debug APKs ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
uses: actions/upload-artifact@v3 ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
with:
name: elementx-debug
path: |
app/build/outputs/apk/debug/*.apk
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1 - uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} 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: | env: |
USERNAME=maestroelement USERNAME=maestroelement
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} 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: on:
workflow_dispatch: workflow_dispatch:
@ -12,7 +12,7 @@ env:
jobs: jobs:
nightly: nightly:
name: Build and publish nightly APK to Firebase name: Build and publish nightly bundle to Firebase
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }} if: ${{ github.repository == 'vector-im/element-x-android' }}
steps: steps:
@ -31,18 +31,21 @@ jobs:
sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
rm towncrier.toml.bak rm towncrier.toml.bak
yes n | towncrier build --version nightly yes n | towncrier build --version nightly
- name: Build and upload Nightly APK - name: Build and upload Nightly application
run: | run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES ./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }} ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }} FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- name: Additionally upload Nightly APK to browserstack for testing - name: Additionally upload Nightly APK to browserstack for testing
continue-on-error: true # don't block anything by this upload failing (for now) 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: env:
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }} BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}

View file

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

View file

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

View file

@ -24,7 +24,7 @@ jobs:
java-version: '17' java-version: '17'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1 uses: gradle/gradle-build-action@v2.7.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - 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: on:
workflow_dispatch: workflow_dispatch:
pull_request: { } pull_request:
merge_group:
push: push:
branches: [ main, develop ] branches: [ main, develop ]
@ -15,6 +16,8 @@ jobs:
tests: tests:
name: Runs unit tests name: Runs unit tests
runs-on: ubuntu-latest 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. # Allow all jobs on main and develop. Just one per PR.
concurrency: concurrency:
@ -33,7 +36,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.6.1 uses: gradle/gradle-build-action@v2.7.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -1,10 +1,12 @@
name: Validate Git LFS name: Validate Git LFS
on: [pull_request] on: [pull_request, merge_group]
jobs: jobs:
build: build:
runs-on: ubuntu-latest 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 name: Validate
steps: steps:
- uses: nschloe/action-cached-lfs-checkout@v1.2.1 - uses: nschloe/action-cached-lfs-checkout@v1.2.1

1
.gitignore vendored
View file

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

View file

@ -8,6 +8,7 @@
<w>measurables</w> <w>measurables</w>
<w>onboarding</w> <w>onboarding</w>
<w>placeables</w> <w>placeables</w>
<w>posthog</w>
<w>showkase</w> <w>showkase</w>
<w>snackbar</w> <w>snackbar</w>
<w>swipeable</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 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 ```shell
maestro test \ 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. 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 ```shell
maestro studio 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) Changes in Element X v0.1.0 (2023-07-19)
======================================== ========================================

View file

@ -13,6 +13,7 @@
* [Kotlin](#kotlin) * [Kotlin](#kotlin)
* [Changelog](#changelog) * [Changelog](#changelog)
* [Code quality](#code-quality) * [Code quality](#code-quality)
* [detekt](#detekt)
* [ktlint](#ktlint) * [ktlint](#ktlint)
* [knit](#knit) * [knit](#knit)
* [lint](#lint) * [lint](#lint)
@ -50,7 +51,7 @@ Note: please make sure that the configuration is `app` and not `samples.minimal`
## Strings ## 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 ### 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 ### 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). 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). - 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 ## 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: Make sure the following commands execute without any error:
<pre> <pre>
./gradlew check ./tools/quality/check.sh
</pre> </pre>
Some separate commands can also be run, see below. Some separate commands can also be run, see below.
#### detekt
<pre>
./gradlew detekt
</pre>
#### ktlint #### ktlint
<pre> <pre>
@ -153,7 +158,7 @@ Make sure the following commands execute without any error:
### Tests ### 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. 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. 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 ### 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 ### 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) [![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) [![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) [![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) [![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)
[![Weblate](https://translate.element.io/widgets/element-android/-/svg-badge.svg)](https://translate.element.io/engage/element-android/?utm_source=widget) [![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 --> <!--- 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: 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 ## 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. 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 ## 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 ## 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 ## Build instructions
@ -54,9 +75,9 @@ Makes sure to select the `app` configuration when building (as we also have samp
## Support ## 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) 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-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). 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-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. 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 ## Copyright & License

View file

@ -129,6 +129,8 @@ android {
// "App Distribution found more than 1 output file for this variant. // "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." // 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" 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 // This file will be generated by the GitHub action
releaseNotesFile = "CHANGES_NIGHTLY.md" releaseNotesFile = "CHANGES_NIGHTLY.md"
groups = "external-testers" 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.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.EmojiInitializer import io.element.android.x.initializer.EmojiInitializer
import io.element.android.x.initializer.MatrixInitializer import io.element.android.x.initializer.TracingInitializer
import io.element.android.x.initializer.TimberInitializer
class ElementXApplication : Application(), DaggerComponentOwner { class ElementXApplication : Application(), DaggerComponentOwner {
@ -39,8 +38,7 @@ class ElementXApplication : Application(), DaggerComponentOwner {
appComponent = DaggerAppComponent.factory().create(applicationContext) appComponent = DaggerAppComponent.factory().create(applicationContext)
AppInitializer.getInstance(this).apply { AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java) initializeComponent(CrashInitializer::class.java)
initializeComponent(TimberInitializer::class.java) initializeComponent(TracingInitializer::class.java)
initializeComponent(MatrixInitializer::class.java)
initializeComponent(EmojiInitializer::class.java) initializeComponent(EmojiInitializer::class.java)
} }
logApplicationInfo() logApplicationInfo()

View file

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

View file

@ -28,7 +28,7 @@ import io.element.android.x.R
@Preview @Preview
@Composable @Composable
fun IconPreview( internal fun IconPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@ -39,7 +39,7 @@ fun IconPreview(
@Preview @Preview
@Composable @Composable
fun RoundIconPreview( internal fun RoundIconPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier.clip(shape = CircleShape)) { 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.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl) testImplementation(projects.features.rageshake.impl)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)

View file

@ -58,7 +58,8 @@ class LoggedInEventProcessor @Inject constructor(
.filter { it } .filter { it }
.onEach { .onEach {
displayMessage(CommonStrings.common_verification_complete) 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.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint 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.invitelist.api.InviteListEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint 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.BackstackNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler 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.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class LoggedInFlowNode @AssistedInject constructor( class LoggedInFlowNode @AssistedInject constructor(
@ -100,13 +102,13 @@ class LoggedInFlowNode @AssistedInject constructor(
) { ) {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() = Unit fun onOpenBugReport()
} }
interface LifecycleCallback : NodeLifecycleCallback { 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( data class Inputs(
@ -123,7 +125,6 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) } plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
@ -138,14 +139,12 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.Ftue) backstack.push(NavTarget.Ftue)
} }
}, },
onResume = { onStop = {
lifecycleScope.launch { //Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
syncService.startSync() coroutineScope.launch {
syncService.stopSync()
} }
}, },
onPause = {
syncService.stopSync()
},
onDestroy = { onDestroy = {
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) } plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
@ -153,22 +152,23 @@ class LoggedInFlowNode @AssistedInject constructor(
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
} }
) )
observeSyncStateAndNetworkStatus() observeSyncStateAndNetworkStatus()
} }
@OptIn(FlowPreview::class)
private fun observeSyncStateAndNetworkStatus() { private fun observeSyncStateAndNetworkStatus() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
combine( 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 networkMonitor.connectivity
) { syncState, networkStatus -> ) { syncState, networkStatus ->
syncState == SyncState.Error && networkStatus == NetworkStatus.Online Pair(syncState, networkStatus)
} }
.distinctUntilChanged() .collect { (syncState, networkStatus) ->
.collect { restartSync -> Timber.d("Sync state: $syncState, network status: $networkStatus")
if (restartSync) { if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
syncService.startSync() syncService.startSync()
} }
} }
@ -305,7 +305,8 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onFtueFlowFinished() { override fun onFtueFlowFinished() {
backstack.pop() backstack.pop()
} }
}).build() })
.build()
} }
} }
} }
@ -350,3 +351,4 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.InviteList) 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.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject

View file

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

View file

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

View file

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

View file

@ -47,7 +47,7 @@ fun LoggedInView(
modifier = Modifier modifier = Modifier
.padding(top = 8.dp) .padding(top = 8.dp)
.align(Alignment.TopCenter), .align(Alignment.TopCenter),
syncState = state.syncState, isVisible = state.showSyncSpinner,
) )
PermissionsView( PermissionsView(
state = state.permissionsState, state = state.permissionsState,
@ -58,7 +58,7 @@ fun LoggedInView(
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview { internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
LoggedInView( LoggedInView(
state = state 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text 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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun SyncStateView( fun SyncStateView(
syncState: SyncState, isVisible: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val animationSpec = spring<Float>(stiffness = 500F) val animationSpec = spring<Float>(stiffness = 500F)
AnimatedVisibility( AnimatedVisibility(
modifier = modifier, modifier = modifier,
visible = syncState.mustBeVisible(), visible = isVisible,
enter = fadeIn(animationSpec = animationSpec), enter = fadeIn(animationSpec = animationSpec),
exit = fadeOut(animationSpec = animationSpec), exit = fadeOut(animationSpec = animationSpec),
) { ) {
@ -60,15 +59,15 @@ fun SyncStateView(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.background(color = ElementTheme.colors.bgSubtleSecondary) .background(color = ElementTheme.colors.bgSubtleSecondary)
.padding(horizontal = 24.dp, vertical = 10.dp), .padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier modifier = Modifier
.progressSemantics() .progressSemantics()
.size(12.dp), .size(12.dp),
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp, 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 @DayNightPreviews
@Composable @Composable
fun SyncStateViewPreview() = ElementPreview { internal fun SyncStateViewPreview() = ElementPreview {
// Add a box to see the shadow // Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) { Box(modifier = Modifier.padding(24.dp)) {
SyncStateView( SyncStateView(
syncState = SyncState.Idle isVisible = true
) )
} }
} }

View file

@ -16,19 +16,13 @@
package io.element.android.appnav.room package io.element.android.appnav.room
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text 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.components.TopAppBar
import io.element.android.libraries.designsystem.theme.placeholderBackground
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -103,20 +95,7 @@ private fun LoadingRoomTopBar(
BackButton(onClick = onBackClicked) BackButton(onClick = onBackClicked)
}, },
title = { title = {
Row( IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp)
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)
}
}, },
windowInsets = WindowInsets(0.dp), windowInsets = WindowInsets(0.dp),
) )
@ -124,12 +103,12 @@ private fun LoadingRoomTopBar(
@Preview @Preview
@Composable @Composable
fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = internal fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = internal fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable

View file

@ -96,7 +96,8 @@ class RoomFlowNode @AssistedInject constructor(
} else { } else {
backstack.newRoot(NavTarget.Loading) backstack.newRoot(NavTarget.Loading)
} }
}.launchIn(lifecycleScope) }
.launchIn(lifecycleScope)
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
@ -74,8 +75,8 @@ class RoomLoadedFlowNode @AssistedInject constructor(
} }
interface LifecycleCallback : NodeLifecycleCallback { interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit fun onFlowCreated(identifier: String, room: MatrixRoom)
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit fun onFlowReleased(identifier: String, room: MatrixRoom)
} }
data class Inputs( data class Inputs(
@ -114,7 +115,8 @@ class RoomLoadedFlowNode @AssistedInject constructor(
room.updateMembers() room.updateMembers()
.onFailure { .onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}") Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess { }
.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}") Timber.v("Success fetching members for room ${room.roomId}")
} }
} }
@ -161,13 +163,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { 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 // 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. // using the room in a child node where it's already closed.
DisposableEffect(Unit) { DisposableEffect(Unit) {
inputs.room.open() inputs.room.subscribeToSync()
onDispose { onDispose {
inputs.room.close() inputs.room.unsubscribeFromSync()
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
inputs.room.destroy()
}
} }
} }
Children( Children(

View file

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

View file

@ -16,17 +16,22 @@
package io.element.android.appnav.loggedin package io.element.android.appnav.loggedin
import app.cash.molecule.RecompositionClock import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.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.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -34,7 +39,7 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() 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( return LoggedInPresenter(
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(roomListService = roomListService),
permissionsPresenterFactory = object : PermissionsPresenter.Factory { permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter { override fun create(permission: String): PermissionsPresenter {
return NoopPermissionsPresenter() return NoopPermissionsPresenter()
} }
}, },
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = object : PushService { pushService = object : PushService {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
} }

View file

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

View file

@ -56,7 +56,7 @@ allprojects {
// activate all available (even unstable) rules. // activate all available (even unstable) rules.
allRules = true allRules = true
// point to your custom config defining rules to run, overwriting default behavior // 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 { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.1.12") detektPlugins("io.nlopez.compose.rules:detekt:0.1.12")
@ -90,6 +90,14 @@ allprojects {
apply { apply {
plugin("org.owasp.dependencycheck") 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: // 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. - `-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. - `-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 ```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 ./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 ### 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. 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 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 different states. See for instance the
file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt) 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 - 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).
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 **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. implementation of our interfaces. Mocking can be used to mock Android framework classes though, such as `Bitmap` for instance.

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4515f7589c422197a82672cdc3e64814ccca9a9a022b806facee44dd67d51ff2
size 1116864

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2afb667a8b679f4395c28407b014f074e8b74745f6ecdd6ad699a6650cee2bc7
size 771160

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:910f9ab58a197a16b1295cd6b65d406ebfff1298c9c128a326edf1ec834d7fa7
size 332936

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7b80b9124c5c04c9db3824b68ab35883d41d05918abd415f7565f87d2713cfa
size 338455

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dadff79ae955ab1da5c0a0567960fc567526ad0d8d2ea418d8adacf3a7a400cc
size 243201

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2f328b3e8bf2ebe4abc4c130e28f6fc2351f5ac339be0819e5fddb657f4a560
size 246731

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:519fb54f0070833b2a4671e1f0fc7b2ec60930d69b592b8a760f52a73dc4fe38
size 132247

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f683a228d7168c75d6f42c353c1a0e169cb22cb8fa6696e9e3ffeb80854b2441
size 131610

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 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 ## Making releasable builds with MapTiler
To insert the MapTiler API key when building an APK, set the To insert the MapTiler API key when building an APK, set the
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build `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 ## Using other map sources or MapTiler styles
If you wish to use an alternative map provider, or custom MapTiler styles, If you wish to use an alternative map provider, you can provide your own implementations of
you can customise the functions in `TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`. `features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.

View file

@ -10,11 +10,11 @@
## Configuration ## 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. 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. *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 ## Overview
- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently. - 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. - The screenshot verification occurs on every pull request as part of the `tests.yml` workflow.
## Setup ## Setup
@ -30,6 +30,14 @@ If installed correctly, `git push` and `git pull` will now include LFS content.
## Recording ## 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 ```shell
./gradlew recordPaparazziDebug ./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 @Preview
@Composable @Composable
fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = internal fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable

View file

@ -52,6 +52,4 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.features.analytics.test)
testImplementation(projects.features.analytics.impl) 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.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart 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.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.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
@ -188,29 +189,28 @@ private fun AnalyticsOptInFooter(
modifier = modifier, modifier = modifier,
) { ) {
Button( Button(
text = stringResource(id = CommonStrings.action_ok),
onClick = onTermsAccepted, onClick = onTermsAccepted,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { )
Text(text = stringResource(id = CommonStrings.action_ok))
}
TextButton( TextButton(
text = stringResource(id = CommonStrings.action_not_now),
size = ButtonSize.Medium,
onClick = onTermsDeclined, onClick = onTermsDeclined,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { )
Text(text = stringResource(id = CommonStrings.action_not_now))
}
} }
} }
@Preview @Preview
@Composable @Composable
fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight { internal fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight {
ContentToPreview(state) ContentToPreview(state)
} }
@Preview @Preview
@Composable @Composable
fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark { internal fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark {
ContentToPreview(state) ContentToPreview(state)
} }

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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> </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 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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@ -35,7 +35,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(), aBuildMeta(),
analyticsService analyticsService
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -53,7 +53,7 @@ class AnalyticsOptInPresenterTest {
aBuildMeta(), aBuildMeta(),
analyticsService analyticsService
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()

View file

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

View file

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

View file

@ -66,7 +66,5 @@ dependencies {
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor) 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.R
import io.element.android.features.createroom.impl.components.UserListView import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListEvents 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.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.Text
@ -103,16 +101,11 @@ fun AddPeopleViewTopBar(
}, },
navigationIcon = { BackButton(onClick = onBackPressed) }, navigationIcon = { BackButton(onClick = onBackPressed) },
actions = { actions = {
val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
TextButton( TextButton(
modifier = Modifier.padding(horizontal = 8.dp), text = stringResource(id = textActionResId),
onClick = onNextPressed, 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.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.RoomPrivacyItem
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -94,11 +93,11 @@ fun RoomPrivacyOption(
@Preview @Preview
@Composable @Composable
fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() } internal fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview @Preview
@Composable @Composable
fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() } internal fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetValue 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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.Text
@ -193,15 +194,10 @@ fun ConfigureRoomToolbar(
navigationIcon = { BackButton(onClick = onBackPressed) }, navigationIcon = { BackButton(onClick = onBackPressed) },
actions = { actions = {
TextButton( TextButton(
modifier = Modifier.padding(horizontal = 8.dp), text = stringResource(CommonStrings.action_create),
enabled = isNextActionEnabled, enabled = isNextActionEnabled,
onClick = onNextPressed, onClick = onNextPressed,
) { )
Text(
text = stringResource(CommonStrings.action_create),
style = ElementTheme.typography.aliasButtonText,
)
}
} }
) )
} }
@ -247,6 +243,9 @@ fun RoomTopic(
placeholder = stringResource(CommonStrings.common_topic_placeholder), placeholder = stringResource(CommonStrings.common_topic_placeholder),
onValueChange = onTopicChanged, onValueChange = onTopicChanged,
maxLines = 3, maxLines = 3,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
) )
} }
@ -277,12 +276,12 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
@Preview @Preview
@Composable @Composable
fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable

View file

@ -41,7 +41,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
} }
), ),
aCreateRoomRootState().copy( aCreateRoomRootState().copy(
startDmAction = Async.Failure(Throwable()), startDmAction = Async.Failure(Throwable("error")),
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
searchQuery = it.userId.value, 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.R
import io.element.android.features.createroom.impl.components.UserListView import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async 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.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark 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.matrix.api.core.RoomId
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as DrawableR
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -162,12 +162,12 @@ fun CreateRoomActionButtonsList(
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
CreateRoomActionButton( CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_groups, iconRes = VectorIcons.Groups,
text = stringResource(id = R.string.screen_create_room_action_create_room), text = stringResource(id = R.string.screen_create_room_action_create_room),
onClick = onNewRoomClicked, onClick = onNewRoomClicked,
) )
CreateRoomActionButton( CreateRoomActionButton(
iconRes = DrawableR.drawable.ic_share, iconRes = VectorIcons.Share,
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName), text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked, onClick = onInvitePeopleClicked,
) )
@ -205,12 +205,12 @@ fun CreateRoomActionButton(
@Preview @Preview
@Composable @Composable
fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) = internal fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
fun CreateRoomRootViewDarkPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) = internal fun CreateRoomRootViewDarkPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @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_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_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_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_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_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> <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 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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@ -43,7 +43,7 @@ class AddPeoplePresenterTests {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// TODO This doesn't actually test anything... // TODO This doesn't actually test anything...

View file

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

View file

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

View file

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

View file

@ -97,9 +97,11 @@ fun WelcomeView(
} }
}, },
footer = { footer = {
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) { Button(
Text(text = stringResource(CommonStrings.action_continue)) text = stringResource(CommonStrings.action_continue),
} modifier = Modifier.fillMaxWidth(),
onClick = onContinueClicked
)
Spacer(modifier = Modifier.height(32.dp)) 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> <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.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test 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.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId 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.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom
@ -56,8 +56,9 @@ class InviteListPresenter @Inject constructor(
@Composable @Composable
override fun present(): InviteListState { override fun present(): InviteListState {
val invites by client val invites by client
.roomSummaryDataSource .roomListService
.inviteRooms() .invites()
.summaries
.collectAsState() .collectAsState()
var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) } var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
@ -152,8 +153,7 @@ class InviteListPresenter @Inject constructor(
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.leave().getOrThrow() it.leave().getOrThrow()
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }.let { }
Unit
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }

View file

@ -86,7 +86,6 @@ fun InviteListView(
title = stringResource(titleResource), title = stringResource(titleResource),
submitText = stringResource(CommonStrings.action_decline), submitText = stringResource(CommonStrings.action_decline),
cancelText = stringResource(CommonStrings.action_cancel), cancelText = stringResource(CommonStrings.action_cancel),
emphasizeSubmitButton = true,
onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) }, onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) },
onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) } 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.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.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -133,23 +132,19 @@ internal fun DefaultInviteSummaryRow(
// CTAs // CTAs
Row(Modifier.padding(top = 12.dp)) { Row(Modifier.padding(top = 12.dp)) {
OutlinedButton( OutlinedButton(
content = { Text(stringResource(CommonStrings.action_decline), style = ElementTheme.typography.aliasButtonText) }, text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClicked, onClick = onDeclineClicked,
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f) size = ButtonSize.Medium,
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Button( Button(
content = { Text(stringResource(CommonStrings.action_accept), style = ElementTheme.typography.aliasButtonText) }, text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClicked, onClick = onAcceptClicked,
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f) size = ButtonSize.Medium,
.heightIn(max = 36.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
) )
} }
} }

View file

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

View file

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

View file

@ -22,12 +22,12 @@ plugins {
id("kotlin-parcelize") id("kotlin-parcelize")
} }
fun readLocalProperty(name: String) = Properties().apply { fun readLocalProperty(name: String): String? = Properties().apply {
try { try {
load(rootProject.file("local.properties").reader()) load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) { } catch (ignored: java.io.IOException) {
} }
}[name] }.getProperty(name)
android { android {
namespace = "io.element.android.features.location.api" namespace = "io.element.android.features.location.api"
@ -37,9 +37,23 @@ android {
type = "string", type = "string",
name = "maptiler_api_key", name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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 androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import io.element.android.features.location.api.internal.StaticMapPlaceholder 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.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview 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.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber import timber.log.Timber
@ -65,23 +65,22 @@ fun StaticMapView(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) } var retryHash by remember { mutableStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
val painter = rememberAsyncImagePainter( val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) { model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
null null
} else { } else {
ImageRequest.Builder(LocalContext.current) ImageRequest.Builder(context)
.data( .data(
staticMapUrl( builder.build(
context = context,
lat = lat, lat = lat,
lon = lon, lon = lon,
zoom = zoom, zoom = zoom,
darkMode = darkMode, darkMode = darkMode,
// Size the map based on DP rather than pixels, as otherwise the features and attribution width = constraints.maxWidth,
// end up being illegibly tiny on high density displays. height = constraints.maxHeight,
width = constraints.maxWidth.toDp().value.toInt(), density = LocalDensity.current.density,
height = constraints.maxHeight.toDp().value.toInt(),
) )
) )
.size(width = constraints.maxWidth, height = constraints.maxHeight) .size(width = constraints.maxWidth, height = constraints.maxHeight)
@ -106,13 +105,7 @@ fun StaticMapView(
resourceId = DesignSystemR.drawable.pin, resourceId = DesignSystemR.drawable.pin,
contentDescription = null, contentDescription = null,
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.align { size, space, _ -> modifier = Modifier.centerBottomEdge(this),
// 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,
)
}
) )
} else { } else {
StaticMapPlaceholder( StaticMapPlaceholder(
@ -127,7 +120,7 @@ fun StaticMapView(
@DayNightPreviews @DayNightPreviews
@Composable @Composable
fun StaticMapViewPreview() = ElementPreview { internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView( StaticMapView(
lat = 0.0, lat = 0.0,
lon = 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