"
+labels: [T-Story]
+
+body:
+- type: textarea
+ attributes:
+ label: Story
+ description: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day.
+ value: |
+ As a user…
+ I want to…
+ so that I can…
+
+ ## Scope
+
+ ```[tasklist]
+ ### Tasklist
+ - [ ] Task 1
+ ```
+
+ - [ ] QA signoff on completion
+ - [ ] Design signoff on completion
+ - [ ] Product signoff on completion
+
+
+ ## Stretch goals
+ None at this time
+
+
+ ## Out of scope
+ -
+ validations:
+ required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
new file mode 100644
index 0000000000..431c018fdd
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
@@ -0,0 +1,57 @@
+
+
+## Type of change
+
+- [ ] Feature
+- [ ] Bugfix
+- [ ] Technical
+- [ ] Other :
+
+## Content
+
+
+
+## Motivation and context
+
+
+
+## Screenshots / GIFs
+
+
+
+
+
+## Tests
+
+
+
+- Step 1
+- Step 2
+- Step ...
+
+## Tested devices
+
+- [ ] Physical
+- [ ] Emulator
+- OS version(s):
+
+## Checklist
+
+
+
+- [ ] Changes has been tested on an Android device or Android emulator with API 21
+- [ ] UI change has been tested on both light and dark themes
+- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#accessibility
+- [ ] Pull request is based on the develop branch
+- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog
+- [ ] Pull request includes screenshots or videos if containing UI changes
+- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
+- [ ] You've made a self review of your PR
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000..29542e98c6
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,26 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ # Updates for Github Actions used in the repo
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ ignore:
+ - dependency-name: "*"
+ reviewers:
+ - "vector-im/element-x-android-reviewers"
+ # Updates for Gradle dependencies used in the app
+ - package-ecosystem: "gradle"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ open-pull-requests-limit: 200
+ ignore:
+ - dependency-name: "*"
+ reviewers:
+ - "vector-im/element-x-android-reviewers"
diff --git a/.github/renovate.json b/.github/renovate.json
new file mode 100644
index 0000000000..b3d57b233b
--- /dev/null
+++ b/.github/renovate.json
@@ -0,0 +1,34 @@
+{
+ "$schema" : "https://docs.renovatebot.com/renovate-schema.json",
+ "extends" : [
+ "config:base"
+ ],
+ "labels" : [
+ "dependencies"
+ ],
+ "ignoreDeps" : [
+ "string:app_name"
+ ],
+ "packageRules" : [
+ {
+ "matchPackagePatterns" : [
+ "^org.jetbrains.kotlin",
+ "^com.google.devtools.ksp",
+ "^androidx.compose.compiler"
+ ],
+ "groupName" : "kotlin"
+ },
+ {
+ "matchPackageNames" : [
+ "org.jetbrains.kotlinx.kover"
+ ],
+ "enabled" : false
+ },
+ {
+ "matchPackagePatterns" : [
+ "^org.maplibre"
+ ],
+ "versioning" : "semver"
+ }
+ ]
+}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000000..7d895d0fda
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,77 @@
+name: APK Build
+
+on:
+ workflow_dispatch:
+ pull_request: { }
+ push:
+ branches: [ main, develop ]
+
+# 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:
+ debug:
+ name: Build debug APKs
+ runs-on: ubuntu-latest
+ if: github.ref != 'refs/heads/main'
+ strategy:
+ fail-fast: false
+ # Allow all jobs on develop. Just one per PR.
+ concurrency:
+ group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}', github.sha) || format('build-debug-{0}', github.ref) }}
+ 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.6.1
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+ - name: Assemble debug APK
+ env:
+ ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+ run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
+ - name: Upload debug APKs
+ uses: actions/upload-artifact@v3
+ with:
+ name: elementx-debug
+ path: |
+ app/build/outputs/apk/debug/*.apk
+ - uses: rnkdsh/action-upload-diawi@v1.5.1
+ id: diawi
+ # Do not fail the whole build if Diawi upload fails
+ continue-on-error: true
+ env:
+ token: ${{ secrets.DIAWI_TOKEN }}
+ if: ${{ github.event_name == 'pull_request' && env.token != '' }}
+ with:
+ token: ${{ env.token }}
+ file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
+ - name: Add or update PR comment with QR Code to download APK.
+ if: ${{ github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
+ uses: NejcZdovc/comment-pr@v2
+ with:
+ message: |
+ :iphone: Scan the QR code below to install the build (arm64 only) for this PR.
+ 
+ If you can't scan the QR code you can install the build via this link: ${{ steps.diawi.outputs['url'] }}
+ # Enables to identify and update existing Ad-hoc release message on new commit in the PR
+ identifier: "GITHUB_COMMENT_QR_CODE"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Compile release sources
+ run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES
+ - name: Compile nightly sources
+ run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES
+ - name: Compile samples minimal
+ run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
new file mode 100644
index 0000000000..223a273b68
--- /dev/null
+++ b/.github/workflows/danger.yml
@@ -0,0 +1,20 @@
+name: Danger CI
+
+on: [pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Danger main check
+ steps:
+ - uses: actions/checkout@v3
+ - run: |
+ npm install --save-dev @babel/plugin-transform-flow-strip-types
+ - name: Danger
+ uses: danger/danger-js@11.2.6
+ with:
+ args: "--dangerfile ./tools/danger/dangerfile.js"
+ env:
+ DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
+ # Fallback for forks
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
new file mode 100644
index 0000000000..33a12d3c54
--- /dev/null
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -0,0 +1,18 @@
+name: Update Gradle Wrapper
+
+on:
+ schedule:
+ - cron: "0 0 * * *"
+
+jobs:
+ update-gradle-wrapper:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Update Gradle Wrapper
+ uses: gradle-update/update-gradle-wrapper-action@v1
+ # Skip in forks
+ if: github.repository == 'vector-im/element-x-android'
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ target-branch: develop
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
new file mode 100644
index 0000000000..7b68c0077d
--- /dev/null
+++ b/.github/workflows/gradle-wrapper-validation.yml
@@ -0,0 +1,14 @@
+name: "Validate Gradle Wrapper"
+on:
+ pull_request: { }
+ push:
+ branches: [ main, develop ]
+
+jobs:
+ validation:
+ name: "Validation"
+ runs-on: ubuntu-latest
+ # No concurrency required, this is a prerequisite to other actions and should run every time.
+ steps:
+ - uses: actions/checkout@v3
+ - uses: gradle/wrapper-validation-action@v1
diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
new file mode 100644
index 0000000000..0349e373bb
--- /dev/null
+++ b/.github/workflows/maestro.yml
@@ -0,0 +1,57 @@
+name: Maestro
+
+# Run this flow only on pull request, and only when the pull request has been approved, to limit our usage of maestro cloud.
+# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-a-workflow-when-a-pull-request-is-approved
+on:
+ workflow_dispatch:
+ pull_request_review:
+ types: [submitted]
+
+# 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:
+ maestro-cloud:
+ name: Maestro test suite
+ runs-on: ubuntu-latest
+ if: github.event.review.state == 'approved' || github.event_name == 'workflow_dispatch'
+ strategy:
+ fail-fast: false
+ # Allow one per PR.
+ concurrency:
+ group: ${{ format('maestro-{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 }}
+ - uses: actions/setup-java@v3
+ name: Use JDK 17
+ with:
+ distribution: 'temurin' # See 'Supported distributions' for available options
+ java-version: '17'
+ - name: Assemble debug APK
+ run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
+ env:
+ ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+ - name: Upload debug APKs
+ uses: actions/upload-artifact@v3
+ with:
+ name: elementx-debug
+ path: |
+ app/build/outputs/apk/debug/*.apk
+ - uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
+ with:
+ api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
+ app-file: app/build/outputs/apk/debug/app-universal-debug.apk
+ env: |
+ USERNAME=maestroelement
+ PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
+ ROOM_NAME=MyRoom
+ INVITEE1_MXID=@maestroelement2:matrix.org
+ INVITEE2_MXID=@maestroelement3:matrix.org
+ APP_ID=io.element.android.x.debug
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 0000000000..95c2deb8eb
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,48 @@
+name: Build and release nightly APK
+
+on:
+ workflow_dispatch:
+ schedule:
+ # Every nights at 4
+ - cron: "0 4 * * *"
+
+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:
+ nightly:
+ name: Build and publish nightly APK to Firebase
+ runs-on: ubuntu-latest
+ if: ${{ github.repository == 'vector-im/element-x-android' }}
+ 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: Install towncrier
+ run: |
+ python3 -m pip install towncrier
+ - name: Prepare changelog file
+ run: |
+ mv towncrier.toml towncrier.toml.bak
+ sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
+ rm towncrier.toml.bak
+ yes n | towncrier build --version nightly
+ - name: Build and upload Nightly APK
+ run: |
+ ./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
+ env:
+ ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+ ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
+ ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
+ ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
+ FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
+ - name: Additionally upload Nightly APK to browserstack for testing
+ continue-on-error: true # don't block anything by this upload failing (for now)
+ run: curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
+ env:
+ BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
+ BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
new file mode 100644
index 0000000000..ce7b763ef1
--- /dev/null
+++ b/.github/workflows/nightlyReports.yml
@@ -0,0 +1,75 @@
+name: Nightly reports
+
+on:
+ workflow_dispatch:
+ schedule:
+ # Every nights at 5
+ - cron: "0 5 * * *"
+
+# 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 4
+
+jobs:
+ nightlyReports:
+ name: Create kover report artifact and upload sonar result.
+ runs-on: ubuntu-latest
+ if: ${{ github.repository == 'vector-im/element-x-android' }}
+ steps:
+ - name: ⏬ Checkout with LFS
+ uses: nschloe/action-cached-lfs-checkout@v1.2.1
+
+ - name: Use JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin' # See 'Supported distributions' for available options
+ java-version: '17'
+
+ - name: ⚙️ Run unit tests, debug and release
+ run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
+
+ - name: 📸 Run screenshot tests
+ run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
+
+ - name: 📈 Generate kover report and verify coverage
+ run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
+
+ - name: ✅ Upload kover report
+ if: always()
+ uses: actions/upload-artifact@v3
+ with:
+ name: kover-results
+ path: |
+ **/build/reports/kover/merged
+
+ - 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
+
+ # Gradle dependency analysis using https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin
+ dependency-analysis:
+ name: Dependency analysis
+ runs-on: ubuntu-latest
+ 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.6.1
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+ - name: Dependency analysis
+ run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
+ - name: Upload dependency analysis
+ if: always()
+ uses: actions/upload-artifact@v3
+ with:
+ name: dependency-analysis
+ path: build/reports/dependency-check-report.html
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 0000000000..94b8b7ff4e
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,74 @@
+name: Code Quality Checks
+
+on:
+ workflow_dispatch:
+ pull_request: { }
+ 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:
+ checkScript:
+ name: Search for forbidden patterns
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Run code quality check suite
+ run: ./tools/check/check_code_quality.sh
+
+ check:
+ name: Project Check Suite
+ runs-on: ubuntu-latest
+ # Allow all jobs on main and develop. Just one per PR.
+ concurrency:
+ group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }}
+ 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.6.1
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+ - name: Run code quality check suite
+ run: ./gradlew runQualityChecks $CI_GRADLE_ARG_PROPERTIES
+ - name: Upload reports
+ if: always()
+ uses: actions/upload-artifact@v3
+ with:
+ name: linting-report
+ path: |
+ */build/reports/**/*.*
+ - 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
+ - name: Danger lint
+ if: always()
+ uses: danger/danger-js@11.2.6
+ with:
+ args: "--dangerfile ./tools/danger/dangerfile-lint.js"
+ env:
+ DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
+ # Fallback for forks
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml
new file mode 100644
index 0000000000..d088b3ad94
--- /dev/null
+++ b/.github/workflows/recordScreenshots.yml
@@ -0,0 +1,35 @@
+name: Record screenshots
+
+on:
+ workflow_dispatch:
+
+# 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
+
+jobs:
+ record:
+ name: Record screenshots on branch ${{ github.ref_name }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: ⏬ Checkout with LFS
+ uses: nschloe/action-cached-lfs-checkout@v1.2.1
+ with:
+ persist-credentials: false
+ - name: ☕️ Use JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin' # See 'Supported distributions' for available options
+ java-version: '17'
+ # Add gradle cache, this should speed up the process
+ - name: Configure gradle
+ uses: gradle/gradle-build-action@v2.6.1
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+ - name: Record screenshots
+ run: "./.github/workflows/scripts/recordScreenshots.sh"
+ env:
+ GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
+ GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
+
diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh
new file mode 100755
index 0000000000..792be75931
--- /dev/null
+++ b/.github/workflows/scripts/recordScreenshots.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+
+#
+# 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.
+#
+
+set -e
+
+TOKEN=$GITHUB_TOKEN
+REPO=$GITHUB_REPOSITORY
+
+SHORT=t:,r:
+LONG=token:,repo:
+OPTS=$(getopt -a -n recordScreenshots --options $SHORT --longoptions $LONG -- "$@")
+
+eval set -- "$OPTS"
+while :
+do
+ case "$1" in
+ -t | --token )
+ TOKEN="$2"
+ shift 2
+ ;;
+ -r | --repo )
+ REPO="$2"
+ shift 2
+ ;;
+ --)
+ shift;
+ break
+ ;;
+ *)
+ echo "Unexpected option: $1"
+ help
+ ;;
+ esac
+done
+
+if [[ -z ${TOKEN} ]]; then
+ echo "No token specified, either set the env var GITHUB_TOKEN or use the --token option"
+ exit 1
+fi
+
+if [[ -z ${REPO} ]]; then
+ echo "No repo specified, either set the env var GITHUB_REPOSITORY or use the --repo option"
+ exit 1
+fi
+
+echo "Deleting previous screenshots"
+./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn
+
+echo "Record screenshots"
+./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn
+
+echo "Committing changes"
+git config user.name "ElementBot"
+git config user.email "benoitm+elementbot@element.io"
+git add -A
+git commit -m "Update screenshots"
+
+BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+echo "Pushing changes"
+git push "https://$TOKEN@github.com/$REPO.git" $BRANCH:refs/heads/$BRANCH
+echo "Done!"
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
new file mode 100644
index 0000000000..3d63f8b542
--- /dev/null
+++ b/.github/workflows/sync-localazy.yml
@@ -0,0 +1,35 @@
+name: Sync Localazy
+on:
+ workflow_dispatch:
+ schedule:
+ # At 00:00 on every Monday UTC
+ - cron: '0 0 * * 1'
+
+jobs:
+ sync-localazy:
+ runs-on: ubuntu-latest
+ # Skip in forks
+ if: github.repository == 'vector-im/element-x-android'
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up Python 3.9
+ uses: actions/setup-python@v4
+ with:
+ python-version: 3.9
+ - name: Setup Localazy
+ run: |
+ curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg
+ echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/localazy.gpg] https://maven.localazy.com/repository/apt/ stable main" | sudo tee /etc/apt/sources.list.d/localazy.list
+ sudo apt-get update && sudo apt-get install localazy
+ - name: Run Localazy script
+ run: ./tools/localazy/downloadStrings.sh --all
+ - name: Create Pull Request for Strings
+ uses: peter-evans/create-pull-request@v5
+ with:
+ token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
+ commit-message: Sync Strings from Localazy
+ title: Sync Strings
+ body: |
+ - Update Strings from Localazy
+ branch: sync-localazy
+ base: develop
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000000..04fd393e25
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,91 @@
+name: Test
+
+on:
+ workflow_dispatch:
+ pull_request: { }
+ push:
+ branches: [ main, develop ]
+
+# 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 4 --warn
+
+jobs:
+ tests:
+ name: Runs unit tests
+ runs-on: ubuntu-latest
+
+ # Allow all jobs on main and develop. Just one per PR.
+ concurrency:
+ group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
+ cancel-in-progress: true
+ steps:
+ - name: ⏬ Checkout with LFS
+ uses: nschloe/action-cached-lfs-checkout@v1.2.1
+ 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.6.1
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+
+ - name: ⚙️ Run unit tests, debug and release
+ run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
+
+ - name: 📸 Run screenshot tests
+ run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
+
+ - name: 📈Generate kover report and verify coverage
+ run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
+
+ - name: 🚫 Upload kover failed coverage reports
+ if: failure()
+ uses: actions/upload-artifact@v3
+ with:
+ name: kover-error-report
+ path: |
+ **/kover/merged/verification/errors.txt
+
+ - name: 📸 Upload Screenshot test report
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: reports
+ path: tests/uitests/build/reports/tests/testDebugUnitTest/
+ retention-days: 5
+
+ - name: 🚫 Upload Screenshot failure differences on error
+ uses: actions/upload-artifact@v3
+ if: failure()
+ with:
+ name: failures
+ path: tests/uitests/out/failures/
+ retention-days: 5
+
+ - name: ✅ Upload kover report (disabled)
+ if: always()
+ run: echo "This is now done only once a day, see nightlyReports.yml"
+
+ - name: 🚫 Upload test results on error
+ if: failure()
+ uses: actions/upload-artifact@v3
+ with:
+ name: tests-and-screenshot-tests-results
+ path: |
+ **/out/failures/
+ **/build/reports/tests/*UnitTest/
+
+ # https://github.com/codecov/codecov-action
+ - name: ☂️ Upload coverage reports to codecov
+ if: always()
+ uses: codecov/codecov-action@v3
+ # with:
+ # files: build/reports/kover/merged/xml/report.xml
diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml
new file mode 100644
index 0000000000..1232b11d92
--- /dev/null
+++ b/.github/workflows/triage-incoming.yml
@@ -0,0 +1,14 @@
+name: Move new issues onto issue triage board v2
+
+on:
+ issues:
+ types: [ opened ]
+
+jobs:
+ triage-new-issues:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/add-to-project@main
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/91
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml
new file mode 100644
index 0000000000..5f7e6cc6ec
--- /dev/null
+++ b/.github/workflows/triage-labelled.yml
@@ -0,0 +1,83 @@
+name: Move labelled issues to correct boards and columns
+
+on:
+ issues:
+ types: [labeled]
+
+jobs:
+ move_element_x_issues:
+ name: ElementX issues to ElementX project board
+ runs-on: ubuntu-latest
+ # Skip in forks
+ if: >
+ github.repository == 'vector-im/element-x-android'
+ steps:
+ - uses: actions/add-to-project@main
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/43
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ move_needs_info:
+ name: Move triaged needs info issues on board
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/add-to-project@main
+ id: addItem
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/91
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+ labeled: X-Needs-Info
+ - name: Print itemId
+ run: echo ${{ steps.addItem.outputs.itemId }}
+ - uses: kalgurn/update-project-item-status@main
+ if: ${{ steps.addItem.outputs.itemId }}
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/91
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+ item-id: ${{ steps.addItem.outputs.itemId }}
+ status: "Needs info"
+
+ ex_plorers:
+ name: Add labelled issues to X-Plorer project
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
+ steps:
+ - uses: actions/add-to-project@main
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/73
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ verticals_feature:
+ name: Add labelled issues to Verticals Feature project
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
+ steps:
+ - uses: actions/add-to-project@main
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/57
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ qa:
+ name: Add labelled issues to QA project
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'Team: QA') ||
+ contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
+ steps:
+ - uses: actions/add-to-project@main
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/69
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ signoff:
+ name: Add labelled issues to signoff project
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
+ steps:
+ - uses: actions/add-to-project@main
+ with:
+ project-url: https://github.com/orgs/vector-im/projects/89
+ github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/.github/workflows/validate-lfs.yml b/.github/workflows/validate-lfs.yml
new file mode 100644
index 0000000000..25fe50359c
--- /dev/null
+++ b/.github/workflows/validate-lfs.yml
@@ -0,0 +1,13 @@
+name: Validate Git LFS
+
+on: [pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: Validate
+ steps:
+ - uses: nschloe/action-cached-lfs-checkout@v1.2.1
+
+ - run: |
+ ./tools/git/validate_lfs.sh
diff --git a/.gitignore b/.gitignore
index 56cc6425e0..70599626ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
# Built application files
*.apk
-*.aar
*.ap_
*.aab
@@ -38,17 +37,25 @@ captures/
# IntelliJ
*.iml
-.idea/workspace.xml
-.idea/tasks.xml
-.idea/gradle.xml
+.idea/.name
.idea/assetWizardSettings.xml
-.idea/dictionaries
-.idea/libraries
-# Android Studio 3 in .gitignore file.
-.idea/caches
+.idea/compiler.xml
+.idea/deploymentTargetDropDown.xml
+.idea/gradle.xml
+.idea/jarRepositories.xml
+.idea/misc.xml
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
+.idea/tasks.xml
+.idea/workspace.xml
+.idea/libraries
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/inspectionProfiles
+# Shelved changes in the IDE
+.idea/shelf
+.idea/sonarlint
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
@@ -83,3 +90,6 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
+
+/tmp
+.DS_Store
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000000..cdef735570
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000000..79ee123c2b
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copyright/NewVector.xml b/.idea/copyright/NewVector.xml
new file mode 100644
index 0000000000..72a4f2e779
--- /dev/null
+++ b/.idea/copyright/NewVector.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml
new file mode 100644
index 0000000000..0875fcecb1
--- /dev/null
+++ b/.idea/copyright/profiles_settings.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml
new file mode 100644
index 0000000000..aafe02a2c8
--- /dev/null
+++ b/.idea/dictionaries/shared.xml
@@ -0,0 +1,17 @@
+
+
+
+ backstack
+ ftue
+ homeserver
+ kover
+ measurables
+ onboarding
+ placeables
+ showkase
+ snackbar
+ swipeable
+ textfields
+
+
+
diff --git a/.idea/icon.png b/.idea/icon.png
new file mode 100644
index 0000000000..6f7872211b
Binary files /dev/null and b/.idea/icon.png differ
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000000..9a55c2de1f
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.maestro/README.md b/.maestro/README.md
new file mode 100644
index 0000000000..cd1f0658a7
--- /dev/null
+++ b/.maestro/README.md
@@ -0,0 +1,73 @@
+# Maestro
+
+Maestro is a framework that we are using to test navigation across the application.
+To setup, please refer at [https://maestro.mobile.dev](https://maestro.mobile.dev)
+
+
+
+* [Run test](#run-test)
+ * [Output](#output)
+* [Write test](#write-test)
+* [CI](#ci)
+* [iOS](#ios)
+* [Future](#future)
+
+
+
+## Run test
+
+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.*
+
+```shell
+maestro test \
+ -e APP_ID=io.element.android.x.debug \
+ -e USERNAME=user1 \
+ -e PASSWORD=123 \
+ -e ROOM_NAME="MyRoom" \
+ -e INVITEE1_MXID=user2 \
+ -e INVITEE2_MXID=user3 \
+ .maestro/allTests.yaml
+```
+
+### Output
+
+Test result will be printed on the console, and screenshots will be generated at `./build/maestro`
+
+## Write test
+
+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.
+
+```shell
+maestro studio
+```
+
+Note that sometimes, this prevent running the test. So kill the `maestro studio` process to be able to run the test again.
+
+Also, if updating the application code, do not forget to deploy again the application before running the maestro tests.
+
+## CI
+
+The CI is running maestro using the workflow `.github/worflow/maestro.yaml` and [maestro cloud](https://cloud.mobile.dev/). For now we are limited to 100 runs a month.
+Some GitHub secrets are used to be able to do that: `MAESTRO_CLOUD_API_KEY`, for now api key from `benoitm@element.io` maestro cloud account, and `MATRIX_MAESTRO_ACCOUNT_PASSWORD` which is the password of the account `@maestroelement:matrix.org`. This account contains a room `MyRoom` to be able to run the maestro test suite.
+
+## iOS
+
+Need to install `idb-companion` first
+
+```shell
+brew install idb-companion
+```
+
+Also:
+https://github.com/mobile-dev-inc/maestro/issues/146
+https://github.com/mobile-dev-inc/maestro/issues/107
+So you have to change your input keyboard to QWERTY for it to work properly.
+
+## Future
+
+- run on Element X iOS. This is already working but it need some change on the test to make it works. Could pass a PLATFORM parameter to have unique test and use conditional test.
+- run specific test on both iOS and Android devices to make them communicate together. Could be possible to test room invite and join, verification, call, etc. To be done when Element X will be able to create account and create room. A main script would be able to detect the Android device and the iOS device, and run several maestro tests sequentially, using `--device` parameter to perform a global test.
diff --git a/.maestro/allTests.yaml b/.maestro/allTests.yaml
new file mode 100644
index 0000000000..8283e9fed5
--- /dev/null
+++ b/.maestro/allTests.yaml
@@ -0,0 +1,7 @@
+appId: ${APP_ID}
+---
+- runFlow: tests/init.yaml
+- runFlow: tests/account/login.yaml
+- runFlow: tests/settings/settings.yaml
+- runFlow: tests/roomList/roomList.yaml
+- runFlow: tests/account/logout.yaml
diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml
new file mode 100644
index 0000000000..df4b12f253
--- /dev/null
+++ b/.maestro/tests/account/changeServer.yaml
@@ -0,0 +1,17 @@
+appId: ${APP_ID}
+---
+- tapOn:
+ id: "login-change_server"
+- takeScreenshot: build/maestro/200-ChangeServer
+- tapOn: "matrix.org"
+- tapOn:
+ id: "login-change_server"
+- tapOn: "Other"
+- tapOn:
+ id: "change_server-server"
+- inputText: "element"
+- hideKeyboard
+- tapOn: "element.io"
+- tapOn: "Cancel"
+- back
+- back
diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml
new file mode 100644
index 0000000000..6126e34459
--- /dev/null
+++ b/.maestro/tests/account/login.yaml
@@ -0,0 +1,30 @@
+appId: ${APP_ID}
+---
+- tapOn: "Continue"
+- runFlow: ../assertions/assertLoginDisplayed.yaml
+- takeScreenshot: build/maestro/100-SignIn
+- runFlow: changeServer.yaml
+- runFlow: ../assertions/assertLoginDisplayed.yaml
+- tapOn:
+ id: "login-continue"
+- tapOn:
+ id: "login-email_username"
+- inputText: ${USERNAME}
+- pressKey: Enter
+- tapOn:
+ id: "login-password"
+- inputText: "wrong-password"
+- pressKey: Enter
+- tapOn: "Continue"
+- tapOn: "OK"
+- tapOn:
+ id: "login-password"
+- eraseText: 20
+- inputText: ${PASSWORD}
+- pressKey: Enter
+- tapOn: "Continue"
+- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml
+- tapOn: "Continue"
+- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
+- tapOn: "Not now"
+- runFlow: ../assertions/assertHomeDisplayed.yaml
diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml
new file mode 100644
index 0000000000..a06ac25e2d
--- /dev/null
+++ b/.maestro/tests/account/logout.yaml
@@ -0,0 +1,13 @@
+appId: ${APP_ID}
+---
+- tapOn:
+ id: "home_screen-settings"
+- tapOn: "Sign out"
+- takeScreenshot: build/maestro/900-SignOutDialg
+# Ensure cancel cancels
+- tapOn: "Cancel"
+- tapOn: "Sign out"
+- tapOn:
+ text: "Sign out"
+ index: 1
+- runFlow: ../assertions/assertInitDisplayed.yaml
diff --git a/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml
new file mode 100644
index 0000000000..96a91a24af
--- /dev/null
+++ b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml
@@ -0,0 +1,5 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible: "Help improve Element X dbg"
+ timeout: 10_000
diff --git a/.maestro/tests/assertions/assertHomeDisplayed.yaml b/.maestro/tests/assertions/assertHomeDisplayed.yaml
new file mode 100644
index 0000000000..6e9eec50db
--- /dev/null
+++ b/.maestro/tests/assertions/assertHomeDisplayed.yaml
@@ -0,0 +1,5 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible: "All Chats"
+ timeout: 10_000
diff --git a/.maestro/tests/assertions/assertInitDisplayed.yaml b/.maestro/tests/assertions/assertInitDisplayed.yaml
new file mode 100644
index 0000000000..417ac87711
--- /dev/null
+++ b/.maestro/tests/assertions/assertInitDisplayed.yaml
@@ -0,0 +1,5 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible: "Be in your element"
+ timeout: 10_000
diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml
new file mode 100644
index 0000000000..3abd86ceef
--- /dev/null
+++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml
@@ -0,0 +1,5 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible: "Change account provider"
+ timeout: 10_000
diff --git a/.maestro/tests/assertions/assertRoomListSynced.yaml b/.maestro/tests/assertions/assertRoomListSynced.yaml
new file mode 100644
index 0000000000..2d13c17df9
--- /dev/null
+++ b/.maestro/tests/assertions/assertRoomListSynced.yaml
@@ -0,0 +1,5 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible: ${ROOM_NAME}
+ timeout: 10_000
diff --git a/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml
new file mode 100644
index 0000000000..73e8e78ef5
--- /dev/null
+++ b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml
@@ -0,0 +1,6 @@
+appId: ${APP_ID}
+---
+- extendedWaitUntil:
+ visible:
+ id: "welcome_screen-title"
+ timeout: 10_000
diff --git a/.maestro/tests/init.yaml b/.maestro/tests/init.yaml
new file mode 100644
index 0000000000..acd5f86dfd
--- /dev/null
+++ b/.maestro/tests/init.yaml
@@ -0,0 +1,7 @@
+appId: ${APP_ID}
+---
+- clearState
+- launchApp:
+ clearKeychain: true
+- runFlow: ./assertions/assertInitDisplayed.yaml
+- takeScreenshot: build/maestro/000-FirstScreen
diff --git a/.maestro/tests/roomList/createAndDeleteDM.yaml b/.maestro/tests/roomList/createAndDeleteDM.yaml
new file mode 100644
index 0000000000..f79a7418e4
--- /dev/null
+++ b/.maestro/tests/roomList/createAndDeleteDM.yaml
@@ -0,0 +1,13 @@
+appId: ${APP_ID}
+---
+# Purpose: Test the creation and deletion of a DM room.
+- tapOn: "Create a new conversation or room"
+- tapOn: "Search for someone"
+- inputText: ${INVITEE1_MXID}
+- tapOn:
+ text: ${INVITEE1_MXID}
+ index: 1
+- takeScreenshot: build/maestro/330-createAndDeleteDM
+- tapOn: "maestroelement2"
+- tapOn: "Leave room"
+- tapOn: "Leave"
diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml
new file mode 100644
index 0000000000..b2b7c1da0b
--- /dev/null
+++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml
@@ -0,0 +1,33 @@
+appId: ${APP_ID}
+---
+# Purpose: Test the creation and deletion of a room
+- tapOn: "Create a new conversation or room"
+- tapOn: "New room"
+- tapOn: "Search for someone"
+- inputText: ${INVITEE1_MXID}
+- tapOn:
+ text: ${INVITEE1_MXID}
+ index: 1
+- tapOn: "Next"
+- tapOn: "e.g. your project name"
+- inputText: "aRoomName"
+- tapOn: "What is this room about?"
+- inputText: "aRoomTopic"
+- tapOn: "Create"
+- takeScreenshot: build/maestro/320-createAndDeleteRoom
+- tapOn: "aRoomName"
+- tapOn: "Invite people"
+# assert there's 1 memeber and 1 invitee
+- tapOn: "Search for someone"
+- inputText: ${INVITEE2_MXID}
+- tapOn:
+ text: ${INVITEE2_MXID}
+ index: 1
+- tapOn: "Send"
+- tapOn: "Back"
+- tapOn: "aRoomName"
+- tapOn: "People"
+# assert there's 1 memeber and 2 invitees
+- tapOn: "Back"
+- tapOn: "Leave room"
+- tapOn: "Leave"
diff --git a/.maestro/tests/roomList/roomContextMenu.yaml b/.maestro/tests/roomList/roomContextMenu.yaml
new file mode 100644
index 0000000000..c2a8764558
--- /dev/null
+++ b/.maestro/tests/roomList/roomContextMenu.yaml
@@ -0,0 +1,14 @@
+appId: ${APP_ID}
+---
+# Purpose: Test the context menu of a room in the room list
+- longPressOn: ${ROOM_NAME}
+- takeScreenshot: build/maestro/310-RoomList-ContextMenu
+- tapOn:
+ text: "Settings"
+ index: 0
+- tapOn: "Back"
+- longPressOn: ${ROOM_NAME}
+- tapOn:
+ text: "Leave room"
+ index: 0
+- tapOn: "Cancel"
diff --git a/.maestro/tests/roomList/roomList.yaml b/.maestro/tests/roomList/roomList.yaml
new file mode 100644
index 0000000000..6365759e72
--- /dev/null
+++ b/.maestro/tests/roomList/roomList.yaml
@@ -0,0 +1,8 @@
+appId: ${APP_ID}
+---
+- runFlow: searchRoomList.yaml
+- takeScreenshot: build/maestro/300-RoomList
+- runFlow: timeline/timeline.yaml
+- runFlow: roomContextMenu.yaml
+- runFlow: createAndDeleteRoom.yaml
+- runFlow: createAndDeleteDM.yaml
diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml
new file mode 100644
index 0000000000..5125109197
--- /dev/null
+++ b/.maestro/tests/roomList/searchRoomList.yaml
@@ -0,0 +1,14 @@
+appId: ${APP_ID}
+---
+- runFlow: ../assertions/assertRoomListSynced.yaml
+- tapOn: "search"
+- inputText: ${ROOM_NAME.substring(0, 3)}
+- takeScreenshot: build/maestro/400-SearchRoom
+- tapOn: ${ROOM_NAME}
+# Back from timeline
+- back
+# Close keyboard
+- hideKeyboard
+# Back from search
+- back
+- runFlow: ../assertions/assertHomeDisplayed.yaml
diff --git a/.maestro/tests/roomList/timeline/messages/location.yaml b/.maestro/tests/roomList/timeline/messages/location.yaml
new file mode 100644
index 0000000000..73dca6eeb4
--- /dev/null
+++ b/.maestro/tests/roomList/timeline/messages/location.yaml
@@ -0,0 +1,7 @@
+appId: ${APP_ID}
+---
+- takeScreenshot: build/maestro/520-Timeline
+- tapOn: "Add attachment"
+- tapOn: "Location"
+- tapOn: "Share my location"
+- takeScreenshot: build/maestro/521-Timeline
diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml
new file mode 100644
index 0000000000..4e3b7bbd45
--- /dev/null
+++ b/.maestro/tests/roomList/timeline/messages/text.yaml
@@ -0,0 +1,8 @@
+appId: ${APP_ID}
+---
+- takeScreenshot: build/maestro/510-Timeline
+- tapOn: "Message"
+- inputText: "Hello world!"
+- tapOn: "Send"
+- hideKeyboard
+- takeScreenshot: build/maestro/511-Timeline
diff --git a/.maestro/tests/roomList/timeline/timeline.yaml b/.maestro/tests/roomList/timeline/timeline.yaml
new file mode 100644
index 0000000000..bec566985d
--- /dev/null
+++ b/.maestro/tests/roomList/timeline/timeline.yaml
@@ -0,0 +1,9 @@
+appId: ${APP_ID}
+---
+# This is the name of one room
+- tapOn: ${ROOM_NAME}
+- takeScreenshot: build/maestro/500-Timeline
+- runFlow: messages/text.yaml
+- runFlow: messages/location.yaml
+- back
+- runFlow: ../../assertions/assertHomeDisplayed.yaml
diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml
new file mode 100644
index 0000000000..2fac9b108a
--- /dev/null
+++ b/.maestro/tests/settings/settings.yaml
@@ -0,0 +1,30 @@
+appId: ${APP_ID}
+---
+- tapOn:
+ id: "home_screen-settings"
+- assertVisible: "Settings"
+- takeScreenshot: build/maestro/600-Settings
+- tapOn:
+ text: "Analytics"
+- assertVisible: "Share analytics data"
+- back
+
+- tapOn:
+ text: "Report bug"
+- assertVisible: "Report a bug"
+- back
+
+- tapOn:
+ text: "About"
+- assertVisible: "Copyright"
+- assertVisible: "Acceptable use policy"
+- assertVisible: "Privacy policy"
+- back
+
+- tapOn:
+ text: "Developer options"
+- assertVisible: "Feature flags"
+- back
+
+- back
+- runFlow: ../assertions/assertHomeDisplayed.yaml
diff --git a/AUTHORS.md b/AUTHORS.md
new file mode 100644
index 0000000000..89404cd73f
--- /dev/null
+++ b/AUTHORS.md
@@ -0,0 +1,17 @@
+A full developer contributors list can be found [here](https://github.com/vector-im/element-x-android/graphs/contributors).
+
+# Core team:
+
+The element.io Android developer team.
+
+# Other contributors
+
+First of all, we thank all contributors who use Element and report problems on this GitHub project or via the integrated rageshake function.
+
+We do not forget all translators, for their work of translating Element into many languages. They are also the authors of Element.
+
+Feel free to add your name below, when you contribute to the project!
+
+Name | Matrix ID | GitHub
+----------|-----------------------------|--------------------------------------
+name | @name:matrix.org | [githubID](https://github.com/githubID)
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000000..e04dbb63c8
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,4 @@
+Changes in Element X v0.1.0 (2023-07-19)
+========================================
+
+First release of Element X 🚀!
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000000..9db04197d5
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1 @@
+* @vector-im/element-x-android-reviewers
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000..f50c8f2a89
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,177 @@
+# Contributing to Element Android
+
+
+
+* [Contributing code to Matrix](#contributing-code-to-matrix)
+* [Developer onboarding](#developer-onboarding)
+* [Android Studio settings](#android-studio-settings)
+* [Compilation](#compilation)
+* [Strings](#strings)
+ * [I want to add new strings to the project](#i-want-to-add-new-strings-to-the-project)
+ * [I want to help translating Element](#i-want-to-help-translating-element)
+* [I want to submit a PR to fix an issue](#i-want-to-submit-a-pr-to-fix-an-issue)
+ * [Kotlin](#kotlin)
+ * [Changelog](#changelog)
+ * [Code quality](#code-quality)
+ * [ktlint](#ktlint)
+ * [knit](#knit)
+ * [lint](#lint)
+ * [Unit tests](#unit-tests)
+ * [Tests](#tests)
+ * [Accessibility](#accessibility)
+ * [Jetpack Compose](#jetpack-compose)
+ * [Authors](#authors)
+* [Thanks](#thanks)
+
+
+
+## Contributing code to Matrix
+
+Please read https://github.com/matrix-org/synapse/blob/master/CONTRIBUTING.md
+
+Element X Android support can be found in this room: [](https://matrix.to/#/#element-android:matrix.org).
+
+The rest of the document contains specific rules for Matrix Android projects
+
+## Developer onboarding
+
+For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
+
+## Android Studio settings
+
+Please set the "hard wrap" setting of Android Studio to 160 chars, this is the setting we use internally to format the source code (Menu `Settings/Editor/Code Style` then `Hard wrap at`).
+Please ensure that you're using the project formatting rules (which are in the project at .idea/codeStyles/), and format the file before committing them.
+
+## Compilation
+
+This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`.
+
+Note: please make sure that the configuration is `app` and not `samples.minimal`.
+
+## Strings
+
+The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with ElementX iOS.
+
+### I want to add new strings to the project
+
+Only the core team can modify or add English strings to Localazy. As an external contributor, if you want to add new strings, feel free to add an Android resource file to the project (for instance a file named `temporary.xml`), with a note in the description of the PR for the reviewer to integrate the String into `Localazy`. If accepted, the reviewer will add the String(s) for you, and then you can download them on your branch (following these [instructions](./tools/localazy/README.md#download-translations)) and remove the temporary file.
+
+Please follow the naming rules for the key. More details in [the dedicated section in this README.md](./tools/localazy/README.md#key-naming-rules)
+
+### I want to help translating Element
+
+Please note that the Localazy project is not open yet for external contributions.
+
+To help translating, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
+
+- If you want to fix an issue with an English string, please open an issue on the github project of ElementX (Android or iOS). Only the core team can modify or add English strings.
+- If you want to fix an issue 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).
+
+## I want to submit a PR to fix an issue
+
+Please have a look in the [dedicated documentation](./docs/pull_request.md) about pull request.
+
+Please check if a corresponding issue exists. If yes, please let us know in a comment that you're working on it.
+If an issue does not exist yet, it may be relevant to open a new issue and let us know that you're implementing it.
+
+### Kotlin
+
+This project is full Kotlin. Please do not write Java classes.
+
+### Changelog
+
+Please create at least one file under ./changelog.d containing details about your change. Towncrier will be used when preparing the release.
+
+Towncrier says to use the PR number for the filename, but the issue number is also fine.
+
+Supported filename extensions are:
+
+- ``.feature``: Signifying a new feature in Element Android or in the Matrix SDK.
+- ``.bugfix``: Signifying a bug fix.
+- ``.wip``: Signifying a work in progress change, typically a component of a larger feature which will be enabled once all tasks are complete.
+- ``.doc``: Signifying a documentation improvement.
+- ``.misc``: Any other changes.
+
+See https://github.com/twisted/towncrier#news-fragments if you need more details.
+
+### Code quality
+
+Make sure the following commands execute without any error:
+
+
+./gradlew check
+
+
+Some separate commands can also be run, see below.
+
+#### ktlint
+
+
+./gradlew ktlintCheck --continue
+
+
+Note that you can run
+
+
+./gradlew ktlintFormat
+
+
+For ktlint to fix some detected errors for you (you still have to check and commit the fix of course)
+
+#### knit
+
+[knit](https://github.com/Kotlin/kotlinx-knit) is a tool which checks markdown files on the project. Also it generates/updates the table of content (toc) of the markdown files.
+
+So everytime the toc should be updated, just run
+
+./gradlew knit
+
+
+and commit the changes.
+
+The CI will check that markdown files are up to date by running
+
+
+./gradlew knitCheck
+
+
+#### lint
+
+
+./gradlew lint
+
+
+### Unit tests
+
+Make sure the following commands execute without any error:
+
+
+./gradlew test
+
+
+### 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.
+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.
+
+### Accessibility
+
+Please consider accessibility as an important point. As a minimum requirement, in layout XML files please use attributes such as `android:contentDescription` and `android:importantForAccessibility`, and test with a screen reader if it's working well. You can add new string resources, dedicated to accessibility, in this case, please prefix theirs id with `a11y_`.
+
+For instance, when updating the image `src` of an ImageView, please also consider updating its `contentDescription`. A good example is a play pause button.
+
+### 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.
+
+### Authors
+
+Feel free to add an entry in file AUTHORS.md
+
+## Thanks
+
+Thanks for contributing to Matrix projects!
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000000..a432dd0319
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+gem 'danger'
diff --git a/README.md b/README.md
index 97a612094d..e31acf87b8 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,69 @@
-# element-x-android-poc
-Prrof Of Concept to run a Matrix client on Android devices using the Matrix Rust Sdk and Jetpack compose
+[](https://github.com/vector-im/element-x-android/actions/workflows/build.yml?query=branch%3Adevelop)
+[](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
+[](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
+[](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android)
+[](https://codecov.io/github/vector-im/element-x-android)
+[](https://matrix.to/#/#element-android:matrix.org)
+[](https://translate.element.io/engage/element-android/?utm_source=widget)
+
+# 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.
+
+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.
+
+
+
+* [Screenshots](#screenshots)
+* [Rust SDK](#rust-sdk)
+* [Status](#status)
+* [Contributing](#contributing)
+* [Build instructions](#build-instructions)
+* [Support](#support)
+* [Copyright & License](#copyright-&-license)
+
+
+
+## Screenshots
+
+Here are some early screenshots of the application:
+
+|||||
+|-|-|-|-|
+
+## 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.
+
+We're doing this as a way to share code between platforms and while we've seen promising results it's still in the experimental stage and bound to change.
+
+## Status
+
+This project is in work in progress. The app does not cover yet all functionalities we expect.
+
+## Contributing
+
+Please see our [contribution guide](CONTRIBUTING.md).
+
+Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/#element-android:matrix.org).
+
+## Build instructions
+
+Just clone the project and open it in Android Studio.
+Makes sure to select the `app` configuration when building (as we also have sample apps in the project).
+
+## Support
+
+When you are experiencing an issue on ElementX Android, please first search in [GitHub issues](https://github.com/vector-im/element-x-android/issues)
+and then in [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org).
+If after your research you still have a question, ask at [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting (Rageshake) from the Element application by shaking your phone or going to the application settings. This is especially recommended when you encounter a crash.
+
+## Copyright & License
+
+Copyright (c) 2022 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in compliance with the License. You may obtain a copy of the License in the [LICENSE](LICENSE) file, or at:
+
+[http://www.apache.org/licenses/LICENSE-2.0](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.
diff --git a/anvilannotations/.gitignore b/anvilannotations/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/anvilannotations/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/anvilannotations/build.gradle.kts b/anvilannotations/build.gradle.kts
new file mode 100644
index 0000000000..aac12fbc58
--- /dev/null
+++ b/anvilannotations/build.gradle.kts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ id("com.android.lint")
+}
+
+dependencies {
+ api(libs.inject)
+}
diff --git a/anvilannotations/src/main/kotlin/io/element/android/anvilannotations/ContributesNode.kt b/anvilannotations/src/main/kotlin/io/element/android/anvilannotations/ContributesNode.kt
new file mode 100644
index 0000000000..cf9f2f3684
--- /dev/null
+++ b/anvilannotations/src/main/kotlin/io/element/android/anvilannotations/ContributesNode.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.anvilannotations
+
+import kotlin.reflect.KClass
+
+/**
+ * Adds Node to the specified component graph.
+ * Equivalent to the following declaration:
+ *
+ * @Module
+ * @ContributesTo(Scope::class)
+ * abstract class YourNodeModule {
+
+ * @Binds
+ * @IntoMap
+ * @NodeKey(YourNode::class)
+ * abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*>
+ *}
+
+ */
+@Target(AnnotationTarget.CLASS)
+annotation class ContributesNode(
+ val scope: KClass<*>,
+)
diff --git a/anvilcodegen/.gitignore b/anvilcodegen/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/anvilcodegen/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts
new file mode 100644
index 0000000000..57758f8909
--- /dev/null
+++ b/anvilcodegen/build.gradle.kts
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.kapt)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ api(libs.anvil.compiler.api)
+ implementation(libs.anvil.compiler.utils)
+ implementation("com.squareup:kotlinpoet:1.14.2")
+ implementation(libs.dagger)
+ compileOnly(libs.google.autoservice.annotations)
+ kapt(libs.google.autoservice)
+}
diff --git a/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeCodeGenerator.kt b/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeCodeGenerator.kt
new file mode 100644
index 0000000000..576a52df89
--- /dev/null
+++ b/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeCodeGenerator.kt
@@ -0,0 +1,154 @@
+/*
+ * 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:OptIn(ExperimentalAnvilApi::class)
+
+package io.element.android.anvilcodegen
+
+import com.google.auto.service.AutoService
+import com.squareup.anvil.annotations.ContributesTo
+import com.squareup.anvil.annotations.ExperimentalAnvilApi
+import com.squareup.anvil.compiler.api.AnvilCompilationException
+import com.squareup.anvil.compiler.api.AnvilContext
+import com.squareup.anvil.compiler.api.CodeGenerator
+import com.squareup.anvil.compiler.api.GeneratedFile
+import com.squareup.anvil.compiler.api.createGeneratedFile
+import com.squareup.anvil.compiler.internal.asClassName
+import com.squareup.anvil.compiler.internal.buildFile
+import com.squareup.anvil.compiler.internal.fqName
+import com.squareup.anvil.compiler.internal.reference.ClassReference
+import com.squareup.anvil.compiler.internal.reference.asClassName
+import com.squareup.anvil.compiler.internal.reference.classAndInnerClassReferences
+import com.squareup.kotlinpoet.AnnotationSpec
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.STAR
+import com.squareup.kotlinpoet.TypeSpec
+import dagger.Binds
+import dagger.Module
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.multibindings.IntoMap
+import io.element.android.anvilannotations.ContributesNode
+import org.jetbrains.kotlin.descriptors.ModuleDescriptor
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtFile
+import java.io.File
+
+/**
+ * This is an anvil plugin that allows Node to use [ContributesNode] alone and let this plugin automatically
+ * handle the rest of the Dagger wiring required for constructor injection.
+ */
+@AutoService(CodeGenerator::class)
+class ContributesNodeCodeGenerator : CodeGenerator {
+
+ override fun isApplicable(context: AnvilContext): Boolean = true
+
+ override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection): Collection {
+ return projectFiles.classAndInnerClassReferences(module)
+ .filter { it.isAnnotatedWith(ContributesNode::class.fqName) }
+ .flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) }
+ .toList()
+ }
+
+ private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
+ val generatedPackage = nodeClass.packageFqName.toString()
+ val moduleClassName = "${nodeClass.shortName}_Module"
+ val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope()
+ val content = FileSpec.buildFile(generatedPackage, moduleClassName) {
+ addType(
+ TypeSpec.classBuilder(moduleClassName)
+ .addModifiers(KModifier.ABSTRACT)
+ .addAnnotation(Module::class)
+ .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build())
+ .addFunction(
+ FunSpec.builder("bind${nodeClass.shortName}Factory")
+ .addModifiers(KModifier.ABSTRACT)
+ .addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory"))
+ .returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR))
+ .addAnnotation(Binds::class)
+ .addAnnotation(IntoMap::class)
+ .addAnnotation(
+ AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember(
+ "%T::class",
+ nodeClass.asClassName()
+ ).build()
+ )
+ .build(),
+ )
+ .build(),
+ )
+ }
+ return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content)
+ }
+
+ private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile {
+ val generatedPackage = nodeClass.packageFqName.toString()
+ val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory"
+ val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) }
+ val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty()
+ if (constructor == null || assistedParameters.size != 2) {
+ throw AnvilCompilationException(
+ "${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters",
+ element = nodeClass.clazz,
+ )
+ }
+ val contextAssistedParam = assistedParameters[0]
+ if (contextAssistedParam.name != "buildContext") {
+ throw AnvilCompilationException(
+ "${nodeClass.fqName} @Assisted parameter must be named buildContext",
+ element = contextAssistedParam.parameter,
+ )
+ }
+ val pluginsAssistedParam = assistedParameters[1]
+ if (pluginsAssistedParam.name != "plugins") {
+ throw AnvilCompilationException(
+ "${nodeClass.fqName} @Assisted parameter must be named plugins",
+ element = pluginsAssistedParam.parameter,
+ )
+ }
+
+ val nodeClassName = nodeClass.asClassName()
+ val buildContextClassName = contextAssistedParam.type().asTypeName()
+ val pluginsClassName = pluginsAssistedParam.type().asTypeName()
+ val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) {
+ addType(
+ TypeSpec.interfaceBuilder(assistedFactoryClassName)
+ .addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName))
+ .addAnnotation(AssistedFactory::class)
+ .addFunction(
+ FunSpec.builder("create")
+ .addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT)
+ .addParameter("buildContext", buildContextClassName)
+ .addParameter("plugins", pluginsClassName)
+ .returns(nodeClassName)
+ .build(),
+ )
+ .build(),
+ )
+ }
+ return createGeneratedFile(codeGenDir, generatedPackage, assistedFactoryClassName, content)
+ }
+
+ companion object {
+ private val assistedNodeFactoryFqName = FqName("io.element.android.libraries.architecture.AssistedNodeFactory")
+ private val nodeKeyFqName = FqName("io.element.android.libraries.architecture.NodeKey")
+ }
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000000..6a28adecf1
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,234 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("UnstableApiUsage")
+
+import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
+import extension.allFeaturesImpl
+import extension.allLibrariesImpl
+import extension.allServicesImpl
+
+plugins {
+ id("io.element.android-compose-application")
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.kapt)
+ id("com.google.firebase.appdistribution") version "4.0.0"
+ id("org.jetbrains.kotlinx.knit") version "0.4.0"
+ id("kotlin-parcelize")
+ // To be able to update the firebase.xml files, uncomment and build the project
+ // id("com.google.gms.google-services")
+}
+
+android {
+ namespace = "io.element.android.x"
+
+ testOptions { unitTests.isIncludeAndroidResources = true }
+
+ defaultConfig {
+ applicationId = "io.element.android.x"
+ targetSdk = Versions.targetSdk
+ versionCode = Versions.versionCode
+ versionName = Versions.versionName
+
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+
+ // Keep abiFilter for the universalApk
+ ndk {
+ abiFilters += listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
+ }
+
+ // Ref: https://developer.android.com/studio/build/configure-apk-splits.html#configure-abi-split
+ splits {
+ // Configures multiple APKs based on ABI.
+ abi {
+ // Enables building multiple APKs per ABI.
+ isEnable = true
+ // By default all ABIs are included, so use reset() and include to specify that we only
+ // want APKs for armeabi-v7a, x86, arm64-v8a and x86_64.
+ // Resets the list of ABIs that Gradle should create APKs for to none.
+ reset()
+ // Specifies a list of ABIs that Gradle should create APKs for.
+ include("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
+ // Generate a universal APK that includes all ABIs, so user who installs from CI tool can use this one by default.
+ isUniversalApk = true
+ }
+ }
+ }
+
+ signingConfigs {
+ named("debug") {
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ storeFile = file("./signature/debug.keystore")
+ storePassword = "android"
+ }
+ register("nightly") {
+ keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID")
+ ?: project.property("signing.element.nightly.keyId") as? String?
+ keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD")
+ ?: project.property("signing.element.nightly.keyPassword") as? String?
+ storeFile = file("./signature/nightly.keystore")
+ storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD")
+ ?: project.property("signing.element.nightly.storePassword") as? String?
+ }
+ }
+
+ buildTypes {
+ named("debug") {
+ resValue("string", "app_name", "Element X dbg")
+ applicationIdSuffix = ".debug"
+ signingConfig = signingConfigs.getByName("debug")
+ }
+
+ named("release") {
+ resValue("string", "app_name", "Element X")
+ signingConfig = signingConfigs.getByName("debug")
+
+ postprocessing {
+ isRemoveUnusedCode = true
+ isObfuscate = false
+ isOptimizeCode = true
+ isRemoveUnusedResources = true
+ proguardFiles("proguard-rules.pro")
+ }
+ }
+
+ register("nightly") {
+ val release = getByName("release")
+ initWith(release)
+ applicationIdSuffix = ".nightly"
+ versionNameSuffix = "-nightly"
+ resValue("string", "app_name", "Element X nightly")
+ matchingFallbacks += listOf("release")
+ signingConfig = signingConfigs.getByName("nightly")
+
+ postprocessing {
+ initWith(release.postprocessing)
+ }
+
+ firebaseAppDistribution {
+ artifactType = "APK"
+ // We upload the universal APK to fix this error:
+ // "App Distribution found more than 1 output file for this variant.
+ // Please contact firebase-support@google.com for help using APK splits with App Distribution."
+ artifactPath = "$rootDir/app/build/outputs/apk/nightly/app-universal-nightly.apk"
+ // This file will be generated by the GitHub action
+ releaseNotesFile = "CHANGES_NIGHTLY.md"
+ groups = "external-testers"
+ // This should not be required, but if I do not add the appId, I get this error:
+ // "App Distribution halted because it had a problem uploading the APK: [404] Requested entity was not found."
+ appId = "1:912726360885:android:e17435e0beb0303000427c"
+ }
+ }
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+androidComponents {
+ // map for the version codes last digit
+ // x86 must have greater values than arm
+ // 64 bits have greater value than 32 bits
+ val abiVersionCodes = mapOf(
+ "armeabi-v7a" to 1,
+ "arm64-v8a" to 2,
+ "x86" to 3,
+ "x86_64" to 4,
+ )
+
+ onVariants { variant ->
+ // Assigns a different version code for each output APK
+ // other than the universal APK.
+ variant.outputs.forEach { output ->
+ val name = output.filters.find { it.filterType == ABI }?.identifier
+
+ // Stores the value of abiCodes that is associated with the ABI for this variant.
+ val abiCode = abiVersionCodes[name] ?: 0
+ // Assigns the new version code to output.versionCode, which changes the version code
+ // for only the output APK, not for the variant itself.
+ output.versionCode.set((output.versionCode.get() ?: 0) * 10 + abiCode)
+ }
+ }
+}
+
+// Knit
+apply {
+ plugin("kotlinx-knit")
+}
+
+knit {
+ files = fileTree(project.rootDir) {
+ include(
+ "**/*.md",
+ "**/*.kt",
+ "*/*.kts",
+ )
+ exclude(
+ "**/build/**",
+ "*/.gradle/**",
+ "*/towncrier/template.md",
+ "**/CHANGES.md",
+ )
+ }
+}
+
+dependencies {
+ allLibrariesImpl()
+ allServicesImpl()
+ allFeaturesImpl(rootDir, logger)
+ implementation(projects.anvilannotations)
+ implementation(projects.appnav)
+ anvil(projects.anvilcodegen)
+
+ coreLibraryDesugaring(libs.android.desugar)
+ implementation(libs.appyx.core)
+ implementation(libs.androidx.splash)
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.corektx)
+ implementation(libs.androidx.lifecycle.runtime)
+ implementation(libs.androidx.lifecycle.process)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.startup)
+ implementation(libs.androidx.preference)
+ implementation(libs.coil)
+
+ implementation(platform(libs.network.okhttp.bom))
+ implementation(libs.network.okhttp.logging)
+ implementation(libs.serialization.json)
+
+ implementation(libs.vanniktech.emoji)
+
+ implementation(libs.dagger)
+ kapt(libs.dagger.compiler)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000000..949ccfce40
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,34 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# JNA
+-dontwarn java.awt.*
+-keep class com.sun.jna.** { *; }
+-keep class * implements com.sun.jna.** { *; }
+
+# kotlinx.serialization
+
+# Kotlin serialization looks up the generated serializer classes through a function on companion
+# objects. The companions are looked up reflectively so we need to explicitly keep these functions.
+-keepclasseswithmembers class **.*$Companion {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+# If a companion has the serializer function, keep the companion field on the original type so that
+# the reflective lookup succeeds.
+-if class **.*$Companion {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+-keepclassmembers class <1>.<2> {
+ <1>.<2>$Companion Companion;
+}
+
+# OkHttp platform used only on JVM and when Conscrypt and other security providers are available.
+# Taken from https://raw.githubusercontent.com/square/okhttp/master/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro
+-dontwarn okhttp3.internal.platform.**
+-dontwarn org.conscrypt.**
+-dontwarn org.bouncycastle.**
+-dontwarn org.openjsse.**
diff --git a/app/signature/debug.keystore b/app/signature/debug.keystore
new file mode 100644
index 0000000000..4a15fc9eca
Binary files /dev/null and b/app/signature/debug.keystore differ
diff --git a/app/signature/nightly.keystore b/app/signature/nightly.keystore
new file mode 100644
index 0000000000..a0e9ba413b
Binary files /dev/null and b/app/signature/nightly.keystore differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..2917c5199b
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000000..a5bcf735dc
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
new file mode 100644
index 0000000000..ec3259fb7c
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt
@@ -0,0 +1,48 @@
+/*
+ * 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
+
+import android.app.Application
+import androidx.startup.AppInitializer
+import io.element.android.libraries.di.DaggerComponentOwner
+import io.element.android.x.di.AppComponent
+import io.element.android.x.di.DaggerAppComponent
+import io.element.android.x.info.logApplicationInfo
+import io.element.android.x.initializer.CrashInitializer
+import io.element.android.x.initializer.EmojiInitializer
+import io.element.android.x.initializer.MatrixInitializer
+import io.element.android.x.initializer.TimberInitializer
+
+class ElementXApplication : Application(), DaggerComponentOwner {
+
+ private lateinit var appComponent: AppComponent
+
+ override val daggerComponent: Any
+ get() = appComponent
+
+ override fun onCreate() {
+ super.onCreate()
+ appComponent = DaggerAppComponent.factory().create(applicationContext)
+ AppInitializer.getInstance(this).apply {
+ initializeComponent(CrashInitializer::class.java)
+ initializeComponent(TimberInitializer::class.java)
+ initializeComponent(MatrixInitializer::class.java)
+ initializeComponent(EmojiInitializer::class.java)
+ }
+ logApplicationInfo()
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
new file mode 100644
index 0000000000..cf37b22159
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
@@ -0,0 +1,127 @@
+/*
+ * 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
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.WindowCompat
+import com.bumble.appyx.core.integration.NodeHost
+import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
+import com.bumble.appyx.core.plugin.NodeReadyObserver
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
+import io.element.android.x.di.AppBindings
+import io.element.android.x.intent.SafeUriHandler
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("MainActivity")
+
+class MainActivity : NodeComponentActivity() {
+
+ private lateinit var mainNode: MainNode
+
+ private lateinit var appBindings: AppBindings
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+ appBindings = bindings()
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContent {
+ MainContent(appBindings)
+ }
+ }
+
+ @Composable
+ private fun MainContent(appBindings: AppBindings) {
+ ElementTheme {
+ CompositionLocalProvider(
+ LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
+ LocalUriHandler provides SafeUriHandler(this),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ NodeHost(integrationPoint = appyxIntegrationPoint) {
+ MainNode(
+ it,
+ appBindings.mainDaggerComponentOwner(),
+ plugins = listOf(
+ object : NodeReadyObserver {
+ override fun init(node: MainNode) {
+ Timber.tag(loggerTag.value).w("onMainNodeInit")
+ mainNode = node
+ mainNode.handleIntent(intent)
+ }
+ }
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Called when:
+ * - the launcher icon is clicked (if the app is already running);
+ * - a notification is clicked.
+ * - the app is going to background (<- this is strange)
+ */
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ Timber.tag(loggerTag.value).w("onNewIntent")
+ // If the mainNode is not init yet, keep the intent for later.
+ // It can happen when the activity is killed by the system. The methods are called in this order :
+ // onCreate(savedInstanceState=true) -> onNewIntent -> onResume -> onMainNodeInit
+ if (::mainNode.isInitialized) {
+ mainNode.handleIntent(intent)
+ } else {
+ setIntent(intent)
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ Timber.tag(loggerTag.value).w("onPause")
+ }
+
+ override fun onResume() {
+ super.onResume()
+ Timber.tag(loggerTag.value).w("onResume")
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Timber.tag(loggerTag.value).w("onDestroy")
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/MainNode.kt b/app/src/main/kotlin/io/element/android/x/MainNode.kt
new file mode 100644
index 0000000000..8e7d0f194d
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/MainNode.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.x
+
+import android.content.Intent
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.ParentNode
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.appnav.LoggedInFlowNode
+import io.element.android.appnav.room.RoomLoadedFlowNode
+import io.element.android.appnav.RootFlowNode
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.DaggerComponentOwner
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.x.di.MainDaggerComponentsOwner
+import io.element.android.x.di.RoomComponent
+import io.element.android.x.di.SessionComponent
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+
+class MainNode(
+ buildContext: BuildContext,
+ private val mainDaggerComponentOwner: MainDaggerComponentsOwner,
+ plugins: List,
+) :
+ ParentNode(
+ navModel = PermanentNavModel(
+ navTargets = setOf(RootNavTarget),
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+ ),
+ DaggerComponentOwner by mainDaggerComponentOwner {
+
+ private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback {
+ override fun onFlowCreated(identifier: String, client: MatrixClient) {
+ val component = bindings().sessionComponentBuilder().client(client).build()
+ mainDaggerComponentOwner.addComponent(identifier, component)
+ }
+
+ override fun onFlowReleased(identifier: String, client: MatrixClient) {
+ mainDaggerComponentOwner.removeComponent(identifier)
+ }
+ }
+
+ private val roomFlowNodeCallback = object : RoomLoadedFlowNode.LifecycleCallback {
+ override fun onFlowCreated(identifier: String, room: MatrixRoom) {
+ val component = bindings().roomComponentBuilder().room(room).build()
+ mainDaggerComponentOwner.addComponent(identifier, component)
+ }
+
+ override fun onFlowReleased(identifier: String, room: MatrixRoom) {
+ mainDaggerComponentOwner.removeComponent(identifier)
+ }
+ }
+
+ override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node {
+ return createNode(
+ context = buildContext,
+ plugins = listOf(
+ loggedInFlowNodeCallback,
+ roomFlowNodeCallback,
+ )
+ )
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(navModel = navModel)
+ }
+
+ fun handleIntent(intent: Intent) {
+ lifecycleScope.launch {
+ waitForChildAttached().handleIntent(intent)
+ }
+ }
+
+ @Parcelize
+ object RootNavTarget : Parcelable
+}
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
new file mode 100644
index 0000000000..4d75d8601e
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.x.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.di.AppScope
+
+@ContributesTo(AppScope::class)
+interface AppBindings {
+ fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
+ fun snackbarDispatcher(): SnackbarDispatcher
+}
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppComponent.kt b/app/src/main/kotlin/io/element/android/x/di/AppComponent.kt
new file mode 100644
index 0000000000..d614556413
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/di/AppComponent.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.x.di
+
+import android.content.Context
+import com.squareup.anvil.annotations.MergeComponent
+import dagger.BindsInstance
+import dagger.Component
+import io.element.android.libraries.architecture.NodeFactoriesBindings
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+
+@SingleIn(AppScope::class)
+@MergeComponent(AppScope::class)
+interface AppComponent : NodeFactoriesBindings {
+
+ @Component.Factory
+ interface Factory {
+ fun create(@ApplicationContext @BindsInstance context: Context): AppComponent
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
new file mode 100644
index 0000000000..a1d0b50522
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
@@ -0,0 +1,108 @@
+/*
+ * 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.x.di
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.content.res.Resources
+import androidx.preference.PreferenceManager
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.core.meta.BuildType
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.DefaultPreferences
+import io.element.android.libraries.di.SingleIn
+import io.element.android.x.BuildConfig
+import io.element.android.x.R
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.plus
+import java.io.File
+
+@Module
+@ContributesTo(AppScope::class)
+object AppModule {
+
+ @Provides
+ fun providesBaseDirectory(@ApplicationContext context: Context): File {
+ return File(context.filesDir, "sessions")
+ }
+
+ @Provides
+ fun providesResources(@ApplicationContext context: Context): Resources {
+ return context.resources
+ }
+
+ @Provides
+ @SingleIn(AppScope::class)
+ fun providesAppCoroutineScope(): CoroutineScope {
+ return MainScope() + CoroutineName("ElementX Scope")
+ }
+
+ @Provides
+ @SingleIn(AppScope::class)
+ fun providesBuildType(): BuildType {
+ return BuildType.valueOf(BuildConfig.BUILD_TYPE.uppercase())
+ }
+
+ @Provides
+ @SingleIn(AppScope::class)
+ fun providesBuildMeta(@ApplicationContext context: Context, buildType: BuildType) = BuildMeta(
+ isDebuggable = BuildConfig.DEBUG,
+ buildType = buildType,
+ applicationName = context.getString(R.string.app_name),
+ applicationId = BuildConfig.APPLICATION_ID,
+ lowPrivacyLoggingEnabled = false, // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
+ versionName = BuildConfig.VERSION_NAME,
+ versionCode = BuildConfig.VERSION_CODE,
+ gitRevision = "TODO", // BuildConfig.GIT_REVISION,
+ gitRevisionDate = "TODO", // BuildConfig.GIT_REVISION_DATE,
+ gitBranchName = "TODO", // BuildConfig.GIT_BRANCH_NAME,
+ flavorDescription = "TODO", // BuildConfig.FLAVOR_DESCRIPTION,
+ flavorShortDescription = "TODO", // BuildConfig.SHORT_FLAVOR_DESCRIPTION,
+ )
+
+ @Provides
+ @SingleIn(AppScope::class)
+ @DefaultPreferences
+ fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ }
+
+ @Provides
+ @SingleIn(AppScope::class)
+ fun providesCoroutineDispatchers(): CoroutineDispatchers {
+ return CoroutineDispatchers(
+ io = Dispatchers.IO,
+ computation = Dispatchers.Default,
+ main = Dispatchers.Main,
+ )
+ }
+
+ @Provides
+ @SingleIn(AppScope::class)
+ fun provideSnackbarDispatcher(): SnackbarDispatcher {
+ return SnackbarDispatcher()
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt b/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt
new file mode 100644
index 0000000000..de800bb587
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.x.di
+
+import android.content.Context
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.DaggerComponentOwner
+import io.element.android.libraries.di.SingleIn
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+class MainDaggerComponentsOwner @Inject constructor(@ApplicationContext context: Context) : DaggerComponentOwner {
+
+ private val daggerComponents = LinkedHashMap().apply {
+ put("app", (context as DaggerComponentOwner).daggerComponent)
+ }
+
+ fun addComponent(identifier: String, component: Any) {
+ daggerComponents[identifier] = component
+ }
+
+ fun removeComponent(identifier: String) {
+ daggerComponents.remove(identifier)
+ }
+
+ /**
+ * We expose the dagger components in the opposite order they arrived.
+ * So we pick the most recent component when searching with the [io.element.android.libraries.architecture.bindings] methods.
+ */
+ override val daggerComponent: Any
+ get() = daggerComponents.values.reversed()
+}
diff --git a/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt b/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
new file mode 100644
index 0000000000..68c700bdb8
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.x.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import com.squareup.anvil.annotations.MergeSubcomponent
+import dagger.BindsInstance
+import dagger.Subcomponent
+import io.element.android.libraries.architecture.NodeFactoriesBindings
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+
+@SingleIn(RoomScope::class)
+@MergeSubcomponent(RoomScope::class)
+interface RoomComponent : NodeFactoriesBindings {
+
+ @Subcomponent.Builder
+ interface Builder {
+ @BindsInstance
+ fun room(room: MatrixRoom): Builder
+ fun build(): RoomComponent
+ }
+
+ @ContributesTo(SessionScope::class)
+ interface ParentBindings {
+ fun roomComponentBuilder(): Builder
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/di/SessionComponent.kt b/app/src/main/kotlin/io/element/android/x/di/SessionComponent.kt
new file mode 100644
index 0000000000..54e8c27498
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/di/SessionComponent.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.x.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import com.squareup.anvil.annotations.MergeSubcomponent
+import dagger.BindsInstance
+import dagger.Subcomponent
+import io.element.android.libraries.architecture.NodeFactoriesBindings
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.MatrixClient
+
+@SingleIn(SessionScope::class)
+@MergeSubcomponent(SessionScope::class)
+interface SessionComponent : NodeFactoriesBindings {
+
+ @Subcomponent.Builder
+ interface Builder {
+ @BindsInstance
+ fun client(matrixClient: MatrixClient): Builder
+ fun build(): SessionComponent
+ }
+
+ @ContributesTo(AppScope::class)
+ interface ParentBindings {
+ fun sessionComponentBuilder(): Builder
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt b/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt
new file mode 100644
index 0000000000..49c2cc5782
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.x.icon
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import io.element.android.x.R
+
+@Preview
+@Composable
+fun IconPreview(
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier) {
+ Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
+ Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ }
+}
+
+@Preview
+@Composable
+fun RoundIconPreview(
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.clip(shape = CircleShape)) {
+ Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
+ Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/info/Logs.kt b/app/src/main/kotlin/io/element/android/x/info/Logs.kt
new file mode 100644
index 0000000000..9e96f48e2d
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/info/Logs.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.x.info
+
+import io.element.android.x.BuildConfig
+import timber.log.Timber
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+fun logApplicationInfo() {
+ val appVersion = buildString {
+ append(BuildConfig.VERSION_NAME)
+ append(" (")
+ append(BuildConfig.VERSION_CODE)
+ append(") - ")
+ append(BuildConfig.BUILD_TYPE)
+ }
+ // TODO Get SDK version somehow
+ val sdkVersion = "SDK VERSION (TODO)"
+ val date = SimpleDateFormat("MM-dd HH:mm:ss.SSSZ", Locale.US).format(Date())
+
+ Timber.d("----------------------------------------------------------------")
+ Timber.d("----------------------------------------------------------------")
+ Timber.d(" Application version: $appVersion")
+ Timber.d(" SDK version: $sdkVersion")
+ Timber.d(" Local time: $date")
+ Timber.d("----------------------------------------------------------------")
+ Timber.d("----------------------------------------------------------------\n\n\n\n")
+}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt
new file mode 100644
index 0000000000..c947bc20e3
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.features.rageshake.impl.crash.VectorUncaughtExceptionHandler
+
+class CrashInitializer : Initializer {
+
+ override fun create(context: Context) {
+ VectorUncaughtExceptionHandler(context).activate()
+ }
+
+ override fun dependencies(): List>> = emptyList()
+}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt
new file mode 100644
index 0000000000..dd1e7455c6
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.x.initializer
+
+import androidx.startup.Initializer
+import com.vanniktech.emoji.EmojiManager
+import com.vanniktech.emoji.google.GoogleEmojiProvider
+
+class EmojiInitializer : Initializer {
+ override fun create(context: android.content.Context) {
+ EmojiManager.install(GoogleEmojiProvider())
+ }
+
+ override fun dependencies(): MutableList>> = mutableListOf()
+}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt
new file mode 100644
index 0000000000..5eebc88756
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 {
+
+ override fun create(context: Context) {
+ if (BuildConfig.DEBUG) {
+ setupTracing(TracingConfigurations.debug)
+ } else {
+ setupTracing(TracingConfigurations.release)
+ }
+ }
+
+ override fun dependencies(): List>> = listOf(TimberInitializer::class.java)
+}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt
new file mode 100644
index 0000000000..5a641d75c6
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.features.rageshake.impl.logs.VectorFileLogger
+import io.element.android.x.BuildConfig
+import timber.log.Timber
+
+class TimberInitializer : Initializer {
+
+ override fun create(context: Context) {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+ Timber.plant(VectorFileLogger(context))
+ }
+
+ override fun dependencies(): List>> = emptyList()
+}
diff --git a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
new file mode 100644
index 0000000000..88a86b9467
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.x.intent
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.deeplink.DeepLinkCreator
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.core.ThreadId
+import io.element.android.libraries.push.impl.intent.IntentProvider
+import io.element.android.x.MainActivity
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class IntentProviderImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val deepLinkCreator: DeepLinkCreator,
+) : IntentProvider {
+ override fun getViewRoomIntent(
+ sessionId: SessionId,
+ roomId: RoomId?,
+ threadId: ThreadId?,
+ ): Intent {
+ return Intent(context, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
+ }
+ }
+
+ override fun getInviteListIntent(sessionId: SessionId): Intent {
+ return Intent(context, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ data = deepLinkCreator.inviteList(sessionId).toUri()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt b/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt
new file mode 100644
index 0000000000..582cd3b7d5
--- /dev/null
+++ b/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.x.intent
+
+import android.app.Activity
+import androidx.compose.ui.platform.UriHandler
+import io.element.android.libraries.androidutils.system.openUrlInExternalApp
+
+class SafeUriHandler(private val activity: Activity) : UriHandler {
+ override fun openUri(uri: String) {
+ activity.openUrlInExternalApp(uri)
+ }
+}
diff --git a/app/src/main/res/drawable/transparent.xml b/app/src/main/res/drawable/transparent.xml
new file mode 100644
index 0000000000..b7e6de414f
--- /dev/null
+++ b/app/src/main/res/drawable/transparent.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..82724deb96
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..82724deb96
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..4bbfe30ed2
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000000..3510298288
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..20b221702c
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..9dd38fd073
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..001f74e9ba
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000000..d3ce4fb227
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..2d4bab337c
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..42a631def3
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..0adb2f3b52
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000000..e73012b493
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..33fd9ab681
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..05f718cf3b
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..6dd6aa3ee6
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000000..1a6c540c52
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..7c4cf9729b
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..448fa261cc
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..adb2a0c794
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000000..576bdfc52d
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..89f421aafc
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..d5c5e2af7d
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000000..a6572451d7
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..1716e39012
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ #FF101317
+
+ #FFFFFFFF
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..fee1385c85
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ ignored
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..530821a92b
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000000..37fe0011dc
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000000..a6ecda4638
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_providers.xml b/app/src/main/res/xml/file_providers.xml
new file mode 100644
index 0000000000..8afae6f313
--- /dev/null
+++ b/app/src/main/res/xml/file_providers.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
new file mode 100644
index 0000000000..6abc3c656b
--- /dev/null
+++ b/appnav/build.gradle.kts
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ */
+
+@file:Suppress("UnstableApiUsage")
+
+import extension.allFeaturesApi
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.kapt)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.appnav"
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ implementation(libs.dagger)
+ kapt(libs.dagger.compiler)
+
+ allFeaturesApi(rootDir, logger)
+
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.deeplink)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.push.api)
+ implementation(projects.libraries.pushproviders.api)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.permissions.noop)
+
+ implementation(libs.coil)
+
+ implementation(projects.features.ftue.api)
+
+ implementation(projects.services.apperror.impl)
+ implementation(projects.services.appnavstate.api)
+ implementation(projects.services.analytics.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.features.rageshake.test)
+ testImplementation(projects.features.rageshake.impl)
+ testImplementation(projects.services.appnavstate.test)
+ testImplementation(libs.test.appyx.junit)
+ testImplementation(libs.test.arch.core)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt
new file mode 100644
index 0000000000..36b267debb
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.appnav
+
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.NewRoot
+import com.bumble.appyx.navmodel.backstack.operation.Remove
+
+/**
+ * Don't process NewRoot if the nav target already exists in the stack.
+ */
+fun BackStack.safeRoot(element: T) {
+ val containsRoot = elements.value.any {
+ it.key.navTarget == element
+ }
+ if (containsRoot) return
+ accept(NewRoot(element))
+}
+
+/**
+ * Remove the last element on the backstack equals to the given one.
+ */
+fun BackStack.removeLast(element: T) {
+ val lastExpectedNavElement = elements.value.lastOrNull {
+ it.key.navTarget == element
+ } ?: return
+ accept(Remove(lastExpectedNavElement.key))
+}
+
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
new file mode 100644
index 0000000000..64c9ec7c4f
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.appnav
+
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.designsystem.utils.SnackbarMessage
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.VerificationFlowState
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class LoggedInEventProcessor @Inject constructor(
+ private val snackbarDispatcher: SnackbarDispatcher,
+ roomMembershipObserver: RoomMembershipObserver,
+ sessionVerificationService: SessionVerificationService,
+) {
+
+ private var observingJob: Job? = null
+
+ private val displayLeftRoomMessage = roomMembershipObserver.updates
+ .map { !it.isUserInRoom }
+
+ private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
+ .map { it == VerificationFlowState.Finished }
+
+ fun observeEvents(coroutineScope: CoroutineScope) {
+ observingJob = coroutineScope.launch {
+ displayLeftRoomMessage
+ .filter { it }
+ .onEach {
+ displayMessage(CommonStrings.common_current_user_left_room)
+ }
+ .launchIn(this)
+
+ displayVerificationSuccessfulMessage
+ .filter { it }
+ .onEach {
+ displayMessage(CommonStrings.common_verification_complete)
+ }.launchIn(this)
+ }
+ }
+
+ fun stopObserving() {
+ observingJob?.cancel()
+ observingJob = null
+ }
+
+ private suspend fun displayMessage(message: Int) {
+ snackbarDispatcher.post(SnackbarMessage(message))
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
new file mode 100644
index 0000000000..4130e5da23
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -0,0 +1,352 @@
+/*
+ * 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.appnav
+
+import android.os.Parcelable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import coil.Coil
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
+import com.bumble.appyx.navmodel.backstack.operation.push
+import com.bumble.appyx.navmodel.backstack.operation.replace
+import com.bumble.appyx.navmodel.backstack.operation.singleTop
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.appnav.loggedin.LoggedInNode
+import io.element.android.appnav.room.RoomFlowNode
+import io.element.android.appnav.room.RoomLoadedFlowNode
+import io.element.android.features.createroom.api.CreateRoomEntryPoint
+import io.element.android.features.invitelist.api.InviteListEntryPoint
+import io.element.android.features.networkmonitor.api.NetworkMonitor
+import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.features.preferences.api.PreferencesEntryPoint
+import io.element.android.features.roomlist.api.RoomListEntryPoint
+import io.element.android.features.verifysession.api.VerifySessionEntryPoint
+import io.element.android.features.ftue.api.FtueEntryPoint
+import io.element.android.features.ftue.api.state.FtueState
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.deeplink.DeeplinkData
+import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.MAIN_SPACE
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
+import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
+import io.element.android.services.appnavstate.api.AppNavigationStateService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(AppScope::class)
+class LoggedInFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val roomListEntryPoint: RoomListEntryPoint,
+ private val preferencesEntryPoint: PreferencesEntryPoint,
+ private val createRoomEntryPoint: CreateRoomEntryPoint,
+ private val appNavigationStateService: AppNavigationStateService,
+ private val verifySessionEntryPoint: VerifySessionEntryPoint,
+ private val inviteListEntryPoint: InviteListEntryPoint,
+ private val ftueEntryPoint: FtueEntryPoint,
+ private val coroutineScope: CoroutineScope,
+ private val networkMonitor: NetworkMonitor,
+ private val notificationDrawerManager: NotificationDrawerManager,
+ private val ftueState: FtueState,
+ snackbarDispatcher: SnackbarDispatcher,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.RoomList,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins
+) {
+
+ interface Callback : Plugin {
+ fun onOpenBugReport() = Unit
+ }
+
+ interface LifecycleCallback : NodeLifecycleCallback {
+ fun onFlowCreated(identifier: String, client: MatrixClient) = Unit
+
+ fun onFlowReleased(identifier: String, client: MatrixClient) = Unit
+ }
+
+ data class Inputs(
+ val matrixClient: MatrixClient
+ ) : NodeInputs
+
+ private val inputs: Inputs = inputs()
+ private val syncService = inputs.matrixClient.syncService()
+ private val loggedInFlowProcessor = LoggedInEventProcessor(
+ snackbarDispatcher,
+ inputs.matrixClient.roomMembershipObserver(),
+ inputs.matrixClient.sessionVerificationService(),
+ )
+
+ override fun onBuilt() {
+ super.onBuilt()
+
+ lifecycle.subscribe(
+ onCreate = {
+ plugins().forEach { it.onFlowCreated(id, inputs.matrixClient) }
+ val imageLoaderFactory = bindings().loggedInImageLoaderFactory()
+ Coil.setImageLoader(imageLoaderFactory)
+ appNavigationStateService.onNavigateToSession(id, inputs.matrixClient.sessionId)
+ // TODO We do not support Space yet, so directly navigate to main space
+ appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
+ loggedInFlowProcessor.observeEvents(coroutineScope)
+
+ if (ftueState.shouldDisplayFlow.value) {
+ backstack.push(NavTarget.Ftue)
+ }
+ },
+ onResume = {
+ lifecycleScope.launch {
+ syncService.startSync()
+ }
+ },
+ onPause = {
+ syncService.stopSync()
+ },
+ onDestroy = {
+ plugins().forEach { it.onFlowReleased(id, inputs.matrixClient) }
+ appNavigationStateService.onLeavingSpace(id)
+ appNavigationStateService.onLeavingSession(id)
+ loggedInFlowProcessor.stopObserving()
+ }
+ )
+
+ observeSyncStateAndNetworkStatus()
+ }
+
+ private fun observeSyncStateAndNetworkStatus() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ combine(
+ syncService.syncState,
+ networkMonitor.connectivity
+ ) { syncState, networkStatus ->
+ syncState == SyncState.Error && networkStatus == NetworkStatus.Online
+ }
+ .distinctUntilChanged()
+ .collect { restartSync ->
+ if (restartSync) {
+ syncService.startSync()
+ }
+ }
+ }
+ }
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object Permanent : NavTarget
+
+ @Parcelize
+ object RoomList : NavTarget
+
+ @Parcelize
+ data class Room(
+ val roomId: RoomId,
+ val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages
+ ) : NavTarget
+
+ @Parcelize
+ object Settings : NavTarget
+
+ @Parcelize
+ object CreateRoom : NavTarget
+
+ @Parcelize
+ object VerifySession : NavTarget
+
+ @Parcelize
+ object InviteList : NavTarget
+
+ @Parcelize
+ object Ftue : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Permanent -> {
+ createNode(buildContext)
+ }
+ NavTarget.RoomList -> {
+ val callback = object : RoomListEntryPoint.Callback {
+ override fun onRoomClicked(roomId: RoomId) {
+ backstack.push(NavTarget.Room(roomId))
+ }
+
+ override fun onSettingsClicked() {
+ backstack.push(NavTarget.Settings)
+ }
+
+ override fun onCreateRoomClicked() {
+ backstack.push(NavTarget.CreateRoom)
+ }
+
+ override fun onSessionVerificationClicked() {
+ backstack.push(NavTarget.VerifySession)
+ }
+
+ override fun onInvitesClicked() {
+ backstack.push(NavTarget.InviteList)
+ }
+
+ override fun onRoomSettingsClicked(roomId: RoomId) {
+ backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomDetails))
+ }
+
+ override fun onReportBugClicked() {
+ plugins().forEach { it.onOpenBugReport() }
+ }
+ }
+ roomListEntryPoint
+ .nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
+ is NavTarget.Room -> {
+ val nodeLifecycleCallbacks = plugins()
+ val callback = object : RoomLoadedFlowNode.Callback {
+ override fun onForwardedToSingleRoom(roomId: RoomId) {
+ coroutineScope.launch { attachRoom(roomId) }
+ }
+ }
+ val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
+ createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
+ }
+ NavTarget.Settings -> {
+ val callback = object : PreferencesEntryPoint.Callback {
+ override fun onOpenBugReport() {
+ plugins().forEach { it.onOpenBugReport() }
+ }
+
+ override fun onVerifyClicked() {
+ backstack.push(NavTarget.VerifySession)
+ }
+ }
+ preferencesEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
+ NavTarget.CreateRoom -> {
+ val callback = object : CreateRoomEntryPoint.Callback {
+ override fun onSuccess(roomId: RoomId) {
+ backstack.replace(NavTarget.Room(roomId))
+ }
+ }
+
+ createRoomEntryPoint
+ .nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
+ NavTarget.VerifySession -> {
+ verifySessionEntryPoint.createNode(this, buildContext)
+ }
+ NavTarget.InviteList -> {
+ val callback = object : InviteListEntryPoint.Callback {
+ override fun onBackClicked() {
+ backstack.pop()
+ }
+
+ override fun onInviteAccepted(roomId: RoomId) {
+ backstack.push(NavTarget.Room(roomId))
+ }
+ }
+
+ inviteListEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
+ NavTarget.Ftue -> {
+ ftueEntryPoint.nodeBuilder(this, buildContext)
+ .callback(object : FtueEntryPoint.Callback {
+ override fun onFtueFlowFinished() {
+ backstack.pop()
+ }
+ }).build()
+ }
+ }
+ }
+
+ suspend fun attachRoot(): Node {
+ return attachChild {
+ backstack.singleTop(NavTarget.RoomList)
+ }
+ }
+
+ suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
+ return attachChild {
+ backstack.singleTop(NavTarget.RoomList)
+ backstack.push(NavTarget.Room(roomId))
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Box(modifier = modifier) {
+ Children(
+ navModel = backstack,
+ modifier = Modifier,
+ // Animate navigation to settings and to a room
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+
+ val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
+
+ if (!isFtueDisplayed) {
+ PermanentChild(navTarget = NavTarget.Permanent)
+ }
+ }
+ }
+
+ internal suspend fun attachRoom(deeplinkData: DeeplinkData.Room) {
+ backstack.push(NavTarget.Room(deeplinkData.roomId))
+ }
+
+ internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) {
+ notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
+ backstack.push(NavTarget.InviteList)
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NodeLifecycleCallback.kt b/appnav/src/main/kotlin/io/element/android/appnav/NodeLifecycleCallback.kt
new file mode 100644
index 0000000000..a06564c3aa
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/NodeLifecycleCallback.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.appnav
+
+import com.bumble.appyx.core.plugin.Plugin
+
+interface NodeLifecycleCallback : Plugin
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
new file mode 100644
index 0000000000..1ed1aec678
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
@@ -0,0 +1,109 @@
+/*
+ * 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.appnav
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import coil.Coil
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.login.api.LoginEntryPoint
+import io.element.android.features.onboarding.api.OnBoardingEntryPoint
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(AppScope::class)
+class NotLoggedInFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val onBoardingEntryPoint: OnBoardingEntryPoint,
+ private val loginEntryPoint: LoginEntryPoint,
+ private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.OnBoarding,
+ savedStateMap = buildContext.savedStateMap
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ override fun onBuilt() {
+ super.onBuilt()
+ lifecycle.subscribe(
+ onCreate = {
+ Coil.setImageLoader(notLoggedInImageLoaderFactory)
+ },
+ )
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object OnBoarding : NavTarget
+
+ @Parcelize
+ data class LoginFlow(
+ val isAccountCreation: Boolean,
+ ) : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.OnBoarding -> {
+ val callback = object : OnBoardingEntryPoint.Callback {
+ override fun onSignUp() {
+ backstack.push(NavTarget.LoginFlow(isAccountCreation = true))
+ }
+
+ override fun onSignIn() {
+ backstack.push(NavTarget.LoginFlow(isAccountCreation = false))
+ }
+ }
+ onBoardingEntryPoint
+ .nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
+ is NavTarget.LoginFlow -> {
+ loginEntryPoint.nodeBuilder(this, buildContext)
+ .params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation))
+ .build()
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ // Animate navigation to login screen
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
new file mode 100644
index 0000000000..089e956c61
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -0,0 +1,256 @@
+/*
+ * 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.appnav
+
+import android.content.Intent
+import android.os.Parcelable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.core.state.MutableSavedStateMap
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.pop
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.appnav.di.MatrixClientsHolder
+import io.element.android.appnav.intent.IntentResolver
+import io.element.android.appnav.intent.ResolvedIntent
+import io.element.android.appnav.root.RootNavStateFlowFactory
+import io.element.android.appnav.root.RootPresenter
+import io.element.android.appnav.root.RootView
+import io.element.android.features.login.api.oidc.OidcAction
+import io.element.android.features.login.api.oidc.OidcActionFlow
+import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.waitForChildAttached
+import io.element.android.libraries.deeplink.DeeplinkData
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.matrix.api.core.SessionId
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+@ContributesNode(AppScope::class)
+class RootFlowNode @AssistedInject constructor(
+ @Assisted val buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val authenticationService: MatrixAuthenticationService,
+ private val navStateFlowFactory: RootNavStateFlowFactory,
+ private val matrixClientsHolder: MatrixClientsHolder,
+ private val presenter: RootPresenter,
+ private val bugReportEntryPoint: BugReportEntryPoint,
+ private val intentResolver: IntentResolver,
+ private val oidcActionFlow: OidcActionFlow,
+) :
+ BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.SplashScreen,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins
+ ) {
+
+ override fun onBuilt() {
+ matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
+ super.onBuilt()
+ observeNavState()
+ }
+
+ override fun onSaveInstanceState(state: MutableSavedStateMap) {
+ super.onSaveInstanceState(state)
+ matrixClientsHolder.saveIntoSavedState(state)
+ navStateFlowFactory.saveIntoSavedState(state)
+ }
+
+ private fun observeNavState() {
+ navStateFlowFactory.create(buildContext.savedStateMap)
+ .distinctUntilChanged()
+ .onEach { navState ->
+ Timber.v("navState=$navState")
+ if (navState.isLoggedIn) {
+ tryToRestoreLatestSession(
+ onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
+ onFailure = { switchToNotLoggedInFlow() }
+ )
+ } else {
+ switchToNotLoggedInFlow()
+ }
+ }
+ .launchIn(lifecycleScope)
+ }
+
+ private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
+ backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
+ }
+
+ private fun switchToNotLoggedInFlow() {
+ matrixClientsHolder.removeAll()
+ backstack.safeRoot(NavTarget.NotLoggedInFlow)
+ }
+
+ private suspend fun restoreSessionIfNeeded(
+ sessionId: SessionId,
+ onFailure: () -> Unit = {},
+ onSuccess: (SessionId) -> Unit = {},
+ ) {
+ matrixClientsHolder.getOrRestore(sessionId)
+ .onSuccess {
+ Timber.v("Succeed to restore session $sessionId")
+ onSuccess(sessionId)
+ }
+ .onFailure {
+ Timber.v("Failed to restore session $sessionId")
+ onFailure()
+ }
+ }
+
+ private suspend fun tryToRestoreLatestSession(
+ onSuccess: (SessionId) -> Unit = {},
+ onFailure: () -> Unit = {}
+ ) {
+ val latestSessionId = authenticationService.getLatestSessionId()
+ if (latestSessionId == null) {
+ onFailure()
+ return
+ }
+ restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess)
+ }
+
+ private fun onOpenBugReport() {
+ backstack.push(NavTarget.BugReport)
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ RootView(
+ state = state,
+ modifier = modifier,
+ onOpenBugReport = this::onOpenBugReport,
+ ) {
+ Children(
+ navModel = backstack,
+ // Animate opening the bug report screen
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object SplashScreen : NavTarget
+
+ @Parcelize
+ object NotLoggedInFlow : NavTarget
+
+ @Parcelize
+ data class LoggedInFlow(
+ val sessionId: SessionId,
+ val navId: Int
+ ) : NavTarget
+
+ @Parcelize
+ object BugReport : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ is NavTarget.LoggedInFlow -> {
+ val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
+ Timber.w("Couldn't find any session, go through SplashScreen")
+ }
+ val inputs = LoggedInFlowNode.Inputs(matrixClient)
+ val callback = object : LoggedInFlowNode.Callback {
+ override fun onOpenBugReport() {
+ backstack.push(NavTarget.BugReport)
+ }
+ }
+ val nodeLifecycleCallbacks = plugins()
+ createNode(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
+ }
+ NavTarget.NotLoggedInFlow -> createNode(buildContext)
+ NavTarget.SplashScreen -> splashNode(buildContext)
+ NavTarget.BugReport -> {
+ val callback = object : BugReportEntryPoint.Callback {
+ override fun onBugReportSent() {
+ backstack.pop()
+ }
+ }
+ bugReportEntryPoint
+ .nodeBuilder(this, buildContext)
+ .callback(callback)
+ .build()
+ }
+ }
+ }
+
+ private fun splashNode(buildContext: BuildContext) = node(buildContext) {
+ Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+
+ suspend fun handleIntent(intent: Intent) {
+ val resolvedIntent = intentResolver.resolve(intent) ?: return
+ when (resolvedIntent) {
+ is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
+ is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
+ }
+ }
+
+ private suspend fun navigateTo(deeplinkData: DeeplinkData) {
+ Timber.d("Navigating to $deeplinkData")
+ attachSession(deeplinkData.sessionId)
+ .apply {
+ when (deeplinkData) {
+ is DeeplinkData.Root -> attachRoot()
+ is DeeplinkData.Room -> attachRoom(deeplinkData)
+ is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
+ }
+ }
+ }
+
+ private fun onOidcAction(oidcAction: OidcAction) {
+ oidcActionFlow.post(oidcAction)
+ }
+
+ private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
+ //TODO handle multi-session
+ return waitForChildAttached { navTarget ->
+ navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
+ }
+ }
+}
+
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
new file mode 100644
index 0000000000..3e36e7d692
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.appnav.di
+
+import com.bumble.appyx.core.state.MutableSavedStateMap
+import com.bumble.appyx.core.state.SavedStateMap
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import io.element.android.libraries.matrix.api.core.SessionId
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+
+private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider {
+
+ private val sessionIdsToMatrixClient = ConcurrentHashMap()
+ private val restoreMutex = Mutex()
+
+ fun removeAll() {
+ sessionIdsToMatrixClient.clear()
+ }
+
+ fun remove(sessionId: SessionId) {
+ sessionIdsToMatrixClient.remove(sessionId)
+ }
+
+ fun getOrNull(sessionId: SessionId): MatrixClient? {
+ return sessionIdsToMatrixClient[sessionId]
+ }
+
+ override suspend fun getOrRestore(sessionId: SessionId): Result {
+ return restoreMutex.withLock {
+ when (val matrixClient = getOrNull(sessionId)) {
+ null -> restore(sessionId)
+ else -> Result.success(matrixClient)
+ }
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun restoreWithSavedState(state: SavedStateMap?) {
+ Timber.d("Restore state")
+ if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also {
+ Timber.w("Restore with non-empty map")
+ }
+ val sessionIds = state[SAVE_INSTANCE_KEY] as? Array
+ Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}")
+ if (sessionIds.isNullOrEmpty()) return
+ // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
+ runBlocking {
+ sessionIds.forEach { sessionId ->
+ restore(sessionId)
+ }
+ }
+ }
+
+ fun saveIntoSavedState(state: MutableSavedStateMap) {
+ val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
+ Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
+ state[SAVE_INSTANCE_KEY] = sessionKeys
+ }
+
+ private suspend fun restore(sessionId: SessionId): Result {
+ Timber.d("Restore matrix session: $sessionId")
+ return authenticationService.restoreSession(sessionId)
+ .onSuccess { matrixClient ->
+ sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
+ }
+ .onFailure {
+ Timber.e("Fail to restore session")
+ }
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
new file mode 100644
index 0000000000..6a3d8ff9dd
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.appnav.intent
+
+import android.content.Intent
+import io.element.android.features.login.api.oidc.OidcAction
+import io.element.android.features.login.api.oidc.OidcIntentResolver
+import io.element.android.libraries.deeplink.DeeplinkData
+import io.element.android.libraries.deeplink.DeeplinkParser
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import timber.log.Timber
+import javax.inject.Inject
+
+sealed interface ResolvedIntent {
+ data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
+ data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
+}
+
+class IntentResolver @Inject constructor(
+ private val deeplinkParser: DeeplinkParser,
+ private val oidcIntentResolver: OidcIntentResolver
+) {
+ fun resolve(intent: Intent): ResolvedIntent? {
+ val deepLinkData = deeplinkParser.getFromIntent(intent)
+ if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
+
+ val oidcAction = oidcIntentResolver.resolve(intent)
+ if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
+
+ // Unknown intent
+ Timber.w("Unknown intent")
+ return null
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt
new file mode 100644
index 0000000000..664ec1f663
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.appnav.loggedin
+
+// sealed interface LoggedInEvents {
+// object MyEvent : LoggedInEvents
+// }
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt
new file mode 100644
index 0000000000..6950b9b699
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.appnav.loggedin
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class LoggedInNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val loggedInPresenter: LoggedInPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val loggedInState = loggedInPresenter.present()
+ LoggedInView(
+ state = loggedInState,
+ modifier = modifier
+ )
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
new file mode 100644
index 0000000000..8910cc3976
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -0,0 +1,70 @@
+/*
+ * 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.appnav.loggedin
+
+import android.Manifest
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
+import io.element.android.libraries.push.api.PushService
+import javax.inject.Inject
+
+class LoggedInPresenter @Inject constructor(
+ private val matrixClient: MatrixClient,
+ private val permissionsPresenterFactory: PermissionsPresenter.Factory,
+ private val pushService: PushService,
+) : Presenter {
+
+ private val postNotificationPermissionsPresenter by lazy {
+ // Ask for POST_NOTIFICATION PERMISSION on Android 13+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ NoopPermissionsPresenter()
+ }
+ }
+
+ @Composable
+ override fun present(): LoggedInState {
+ LaunchedEffect(Unit) {
+ // Ensure pusher is registered
+ // TODO Manually select push provider for now
+ val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
+ val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
+ pushService.registerWith(matrixClient, pushProvider, distributor)
+ }
+
+ val syncState = matrixClient.syncService().syncState.collectAsState()
+ val permissionsState = postNotificationPermissionsPresenter.present()
+
+ // fun handleEvents(event: LoggedInEvents) {
+ // when (event) {
+ // }
+ // }
+
+ return LoggedInState(
+ syncState = syncState.value,
+ permissionsState = permissionsState,
+ // eventSink = ::handleEvents
+ )
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
new file mode 100644
index 0000000000..075242cddb
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.appnav.loggedin
+
+import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.libraries.permissions.api.PermissionsState
+
+data class LoggedInState(
+ val syncState: SyncState,
+ val permissionsState: PermissionsState,
+ // val eventSink: (LoggedInEvents) -> Unit
+)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
new file mode 100644
index 0000000000..e8a8a4762c
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.appnav.loggedin
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
+
+open class LoggedInStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLoggedInState(),
+ aLoggedInState(syncState = SyncState.Idle),
+ // Add other state here
+ )
+}
+
+fun aLoggedInState(
+ syncState: SyncState = SyncState.Running,
+) = LoggedInState(
+ syncState = syncState,
+ permissionsState = createDummyPostNotificationPermissionsState(),
+ // eventSink = {}
+)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
new file mode 100644
index 0000000000..60784ea4ed
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.appnav.loggedin
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.androidutils.system.openAppSettingsPage
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.permissions.api.PermissionsView
+
+@Composable
+fun LoggedInView(
+ state: LoggedInState,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ ) {
+ SyncStateView(
+ modifier = Modifier
+ .padding(top = 8.dp)
+ .align(Alignment.TopCenter),
+ syncState = state.syncState,
+ )
+ PermissionsView(
+ state = state.permissionsState,
+ openSystemSettings = context::openAppSettingsPage
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
+ LoggedInView(
+ state = state
+ )
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt
new file mode 100644
index 0000000000..5108bb8716
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.appnav.loggedin
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Surface
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.sync.SyncState
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun SyncStateView(
+ syncState: SyncState,
+ modifier: Modifier = Modifier
+) {
+ val animationSpec = spring(stiffness = 500F)
+ AnimatedVisibility(
+ modifier = modifier,
+ visible = syncState.mustBeVisible(),
+ enter = fadeIn(animationSpec = animationSpec),
+ exit = fadeOut(animationSpec = animationSpec),
+ ) {
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ shadowElevation = 8.dp,
+ ) {
+ Row(
+ modifier = Modifier
+ .background(color = ElementTheme.colors.bgSubtleSecondary)
+ .padding(horizontal = 24.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .progressSemantics()
+ .size(12.dp),
+ color = ElementTheme.colors.textPrimary,
+ strokeWidth = 1.5.dp,
+ )
+ Text(
+ text = stringResource(id = CommonStrings.common_syncing),
+ color = ElementTheme.colors.textPrimary,
+ style = ElementTheme.typography.fontBodyMdMedium
+ )
+ }
+ }
+ }
+}
+
+private fun SyncState.mustBeVisible() = when (this) {
+ SyncState.Idle -> true /* Cold start of the app */
+ SyncState.Running -> false
+ SyncState.Error -> false /* In this case, the network error banner can be displayed */
+ SyncState.Terminated -> true /* The app is resumed and the sync is started again */
+}
+
+@DayNightPreviews
+@Composable
+fun SyncStateViewPreview() = ElementPreview {
+ // Add a box to see the shadow
+ Box(modifier = Modifier.padding(24.dp)) {
+ SyncStateView(
+ syncState = SyncState.Idle
+ )
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt
new file mode 100644
index 0000000000..e8d68a3e94
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.appnav.room
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
+import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.theme.placeholderBackground
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun LoadingRoomNodeView(
+ state: LoadingRoomState,
+ hasNetworkConnection: Boolean,
+ onBackClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ Column {
+ ConnectivityIndicatorView(isOnline = hasNetworkConnection)
+ LoadingRoomTopBar(onBackClicked)
+ }
+ },
+ content = { padding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ if (state is LoadingRoomState.Error) {
+ Text(
+ text = stringResource(id = CommonStrings.error_unknown),
+ color = ElementTheme.colors.textSecondary,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ )
+ } else {
+ CircularProgressIndicator()
+ }
+ }
+ },
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun LoadingRoomTopBar(
+ onBackClicked: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ TopAppBar(
+ modifier = modifier,
+ navigationIcon = {
+ BackButton(onClick = onBackClicked)
+ },
+ title = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(AvatarSize.TimelineRoom.dp)
+ .align(Alignment.CenterVertically)
+ .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ PlaceholderAtom(width = 20.dp, height = 7.dp)
+ Spacer(modifier = Modifier.width(7.dp))
+ PlaceholderAtom(width = 45.dp, height = 7.dp)
+ }
+ },
+ windowInsets = WindowInsets(0.dp),
+ )
+}
+
+@Preview
+@Composable
+fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: LoadingRoomState) {
+ LoadingRoomNodeView(
+ state = state,
+ onBackClicked = {},
+ hasNetworkConnection = false
+ )
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt
new file mode 100644
index 0000000000..db4627c3b4
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.appnav.room
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+sealed interface LoadingRoomState {
+ object Loading : LoadingRoomState
+ object Error : LoadingRoomState
+ data class Loaded(val room: MatrixRoom) : LoadingRoomState
+}
+
+open class LoadingRoomStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ LoadingRoomState.Loading,
+ LoadingRoomState.Error
+ )
+}
+
+@SingleIn(SessionScope::class)
+class LoadingRoomStateFlowFactory @Inject constructor(private val matrixClient: MatrixClient) {
+
+ fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow =
+ getRoomFlow(roomId)
+ .map { room ->
+ if (room != null) {
+ LoadingRoomState.Loaded(room)
+ } else {
+ LoadingRoomState.Error
+ }
+ }
+ .stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading)
+
+ private fun getRoomFlow(roomId: RoomId): Flow = suspend {
+ matrixClient.getRoom(roomId = roomId)
+ }
+ .asFlow()
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
new file mode 100644
index 0000000000..20ec9f48b4
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -0,0 +1,140 @@
+/*
+ * 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:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.appnav.room
+
+import android.os.Parcelable
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.appnav.NodeLifecycleCallback
+import io.element.android.features.networkmonitor.api.NetworkMonitor
+import io.element.android.features.networkmonitor.api.NetworkStatus
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class RoomFlowNode @AssistedInject constructor(
+ @Assisted val buildContext: BuildContext,
+ @Assisted plugins: List,
+ loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
+ private val networkMonitor: NetworkMonitor,
+) :
+ BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Loading,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins
+ ) {
+
+ data class Inputs(
+ val roomId: RoomId,
+ val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages,
+ ) : NodeInputs
+
+ private val inputs: Inputs = inputs()
+ private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId)
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object Loading : NavTarget
+
+ @Parcelize
+ object Loaded : NavTarget
+ }
+
+ override fun onBuilt() {
+ super.onBuilt()
+ loadingRoomStateStateFlow
+ .map {
+ it is LoadingRoomState.Loaded
+ }
+ .distinctUntilChanged()
+ .onEach { isLoaded ->
+ if (isLoaded) {
+ backstack.newRoot(NavTarget.Loaded)
+ } else {
+ backstack.newRoot(NavTarget.Loading)
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Loaded -> {
+ val nodeLifecycleCallbacks = plugins()
+ val roomFlowNodeCallback = plugins()
+ val awaitRoomState = loadingRoomStateStateFlow.value
+ if (awaitRoomState is LoadingRoomState.Loaded) {
+ val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement)
+ createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback + nodeLifecycleCallbacks)
+ } else {
+ loadingNode(buildContext, this::navigateUp)
+ }
+ }
+ NavTarget.Loading -> {
+ loadingNode(buildContext, this::navigateUp)
+ }
+ }
+ }
+
+ private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier ->
+ val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
+ val networkStatus by networkMonitor.connectivity.collectAsState()
+ LoadingRoomNodeView(
+ state = loadingRoomState,
+ hasNetworkConnection = networkStatus == NetworkStatus.Online,
+ modifier = modifier,
+ onBackClicked = onBackClicked
+ )
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ )
+ }
+}
+
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
new file mode 100644
index 0000000000..73a8579b07
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
@@ -0,0 +1,179 @@
+/*
+ * 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.appnav.room
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.appnav.NodeLifecycleCallback
+import io.element.android.features.messages.api.MessagesEntryPoint
+import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+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.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.services.appnavstate.api.AppNavigationStateService
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+@ContributesNode(SessionScope::class)
+class RoomLoadedFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val messagesEntryPoint: MessagesEntryPoint,
+ private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
+ private val appNavigationStateService: AppNavigationStateService,
+ roomMembershipObserver: RoomMembershipObserver,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+
+ interface Callback : Plugin {
+ fun onForwardedToSingleRoom(roomId: RoomId)
+ }
+
+ interface LifecycleCallback : NodeLifecycleCallback {
+ fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
+ fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
+ }
+
+ data class Inputs(
+ val room: MatrixRoom,
+ val initialElement: NavTarget = NavTarget.Messages,
+ ) : NodeInputs
+
+ private val inputs: Inputs = inputs()
+ private val callbacks = plugins.filterIsInstance()
+
+ init {
+ lifecycle.subscribe(
+ onCreate = {
+ Timber.v("OnCreate")
+ plugins().forEach { it.onFlowCreated(id, inputs.room) }
+ appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
+ fetchRoomMembers()
+ },
+ onDestroy = {
+ Timber.v("OnDestroy")
+ plugins().forEach { it.onFlowReleased(id, inputs.room) }
+ appNavigationStateService.onLeavingRoom(id)
+ }
+ )
+ roomMembershipObserver.updates
+ .filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom }
+ .onEach {
+ navigateUp()
+ }
+ .launchIn(lifecycleScope)
+ inputs()
+ }
+
+ private fun fetchRoomMembers() = lifecycleScope.launch {
+ val room = inputs.room
+ room.updateMembers()
+ .onFailure {
+ Timber.e(it, "Fail to fetch members for room ${room.roomId}")
+ }.onSuccess {
+ Timber.v("Success fetching members for room ${room.roomId}")
+ }
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Messages -> {
+ val callback = object : MessagesEntryPoint.Callback {
+ override fun onRoomDetailsClicked() {
+ backstack.push(NavTarget.RoomDetails)
+ }
+
+ override fun onUserDataClicked(userId: UserId) {
+ backstack.push(NavTarget.RoomMemberDetails(userId))
+ }
+
+ override fun onForwardedToSingleRoom(roomId: RoomId) {
+ callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
+ }
+ }
+ messagesEntryPoint.createNode(this, buildContext, callback)
+ }
+ NavTarget.RoomDetails -> {
+ val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails)
+ roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
+ }
+ is NavTarget.RoomMemberDetails -> {
+ val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
+ roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
+ }
+ }
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object Messages : NavTarget
+
+ @Parcelize
+ object RoomDetails : NavTarget
+
+ @Parcelize
+ data class RoomMemberDetails(val userId: UserId) : NavTarget
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ // Rely on the View Lifecycle instead of the Node Lifecycle,
+ // because this node enters 'onDestroy' before his children, so it can leads to
+ // using the room in a child node where it's already closed.
+ DisposableEffect(Unit) {
+ inputs.room.open()
+ onDispose {
+ inputs.room.close()
+ }
+ }
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt
new file mode 100644
index 0000000000..ed3ac15972
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.appnav.root
+
+/**
+ * [RootNavState] produced by [RootNavStateFlowFactory].
+ */
+data class RootNavState(
+ /**
+ * This value is incremented when a clear cache is done.
+ * Can be useful to track to force ui state to re-render
+ */
+ val cacheIndex: Int,
+ /**
+ * true if we are currently loggedIn.
+ */
+ val isLoggedIn: Boolean
+)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
new file mode 100644
index 0000000000..0e8d93b0c9
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.appnav.root
+
+import com.bumble.appyx.core.state.MutableSavedStateMap
+import com.bumble.appyx.core.state.SavedStateMap
+import io.element.android.appnav.di.MatrixClientsHolder
+import io.element.android.features.login.api.LoginUserStory
+import io.element.android.features.preferences.api.CacheService
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onEach
+import javax.inject.Inject
+
+private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY"
+
+/**
+ * This class is responsible for creating a flow of [RootNavState].
+ * It gathers data from multiple datasource and creates a unique one.
+ */
+class RootNavStateFlowFactory @Inject constructor(
+ private val authenticationService: MatrixAuthenticationService,
+ private val cacheService: CacheService,
+ private val matrixClientsHolder: MatrixClientsHolder,
+ private val loginUserStory: LoginUserStory,
+) {
+
+ private var currentCacheIndex = 0
+
+ fun create(savedStateMap: SavedStateMap?): Flow {
+ return combine(
+ cacheIndexFlow(savedStateMap),
+ isUserLoggedInFlow(),
+ ) { cacheIndex, isLoggedIn ->
+ RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn)
+ }
+ }
+
+ fun saveIntoSavedState(stateMap: MutableSavedStateMap) {
+ stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex
+ }
+
+ /**
+ * @return a flow of integer, where each time a clear cache is done, we have a new incremented value.
+ */
+ private fun cacheIndexFlow(savedStateMap: SavedStateMap?): Flow {
+ val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
+ return cacheService.clearedCacheEventFlow
+ .onEach { sessionId ->
+ matrixClientsHolder.remove(sessionId)
+ }
+ .toIndexFlow(initialCacheIndex)
+ .onEach { cacheIndex ->
+ currentCacheIndex = cacheIndex
+ }
+ }
+
+ private fun isUserLoggedInFlow(): Flow {
+ return combine(
+ authenticationService.isLoggedIn(),
+ loginUserStory.loginFlowIsDone
+ ) { isLoggedIn, loginFlowIsDone ->
+ isLoggedIn && loginFlowIsDone
+ }
+ .distinctUntilChanged()
+ }
+
+ /**
+ * @return a flow of integer that increments the value by one each time a new element is emitted upstream.
+ */
+ private fun Flow.toIndexFlow(initialValue: Int): Flow = flow {
+ var index = initialValue
+ emit(initialValue)
+ collect {
+ emit(++index)
+ }
+ }
+
+ private fun SavedStateMap?.getCacheIndexOrDefault(): Int {
+ return this?.get(SAVE_INSTANCE_KEY) as? Int ?: 0
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
new file mode 100644
index 0000000000..cffc4cf35c
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.appnav.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
+import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.services.apperror.api.AppErrorStateService
+import javax.inject.Inject
+
+class RootPresenter @Inject constructor(
+ private val crashDetectionPresenter: CrashDetectionPresenter,
+ private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
+ private val appErrorStateService: AppErrorStateService,
+) : Presenter {
+
+ @Composable
+ override fun present(): RootState {
+ val rageshakeDetectionState = rageshakeDetectionPresenter.present()
+ val crashDetectionState = crashDetectionPresenter.present()
+ val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState()
+
+ return RootState(
+ rageshakeDetectionState = rageshakeDetectionState,
+ crashDetectionState = crashDetectionState,
+ errorState = appErrorState,
+ )
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt
new file mode 100644
index 0000000000..704adb5df3
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.appnav.root
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.rageshake.api.crash.CrashDetectionState
+import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
+import io.element.android.services.apperror.api.AppErrorState
+
+@Immutable
+data class RootState(
+ val rageshakeDetectionState: RageshakeDetectionState,
+ val crashDetectionState: CrashDetectionState,
+ val errorState: AppErrorState,
+)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
new file mode 100644
index 0000000000..c8b5413e60
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.appnav.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.rageshake.api.crash.aCrashDetectionState
+import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
+import io.element.android.services.apperror.api.AppErrorState
+import io.element.android.services.apperror.api.aAppErrorState
+
+open class RootStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aRootState().copy(
+ rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = false),
+ crashDetectionState = aCrashDetectionState().copy(crashDetected = true),
+ ),
+ aRootState().copy(
+ rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
+ crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
+ ),
+ aRootState().copy(
+ errorState = aAppErrorState(),
+ )
+ )
+}
+
+fun aRootState() = RootState(
+ rageshakeDetectionState = aRageshakeDetectionState(),
+ crashDetectionState = aCrashDetectionState(),
+ errorState = AppErrorState.NoError,
+)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
new file mode 100644
index 0000000000..a52ee59261
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.appnav.root
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
+import io.element.android.features.rageshake.api.crash.CrashDetectionView
+import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
+import io.element.android.features.rageshake.api.detection.RageshakeDetectionView
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.services.apperror.impl.AppErrorView
+
+@Composable
+fun RootView(
+ state: RootState,
+ modifier: Modifier = Modifier,
+ onOpenBugReport: () -> Unit = {},
+ children: @Composable BoxScope.() -> Unit,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ children()
+
+ fun onOpenBugReport() {
+ state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
+ state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss)
+ onOpenBugReport.invoke()
+ }
+
+ RageshakeDetectionView(
+ state = state.rageshakeDetectionState,
+ onOpenBugReport = ::onOpenBugReport,
+ )
+ CrashDetectionView(
+ state = state.crashDetectionState,
+ onOpenBugReport = ::onOpenBugReport,
+ )
+ AppErrorView(
+ state = state.errorState,
+ )
+ }
+}
+
+@Preview
+@Composable
+internal fun RootLightPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreviewLight { ContentToPreview(rootState) }
+
+@Preview
+@Composable
+internal fun RootDarkPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreviewDark { ContentToPreview(rootState) }
+
+@Composable
+private fun ContentToPreview(rootState: RootState) {
+ RootView(rootState) {
+ Text("Children")
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
new file mode 100644
index 0000000000..48efd77808
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.appnav
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.Lifecycle
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.activeElement
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
+import com.google.common.truth.Truth
+import io.element.android.appnav.room.RoomLoadedFlowNode
+import io.element.android.features.messages.api.MessagesEntryPoint
+import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
+import io.element.android.libraries.architecture.childNode
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
+import org.junit.Rule
+import org.junit.Test
+
+class RoomFlowNodeTest {
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private class FakeMessagesEntryPoint : MessagesEntryPoint {
+
+ var nodeId: String? = null
+ var callback: MessagesEntryPoint.Callback? = null
+
+ override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node {
+ return node(buildContext) {}.also {
+ nodeId = it.id
+ this.callback = callback
+ }
+ }
+ }
+
+ private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint {
+
+ var nodeId: String? = null
+
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ inputs: RoomDetailsEntryPoint.Inputs,
+ plugins: List
+ ): Node {
+ return node(buildContext) {}.also {
+ nodeId = it.id
+ }
+ }
+ }
+
+ private fun aRoomFlowNode(
+ plugins: List,
+ messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
+ roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
+ ) = RoomLoadedFlowNode(
+ buildContext = BuildContext.root(savedStateMap = null),
+ plugins = plugins,
+ messagesEntryPoint = messagesEntryPoint,
+ roomDetailsEntryPoint = roomDetailsEntryPoint,
+ appNavigationStateService = FakeAppNavigationStateService(),
+ roomMembershipObserver = RoomMembershipObserver()
+ )
+
+ @Test
+ fun `given a room flow node when initialized then it loads messages entry point`() {
+ // GIVEN
+ val room = FakeMatrixRoom()
+ val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
+ val inputs = RoomLoadedFlowNode.Inputs(room)
+ val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint)
+ // WHEN
+ val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
+
+ // THEN
+ Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages)
+ roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
+ val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!!
+ Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
+ }
+
+ @Test
+ fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() {
+ // GIVEN
+ val room = FakeMatrixRoom()
+ val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
+ val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
+ val inputs = RoomLoadedFlowNode.Inputs(room)
+ val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint)
+ val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
+ // WHEN
+ fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()
+ // THEN
+ roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
+ val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!!
+ Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
new file mode 100644
index 0000000000..0efa9e7f3b
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.appnav
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.appnav.root.RootPresenter
+import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
+import io.element.android.features.rageshake.impl.detection.DefaultRageshakeDetectionPresenter
+import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
+import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
+import io.element.android.features.rageshake.test.rageshake.FakeRageShake
+import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
+import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
+import io.element.android.services.apperror.api.AppErrorState
+import io.element.android.services.apperror.api.AppErrorStateService
+import io.element.android.services.apperror.impl.DefaultAppErrorStateService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class RootPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.crashDetectionState.crashDetected).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - passes app error state`() = runTest {
+ val presenter = createPresenter(
+ appErrorService = DefaultAppErrorStateService().apply {
+ showError("Bad news", "Something bad happened")
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+
+ val initialState = awaitItem()
+ assertThat(initialState.errorState).isInstanceOf(AppErrorState.Error::class.java)
+ val initialErrorState = initialState.errorState as AppErrorState.Error
+ assertThat(initialErrorState.title).isEqualTo("Bad news")
+ assertThat(initialErrorState.body).isEqualTo("Something bad happened")
+
+ initialErrorState.dismiss()
+ assertThat(awaitItem().errorState).isInstanceOf(AppErrorState.NoError::class.java)
+ }
+ }
+
+ private fun createPresenter(
+ appErrorService: AppErrorStateService = DefaultAppErrorStateService()
+ ): RootPresenter {
+ val crashDataStore = FakeCrashDataStore()
+ val rageshakeDataStore = FakeRageshakeDataStore()
+ val rageshake = FakeRageShake()
+ val screenshotHolder = FakeScreenshotHolder()
+ val crashDetectionPresenter = DefaultCrashDetectionPresenter(
+ crashDataStore = crashDataStore
+ )
+ val rageshakeDetectionPresenter = DefaultRageshakeDetectionPresenter(
+ screenshotHolder = screenshotHolder,
+ rageShake = rageshake,
+ preferencesPresenter = DefaultRageshakePreferencesPresenter(
+ rageshake = rageshake,
+ rageshakeDataStore = rageshakeDataStore,
+ )
+ )
+ return RootPresenter(
+ crashDetectionPresenter = crashDetectionPresenter,
+ rageshakeDetectionPresenter = rageshakeDetectionPresenter,
+ appErrorStateService = appErrorService,
+ )
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
new file mode 100644
index 0000000000..83bda0ad82
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.appnav.loggedin
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
+import io.element.android.libraries.push.api.PushService
+import io.element.android.libraries.pushproviders.api.Distributor
+import io.element.android.libraries.pushproviders.api.PushProvider
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LoggedInPresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.permissionsState.permission).isEmpty()
+ }
+ }
+
+ private fun createPresenter(): LoggedInPresenter {
+ return LoggedInPresenter(
+ matrixClient = FakeMatrixClient(),
+ permissionsPresenterFactory = object : PermissionsPresenter.Factory {
+ override fun create(permission: String): PermissionsPresenter {
+ return NoopPermissionsPresenter()
+ }
+ },
+ pushService = object : PushService {
+ override fun notificationStyleChanged() {
+ }
+
+ override fun getAvailablePushProviders(): List {
+ return emptyList()
+ }
+
+ override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
+ }
+
+ override suspend fun testPush() {
+ }
+ }
+ )
+ }
+}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt
new file mode 100644
index 0000000000..17b6f6deb9
--- /dev/null
+++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.appnav.room
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LoadingRoomStateFlowFactoryTest {
+
+ @Test
+ fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
+ val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID)
+ val matrixClient = FakeMatrixClient(A_SESSION_ID).apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
+ flowFactory
+ .create(this, A_ROOM_ID)
+ .test {
+ Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
+ Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
+ }
+ }
+
+ @Test
+ fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
+ val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID)
+ val roomSummaryDataSource = FakeRoomSummaryDataSource()
+ val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource)
+ val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
+ flowFactory
+ .create(this, A_ROOM_ID)
+ .test {
+ Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
+ matrixClient.givenGetRoomResult(A_ROOM_ID, room)
+ roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1))
+ Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
+ }
+ }
+
+ @Test
+ fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource()
+ val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource)
+ val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
+ flowFactory
+ .create(this, A_ROOM_ID)
+ .test {
+ Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
+ roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1))
+ Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
+ }
+ }
+
+
+
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000000..c03881144e
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,337 @@
+import kotlinx.kover.api.KoverTaskExtension
+import org.jetbrains.kotlin.cli.common.toBooleanLenient
+
+buildscript {
+ dependencies {
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22")
+ classpath("com.google.gms:google-services:4.3.15")
+ }
+}
+
+/*
+ * 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.
+ */
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.anvil) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.kapt) apply false
+ alias(libs.plugins.dependencycheck) apply false
+ alias(libs.plugins.detekt)
+ alias(libs.plugins.ktlint)
+ alias(libs.plugins.dependencygraph)
+ alias(libs.plugins.sonarqube)
+ alias(libs.plugins.kover)
+}
+
+tasks.register("clean").configure {
+ delete(rootProject.buildDir)
+}
+
+allprojects {
+ // Detekt
+ apply {
+ plugin("io.gitlab.arturbosch.detekt")
+ }
+ detekt {
+ // preconfigure defaults
+ buildUponDefaultConfig = true
+ // activate all available (even unstable) rules.
+ allRules = true
+ // point to your custom config defining rules to run, overwriting default behavior
+ config = files("$rootDir/tools/detekt/detekt.yml")
+ }
+ dependencies {
+ detektPlugins("io.nlopez.compose.rules:detekt:0.1.12")
+ }
+
+ // KtLint
+ apply {
+ plugin("org.jlleitschuh.gradle.ktlint")
+ }
+
+ // See https://github.com/JLLeitschuh/ktlint-gradle#configuration
+ configure {
+ // See https://github.com/pinterest/ktlint/releases/
+ // TODO Regularly check for new version here ^
+ version.set("0.48.2")
+ android.set(true)
+ ignoreFailures.set(false)
+ enableExperimentalRules.set(true)
+ // display the corresponding rule
+ verbose.set(true)
+ reporters {
+ reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN)
+ // To have XML report for Danger
+ reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
+ }
+ filter {
+ exclude { element -> element.file.path.contains("$buildDir/generated/") }
+ }
+ }
+ // Dependency check
+ apply {
+ plugin("org.owasp.dependencycheck")
+ }
+}
+
+// To run a sonar analysis:
+// Run './gradlew sonar -Dsonar.login='
+// The SONAR_LOGIN is stored in passbolt as Token Sonar Cloud Bma
+// Sonar result can be found here: https://sonarcloud.io/project/overview?id=vector-im_element-x-android
+sonar {
+ properties {
+ property("sonar.projectName", "element-x-android")
+ property("sonar.projectKey", "vector-im_element-x-android")
+ property("sonar.host.url", "https://sonarcloud.io")
+ property("sonar.projectVersion", "1.0") // TODO project(":app").android.defaultConfig.versionName)
+ property("sonar.sourceEncoding", "UTF-8")
+ property("sonar.links.homepage", "https://github.com/vector-im/element-x-android/")
+ property("sonar.links.ci", "https://github.com/vector-im/element-x-android/actions")
+ property("sonar.links.scm", "https://github.com/vector-im/element-x-android/")
+ property("sonar.links.issue", "https://github.com/vector-im/element-x-android/issues")
+ property("sonar.organization", "new_vector_ltd_organization")
+ property("sonar.login", if (project.hasProperty("SONAR_LOGIN")) project.property("SONAR_LOGIN")!! else "invalid")
+
+ // exclude source code from analyses separated by a colon (:)
+ // Exclude Java source
+ property("sonar.exclusions", "**/BugReporterMultipartBody.java")
+ }
+}
+
+allprojects {
+ val projectDir = projectDir.toString()
+ sonar {
+ properties {
+ // Note: folders `kotlin` are not supported (yet), I asked on their side: https://community.sonarsource.com/t/82824
+ // As a workaround provide the path in `sonar.sources` property.
+ if (File("$projectDir/src/main/kotlin").exists()) {
+ property("sonar.sources", "src/main/kotlin")
+ }
+ if (File("$projectDir/src/test/kotlin").exists()) {
+ property("sonar.tests", "src/test/kotlin")
+ }
+ }
+ }
+}
+
+allprojects {
+ tasks.withType {
+ maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
+
+ val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) }
+ if (isScreenshotTest) {
+ // Increase heap size for screenshot tests
+ maxHeapSize = "1g"
+ } else {
+ // Disable screenshot tests by default
+ exclude("**/ScreenshotTest*")
+ }
+ }
+}
+
+allprojects {
+ apply(plugin = "kover")
+}
+
+// https://kotlin.github.io/kotlinx-kover/
+// Run `./gradlew koverMergedHtmlReport` to get report at ./build/reports/kover
+// Run `./gradlew koverMergedReport` to also get XML report
+koverMerged {
+ enable()
+
+ filters {
+ classes {
+ excludes.addAll(
+ listOf(
+ // Exclude generated classes.
+ "*_ModuleKt",
+ "anvil.hint.binding.io.element.*",
+ "anvil.hint.merge.*",
+ "anvil.module.*",
+ "com.airbnb.android.showkase*",
+ "io.element.android.libraries.designsystem.showkase.*",
+ "*_Factory",
+ "*_Factory$*",
+ "*_Module",
+ "*_Module$*",
+ "*Module_Provides*",
+ "Dagger*Component*",
+ "*ComposableSingletons$*",
+ "*_AssistedFactory_Impl*",
+ "*BuildConfig",
+ // Generated by Showkase
+ "*Ioelementandroid*PreviewKt$*",
+ "*Ioelementandroid*PreviewKt",
+ // Other
+ // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
+ "*Node",
+ "*Node$*",
+ )
+ )
+ }
+
+ annotations {
+ excludes.addAll(
+ listOf(
+ "*Preview",
+ )
+ )
+ }
+
+ projects {
+ excludes.addAll(
+ listOf(
+ ":anvilannotations",
+ ":anvilcodegen",
+ ":samples:minimal",
+ ":tests:testutils",
+ )
+ )
+ }
+ }
+
+ // Run ./gradlew koverMergedVerify to check the rules.
+ verify {
+ // Does not seems to work, so also run the task manually on the workflow.
+ onCheck.set(true)
+ // General rule: minimum code coverage.
+ rule {
+ name = "Global minimum code coverage."
+ target = kotlinx.kover.api.VerificationTarget.ALL
+ bound {
+ minValue = 55
+ // Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
+ // For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
+ // minValue to 25 and maxValue to 35.
+ maxValue = 65
+ counter = kotlinx.kover.api.CounterType.INSTRUCTION
+ valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
+ }
+ }
+ // Rule to ensure that coverage of Presenters is sufficient.
+ rule {
+ name = "Check code coverage of presenters"
+ target = kotlinx.kover.api.VerificationTarget.CLASS
+ overrideClassFilter {
+ includes += "*Presenter"
+ excludes += "*Fake*Presenter"
+ excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
+ }
+ bound {
+ minValue = 85
+ counter = kotlinx.kover.api.CounterType.INSTRUCTION
+ valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
+ }
+ }
+ // Rule to ensure that coverage of States is sufficient.
+ rule {
+ name = "Check code coverage of states"
+ target = kotlinx.kover.api.VerificationTarget.CLASS
+ overrideClassFilter {
+ includes += "^*State$"
+ excludes += "io.element.android.appnav.root.RootNavState*"
+ excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
+ excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
+ excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
+ excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
+ excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
+ excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
+ excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
+ excludes += "io.element.android.features.location.impl.map.MapState*"
+ excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
+ excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
+ excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*"
+ excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*"
+ excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*"
+ excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState"
+ excludes += "io.element.android.libraries.maplibre.compose.SymbolState*"
+ excludes += "io.element.android.features.ftue.api.state.*"
+ excludes += "io.element.android.features.ftue.impl.welcome.state.*"
+ }
+ bound {
+ minValue = 90
+ counter = kotlinx.kover.api.CounterType.INSTRUCTION
+ valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
+ }
+ }
+ // Rule to ensure that coverage of Views is sufficient (deactivated for now).
+ rule {
+ name = "Check code coverage of views"
+ target = kotlinx.kover.api.VerificationTarget.CLASS
+ overrideClassFilter {
+ includes += "*ViewKt"
+ }
+ bound {
+ // TODO Update this value, for now there are too many missing tests.
+ minValue = 0
+ counter = kotlinx.kover.api.CounterType.INSTRUCTION
+ valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
+ }
+ }
+ }
+}
+
+// When running on the CI, run only debug test variants
+val ciBuildProperty = "ci-build"
+val isCiBuild = if (project.hasProperty(ciBuildProperty)) {
+ val raw = project.property(ciBuildProperty) as? String
+ raw?.toBooleanLenient() == true || raw?.toIntOrNull() == 1
+} else {
+ false
+}
+if (isCiBuild) {
+ allprojects {
+ afterEvaluate {
+ tasks.withType().configureEach {
+ extensions.configure {
+ val enabled = name.contains("debug", ignoreCase = true)
+ isDisabled.set(!enabled)
+ }
+ }
+ }
+ }
+}
+
+// Register quality check tasks.
+tasks.register("runQualityChecks") {
+ project.subprojects {
+ // For some reason `findByName("lint")` doesn't work
+ tasks.findByPath("$path:lint")?.let { dependsOn(it) }
+ tasks.findByName("detekt")?.let { dependsOn(it) }
+ tasks.findByName("ktlintCheck")?.let { dependsOn(it) }
+ }
+ dependsOn(":app:knitCheck")
+}
+
+// Make sure to delete old screenshots before recording new ones
+subprojects {
+ val snapshotsDir = File("${project.projectDir}/src/test/snapshots")
+ val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") {
+ onlyIf { snapshotsDir.exists() }
+ doFirst {
+ println("Delete previous screenshots located at $snapshotsDir\n")
+ snapshotsDir.deleteRecursively()
+ }
+ }
+ tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask)
+ tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask)
+ tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask)
+}
diff --git a/changelog.d/.gitignore b/changelog.d/.gitignore
new file mode 100644
index 0000000000..b722e9e13e
--- /dev/null
+++ b/changelog.d/.gitignore
@@ -0,0 +1 @@
+!.gitignore
\ No newline at end of file
diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md
new file mode 100644
index 0000000000..9198137577
--- /dev/null
+++ b/docs/_developer_onboarding.md
@@ -0,0 +1,437 @@
+# Developer on boarding
+
+
+
+* [Introduction](#introduction)
+ * [Quick introduction to Matrix](#quick-introduction-to-matrix)
+ * [Matrix data](#matrix-data)
+ * [Room](#room)
+ * [Event](#event)
+ * [Sync](#sync)
+ * [Rust SDK](#rust-sdk)
+ * [Matrix Rust Component Kotlin](#matrix-rust-component-kotlin)
+ * [Build the SDK locally](#build-the-sdk-locally)
+ * [The Android project](#the-android-project)
+ * [Application](#application)
+ * [Jetpack Compose](#jetpack-compose)
+ * [Global architecture](#global-architecture)
+ * [Template and naming](#template-and-naming)
+ * [Push](#push)
+ * [Dependencies management](#dependencies-management)
+ * [Test](#test)
+ * [Code coverage](#code-coverage)
+ * [Other points](#other-points)
+ * [Logging](#logging)
+ * [Translations](#translations)
+ * [Rageshake](#rageshake)
+ * [Tips](#tips)
+* [Happy coding!](#happy-coding)
+
+
+
+## Introduction
+
+This doc is a quick introduction about the project and its architecture.
+
+It's aim is to help new developers to understand the overall project and where to start developing.
+
+Other useful documentation:
+
+- all the docs in this folder!
+- the [contributing doc](../CONTRIBUTING.md), that you should also read carefully.
+
+### Quick introduction to Matrix
+
+Matrix website: [matrix.org](https://matrix.org), [discover page](https://matrix.org/discover).
+*Note*: Matrix.org is also hosting a homeserver ([.well-known file](https://matrix.org/.well-known/matrix/client)).
+The reference homeserver (this is how Matrix servers are called) implementation is [Synapse](https://github.com/matrix-org/synapse/). But other implementations
+exist. The Matrix specification is here to ensure that any Matrix client, such as Element Android and its SDK can talk to any Matrix server.
+
+Have a quick look to the client-server API documentation: [Client-server documentation](https://spec.matrix.org/v1.3/client-server-api/). Other network API
+exist, the list is here: (https://spec.matrix.org/latest/)
+
+Matrix is an open source protocol. Change are possible and are tracked using [this GitHub repository](https://github.com/matrix-org/matrix-doc/). Changes to the
+protocol are called MSC: Matrix Spec Change. These are PullRequest to this project.
+
+Matrix object are Json data. Unstable prefixes must be used for Json keys when the MSC is not merged (i.e. accepted).
+
+#### Matrix data
+
+There are many object and data in the Matrix worlds. Let's focus on the most important and used, `Room` and `Event`
+
+##### Room
+
+`Room` is a place which contains ordered `Event`s. They are identified with their `room_id`. Nearly all the data are stored in rooms, and shared using
+homeserver to all the Room Member.
+
+*Note*: Spaces are also Rooms with a different `type`.
+
+##### Event
+
+`Events` are items of a Room, where data is embedded.
+
+There are 2 types of Room Event:
+
+- Regular Events: contain useful content for the user (message, image, etc.), but are not necessarily displayed as this in the timeline (reaction, message
+ edition, call signaling).
+- State Events: contain the state of the Room (name, topic, etc.). They have a non null value for the key `state_key`.
+
+Also all the Room Member details are in State Events: one State Event per member. In this case, the `state_key` is the matrixId (= userId).
+
+Important Fields of an Event:
+
+- `event_id`: unique across the Matrix universe;
+- `room_id`: the room the Event belongs to;
+- `type`: describe what the Event contain, especially in the `content` section, and how the SDK should handle this Event;
+- `content`: dynamic Event data; depends on the `type`.
+
+So we have a triple `event_id`, `type`, `state_key` which uniquely defines an Event.
+
+#### Sync
+
+This is managed by the Rust SDK.
+
+### Rust SDK
+
+The Rust SDK is hosted here: https://github.com/matrix-org/matrix-rust-sdk.
+
+This repository contains an implementation of a Matrix client-server library written in Rust.
+
+With some bindings we can embed this sdk inside other environments, like Swift or Kotlin, with the help of [Uniffi](https://github.com/mozilla/uniffi-rs).
+From these kotlin bindings we can generate native libs (.so files) and kotlin classes/interfaces.
+
+#### Matrix Rust Component Kotlin
+
+To use these bindings in an android project, we need to wrap this up into an android library (as the form of an .aar file).
+This is the goal of https://github.com/matrix-org/matrix-rust-components-kotlin.
+This repository is used for distributing kotlin releases of the Matrix Rust SDK.
+It'll provide the corresponding aar and also publish them on maven.
+
+Most of the time you want to use the releases made on maven with gradle:
+
+```groovy
+implementation("org.matrix.rustcomponents:sdk-android:latest-version")
+```
+
+You can also have access to the aars through the [release](https://github.com/matrix-org/matrix-rust-components-kotlin/releases) page.
+
+#### Build the SDK locally
+
+If you need to locally build the sdk-android you can use
+the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script.
+
+For this, you first need to ensure to setup :
+
+- rust environment (check https://rust-lang.github.io/rustup/ if needed)
+- cargo-ndk < 2.12.0
+```shell
+cargo install cargo-ndk --version 2.11.0
+```
+- android targets
+```shell
+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
+```
+- checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories
+```shell
+git clone git@github.com:matrix-org/matrix-rust-sdk.git
+git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git
+```
+
+Then you can launch the build script from the matrix-rust-components-kotlin repository with the following params:
+
+- `-p` Local path to the rust-sdk repository
+- `-o` Optional output path with the expected name of the aar file. By default the aar will be located in the corresponding build/outputs/aar directory.
+- `-r` Flag to build in release mode
+- `-m` Option to select the gradle module to build. Default is sdk.
+- `-t` Option to to select an android target to build against. Default will build for all targets.
+
+So for example to build the sdk against aarch64-linux-android target and copy the generated aar to ElementX project:
+
+```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
+```
+
+Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`:
+
+```groovy
+dependencies {
+ api(projects.libraries.rustsdk) // <- use the local version of the sdk. Uncomment this line.
+ //implementation(libs.matrix.sdk) // <- use the released version. Comment this line.
+}
+```
+
+You are good to test your local rust development now!
+
+### The Android project
+
+The project should compile out of the box.
+
+This Android project is a multi modules project.
+
+- `app` module is the Android application module. Other modules are libraries;
+- `features` modules contain some UI and can be seen as screen or flow of screens of the application;
+- `libraries` modules contain classes that can be useful for other modules to work.
+
+A few details about some modules:
+
+- `libraries-core` module contains utility classes;
+- `libraries-designsystem` module contains Composables which can be used across the app (theme, etc.);
+- `libraries-elementresources` module contains resource from Element Android (mainly strings);
+- `libraries-matrix` module contains wrappers around the Matrix Rust SDK.
+
+Most of the time a feature module should not know anything about other feature module.
+The navigation glue is currently done in the `app` module.
+
+Here is the current simplified module dependency graph:
+
+
+
+```mermaid
+flowchart TD
+ subgraph Application
+ app([:app])--implementation-->appnav([:appnav])
+ end
+ subgraph Features
+ featureapi([:features:*:api])
+ featureimpl([:features:*:impl])
+ end
+ subgraph Libraries
+ subgraph Matrix
+ matrixapi([:matrix:api])
+ matriximpl([:matrix:impl])
+ end
+ libraryarch([:libraries:architecture])
+ libraryapi([:libraries:*:api])
+ libraryimpl([:libraries:*:impl])
+ end
+ subgraph Matrix RustSdk
+ RustSdk([Rust Sdk])
+ end
+
+ app--implementation-->featureimpl
+ app--implementation-->libraryimpl
+ appnav--implementation-->featureapi
+ appnav--implementation-->libraryarch
+ featureimpl--api-->featureapi
+ featureimpl--implementation-->matrixapi
+ featureimpl--implementation-->libraryapi
+ featureimpl--implementation-->libraryarch
+ matriximpl--implementation-->matrixapi
+ matrixapi--api-->RustSdk
+ matriximpl--api-->RustSdk
+ featureapi--implementation-->libraryarch
+ libraryimpl--api-->libraryapi
+```
+
+### Application
+
+This Android project mainly handle the application layer of the whole software. The communication with the Matrix server, as well as the local storage, the
+cryptography (encryption and decryption of Event, key management, etc.) is managed by the Rust SDK.
+
+The application is responsible to store the session credentials though.
+
+#### Jetpack Compose
+
+Compose is essentially two libraries : Compose Compiler and Compose UI. The compiler (and his runtime) is actually not specific to UI at all and offer powerful
+state management APIs. See https://jakewharton.com/a-jetpack-compose-by-any-other-name/
+
+Some useful links:
+
+- https://developer.android.com/jetpack/compose/mental-model
+- https://developer.android.com/jetpack/compose/libraries
+- https://developer.android.com/jetpack/compose/modifiers-list
+- https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md#api-guidelines-for-jetpack-compose
+
+About Preview
+
+- https://alexzh.com/jetpack-compose-preview/
+
+#### Global architecture
+
+Main libraries and frameworks used in this application:
+
+- Navigation state with [Appyx](https://bumble-tech.github.io/appyx/). Please
+ watch [this video](https://www.droidcon.com/2022/11/15/model-driven-navigation-with-appyx-from-zero-to-hero/) to learn more about Appyx!
+- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil). Please
+ watch [this video](https://www.droidcon.com/2022/06/28/dagger-anvil-learning-to-love-dependency-injection/) to learn more about Anvil!
+- Reactive State management with Compose runtime and [Molecule](https://github.com/cashapp/molecule)
+
+Some patterns are inspired by [Circuit](https://slackhq.github.io/circuit/)
+
+Here are the main points:
+
+1. `Presenter` and `View` does not communicate with each other directly, but through `State` and `Event`
+2. Views are compose first
+3. Presenters are also compose first, and have a single `present(): State` method. It's using the power of compose-runtime/compiler.
+4. The point of connection between a `View` and a `Presenter` is a `Node`.
+5. A `Node` is also responsible for managing Dagger components if any.
+6. A `ParentNode` has some children `Node` and only know about them.
+7. This is a single activity full compose application. The `MainActivity` is responsible for holding and configuring the `RootNode`.
+8. There is no more needs for Android Architecture Component ViewModel as configuration change should be handled by Composable if needed.
+
+#### Template and naming
+
+This documentation provides you with the steps to install and use the AS plugin for generating modules in your project.
+The plugin and templates will help you quickly create new features with a standardized structure.
+
+A. Installation
+
+Follow these steps to install and configure the plugin and templates:
+
+1. Install the AS plugin for generating modules :
+ [Generate Module from Template](https://plugins.jetbrains.com/plugin/13586-generate-module-from-template)
+2. Import file templates in AS :
+ - Navigate to File/Manage IDE Settings/Import Settings
+ - Pick the `tools/templates/file_templates.zip` files
+ - Click on OK
+3. Configure generate-module-from-template plugin :
+ - Navigate to AS/Settings/Tools/Module Template Settings
+ - Click on + / Import From File
+ - Pick the `tools/templates/FeatureModule.json`
+
+Everything should be ready to use.
+
+B. Usage
+
+Example for a new feature called RoomDetails:
+
+1. Right-click on the features package and click on Create Module from Template
+2. Fill the 2 text fields like so:
+ - MODULE_NAME = roomdetails
+ - FEATURE_NAME = RoomDetails
+3. Click on Next
+4. Verify that the structure looks ok and click on Finish
+5. The modules api/impl should be created under `features/roomdetails` directory.
+6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
+7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
+ To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
+ Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
+
+
+Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
+suffix `Presenter`,states MUST have a suffix `State`, etc. Also we want to have a common naming along all the modules.
+
+### Push
+
+**Note** Firebase Push is not yet implemented on the project.
+
+Please see the dedicated [documentation](notifications.md) for more details.
+
+This is the classical scenario:
+
+- App receives a Push. Note: Push is ignored if app is in foreground;
+- App asks the SDK to load Event data (fastlane mode). We have a change to get the data faster and display the notification faster;
+- App asks the SDK to perform a sync request.
+
+### Dependencies management
+
+We are using [Gradle version catalog](https://docs.gradle.org/current/userguide/platforms.html#sub:central-declaration-of-dependencies) on this project.
+
+All the dependencies (including android artifact, gradle plugin, etc.) should be declared in [../gradle/libs.versions.toml](libs.versions.toml) file.
+Some dependency, mainly because they are not shared can be declared in `build.gradle.kts` files.
+
+[Renovate](https://github.com/apps/renovate) is set up on the project. This tool will automatically create Pull Request to upgrade our dependencies one by one. A [dependency dashboard issue](https://github.com/vector-im/element-x-android/issues/150) is maintained by the tool and allow to perform some actions.
+
+### Test
+
+We have 3 tests frameworks in place, and this should be sufficient to guarantee a good code coverage and limit regressions hopefully:
+
+- Maestro to test the global usage of the application. See the related [documentation](../.maestro/README.md).
+- Combination of [Showkase](https://github.com/airbnb/Showkase) and [Paparazzi](https://github.com/cashapp/paparazzi), to test UI pixel perfect. To add test,
+ just add `@Preview` for the composable you are adding. See the related [documentation](screenshot_testing.md) and see in the template the
+ file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewProvider to provide
+ different states. See for instance the
+ file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt)
+ - Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the
+ class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt).
+
+**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.
+
+### Code coverage
+
+[kover](https://github.com/Kotlin/kotlinx-kover) is used to compute code coverage. Only have unit tests can produce code coverage result. Running Maestro does
+not participate to the code coverage results.
+
+Kover configuration is defined in the main [build.gradle.kts](../build.gradle.kts) file.
+
+To compute the code coverage, run:
+
+```bash
+./gradlew koverMergedReport
+```
+
+and open the Html report: [../build/reports/kover/merged/html/index.html](../build/reports/kover/merged/html/index.html)
+
+To ensure that the code coverage threshold are OK, you can run
+
+```bash
+./gradlew koverMergedVerify
+```
+
+Note that the CI performs this check on every pull requests.
+
+Also, if the rule `Global minimum code coverage.` is in error because code coverage is `> maxValue`, `minValue` and `maxValue` can be updated for this rule in
+the file [build.gradle.kts](../build.gradle.kts) (you will see further instructions there).
+
+### Other points
+
+#### Logging
+
+**Important warning: ** NEVER log private user data, or use the flag `LOG_PRIVATE_DATA`. Be very careful when logging `data class`, all the content will be
+output!
+
+[Timber](https://github.com/JakeWharton/timber) is used to log data to logcat. We do not use directly the `Log` class. If possible please use a tag, as per
+
+````kotlin
+Timber.tag(loggerTag.value).d("my log")
+````
+
+because automatic tag (= class name) will not be available on the release version.
+
+Also generally it is recommended to provide the `Throwable` to the Timber log functions.
+
+Last point, note that `Timber.v` function may have no effect on some devices. Prefer using `Timber.d` and up.
+
+
+#### Translations
+
+Translations are handled through localazy. See [the dedicated README.md file](../tools/localazy/README.md) for information on how
+to configure new modules etc.
+
+#### Rageshake
+
+Rageshake is a feature to send bug report directly from the application. Just shake your phone and you will be prompted to send a bug report.
+
+Bug reports can contain:
+
+- a screenshot of the current application state
+- the application logs from up to 15 application starts
+- the logcat logs
+
+The data will be sent to an internal server, which is not publicly accessible. A GitHub issue will also be created to a private GitHub repository.
+
+Rageshake can be very useful to get logs from a release version of the application.
+
+### Tips
+
+- Element Android has a `developer mode` in the `Settings/Advanced settings`. Other useful options are available here; (TODO Not supported yet!)
+- Show hidden Events can also help to debug feature. When developer mode is enabled, it is possible to view the source (= the Json content) of any Events; (TODO
+ Not supported yet!)
+- Type `/devtools` in a Room composer to access a developer menu. There are some other entry points. Developer mode has to be enabled; (TODO Not supported yet!)
+- Hidden debug menu: when developer mode is enabled and on debug build, there are some extra screens that can be accessible using the green wheel. In those
+ screens, it will be possible to toggle some feature flags; (TODO Not supported yet!)
+- Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on
+ the screen can also help to find the running code in the codebase.
+- When this is possible, prefer using `sealed interface` instead of `sealed class`;
+- When writing temporary code, using the string "DO NOT COMMIT" in a comment can help to avoid committing things by mistake. If committed and pushed, the CI
+ will detect this String and will warn the user about it. (TODO Not supported yet!)
+- Very occasionally the gradle cache misbehaves and causes problems with Dagger. Try building with `--no-build-cache` if Dagger isn't behaving how you expect.
+
+## Happy coding!
+
+The team is here to support you, feel free to ask anything to other developers.
+
+Also please feel free to update this documentation, if incomplete/wrong/obsolete/etc.
+
+**Thanks!**
diff --git a/docs/analytics.md b/docs/analytics.md
new file mode 100644
index 0000000000..b3f592c227
--- /dev/null
+++ b/docs/analytics.md
@@ -0,0 +1,11 @@
+# Analytics in Element
+
+
+
+* [TODO](#todo)
+
+
+
+## TODO
+
+There is no analytics in the project yet.
diff --git a/docs/danger.md b/docs/danger.md
new file mode 100644
index 0000000000..e6fa74dec2
--- /dev/null
+++ b/docs/danger.md
@@ -0,0 +1,106 @@
+## Danger
+
+
+
+* [What does danger checks](#what-does-danger-checks)
+ * [PR check](#pr-check)
+ * [Quality check](#quality-check)
+* [Setup](#setup)
+* [Run danger locally](#run-danger-locally)
+* [Danger user](#danger-user)
+* [Useful links](#useful-links)
+
+
+
+## What does danger checks
+
+### PR check
+
+See the [dangerfile](../tools/danger/dangerfile.js). If you add rules in the dangerfile, please update the list below!
+
+Here are the checks that Danger does so far:
+
+- PR description is not empty
+- Big PR got a warning to recommend to split
+- PR contains a file for towncrier and extension is checked
+- PR does not modify frozen classes
+- PR contains a Sign-Off, with exception for Element employee contributors
+- PR with change on layout should include screenshot in the description (TODO Not supported yet!)
+- PR which adds png file warn about the usage of vector drawables
+- non draft PR should have a reviewer
+- files containing translations are not modified by developers
+
+### Quality check
+
+After all the checks that generate checkstyle XML report, such as Ktlint, lint, or Detekt, Danger is run with this [dangerfile](../tools/danger/dangerfile-lint.js), in order to post comments to the PR with the detected error and warnings.
+
+To run locally, you will have to install the plugin `danger-plugin-lint-report` using:
+
+```shell
+yarn add danger-plugin-lint-report --dev
+```
+
+## Setup
+
+This operation should not be necessary, since Danger is already setup for the project.
+
+To setup danger to the project, run:
+
+```shell
+bundle exec danger init
+```
+
+## Run danger locally
+
+When modifying the [dangerfile](../tools/danger/dangerfile.js), you can check it by running Danger locally.
+
+To run danger locally, install it and run:
+
+```shell
+bundle exec danger pr --dangerfile=./tools/danger/dangerfile.js
+```
+
+For instance:
+
+```shell
+bundle exec danger pr https://github.com/vector-im/element-android/pull/6637 --dangerfile=./tools/danger/dangerfile.js
+```
+
+We may need to create a GitHub token to have less API rate limiting, and then set the env var:
+
+```shell
+export DANGER_GITHUB_API_TOKEN='YOUR_TOKEN'
+```
+
+Swift and Kotlin (just in case)
+
+```shell
+bundle exec danger-swift pr --dangerfile=./tools/danger/dangerfile.js
+bundle exec danger-kotlin pr --dangerfile=./tools/danger/dangerfile.js
+```
+
+## Danger user
+
+To let Danger check all the PRs, including PRs form forks, a GitHub account have been created:
+- login: ElementBot
+- password: Stored on Passbolt
+- GitHub token: A token with limited access has been created and added to the repository https://github.com/vector-im/element-android as secret DANGER_GITHUB_API_TOKEN. This token is not saved anywhere else. In case of problem, just delete it and create a new one, then update the secret.
+
+PRs from forks do not always have access to the secret `secrets.DANGER_GITHUB_API_TOKEN`, so `secrets.GITHUB_TOKEN` is also provided to the job environment. If `secrets.DANGER_GITHUB_API_TOKEN` is available, it will be used, so user `ElementBot` will comment the PR. Else `secrets.GITHUB_TOKEN` will be used, and bot `github-actions` will comment the PR.
+
+## Useful links
+
+- https://danger.systems/
+- https://danger.systems/js/
+- https://danger.systems/js/guides/getting_started.html
+- https://danger.systems/js/reference.html
+- https://github.com/danger/awesome-danger
+
+Some danger files to get inspired from
+
+- https://github.com/artsy/emission/blob/master/dangerfile.ts
+- https://github.com/facebook/react-native/blob/master/bots/dangerfile.js
+- https://github.com/apollographql/apollo-client/blob/master/config/dangerfile.ts
+- https://github.com/styleguidist/react-styleguidist/blob/master/dangerfile.js
+- https://github.com/storybooks/storybook/blob/master/dangerfile.js
+- https://github.com/ReactiveX/rxjs/blob/master/dangerfile.js
diff --git a/docs/design.md b/docs/design.md
new file mode 100644
index 0000000000..58723eb28d
--- /dev/null
+++ b/docs/design.md
@@ -0,0 +1,161 @@
+# Element Android design
+
+
+
+* [Introduction](#introduction)
+* [How to import from Figma to the Element Android project](#how-to-import-from-figma-to-the-element-android-project)
+ * [Colors](#colors)
+ * [Text](#text)
+ * [Dimension, position and margin](#dimension-position-and-margin)
+ * [Icons](#icons)
+ * [Custom icons](#custom-icons)
+ * [Export drawable from Figma](#export-drawable-from-figma)
+ * [Import in Android Studio](#import-in-android-studio)
+ * [Images](#images)
+* [Figma links](#figma-links)
+ * [Compound](#compound)
+ * [Login](#login)
+ * [Login v2](#login-v2)
+ * [Room list](#room-list)
+ * [Timeline](#timeline)
+ * [Voice message](#voice-message)
+ * [Room settings](#room-settings)
+ * [VoIP](#voip)
+ * [Presence](#presence)
+ * [Spaces](#spaces)
+ * [List to be continued...](#list-to-be-continued)
+
+
+
+**TODO This documentation is a bit outdated and must be updated when we will set up the design components.**
+
+## Introduction
+
+Design at element.io is done using Figma - https://www.figma.com
+You will find guidance to build using interface on the [Compound documentation – Element's design system](https://compound.element.io)
+
+## How to import from Figma to the Element Android project
+
+Integration should be done using the Android development best practice, and should follow the existing convention in the code.
+
+### Colors
+
+Element Android already contains all the colors which can be used by the designer, in the module `ui-style`.
+Some of them depend on the theme, so ensure to use theme attributes and not colors directly.
+
+A comprehensive [color definition documentation](https://compound.element.io/?path=/docs/tokens-color-palettes--docs) is available in Compound.
+
+
+### Text
+
+ - click on a text on Figma
+ - on the right panel, information about the style and colors are displayed
+ - in Element Android, text style are already defined, generally you should not create new style
+ - apply the style and the color to the layout
+
+### Dimension, position and margin
+
+ - click on an item on Figma
+ - dimensions of the item will be displayed.
+ - move the mouse to other items to get relative positioning, margin, etc.
+
+### Icons
+
+Most icons should be available as part of the [Compound icon library](https://compound.element.io/?path=/docs/tokens-icons--docs)
+
+All drawable are auto-generated as part of the design tokens library. You can find
+all assets in [`vector-im/compound-design-tokens#assets/android`](https://github.com/vector-im/compound-design-tokens/tree/develop/assets/android)
+
+If you are missing an icon, follow to [contribution guidelines for icons](https://www.figma.com/file/gkNXqPoiJhEv2wt0EJpew4/Compound-Icons?type=design&node-id=178-3119&t=j2uSJD9xPXJn5aRM-0)
+
+#### Custom icons
+
+##### Export drawable from Figma
+
+ - click on the element to export
+ - ensure that the correct layer is selected. Sometimes the parent layer has to be selected on the left panel
+ - on the right panel, click on "export"
+ - select SVG
+ - you can check the preview of what will be exported
+ - click on "export" and save the file locally
+ - unzip the file if necessary
+
+It's also possible for any icon to go to the main component by right-clicking on the icon.
+
+##### Import in Android Studio
+
+ - right click on the drawable folder where the drawable will be created
+ - click on "New"/"Vector Asset"
+ - select the exported file
+ - update the filename if necessary
+ - click on "Next" and click on "Finish"
+ - open the created vector drawable
+ - optionally update the color(s) to "#FF0000" (red) to ensure that the drawable is correctly tinted at runtime.
+
+### Images
+
+Android 4.3 (18+) fully supports the WebP image format which can often provide smaller image sizes without drastically impacting image quality (depending on the output encoding quality).
+When importing non vector images, WebP is the preferred format.
+
+Images can be converted to the WebP within Android Studio by
+ - right clicking the image file within the project file explorer
+ - select `Convert to WebP`
+
+https://developer.android.com/studio/write/convert-webp
+
+## Figma links
+
+Figma links can be included in the layout, for future reference, but it is also OK to add a paragraph below here, to centralize the information
+
+Main entry point: https://www.figma.com/files/project/5612863/Element?fuid=779371459522484071
+
+Note: all the Figma links are not publicly available.
+
+### Compound
+
+Compound is Element's design system where you'll find styles and documentation
+regarding user interfaces.
+
+- Documentation: [https://compound.element.io](https://compound.element.io)
+- [Compound Android – Figma document](https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components)
+- [Compound Styles - Figma document](https://www.figma.com/file/PpKepmHKGikp33Ql7iivbn/Compound-Styles?type=design)
+- [Compound Icons - Figma document](https://www.figma.com/file/gkNXqPoiJhEv2wt0EJpew4/Compound-Icons)
+
+### Login
+
+TBD
+
+#### Login v2
+
+https://www.figma.com/file/xdV4PuI3DlzA1EiBvbrggz/Login-Flow-v2
+
+### Room list
+
+TBD
+
+### Timeline
+
+https://www.figma.com/file/x1HYYLYMmbYnhfoz2c2nGD/%5BRiotX%5D-Misc?node-id=0%3A1
+
+### Voice message
+
+https://www.figma.com/file/uaWc62Ux2DkZC4OGtAGcNc/Voice-Messages?node-id=473%3A12
+
+### Room settings
+
+TBD
+
+### VoIP
+
+https://www.figma.com/file/V6m2z0oAtUV1l8MdyIrAep/VoIP?node-id=4254%3A25767
+
+### Presence
+
+https://www.figma.com/file/qmvEskET5JWva8jZJ4jX8o/Presence---User-Status?node-id=114%3A9174
+(Option B is chosen)
+
+### Spaces
+
+https://www.figma.com/file/m7L63aGPW7iHnIYStfdxCe/Spaces?node-id=192%3A30161
+
+### List to be continued...
diff --git a/docs/images/module_graph.png b/docs/images/module_graph.png
new file mode 100644
index 0000000000..3be0256646
Binary files /dev/null and b/docs/images/module_graph.png differ
diff --git a/docs/images/screen1.png b/docs/images/screen1.png
new file mode 100644
index 0000000000..9f9d7747ff
Binary files /dev/null and b/docs/images/screen1.png differ
diff --git a/docs/images/screen2.png b/docs/images/screen2.png
new file mode 100644
index 0000000000..a5733003d6
Binary files /dev/null and b/docs/images/screen2.png differ
diff --git a/docs/images/screen3.png b/docs/images/screen3.png
new file mode 100644
index 0000000000..3edb49d086
Binary files /dev/null and b/docs/images/screen3.png differ
diff --git a/docs/images/screen4.png b/docs/images/screen4.png
new file mode 100644
index 0000000000..53da801a1b
Binary files /dev/null and b/docs/images/screen4.png differ
diff --git a/docs/installing_from_ci.md b/docs/installing_from_ci.md
new file mode 100644
index 0000000000..634ee905ab
--- /dev/null
+++ b/docs/installing_from_ci.md
@@ -0,0 +1,49 @@
+## Installing from CI
+
+
+
+ * [Installing from GitHub](#installing-from-github)
+ * [Create a GitHub token](#create-a-github-token)
+ * [Provide artifact URL](#provide-artifact-url)
+ * [Next steps](#next-steps)
+ * [Future improvement](#future-improvement)
+
+
+
+Installing APK build by the CI is possible
+
+### Installing from GitHub
+
+TODO Import the script from Element Android and make it work, then update this documentation.
+
+To install an APK built by a GitHub action, run the script `./tools/install/installFromGitHub.sh`. You will need to pass a GitHub token to do so.
+
+#### Create a GitHub token
+
+You can create a GitHub token going to your Github account, at this page: [https://github.com/settings/tokens](https://github.com/settings/tokens).
+
+You need to create a token (classic) with the scope `repo/public_repo`. So just check the corresponding checkbox.
+Validity can be long since the scope of this token is limited. You will still be able to delete the token and generate a new one.
+Click on Generate token and save the token locally.
+
+### Provide artifact URL
+
+The script will ask for an artifact URL. You can get this artifact URL by following these steps:
+
+- open the pull request
+- in the check at the bottom, click on `APK Build / Build debug APKs`
+- click on `Summary`
+- scroll to the bottom of the page
+- copy the link `vector-Fdroid-debug` if you want the F-Droid variant or `vector-Gplay-debug` if you want the Gplay variant.
+
+The copied link can be provided to the script.
+
+### Next steps
+
+The script will download the artifact, unzip it and install the correct version (regarding arch) on your device.
+
+Files will be added to the folder `./tmp/DebugApks`. Feel free to cleanup this folder from time to time, the script will not delete files.
+
+### Future improvement
+
+The script could ask the user for a Pull Request number and Gplay/Fdroid choice like it was done with Buildkite script. Using GitHub API may be possible to do that.
diff --git a/docs/integration_tests.md b/docs/integration_tests.md
new file mode 100644
index 0000000000..b5a830e7ff
--- /dev/null
+++ b/docs/integration_tests.md
@@ -0,0 +1,131 @@
+# Integration tests
+
+
+
+* [Pre requirements](#pre-requirements)
+* [Install and run Synapse](#install-and-run-synapse)
+* [Run the test](#run-the-test)
+* [Stop Synapse](#stop-synapse)
+* [Troubleshoot](#troubleshoot)
+ * [Android Emulator does cannot reach the homeserver](#android-emulator-does-cannot-reach-the-homeserver)
+ * [Tests partially run but some fail with "Unable to contact localhost:8080"](#tests-partially-run-but-some-fail-with-"unable-to-contact-localhost:8080")
+ * [virtualenv command fails](#virtualenv-command-fails)
+
+
+
+Integration tests are useful to ensure that the code works well for any use cases.
+
+They can also be used as sample on how to use the Matrix SDK.
+
+In a ideal world, every API of the SDK should be covered by integration tests. For the moment, we have test mainly for the Crypto part, which is the tricky part. But it covers quite a lot of features: accounts creation, login to existing account, send encrypted messages, keys backup, verification, etc.
+
+The Matrix SDK is able to open multiple sessions, for the same user, of for different users. This way we can test communication between several sessions on a single device.
+
+## Pre requirements
+
+Integration tests need a homeserver running on localhost.
+
+The documentation describes what we do to have one, using [Synapse](https://github.com/matrix-org/synapse/), which is the Matrix reference homeserver.
+
+## Install and run Synapse
+
+Steps:
+
+- Install virtualenv
+
+```bash
+python3 -m pip install virtualenv
+```
+
+- Clone Synapse repository
+
+```bash
+git clone -b develop https://github.com/matrix-org/synapse.git
+```
+or
+```bash
+git clone -b develop git@github.com:matrix-org/synapse.git
+```
+
+You should have the develop branch cloned by default.
+
+- Run synapse, from the Synapse folder you just cloned
+
+```bash
+virtualenv -p python3 env
+source env/bin/activate
+pip install -e .
+demo/start.sh --no-rate-limit
+
+```
+
+Alternatively, to install the latest Synapse release package (and not a cloned branch) you can run the following instead of `git clone` and `pip install -e .`:
+
+```bash
+pip install matrix-synapse
+```
+
+On your first run, you will want to stop the demo and edit the config to correct the `public_baseurl` to http://10.0.2.2:8080 and restart the server.
+
+You should now have 3 running federated Synapse instances 🎉, at http://127.0.0.1:8080/, http://127.0.0.1:8081/ and http://127.0.0.1:8082/, which should display a "It Works! Synapse is running" message.
+
+## Run the test
+
+It's recommended to run tests using an Android Emulator and not a real device. First reason for that is that the tests will use http://10.0.2.2:8080 to connect to Synapse, which run locally on your machine.
+
+You can run all the tests in the `androidTest` folders.
+
+It can be done using this command:
+
+```bash
+./gradlew vector:connectedAndroidTest matrix-sdk-android:connectedAndroidTest
+```
+
+## Stop Synapse
+
+To stop Synapse, you can run the following commands:
+
+```bash
+./demo/stop.sh
+```
+
+And you can deactivate the virtualenv:
+
+```bash
+deactivate
+```
+
+## Troubleshoot
+
+You'll need python3 to be able to run synapse
+
+### Android Emulator does cannot reach the homeserver
+
+Try on the Emulator browser to open "http://10.0.2.2:8080". You should see the "Synapse is running" message.
+
+### Tests partially run but some fail with "Unable to contact localhost:8080"
+
+This is because the `public_baseurl` of synapse is not consistent with the endpoint that the tests are connecting to.
+
+Ensure you have the following configuration in `demo/etc/8080.config`.
+
+```
+public_baseurl: http://10.0.2.2:8080/
+```
+
+After changing this you will need to restart synapse using `demo/stop.sh` and `demo/start.sh` to load the new configuration.
+
+### virtualenv command fails
+
+You can try using
+```bash
+python3 -m venv env
+```
+or
+```bash
+python3 -m virtualenv env
+```
+instead of
+```bash
+virtualenv -p python3 env
+```
diff --git a/docs/maps.md b/docs/maps.md
new file mode 100644
index 0000000000..cc00905986
--- /dev/null
+++ b/docs/maps.md
@@ -0,0 +1,42 @@
+# Use of maps
+
+
+
+* [Overview](#overview)
+* [Local development with MapTiler](#local-development-with-maptiler)
+* [Making releasable builds with MapTiler](#making-releasable-builds-with-maptiler)
+* [Using other map sources or MapTiler styles](#using-other-map-sources-or-maptiler-styles)
+
+
+
+## Overview
+
+Element Android uses [MapTiler](https://www.maptiler.com/) to provide map
+imagery where required. MapTiler requires an API key, which we bake in to
+the app at release time.
+
+## Local development with MapTiler
+
+If you're developing the application and want maps to render properly you can
+sign up for the [MapTiler free tier](https://www.maptiler.com/cloud/pricing/).
+
+Place your API key in `local.properties` with the key
+`services.maptiler.apikey`, e.g.:
+
+```properties
+services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
+```
+
+## Making releasable builds with MapTiler
+
+To insert the MapTiler API key when building an APK, set the
+`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
+environment.
+
+## Using other map sources or MapTiler styles
+
+If you wish to use an alternative map provider, or custom MapTiler styles,
+you can customise the functions in
+`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
+We've kept this file small and self contained to minimise the chances of merge
+collisions in forks.
diff --git a/docs/nightly_build.md b/docs/nightly_build.md
new file mode 100644
index 0000000000..9abd59a67b
--- /dev/null
+++ b/docs/nightly_build.md
@@ -0,0 +1,52 @@
+# Nightly builds
+
+
+
+* [Configuration](#configuration)
+* [How to register to get nightly build](#how-to-register-to-get-nightly-build)
+* [Build nightly manually](#build-nightly-manually)
+
+
+
+## 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.)
+
+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).
+
+*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.
+
+## How to register to get nightly build
+
+Click on this link and follow the instruction: [https://appdistribution.firebase.dev/i/7de2dbc61e7fb2a6](https://appdistribution.firebase.dev/i/7de2dbc61e7fb2a6)
+
+## Build nightly manually
+
+Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`:
+
+```
+signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT
+signing.element.nightly.keyId=VALUE_FROM_PASSBOLT
+signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT
+```
+
+You will also need to add the environment variable `FIREBASE_TOKEN`:
+
+```sh
+export FIREBASE_TOKEN=VALUE_FROM_PASSBOLT
+```
+
+Then you can run the following commands (which are also used in the file for [the GitHub action](../.github/workflows/nightly.yml)):
+
+```sh
+git checkout develop
+mv towncrier.toml towncrier.toml.bak
+sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml
+rm towncrier.toml.bak
+yes n | towncrier build --version nightly
+./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES
+```
+
+Then you can reset the change on the codebase.
diff --git a/docs/notifications.md b/docs/notifications.md
new file mode 100644
index 0000000000..612b8785b8
--- /dev/null
+++ b/docs/notifications.md
@@ -0,0 +1,284 @@
+This document aims to describe how Element android displays notifications to the end user. It also clarifies notifications and background settings in the app.
+
+# Table of Contents
+
+
+
+* [Prerequisites Knowledge](#prerequisites-knowledge)
+ * [How does a matrix client get a message from a homeserver?](#how-does-a-matrix-client-get-a-message-from-a-homeserver?)
+ * [How does a mobile app receives push notification](#how-does-a-mobile-app-receives-push-notification)
+ * [Push VS Notification](#push-vs-notification)
+ * [Push in the matrix federated world](#push-in-the-matrix-federated-world)
+ * [How does the homeserver know when to notify a client?](#how-does-the-homeserver-know-when-to-notify-a-client?)
+ * [Push vs privacy, and mitigation](#push-vs-privacy-and-mitigation)
+ * [Background processing limitations](#background-processing-limitations)
+* [Element Notification implementations](#element-notification-implementations)
+ * [Requirements](#requirements)
+ * [Foreground sync mode (Gplay and F-Droid)](#foreground-sync-mode-gplay-and-f-droid)
+ * [Push (FCM) received in background](#push-fcm-received-in-background)
+ * [FCM Fallback mode](#fcm-fallback-mode)
+ * [F-Droid background Mode](#f-droid-background-mode)
+* [Application Settings](#application-settings)
+
+
+
+
+First let's start with some prerequisite knowledge
+
+## Prerequisites Knowledge
+
+### How does a matrix client get a message from a homeserver?
+
+In order to get messages from a homeserver, a matrix client need to perform a ``sync`` operation.
+
+`To read events, the intended flow of operation is for clients to first call the /sync API without a since parameter. This returns the most recent message events for each room, as well as the state of the room at the start of the returned timeline. `
+
+The client need to call the `sync` API periodically in order to get incremental updates of the server state (new messages).
+This mechanism is known as **HTTP long Polling**.
+
+Using the **HTTP Long Polling** mechanism a client polls a server requesting new information.
+The server *holds the request open until new data is available*.
+Once available, the server responds and sends the new information.
+When the client receives the new information, it immediately sends another request, and the operation is repeated.
+This effectively emulates a server push feature.
+
+The HTTP long Polling can be fine tuned in the **SDK** using two parameters:
+* timeout (Sync request timeout)
+* delay (Delay between each sync)
+
+**timeout** is a server parameter, defined by:
+```
+The maximum time to wait, in milliseconds, before returning this request.`
+If no events (or other data) become available before this time elapses, the server will return a response with empty fields.
+By default, this is 0, so the server will return immediately even if the response is empty.
+```
+
+**delay** is a client preference. When the server responds to a sync request, the client waits for `delay`before calling a new sync.
+
+When the Element Android app is open (i.e in foreground state), the default timeout is 30 seconds, and delay is 0.
+
+### How does a mobile app receives push notification
+
+Push notification is used as a way to wake up a mobile application when some important information is available and should be processed.
+
+Typically in order to get push notification, an application relies on a **Push Notification Service** or **Push Provider**.
+
+For example iOS uses APNS (Apple Push Notification Service).
+Most of android devices relies on Google's Firebase Cloud Messaging (FCM).
+ > FCM has replaced Google Cloud Messaging (GCM - deprecated April 10 2018)
+
+FCM will only work on android devices that have Google plays services installed
+(In simple terms, Google Play Services is a background service that runs on Android, which in turn helps in integrating Google’s advanced functionalities to other applications)
+
+De-Googlified devices need to rely on something else in order to stay up to date with a server.
+There some cases when devices with google services cannot use FCM (network infrastructure limitations -firewalls-,
+ privacy and or independence requirement, source code licence)
+
+### Push VS Notification
+
+This need some disambiguation, because it is the source of common confusion:
+
+
+*The fact that you see a notification on your screen does not mean that you have successfully configured your PUSH platform.*
+
+ Technically there is a difference between a push and a notification. A notification is what you see on screen and/or in the notification Menu/Drawer (in the top bar of the phone).
+
+ Notifications are not always triggered by a push (One can display a notification locally triggered by an alarm)
+
+
+### Push in the matrix federated world
+
+In order to send a push to a mobile, App developers need to have a server that will use the FCM APIs, and these APIs requires authentication!
+This server is called a **Push Gateway** in the matrix world
+
+That means that Element Android, a matrix client created by New Vector, is using a **Push Gateway** with the needed credentials (FCM API secret Key) in order to send push to the New Vector client.
+
+If you create your own matrix client, you will also need to deploy an instance of a **Push Gateway** with the credentials needed to use FCM for your app.
+
+On registration, a matrix client must tell its homeserver what Push Gateway to use.
+
+See [Sygnal](https://github.com/matrix-org/sygnal/) for a reference implementation.
+```
+
+ +--------------------+ +-------------------+
+ Matrix HTTP | | | |
+ Notification Protocol | App Developer | | Device Vendor |
+ | | | |
+ +-------------------+ | +----------------+ | | +---------------+ |
+ | | | | | | | | | |
+ | Matrix homeserver +-----> Push Gateway +------> Push Provider | |
+ | | | | | | | | | |
+ +-^-----------------+ | +----------------+ | | +----+----------+ |
+ | | | | | |
+ Matrix | | | | | |
+Client/Server API + | | | | |
+ | | +--------------------+ +-------------------+
+ | +--+-+ |
+ | | <-------------------------------------------+
+ +---+ |
+ | | Provider Push Protocol
+ +----+
+
+ Mobile Device or Client
+```
+
+Recommended reading:
+* https://thomask.sdf.org/blog/2016/12/11/riots-magical-push-notifications-in-ios.html
+* https://matrix.org/docs/spec/client_server/r0.4.0.html#id128
+
+
+### How does the homeserver know when to notify a client?
+
+This is defined by [**push rules**](https://matrix.org/docs/spec/client_server/r0.4.0.html#push-rules-).
+
+`A push rule is a single rule that states under what conditions an event should be passed onto a push gateway and how the notification should be presented (sound / importance).`
+
+A homeserver can be configured with default rules (for Direct messages, group messages, mentions, etc.. ).
+
+There are different kind of push rules, it can be per room (each new message on this room should be notified), it can also define a pattern that a message should match (when you are mentioned, or key word based).
+
+Notifications have 2 'levels' (`highlighted = true/false sound = default/custom`). In Element these notifications level are reflected as Noisy/Silent.
+
+**What about encrypted messages?**
+
+Of course, content patterns matching cannot be used for encrypted messages server side (as the content is encrypted).
+
+That is why clients are able to **process the push rules client side** to decide what kind of notification should be presented for a given event.
+
+### Push vs privacy, and mitigation
+
+As seen previously, App developers don't directly send a push to the end user's device, they use a Push Provider as intermediary. So technically this intermediary is able to read the content of what is sent.
+
+App developers usually mitigate this by sending a `silent notification`, that is a notification with no identifiable data, or with an encrypted payload. When the push is received the app can then synchronise to it's server in order to generate a local notification.
+
+
+### Background processing limitations
+
+A mobile applications process live in a managed word, meaning that its process can be limited (e.g no network access), stopped or killed at almost anytime by the Operating System.
+
+In order to improve the battery life of their devices some constructors started to implement mechanism to drastically limit background execution of applications (e.g MIUI/Xiaomi restrictions, Sony stamina mode).
+Then starting android M, android has also put more focus on improving device performances, introducing several IDLE modes, App-Standby, Light Doze, Doze.
+
+In a nutshell, apps can't do much in background now.
+
+If the devices is not plugged and stays IDLE for a certain amount of time, radio (mobile connectivity) and CPU can/will be turned off.
+
+For an application like Element, where users can receive important information at anytime, the best option is to rely on a push system (Google's Firebase Message a.k.a FCM). FCM high priority push can wake up the device and whitelist an application to perform background task (for a limited but unspecified amount of time).
+
+Notice that this is still evolving, and in future versions application that has been 'background restricted' by users won't be able to wake up even when a high priority push is received. Also high priority notifications could be rate limited (not defined anywhere)
+
+It's getting a lot more complicated when you cannot rely on FCM (because: closed sources, network/firewall restrictions, privacy concerns).
+The documentation on this subject is vague, and as per our experiments not always exact, also device's behaviour is fragmented.
+
+It is getting more and more complex to have reliable notifications when FCM is not used.
+
+## Element Notification implementations
+
+### Requirements
+
+Element Android must work with and without FCM.
+* The Element android app published on F-Droid do not rely on FCM (all related dependencies are not present)
+* The Element android app published on google play rely on FCM, with a fallback mode when FCM registration has failed (e.g outdated or missing Google Play Services)
+
+### Foreground sync mode (Gplay and F-Droid)
+
+When in foreground, Element performs sync continuously with a timeout value set to 10 seconds (see HttpPooling).
+
+As this mode does not need to live beyond the scope of the application, and as per Google recommendation, Element uses the internal app resources (Thread and Timers) to perform the syncs.
+
+This mode is turned on when the app enters foreground, and off when enters background.
+
+In background, and depending on whether push is available or not, Element will use different methods to perform the syncs (Workers / Alarms / Service)
+
+### Push (FCM) received in background
+
+In order to enable Push, Element must first get a push token from the firebase SDK, then register a pusher with this token on the homeserver.
+
+When a message should be notified to a user, the user's homeserver notifies the registered `push gateway` for Element, that is [sygnal](https://github.com/matrix-org/sygnal) _- The reference implementation for push gateways -_ hosted by matrix.org.
+
+This sygnal instance is configured with the required FCM API authentication token, and will then use the FCM API in order to notify the user's device running Element.
+
+```
+Homeserver ----> Sygnal (configured for Element) ----> FCM ----> Element
+```
+
+The push gateway is configured to only send `(eventId,roomId)` in the push payload (for better [privacy](#push-vs-privacy-and-mitigation)).
+
+Element needs then to synchronise with the user's homeserver, in order to resolve the event and create a notification.
+
+As per [Google recommendation](https://android-developers.googleblog.com/2018/09/notifying-your-users-with-fcm.html), Element will then use the WorkManager API in order to trigger a background sync.
+
+**Google recommendations:**
+> We recommend using FCM messages in combination with the WorkManager 1 or JobScheduler API
+
+> Avoid background services. One common pitfall is using a background service to fetch data in the FCM message handler, since background service will be stopped by the system per recent changes to Google Play Policy
+
+```
+Homeserver ----> Sygnal ----> FCM ----> Element
+ (Sync) ----> Homeserver
+ <----
+ Display notification
+```
+
+**Possible outcomes**
+
+Upon reception of the FCM push, Element will perform a sync call to the homeserver, during this process it is possible that:
+ * Happy path, the sync is performed, the message resolved and displayed in the notification drawer
+ * The notified message is not in the sync. Can happen if a lot of things did happen since the push (`gappy sync`)
+ * The sync generates additional notifications (e.g an encrypted message where the user is mentioned detected locally)
+ * The sync takes too long and the process is killed before completion, or network is not reliable and the sync fails.
+
+Element implements several strategies in these cases (TODO document)
+
+### FCM Fallback mode
+
+It is possible that Element is not able to get a FCM push token.
+Common errors (among several others) that can cause that:
+* Google Play Services is outdated
+* Google Play Service fails in someways with FCM servers (infamous `SERVICE_NOT_AVAILABLE`)
+
+If Element is able to detect one of this cases, it will notifies it to the users and when possible help him fix it via a dedicated troubleshoot screen.
+
+Meanwhile, in order to offer a minimal service, and as per Google's recommendation for background activities, Element will launch periodic background sync in order to stays in sync with servers.
+
+The fallback mode is impacted by all the battery life saving mechanism implemented by android. Meaning that if the app is not used for a certain amount of time (`App-Standby`), or the device stays still and unplugged (`Light Doze`) , the sync will become less frequent.
+
+And if the device stays unplugged and still for too long (`Doze Mode`), no background sync will be perform at all (the system's `Ignore Battery Optimization option` has no effect on that).
+
+ Also the time interval between sync is elastic, controlled by the system to group other apps background sync request and start radio/cpu only once for all.
+
+Usually in this mode, what happen is when you take back your phone in your hand, you suddenly receive notifications.
+
+The fallback mode is supposed to be a temporary state waiting for the user to fix issues for FCM, or for App Developers that has done a fork to correctly configure their FCM settings.
+
+### F-Droid background Mode
+
+The F-Droid Element flavor has no dependencies to FCM, therefore cannot relies on Push.
+
+Also Google's recommended background processing method cannot be applied. This is because all of these methods are affected by IDLE modes, and will result on the user not being notified at all when the app is in a Doze mode (only in maintenance windows that could happens only after hours).
+
+Only solution left is to use `AlarmManager`, that offers new API to allow launching some process even if the App is in IDLE modes.
+
+Notice that these alarms, due to their potential impact on battery life, can still be restricted by the system. Documentation says that they will not be triggered more than every minutes under normal system operation, and when in low power mode about every 15 mn.
+
+These restrictions can be relaxed by requiring the app to be white listed from battery optimization.
+
+F-Droid version will schedule alarms that will then trigger a Broadcast Receiver, that in turn will launch a Service (in the classic android way), and the reschedule an alarm for next time.
+
+Depending on the system status (or device make), it is still possible that the app is not given enough time to launch the service, or that the radio is still turned off thus preventing the sync to success (that's why Alarms are not recommended for network related tasks).
+
+That is why on Element F-Droid, the broadcast receiver will acquire a temporary WAKE_LOCK for several seconds (thus securing cpu/network), and launch the service in foreground. The service performs the sync.
+
+Note that foreground services require to put a notification informing the user that the app is doing something even if not launched).
+
+## Application Settings
+
+**Notifications > Enable notifications for this account**
+
+Configure Sygnal to send or not notifications to all user devices.
+
+**Notifications > Enable notifications for this device**
+
+Disable notifications locally. The push server will continue to send notifications to the device but this one will ignore them.
+
+
diff --git a/docs/oidc.md b/docs/oidc.md
new file mode 100644
index 0000000000..5f9e70268d
--- /dev/null
+++ b/docs/oidc.md
@@ -0,0 +1,47 @@
+This file contains some rough notes about Oidc implementation, with some examples of actual data.
+
+[ios implementation](https://github.com/vector-im/element-x-ios/compare/develop...doug/oidc-temp)
+
+Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
+
+Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
+
+Server list: https://github.com/vector-im/oidc-playground
+
+Metadata iOS: (from https://github.com/vector-im/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
+
+clientName: InfoPlistReader.main.bundleDisplayName,
+redirectUri: "io.element:/callback",
+clientUri: "https://element.io",
+tosUri: "https://element.io/user-terms-of-service",
+policyUri: "https://element.io/privacy"
+
+
+Android:
+clientName = "Element",
+redirectUri = "io.element:/callback",
+clientUri = "https://element.io",
+tosUri = "https://element.io/user-terms-of-service",
+policyUri = "https://element.io/privacy"
+
+
+Example of OidcData (from presentUrl callback):
+url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
+
+Formatted url:
+https://auth-oidc.lab.element.dev/authorize?
+ response_type=code&
+ client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&
+ redirect_uri=io.element%3A%2Fcallback&
+ scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&
+ state=ex6mNJVFZ5jn9wL8&
+ nonce=NZ93DOyIGQd9exPQ&
+ code_challenge_method=S256&
+ code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&
+ prompt=consent
+
+state: ex6mNJVFZ5jn9wL8
+
+
+Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
+Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
diff --git a/docs/pull_request.md b/docs/pull_request.md
new file mode 100644
index 0000000000..6144dd0d92
--- /dev/null
+++ b/docs/pull_request.md
@@ -0,0 +1,290 @@
+# Pull requests
+
+
+
+* [Introduction](#introduction)
+* [Who should read this document?](#who-should-read-this-document?)
+* [Submitting PR](#submitting-pr)
+ * [Who can submit pull requests?](#who-can-submit-pull-requests?)
+ * [Humans](#humans)
+ * [Draft PR?](#draft-pr?)
+ * [Base branch](#base-branch)
+ * [PR Review Assignment](#pr-review-assignment)
+ * [PR review time](#pr-review-time)
+ * [Re-request PR review](#re-request-pr-review)
+ * [When create split PR?](#when-create-split-pr?)
+ * [Avoid fixing other unrelated issue in a big PR](#avoid-fixing-other-unrelated-issue-in-a-big-pr)
+ * [Bots](#bots)
+ * [Renovate](#renovate)
+ * [Gradle wrapper](#gradle-wrapper)
+ * [Sync analytics plan](#sync-analytics-plan)
+* [Reviewing PR](#reviewing-pr)
+ * [Who can review pull requests?](#who-can-review-pull-requests?)
+ * [What to have in mind when reviewing a PR](#what-to-have-in-mind-when-reviewing-a-pr)
+ * [Rules](#rules)
+ * [Check the form](#check-the-form)
+ * [PR title](#pr-title)
+ * [PR description](#pr-description)
+ * [File change](#file-change)
+ * [Check the commit](#check-the-commit)
+ * [Check the substance](#check-the-substance)
+ * [Make a dedicated meeting to review the PR](#make-a-dedicated-meeting-to-review-the-pr)
+ * [What happen to the issue(s)?](#what-happen-to-the-issues?)
+ * [Merge conflict](#merge-conflict)
+ * [When and who can merge PR](#when-and-who-can-merge-pr)
+ * [Merge type](#merge-type)
+ * [Resolve conversation](#resolve-conversation)
+* [Responsibility](#responsibility)
+
+
+
+## Introduction
+
+This document gives some clue about how to efficiently manage Pull Requests (PR). This document is a first draft and may be improved later.
+
+## Who should read this document?
+
+Every pull request reviewers, but also probably every ones who submit PRs.
+
+## Submitting PR
+
+### Who can submit pull requests?
+
+Basically every one who wants to contribute to the project! But there are some rules to follow.
+
+#### Humans
+
+People with write access to the project can directly clone the project, push their branches and create PR.
+
+External contributors must first fork the project and create PR to the mainline from there.
+
+##### Draft PR?
+
+Draft PR can be created when the submitter does not expect the PR to be reviewed and merged yet. It can be useful to publicly show the work, or to do a self-review first.
+
+Draft PR can also be created when it depends on other un-merged PR.
+
+In any case, it is better to explicitly declare in the description why the PR is a draft PR.
+
+Also, draft PR should not stay indefinitely in this state. It may be removed if it is the case and the submitter does not update it after a few days.
+
+##### Base branch
+
+The `develop` branch is generally the base branch for every PRs.
+
+Exceptions can occur:
+
+- if a feature implementation is split into multiple PRs. We can have a chain of PRs in this case. PR can be merged one by one on develop, and GitHub change the target branch to `develop` for the next PR automatically.
+- we want to merge a PR from the community, but there is still work to do, and the PR is not updated by the submitter. First, we can kindly ask the submitter if they will update their PR, by commenting it. If there is no answer after a few days (including a week-end), we can create a new branch, push it, and change the target branch of the PR to this new branch. The PR can then be merged, and we can add more commits to fix the issues. After that a new PR can be created with `develop` as a target branch.
+
+**Important notice 1:** Releases are created from the `develop` branch. So `develop` branch should always contain a "releasable" source code. So when a feature is being implemented with several PRs, it has to be disabled by default (using a feature flag for instance), until the feature is fully implemented. A last PR to enable the feature can then be created.
+
+**Important notice 2:** Database migration: some developers and some people from the community are using the nightly build from `develop`. Multiple database migrations should be properly handled for them. It is OK to have multiple migrations between 2 releases, It is not OK to add steps to existing database migrations on `develop`. So for instance `develop` users will migrate from version 11 to version 12, then 13, then 14, and `main` users will do all those steps after they get the app upgrade.
+
+##### PR Review Assignment
+
+We use automatic assignment for PR reviews. **A PR is automatically routed by GitHub to one team member** using the round robin algorithm. Additional reviewers can be used for complex changes or when the first reviewer is not confident enough on the changes.
+The process is the following:
+
+- The PR creator selects the [element-x-android-reviewers](https://github.com/orgs/vector-im/teams/element-x-android-reviewers) team as a reviewer.
+- GitHub automatically assign the reviewer. If the reviewer is not available (holiday, etc.), remove them and set again the team, GitHub will select another reviewer.
+- Alternatively, the PR creator can directly assign specific people if they have another Android developer in their team or they think a specific reviewer should take a look at their PR.
+- Reviewers get a notification to make the review: they review the code following the good practice (see the rest of this document).
+- After making their own review, if they feel not confident enough, they can ask another person for a full review, or they can tag someone within a PR comment to check specific lines.
+
+For PRs coming from the community, the issue wrangler can assign either the team [element-x-android-reviewers](https://github.com/orgs/vector-im/teams/element-x-android-reviewers) or any member directly.
+
+##### PR review time
+
+As a PR submitter, you deserve a quick review. As a reviewer, you should do your best to unblock others.
+
+Some tips to achieve it:
+
+- Set up your GH notifications correctly
+- Check your pulls page: [https://github.com/pulls](https://github.com/pulls)
+- Check your pending assigned PRs before starting or resuming your day to day tasks
+- If you are busy with high priority tasks, inform the author. They will find another developer
+
+It is hard to define a deadline for a review. It depends on the PR size and the complexity. Let's start with a goal of 24h (working day!) for a PR smaller than 500 lines. If bigger, the submitter and the reviewer should discuss.
+
+After this time, the submitter can ping the reviewer to get a status of the review.
+
+##### Re-request PR review
+
+Once all the remarks have been handled, it's possible to re-request a review from the (same) reviewer to let them know that the PR has been updated the PR is ready to be reviewed again. Use the double arrow next to the reviewer name to do that.
+
+##### When create split PR?
+
+To implement big new feature, it may be efficient to split the work into several smaller and scoped PRs. They will be easier to review, and they can be merged on `develop` faster.
+
+Big PR can take time, and there is a risk of future merge conflict.
+
+Feature flag can be used to avoid half implemented feature to be available in the application.
+
+That said, splitting into several PRs should not have the side effect to have more review to do, for instance if some code is added, then finally removed.
+
+##### Avoid fixing other unrelated issue in a big PR
+
+Each PR should focus on a single task. If other issues may be fixed when working in the area of it, it's preferable to open a dedicated PR.
+
+It will have the advantage to be reviewed and merged faster, and not interfere with the main PR.
+
+It's also applicable for code rework (such as renaming for instance), or code formatting. Sometimes, it is more efficient to extract that work to a dedicated PR, and rebase your branch once this "rework" PR has been merged.
+
+#### Bots
+
+Some bots can create PR, but they still have to be reviewed by the team
+
+##### Renovate
+
+Renovate is a tool which maintain all our external dependencies up to date. A dedicated PR is created for each new available release for one of our external dependencies.
+
+To review such PR, you have to
+ - **IMPORTANT** check the diff files (as always).
+ - Check the release note. Some existing bugs in Element project may be fixed by the upgrade
+ - Make sure that the CI is happy
+ - If the code does not compile (API break for instance), you have to checkout the branch and push new commits
+ - Do some smoke test, depending of the library which has been upgraded
+
+For some reasons (like for instance a change in package declaration) the tool sometimes does not upgrade some dependencies. In this case, and when detected, the upgrade has to be done manually.
+
+##### Gradle wrapper
+
+`Update Gradle Wrapper` is a tool which can create PR to upgrade our gradle.properties file.
+Review such PR is the same recipe than for PR from Dependabot
+
+##### Sync analytics plan
+
+This tools imports any update in the analytics plan. See instruction in the PR itself to handle it.
+More info can be found in the file [analytics.md](./analytics.md)
+
+## Reviewing PR
+
+### Who can review pull requests?
+
+As an open source project, every one can review each others PR. Of course an approval from internal developer is mandatory for a PR to be merged.
+But comment in PR from the community are always appreciated!
+
+### What to have in mind when reviewing a PR
+
+1. User experience: is the UX and UI correct? You will probably be the second person to test the new thing, the first one is the developer.
+2. Developer experience: does the code look nice and decoupled? No big functions, new classes added to the right module, etc.
+3. Code maintenance. A bit similar to point 2. Tricky code must be documented for instance
+4. Fork consideration. Will configuration of forks be easy? Some documentation may help in some cases.
+5. We are building long term products. "Quick and dirty" code must be avoided.
+6. The PR includes new tests for the added code, updated test for the existing code
+7. All PRs from external contributors **MUST** include a sign-off. It's in the checklist, and sometimes it's checked by the submitter, but there is actually no sign-off. In this case, ask nicely for a sign-off and request changes (do not approve the PR, even if everything else is fine).
+
+### Rules
+
+#### Check the form
+
+##### PR title
+
+PR title should describe in one line what's brought by the PR. Reviewer can edit the title if it's not clear enough, or to add suffix like `[BLOCKED]` or similar. Fixing typo is also a good practice, since GitHub search is quite not efficient, so the words must be spelled without any issue. Adding suffix will help when viewing the PR list.
+
+It's free form, but prefix tags could also be used to help understand what's in the PR.
+
+Examples of prefixes:
+- `[Refacto]`
+- `[Feature]`
+- `[Bugfix]`
+- etc.
+
+Also, it's still possible to add labels to the PRs, such as `A-` or `T-` labels, even if this is not a strong requirement. We prefer to spend time to add labels on issues.
+
+##### PR description
+
+PR description should follow the PR template, and at least provide some context about the code change.
+
+##### File change
+
+1. Code should follow the guidelines
+2. Code should be formatted correctly
+3. XML attribute must be sorted
+4. New code is added at the correct location
+5. New classes are added to the correct location
+6. Naming is correct. Naming is really important, it's considered part of the documentation
+7. Architecture is followed. For instance, the logic is in the ViewModel and not in the Fragment
+8. There is at least one file for the changelog. Exception if the PR fixes something which has not been released yet. Changelog content should target their audience: `.sdk` extension are mainly targeted for developers, other extensions are targeted for users and forks maintainers. It should generally describe visual change rather than give technical details. More details can be found [here](../CONTRIBUTING.md#changelog).
+9. PR includes tests. allScreensTest when applicable, and unit tests
+10. Avoid over complicating things. Keep it simple (KISS)!
+11. PR contains only the expected change. Sometimes, the diff is showing changes that are already on `develop`. This is not good, submitter has to fix that up.
+
+##### Check the commit
+
+Commit message must be short, one line and valuable. "WIP" is not a good commit message. Commit message can contain issue number, starting with `#`. GitHub will add some link between the issue and such commit, which can be useful. It's possible to change a commit message at any time (may require a force push).
+
+Commit messages can contain extra lines with more details, links, etc. But keep in mind that those lines are quite less visible than the first line.
+
+Also commit history should be nice. Having commits like "Adding temporary code" then later "Removing temporary code" is not good. The branch has to be rebased and those commit have to be dropped.
+
+PR merger could decide to squash and merge if commit history is not good.
+
+Commit like "Code review fixes" is good when reviewing the PR, since new changes can be reviewed easily, but is less valuable when looking at git history. To avoid this, PR submitter should always push new commits after a review (no commit amend with force push), and when the PR is approved decide to interactive rebase the PR to improve the git history and reduce noise.
+
+##### Check the substance
+
+1. Test the changes!
+2. Test the nominal case and the edge cases
+3. Run the sanity test for critical PR
+
+##### Make a dedicated meeting to review the PR
+
+Sometimes a big PR can be hard to review. Setting up a call with the PR submitter can speed up the communication, rather than putting comments and questions in GitHub comments. It has the inconvenience of making the discussion non-public, consider including a summary of the main points of the "offline" conversation in the PR.
+
+### What happen to the issue(s)?
+
+The issue(s) should be referenced in the PR description using keywords like `Closes` of `Fixes` followed by the issue number.
+
+Example:
+> Closes #1
+
+Note that you have to repeat the keyword in case of a list of issue
+
+> Closes #1, Closes #2, etc.
+
+When PR will be merged, such referenced issue will be automatically closed.
+It is up to the person who has merged the PR to go to the (closed) issue(s) and to add a comment to inform in which version the issue fix will be available. Use the current version of `develop` branch.
+
+> Closed in Element Android v1.x.y
+
+### Merge conflict
+
+It's up to the submitter to handle merge conflict. Sometimes, they can be fixed directly from GitHub, sometimes this is not possible. The branch can be rebased on `develop`, or the `develop` branch can be merged on the branch, it's up to the submitter to decide what is best.
+Keep in mind that Github Actions are not run in case of conflict.
+
+### When and who can merge PR
+
+PR can be merged by the submitter, if and only if at least one approval from another developer is done. Approval from all people added as reviewer is also a good thing to have. Approval from design team may be mandatory, but is not sufficient to merge a PR.
+
+PR can also be merged by the reviewer, to reduce the time the PR is open. But only if the PR is not in draft and the change are quite small, or behind a feature flag.
+
+Dangerous PR should not be merged just before a release. Dangerous PR are PR that could break the app. Update of Realm library, rework in the chunk of Events management in the SDK, etc.
+
+We prefer to merge such PR after a release so that it can be tested during several days by the team before behind included in a release candidate.
+
+PR from bots will always be merged by the reviewer, right after approving the changes, or in case of critical changes, right after a release.
+
+#### Merge type
+
+Generally we use "Create a merge commit", which has the advantage to keep the branch visible.
+
+If git history is noisy (code added, then removed, etc.), it's possible to use "Squash and merge". But the branch will not be visible anymore, a commit will be added on top of develop. Git commit message can (and probably must) be edited from the GitHub web app. It's better if the submitter do the work to cleanup the git history by using a git interactive rebase of their branch.
+
+### Resolve conversation
+
+Generally we do not close conversation added during PR review and update by clicking on "Resolve conversation"
+If the submitter or the reviewer do so, it will more difficult for further readers to see again the content. They will have to open the conversation to see it again. it's a waste of time.
+
+When remarks are handled, a small comment like "done" is enough, commit hash can also be added to the conversation.
+
+Exception: for big PRs with lots of conversations, using "Resolve conversation" may help to see the remaining remarks.
+
+Also "Resolve conversation" should probably be hit by the creator of the conversation.
+
+## Responsibility
+
+PR submitter is responsible of the incoming change. PR reviewers who approved the PR take a part of responsibility on the code which will land to develop, and then be used by our users, and the user of our forks.
+
+That said, bug may still be merged on `develop`, this is still acceptable of course. In this case, please make sure an issue is created and correctly labelled. Ideally, such issues should be fixed before the next release candidate, i.e. with a higher priority. But as we release the application every 10 working days, it can be hard to fix every bugs. That's why PR should be fully tested and reviewed before being merge and we should never comment code review remark with "will be handled later", or similar comments.
diff --git a/docs/screenshot_testing.md b/docs/screenshot_testing.md
new file mode 100644
index 0000000000..37299af7fc
--- /dev/null
+++ b/docs/screenshot_testing.md
@@ -0,0 +1,59 @@
+# Screenshot testing
+
+
+
+* [Overview](#overview)
+* [Setup](#setup)
+* [Recording](#recording)
+* [Verifying](#verifying)
+* [Contributing](#contributing)
+
+
+
+## Overview
+
+- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently.
+- ElementX uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase).
+- The screenshot verification occurs on every pull request as part of the `tests.yml` workflow.
+
+## Setup
+
+- Install Git LFS through your package manager of choice (`brew install git-lfs` | `yay -S git-lfs`).
+- Install the Git LFS hooks into the project.
+
+```shell
+# with element-android as the current working directory
+git lfs install --local
+```
+
+If installed correctly, `git push` and `git pull` will now include LFS content.
+
+## Recording
+
+```shell
+./gradlew recordPaparazziDebug
+```
+
+The task will delete the content of the folder `/snapshots` before recording (see the task `removeOldSnapshots` defined in the project).
+
+If this is not the case, you can run
+
+```shell
+rm -rf ./tests/uitests/src/test/snapshots
+```
+
+Paparazzi will generate images in `:tests:uitests/src/test/snapshots`, which will need to be committed to the repository using Git LFS.
+
+## Verifying
+
+```shell
+./gradlew verifyPaparazziDebug
+```
+
+In the case of failure, Paparazzi will generate images in `:tests:uitests/out/failure`. The images will show the expected and actual screenshots along with a delta of the two images.
+
+## Contributing
+
+- Creating Previewable Composable will automatically creates new screenshot tests.
+- After creating the new test, record and commit the newly rendered screens.
+- `./tools/git/validate_lfs.sh` can be run to ensure everything is working correctly with Git LFS, the CI also runs this check.
diff --git a/fastlane/metadata/android/en-US/changelogs/1001000.txt b/fastlane/metadata/android/en-US/changelogs/1001000.txt
new file mode 100644
index 0000000000..78dd519c46
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/1001000.txt
@@ -0,0 +1 @@
+First release of Element X 🚀!
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000000..7272246de5
--- /dev/null
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,23 @@
+Element X is the future Element.
+
+It is the brand new, and fastest ever, Matrix client. It is for personal and community use, and will support enterprise functionality later this year.
+
+A complete new build, Element X transforms performance. It’s not just the fastest Matrix client, it’s also fresher and more reliable.
+
+It’s so fast for a number of reasons, but in particular we’ve introduced a completely new syncing service (‘sliding sync’). So even in big end-to-end encrypted chat rooms it operates incredibly quickly.
+
+It’s fresher because we’ve rebuilt the entire user experience. All the power of Matrix - and the complexity of decentralized end-to-end encryption - is now hidden under a beautiful and intuitive user interface using the very latest frameworks and accessibility features.
+
+Element X delivers speed, usability and reliability on the decentralized Matrix open standard.
+
+Own your data
+Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
+
+Interoperate natively
+Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends are on a different Matrix-based app you can still connect and chat.
+
+Encrypt your data
+Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages. And Element X E2EE applies to voice and video calls too.
+
+Chat across multiple devices
+Stay in touch wherever you are with fully synchronized message history across all your devices, even those running ‘traditional’ Element, and on the web at https://app.element.io
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png
new file mode 100644
index 0000000000..37975de877
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png
new file mode 100644
index 0000000000..cfe22b43cd
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
new file mode 100644
index 0000000000..b1668d1b2d
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
new file mode 100644
index 0000000000..30d6a71a01
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
new file mode 100644
index 0000000000..c62d890ca2
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
new file mode 100644
index 0000000000..0a612798a6
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000000..c474361017
--- /dev/null
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+Fastest ever Matrix client
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
new file mode 100644
index 0000000000..1e66e9042e
--- /dev/null
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -0,0 +1 @@
+Element X - Secure messenger
\ No newline at end of file
diff --git a/features/analytics/api/build.gradle.kts b/features/analytics/api/build.gradle.kts
new file mode 100644
index 0000000000..3d3a3b9189
--- /dev/null
+++ b/features/analytics/api/build.gradle.kts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.features.analytics.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.uiStrings)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt
new file mode 100644
index 0000000000..b773754a11
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.analytics.api
+
+import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
+
+interface AnalyticsEntryPoint : SimpleFeatureEntryPoint
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt
new file mode 100644
index 0000000000..0804f8ea44
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.analytics.api
+
+sealed interface AnalyticsOptInEvents {
+ data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvents
+}
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt
new file mode 100644
index 0000000000..883e0d1dc3
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.analytics.api
+
+object Config {
+ const val POLICY_LINK = "https://element.io/cookie-policy"
+}
+
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesPresenter.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesPresenter.kt
new file mode 100644
index 0000000000..ad7538cafe
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesPresenter.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.analytics.api.preferences
+
+import io.element.android.libraries.architecture.Presenter
+
+interface AnalyticsPreferencesPresenter : Presenter
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt
new file mode 100644
index 0000000000..7cf0f51dfd
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.analytics.api.preferences
+
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+
+data class AnalyticsPreferencesState(
+ val applicationName: String,
+ val isEnabled: Boolean,
+ val eventSink: (AnalyticsOptInEvents) -> Unit,
+)
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
new file mode 100644
index 0000000000..ea397b4d67
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.analytics.api.preferences
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class AnalyticsPreferencesStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aAnalyticsPreferencesState().copy(isEnabled = true),
+ )
+}
+
+fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
+ applicationName = "Element X",
+ isEnabled = false,
+ eventSink = {}
+)
diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
new file mode 100644
index 0000000000..f6d77226b9
--- /dev/null
+++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.analytics.api.preferences
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.theme.LinkColor
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun AnalyticsPreferencesView(
+ state: AnalyticsPreferencesState,
+ modifier: Modifier = Modifier,
+) {
+ fun onEnabledChanged(isEnabled: Boolean) {
+ state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
+ }
+
+ val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName)
+ val secondPart = buildAnnotatedStringWithColoredPart(
+ CommonStrings.screen_analytics_settings_read_terms,
+ CommonStrings.screen_analytics_settings_read_terms_content_link
+ )
+ val subtitle = "$firstPart\n\n$secondPart"
+
+ PreferenceSwitch(
+ modifier = modifier,
+ title = stringResource(id = CommonStrings.screen_analytics_settings_share_data),
+ subtitle = subtitle,
+ isChecked = state.isEnabled,
+ onCheckedChange = ::onEnabledChanged,
+ switchAlignment = Alignment.Top,
+ )
+}
+
+@Composable
+fun buildAnnotatedStringWithColoredPart(
+ @StringRes fullTextRes: Int,
+ @StringRes coloredTextRes: Int,
+ color: Color = LinkColor,
+ underline: Boolean = true,
+) = buildAnnotatedString {
+ val coloredPart = stringResource(coloredTextRes)
+ val fullText = stringResource(fullTextRes, coloredPart)
+ val startIndex = fullText.indexOf(coloredPart)
+ append(fullText)
+ addStyle(
+ style = SpanStyle(
+ color = color,
+ textDecoration = if (underline) TextDecoration.Underline else null
+ ), start = startIndex, end = startIndex + coloredPart.length
+ )
+}
+
+@Preview
+@Composable
+fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: AnalyticsPreferencesState) {
+ AnalyticsPreferencesView(state)
+}
diff --git a/features/analytics/impl/build.gradle.kts b/features/analytics/impl/build.gradle.kts
new file mode 100644
index 0000000000..3bf58ab636
--- /dev/null
+++ b/features/analytics/impl/build.gradle.kts
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.analytics.impl"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ api(projects.features.analytics.api)
+ api(projects.services.analytics.api)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.browser)
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.mockk)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.features.analytics.test)
+ testImplementation(projects.features.analytics.impl)
+
+ androidTestImplementation(libs.test.junitext)
+}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt
new file mode 100644
index 0000000000..ab060a51cf
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.analytics.impl
+
+import android.app.Activity
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.analytics.api.Config
+import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
+import io.element.android.libraries.di.AppScope
+
+@ContributesNode(AppScope::class)
+class AnalyticsOptInNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: AnalyticsOptInPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ private fun onClickTerms(activity: Activity, darkTheme: Boolean) {
+ activity.openUrlInChromeCustomTab(null, darkTheme, Config.POLICY_LINK)
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val activity = LocalContext.current as Activity
+ val isDark = MaterialTheme.colors.isLight.not()
+ val state = presenter.present()
+ AnalyticsOptInView(
+ state = state,
+ modifier = modifier,
+ onClickTerms = { onClickTerms(activity, isDark) },
+ )
+ }
+}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
new file mode 100644
index 0000000000..3cd2203dbe
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.analytics.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class AnalyticsOptInPresenter @Inject constructor(
+ private val buildMeta: BuildMeta,
+ private val analyticsService: AnalyticsService,
+) : Presenter {
+
+ @Composable
+ override fun present(): AnalyticsOptInState {
+ val localCoroutineScope = rememberCoroutineScope()
+
+ fun handleEvents(event: AnalyticsOptInEvents) {
+ when (event) {
+ is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
+ }
+ localCoroutineScope.launch {
+ analyticsService.setDidAskUserConsent()
+ }
+ }
+
+ return AnalyticsOptInState(
+ applicationName = buildMeta.applicationName,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch {
+ analyticsService.setUserConsent(enabled)
+ }
+}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
new file mode 100644
index 0000000000..a12cbaa7ea
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.analytics.impl
+
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+
+data class AnalyticsOptInState(
+ val applicationName: String,
+ val eventSink: (AnalyticsOptInEvents) -> Unit
+)
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
new file mode 100644
index 0000000000..544e4a5649
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.analytics.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import javax.inject.Inject
+
+open class AnalyticsOptInStateProvider @Inject constructor(
+) : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aAnalyticsOptInState(),
+ )
+}
+
+fun aAnalyticsOptInState() = AnalyticsOptInState(
+ applicationName = "Element X",
+ eventSink = {}
+)
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
new file mode 100644
index 0000000000..a27e6e7399
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt
@@ -0,0 +1,223 @@
+/*
+ * 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.analytics.impl
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Poll
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.BiasAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
+import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+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.temporaryColorBgSpecial
+import io.element.android.libraries.designsystem.utils.LogCompositions
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun AnalyticsOptInView(
+ state: AnalyticsOptInState,
+ onClickTerms: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LogCompositions(tag = "Analytics", msg = "Root")
+ val eventSink = state.eventSink
+
+ fun onTermsAccepted() {
+ eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
+ }
+
+ fun onTermsDeclined() {
+ eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
+ }
+
+ BackHandler(onBack = ::onTermsDeclined)
+ HeaderFooterPage(
+ modifier = modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .imePadding(),
+ header = { AnalyticsOptInHeader(state, onClickTerms) },
+ content = { AnalyticsOptInContent() },
+ footer = {
+ AnalyticsOptInFooter(
+ onTermsAccepted = ::onTermsAccepted,
+ onTermsDeclined = ::onTermsDeclined,
+ )
+ }
+ )
+}
+
+@Composable
+private fun AnalyticsOptInHeader(
+ state: AnalyticsOptInState,
+ onClickTerms: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ IconTitleSubtitleMolecule(
+ modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
+ title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
+ subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
+ iconImageVector = Icons.Filled.Poll
+ )
+ Text(
+ text = buildAnnotatedStringWithStyledPart(
+ R.string.screen_analytics_prompt_read_terms,
+ R.string.screen_analytics_prompt_read_terms_content_link,
+ color = Color.Unspecified,
+ underline = false,
+ bold = true,
+ ),
+ modifier = Modifier
+ .clip(shape = RoundedCornerShape(8.dp))
+ .clickable { onClickTerms() }
+ .padding(8.dp),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ }
+}
+
+@Composable
+private fun CheckIcon(modifier: Modifier = Modifier) {
+ Icon(
+ modifier = modifier
+ .size(20.dp)
+ .background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
+ .padding(2.dp),
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = ElementTheme.colors.textActionAccent,
+ )
+}
+
+@Composable
+private fun AnalyticsOptInContent(
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = BiasAlignment(
+ horizontalBias = 0f,
+ verticalBias = -0.4f
+ )
+ ) {
+ InfoListOrganism(
+ items = persistentListOf(
+ InfoListItem(
+ message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
+ iconComposable = { CheckIcon() },
+ ),
+ InfoListItem(
+ message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
+ iconComposable = { CheckIcon() },
+ ),
+ InfoListItem(
+ message = stringResource(id = R.string.screen_analytics_prompt_settings),
+ iconComposable = { CheckIcon() },
+ ),
+ ),
+ textStyle = ElementTheme.typography.fontBodyMdMedium,
+ iconTint = ElementTheme.colors.textPrimary,
+ backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
+ )
+ }
+}
+
+@Composable
+private fun AnalyticsOptInFooter(
+ onTermsAccepted: () -> Unit,
+ onTermsDeclined: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ButtonColumnMolecule(
+ modifier = modifier,
+ ) {
+ Button(
+ onClick = onTermsAccepted,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = stringResource(id = CommonStrings.action_ok))
+ }
+ TextButton(
+ onClick = onTermsDeclined,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text = stringResource(id = CommonStrings.action_not_now))
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight {
+ ContentToPreview(state)
+}
+
+@Preview
+@Composable
+fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark {
+ ContentToPreview(state)
+}
+
+@Composable
+private fun ContentToPreview(state: AnalyticsOptInState) {
+ AnalyticsOptInView(
+ state = state,
+ onClickTerms = {},
+ )
+}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt
new file mode 100644
index 0000000000..6b2e26f763
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.analytics.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.analytics.api.AnalyticsEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultAnalyticsEntryPoint @Inject constructor() : AnalyticsEntryPoint {
+ override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
+ return parentNode.createNode(buildContext)
+ }
+}
diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt
new file mode 100644
index 0000000000..6debe4c232
--- /dev/null
+++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.analytics.impl.preferences
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.rememberCoroutineScope
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter
+import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.di.AppScope
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultAnalyticsPreferencesPresenter @Inject constructor(
+ private val analyticsService: AnalyticsService,
+ private val buildMeta: BuildMeta,
+) : AnalyticsPreferencesPresenter {
+
+ @Composable
+ override fun present(): AnalyticsPreferencesState {
+ val localCoroutineScope = rememberCoroutineScope()
+ val isEnabled = analyticsService.getUserConsent()
+ .collectAsState(initial = false)
+
+ fun handleEvents(event: AnalyticsOptInEvents) {
+ when (event) {
+ is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
+ }
+ }
+
+ return AnalyticsPreferencesState(
+ applicationName = buildMeta.applicationName,
+ isEnabled = isEnabled.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch {
+ analyticsService.setUserConsent(enabled)
+ }
+}
diff --git a/features/analytics/impl/src/main/res/values-cs/translations.xml b/features/analytics/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..b75b359216
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nezaznamenáváme ani neprofilujeme žádné údaje o účtu"
+ "Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy."
+ "Můžete si přečíst všechny naše podmínky %1$s."
+ "zde"
+ "Tuto funkci můžete kdykoli vypnout"
+ "Nesdílíme informace s třetími stranami"
+ "Pomozte vylepšit %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..979048344f
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Wir erfassen und analysieren ""keine"" Account-Daten"
+ "Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."
+ "Sie können alle unsere Nutzerbedingungen %1$s lesen."
+ "hier"
+ "Sie können die Analyse jederzeit in den Einstellungen deaktivieren"
+ "Wir geben ""keine"" Informationen an Dritte weiter"
+ "Helfen Sie %1$s zu verbessern"
+
diff --git a/features/analytics/impl/src/main/res/values-fr/translations.xml b/features/analytics/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..55231f7b6c
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nous n\'enregistrerons ni ne traiterons aucune donnée personnelle"
+ "Partagez des données d\'utilisation anonymes pour nous aider à identifier les problèmes."
+ "Consultez nos conditions d\'utilisation %1$s."
+ "ici"
+ "Vous pouvez désactiver cette fonction à tout moment"
+ "Nous ne partagerons pas vos données avec des tiers"
+ "Aidez-nous à améliorer %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-ro/translations.xml b/features/analytics/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..f9fd53a184
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nu vom înregistra și nu vom face profiluri cu privire la datele personale."
+ "Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme."
+ "Puteți citi toate condițiile noastre %1$s."
+ "aici"
+ "Puteți dezactiva această opțiune oricând din setări"
+ "Nu vom partaja datele dvs. cu terțe părți"
+ "Ajutați la îmbunătățirea %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-sk/translations.xml b/features/analytics/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..d16c34dc60
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nezaznamenávame ani neprofilujeme žiadne osobné údaje"
+ "Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy."
+ "Môžete si prečítať všetky naše podmienky %1$s."
+ "tu"
+ "Môžete to kedykoľvek vypnúť"
+ "Vaše údaje nebudeme zdieľať s tretími stranami"
+ "Pomôžte zlepšiť %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values/localazy.xml b/features/analytics/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..a496bdd0c6
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,10 @@
+
+
+ "We won\'t record or profile any personal data"
+ "Share anonymous usage data to help us identify issues."
+ "You can read all our terms %1$s."
+ "here"
+ "You can turn this off anytime"
+ "We won\'t share your data with third parties"
+ "Help improve %1$s"
+
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
new file mode 100644
index 0000000000..a8e42ceb01
--- /dev/null
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.analytics.impl
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AnalyticsOptInPresenterTest {
+ @Test
+ fun `present - enable`() = runTest {
+ val analyticsService = FakeAnalyticsService(isEnabled = false)
+ val presenter = AnalyticsOptInPresenter(
+ aBuildMeta(),
+ analyticsService
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
+ assertThat(analyticsService.didAskUserConsent().first()).isTrue()
+ assertThat(analyticsService.getUserConsent().first()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - not now`() = runTest {
+ val analyticsService = FakeAnalyticsService(isEnabled = false)
+ val presenter = AnalyticsOptInPresenter(
+ aBuildMeta(),
+ analyticsService
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(analyticsService.didAskUserConsent().first()).isFalse()
+ initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
+ assertThat(analyticsService.didAskUserConsent().first()).isTrue()
+ assertThat(analyticsService.getUserConsent().first()).isFalse()
+ }
+ }
+}
+
diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
new file mode 100644
index 0000000000..8469abe769
--- /dev/null
+++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.analytics.impl.preferences
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.analytics.api.AnalyticsOptInEvents
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AnalyticsPreferencesPresenterTest {
+ @Test
+ fun `present - initial state available`() = runTest {
+ val presenter = DefaultAnalyticsPreferencesPresenter(
+ FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
+ aBuildMeta()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - initial state not available`() = runTest {
+ val presenter = DefaultAnalyticsPreferencesPresenter(
+ FakeAnalyticsService(isEnabled = false, didAskUserConsent = false),
+ aBuildMeta()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.isEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - enable and disable`() = runTest {
+ val presenter = DefaultAnalyticsPreferencesPresenter(
+ FakeAnalyticsService(isEnabled = true, didAskUserConsent = true),
+ aBuildMeta()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isEnabled).isTrue()
+ initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
+ assertThat(awaitItem().isEnabled).isFalse()
+ initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
+ assertThat(awaitItem().isEnabled).isTrue()
+ }
+ }
+}
+
diff --git a/features/analytics/test/build.gradle.kts b/features/analytics/test/build.gradle.kts
new file mode 100644
index 0000000000..9f1796b156
--- /dev/null
+++ b/features/analytics/test/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.analytics.test"
+}
+
+dependencies {
+ implementation(projects.services.analytics.api)
+ implementation(projects.libraries.core)
+ implementation(libs.coroutines.core)
+}
diff --git a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt
new file mode 100644
index 0000000000..6e84c58d2a
--- /dev/null
+++ b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.analytics.test
+
+import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
+import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
+import im.vector.app.features.analytics.plan.UserProperties
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analyticsproviders.api.AnalyticsProvider
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeAnalyticsService(
+ isEnabled: Boolean = false,
+ didAskUserConsent: Boolean = false
+): AnalyticsService {
+
+ private val isEnabledFlow = MutableStateFlow(isEnabled)
+ private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent)
+ val capturedEvents = mutableListOf()
+
+ override fun getAvailableAnalyticsProviders(): List = emptyList()
+
+ override fun getUserConsent(): Flow = isEnabledFlow
+
+ override suspend fun setUserConsent(userConsent: Boolean) {
+ isEnabledFlow.value = userConsent
+ }
+
+ override fun didAskUserConsent(): Flow = didAskUserConsentFlow
+
+ override suspend fun setDidAskUserConsent() {
+ didAskUserConsentFlow.value = true
+ }
+
+ override fun getAnalyticsId(): Flow = MutableStateFlow("")
+
+ override suspend fun setAnalyticsId(analyticsId: String) {
+ }
+
+ override suspend fun onSignOut() {
+ }
+
+ override fun capture(event: VectorAnalyticsEvent) {
+ capturedEvents += event
+ }
+
+ override fun screen(screen: VectorAnalyticsScreen) {
+ }
+
+ override fun updateUserProperties(userProperties: UserProperties) {
+ }
+
+ override fun trackError(throwable: Throwable) {
+ }
+
+ override suspend fun reset() {
+ didAskUserConsentFlow.value = false
+ }
+}
diff --git a/features/createroom/api/build.gradle.kts b/features/createroom/api/build.gradle.kts
new file mode 100644
index 0000000000..a1ec16ef36
--- /dev/null
+++ b/features/createroom/api/build.gradle.kts
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.createroom.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
new file mode 100644
index 0000000000..18e0e4e28f
--- /dev/null
+++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.createroom.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface CreateRoomEntryPoint : FeatureEntryPoint {
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onSuccess(roomId: RoomId)
+ }
+}
diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts
new file mode 100644
index 0000000000..8bb343c10a
--- /dev/null
+++ b/features/createroom/impl/build.gradle.kts
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.createroom.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.deeplink)
+ implementation(projects.libraries.mediapickers.api)
+ implementation(projects.libraries.mediaupload.api)
+ implementation(projects.libraries.usersearch.impl)
+ implementation(projects.services.analytics.api)
+ implementation(libs.coil.compose)
+ api(projects.features.createroom.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.test.mockk)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.robolectric)
+ testImplementation(projects.features.analytics.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.mediapickers.test)
+ testImplementation(projects.libraries.mediaupload.test)
+ testImplementation(projects.libraries.usersearch.test)
+
+ androidTestImplementation(libs.test.junitext)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
new file mode 100644
index 0000000000..a5a78e54d5
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.createroom.impl
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
+import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
+import io.element.android.features.createroom.impl.di.CreateRoomComponent
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.DaggerComponentOwner
+import io.element.android.libraries.di.SessionScope
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class ConfigureRoomFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : DaggerComponentOwner,
+ BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins
+ ) {
+
+ private val component by lazy {
+ parent!!.bindings().createRoomComponentBuilder().build()
+ }
+
+ override val daggerComponent: Any
+ get() = component
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object Root : NavTarget
+
+ @Parcelize
+ object ConfigureRoom : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : AddPeopleNode.Callback {
+ override fun onContinue() {
+ backstack.push(NavTarget.ConfigureRoom)
+ }
+ }
+ createNode(context = buildContext, plugins = listOf(callback))
+ }
+ NavTarget.ConfigureRoom -> {
+ val callbacks = plugins()
+ createNode(context = buildContext, plugins = callbacks)
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler()
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt
new file mode 100644
index 0000000000..8f6f6e14d9
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.createroom.impl
+
+import android.net.Uri
+import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+data class CreateRoomConfig(
+ val roomName: String? = null,
+ val topic: String? = null,
+ val avatarUri: Uri? = null,
+ val invites: ImmutableList = persistentListOf(),
+ val privacy: RoomPrivacy = RoomPrivacy.Private,
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
new file mode 100644
index 0000000000..f79284d082
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.createroom.impl
+
+import android.net.Uri
+import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
+import io.element.android.features.createroom.impl.di.CreateRoomScope
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.libraries.androidutils.file.safeDelete
+import io.element.android.libraries.di.SingleIn
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
+import java.io.File
+import javax.inject.Inject
+
+@SingleIn(CreateRoomScope::class)
+class CreateRoomDataStore @Inject constructor(
+ val selectedUserListDataStore: UserListDataStore,
+) {
+
+ private val createRoomConfigFlow: MutableStateFlow = MutableStateFlow(CreateRoomConfig())
+ private var cachedAvatarUri: Uri? = null
+ set(value) {
+ field?.path?.let { File(it) }?.safeDelete()
+ field = value
+ }
+
+ fun getCreateRoomConfig(): Flow = combine(
+ selectedUserListDataStore.selectedUsers(),
+ createRoomConfigFlow,
+ ) { selectedUsers, config ->
+ config.copy(invites = selectedUsers.toImmutableList())
+ }
+
+ fun setRoomName(roomName: String?) {
+ createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() }))
+ }
+
+ fun setTopic(topic: String?) {
+ createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
+ }
+
+ fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
+ cachedAvatarUri = uri.takeIf { cached }
+ createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri))
+ }
+
+ fun setPrivacy(privacy: RoomPrivacy) {
+ createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
+ }
+
+ fun clearCachedData() {
+ cachedAvatarUri = null
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
new file mode 100644
index 0000000000..6f447e6bc9
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.createroom.impl
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.push
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.api.CreateRoomEntryPoint
+import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
+import io.element.android.features.createroom.impl.root.CreateRoomRootNode
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class CreateRoomFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins
+) {
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object Root : NavTarget
+
+ @Parcelize
+ object NewRoom : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : CreateRoomRootNode.Callback {
+ override fun onCreateNewRoom() {
+ backstack.push(NavTarget.NewRoom)
+ }
+
+ override fun onStartChatSuccess(roomId: RoomId) {
+ plugins().forEach { it.onSuccess(roomId) }
+ }
+ }
+ createNode(context = buildContext, plugins = listOf(callback))
+ }
+ NavTarget.NewRoom -> {
+ val callback = object : ConfigureRoomNode.Callback {
+ override fun onCreateRoomSuccess(roomId: RoomId) {
+ plugins().forEach { it.onSuccess(roomId) }
+ }
+ }
+ createNode(context = buildContext, plugins = listOf(callback))
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler()
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
new file mode 100644
index 0000000000..34e514be3e
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.createroom.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.createroom.api.CreateRoomEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint {
+
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder {
+
+ val plugins = ArrayList()
+
+ return object : CreateRoomEntryPoint.NodeBuilder {
+
+ override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt
new file mode 100644
index 0000000000..1b4bd9ac8d
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.createroom.impl.addpeople
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.impl.di.CreateRoomScope
+
+@ContributesNode(CreateRoomScope::class)
+class AddPeopleNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: AddPeoplePresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun onContinue()
+ }
+
+ private fun onContinue() {
+ plugins().forEach { it.onContinue() }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ AddPeopleView(
+ state = state,
+ modifier = modifier,
+ onBackPressed = this::navigateUp,
+ onNextPressed = this::onContinue,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt
new file mode 100644
index 0000000000..0927e193ad
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.createroom.impl.addpeople
+
+import androidx.compose.runtime.Composable
+import io.element.android.features.createroom.impl.CreateRoomDataStore
+import io.element.android.features.createroom.impl.userlist.SelectionMode
+import io.element.android.features.createroom.impl.userlist.UserListPresenter
+import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.usersearch.api.UserRepository
+import javax.inject.Inject
+
+class AddPeoplePresenter @Inject constructor(
+ private val userListPresenterFactory: UserListPresenter.Factory,
+ private val userRepository: UserRepository,
+ private val dataStore: CreateRoomDataStore,
+) : Presenter {
+
+ private val userListPresenter by lazy {
+ userListPresenterFactory.create(
+ UserListPresenterArgs(
+ selectionMode = SelectionMode.Multiple,
+ ),
+ userRepository,
+ dataStore.selectedUserListDataStore,
+ )
+ }
+
+ @Composable
+ override fun present(): UserListState {
+ return userListPresenter.present()
+ }
+}
+
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
new file mode 100644
index 0000000000..48ad56caf4
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.createroom.impl.addpeople
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.createroom.impl.userlist.SelectionMode
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
+import io.element.android.features.createroom.impl.userlist.aUserListState
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import kotlinx.collections.immutable.toImmutableList
+
+open class AddPeopleUserListStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aUserListState(),
+ aUserListState().copy(
+ searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
+ selectedUsers = aListOfSelectedUsers(),
+ isSearchActive = false,
+ selectionMode = SelectionMode.Multiple,
+ ),
+ aUserListState().copy(
+ searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
+ selectedUsers = aListOfSelectedUsers(),
+ isSearchActive = true,
+ selectionMode = SelectionMode.Multiple,
+ )
+ )
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
new file mode 100644
index 0000000000..da1f43391b
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.features.createroom.impl.addpeople
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.createroom.impl.R
+import io.element.android.features.createroom.impl.components.UserListView
+import io.element.android.features.createroom.impl.userlist.UserListEvents
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasButtonText
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun AddPeopleView(
+ state: UserListState,
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+ onNextPressed: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ AddPeopleViewTopBar(
+ hasSelectedUsers = state.selectedUsers.isNotEmpty(),
+ onBackPressed = {
+ if (state.isSearchActive) {
+ state.eventSink(UserListEvents.OnSearchActiveChanged(false))
+ } else {
+ onBackPressed()
+ }
+ },
+ onNextPressed = onNextPressed,
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .consumeWindowInsets(padding),
+ ) {
+ UserListView(
+ modifier = Modifier
+ .fillMaxWidth(),
+ state = state,
+ showBackButton = false,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddPeopleViewTopBar(
+ hasSelectedUsers: Boolean,
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+ onNextPressed: () -> Unit = {},
+) {
+ TopAppBar(
+ modifier = modifier,
+ title = {
+ Text(
+ text = stringResource(id = R.string.screen_create_room_add_people_title),
+ style = ElementTheme.typography.aliasScreenTitle
+ )
+ },
+ navigationIcon = { BackButton(onClick = onBackPressed) },
+ actions = {
+ TextButton(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ onClick = onNextPressed,
+ ) {
+ val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
+ Text(
+ text = stringResource(id = textActionResId),
+ style = ElementTheme.typography.aliasButtonText,
+ )
+ }
+ }
+ )
+}
+
+@Preview
+@Composable
+internal fun AddPeopleViewLightPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun AddPeopleViewDarkPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: UserListState) {
+ AddPeopleView(state = state)
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt
new file mode 100644
index 0000000000..ee664673f8
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.createroom.impl.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
+import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.RadioButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.ElementTheme
+
+@Composable
+fun RoomPrivacyOption(
+ roomPrivacyItem: RoomPrivacyItem,
+ modifier: Modifier = Modifier,
+ isSelected: Boolean = false,
+ onOptionSelected: (RoomPrivacyItem) -> Unit = {},
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = isSelected,
+ onClick = { onOptionSelected(roomPrivacyItem) },
+ role = Role.RadioButton,
+ )
+ .padding(8.dp),
+ ) {
+ Icon(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ imageVector = roomPrivacyItem.icon,
+ contentDescription = "",
+ tint = MaterialTheme.colorScheme.secondary,
+ )
+
+ Column(
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp)
+ ) {
+ Text(
+ text = roomPrivacyItem.title,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.size(3.dp))
+ Text(
+ text = roomPrivacyItem.description,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ }
+
+ RadioButton(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .size(48.dp),
+ selected = isSelected,
+ onClick = null // null recommended for accessibility with screenreaders
+ )
+ }
+}
+
+@Preview
+@Composable
+fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() }
+
+@Preview
+@Composable
+fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() }
+
+@Composable
+private fun ContentToPreview() {
+ val aRoomPrivacyItem = roomPrivacyItems().first()
+ Column {
+ RoomPrivacyOption(
+ roomPrivacyItem = aRoomPrivacyItem,
+ isSelected = true,
+ )
+ RoomPrivacyOption(
+ roomPrivacyItem = aRoomPrivacyItem,
+ isSelected = false,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt
new file mode 100644
index 0000000000..7b726e08a2
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.createroom.impl.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.ElementThemedPreview
+import io.element.android.libraries.designsystem.theme.components.Divider
+import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
+import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.usersearch.api.UserSearchResult
+
+@Composable
+fun SearchMultipleUsersResultItem(
+ searchResult: UserSearchResult,
+ isUserSelected: Boolean,
+ modifier: Modifier = Modifier,
+ onCheckedChange: (Boolean) -> Unit = {},
+) {
+ if (searchResult.isUnresolved) {
+ CheckableUnresolvedUserRow(
+ checked = isUserSelected,
+ modifier = modifier,
+ avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
+ id = searchResult.matrixUser.userId.value,
+ onCheckedChange = onCheckedChange,
+ )
+ } else {
+ CheckableMatrixUserRow(
+ checked = isUserSelected,
+ modifier = modifier,
+ matrixUser = searchResult.matrixUser,
+ avatarSize = AvatarSize.UserListItem,
+ onCheckedChange = onCheckedChange,
+ )
+ }
+}
+
+@Preview
+@Composable
+internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { ContentToPreview() }
+
+@Composable
+private fun ContentToPreview() {
+ Column {
+ SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
+ Divider()
+ SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true)
+ Divider()
+ SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false)
+ Divider()
+ SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true)
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt
new file mode 100644
index 0000000000..72ebee9615
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.createroom.impl.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.ElementThemedPreview
+import io.element.android.libraries.designsystem.theme.components.Divider
+import io.element.android.libraries.matrix.ui.components.MatrixUserRow
+import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.matrix.ui.model.getAvatarData
+import io.element.android.libraries.usersearch.api.UserSearchResult
+
+@Composable
+fun SearchSingleUserResultItem(
+ searchResult: UserSearchResult,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+) {
+ if (searchResult.isUnresolved) {
+ UnresolvedUserRow(
+ modifier = modifier.clickable(onClick = onClick),
+ avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem),
+ id = searchResult.matrixUser.userId.value,
+ )
+ } else {
+ MatrixUserRow(
+ modifier = modifier.clickable(onClick = onClick),
+ matrixUser = searchResult.matrixUser,
+ avatarSize = AvatarSize.UserListItem,
+ )
+ }
+}
+
+@Preview
+@Composable
+internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview { ContentToPreview() }
+
+@Composable
+private fun ContentToPreview() {
+ Column {
+ SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
+ Divider()
+ SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true))
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt
new file mode 100644
index 0000000000..fdcd8900b4
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.createroom.impl.components
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.theme.components.Divider
+import io.element.android.libraries.designsystem.theme.components.SearchBar
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.SelectedUsersList
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.usersearch.api.UserSearchResult
+import kotlinx.collections.immutable.ImmutableList
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchUserBar(
+ query: String,
+ state: SearchBarResultState>,
+ selectedUsers: ImmutableList,
+ active: Boolean,
+ isMultiSelectionEnabled: Boolean,
+ modifier: Modifier = Modifier,
+ showBackButton: Boolean = true,
+ placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
+ onActiveChanged: (Boolean) -> Unit = {},
+ onTextChanged: (String) -> Unit = {},
+ onUserSelected: (MatrixUser) -> Unit = {},
+ onUserDeselected: (MatrixUser) -> Unit = {},
+) {
+ val columnState = rememberLazyListState()
+
+ SearchBar(
+ query = query,
+ onQueryChange = onTextChanged,
+ active = active,
+ onActiveChange = onActiveChanged,
+ modifier = modifier,
+ placeHolderTitle = placeHolderTitle,
+ showBackButton = showBackButton,
+ contentPrefix = {
+ if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
+ // We want the selected users to behave a bit like a top bar - when the list below is scrolled, the colour
+ // should change to indicate elevation.
+
+ val elevation = remember {
+ derivedStateOf {
+ if (columnState.canScrollBackward) {
+ 4.dp
+ } else {
+ 0.dp
+ }
+ }
+ }
+
+ val appBarContainerColor by animateColorAsState(
+ targetValue = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation.value),
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
+ )
+
+ SelectedUsersList(
+ contentPadding = PaddingValues(16.dp),
+ selectedUsers = selectedUsers,
+ autoScroll = true,
+ onUserRemoved = onUserDeselected,
+ modifier = Modifier.background(appBarContainerColor)
+ )
+ }
+ },
+ resultState = state,
+ resultHandler = { users ->
+ LazyColumn(state = columnState) {
+ if (isMultiSelectionEnabled) {
+ itemsIndexed(users) { index, searchResult ->
+ SearchMultipleUsersResultItem(
+ modifier = Modifier.fillMaxWidth(),
+ searchResult = searchResult,
+ isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
+ onCheckedChange = { checked ->
+ if (checked) {
+ onUserSelected(searchResult.matrixUser)
+ } else {
+ onUserDeselected(searchResult.matrixUser)
+ }
+ }
+ )
+ if (index < users.lastIndex) {
+ Divider()
+ }
+ }
+ } else {
+ itemsIndexed(users) { index, searchResult ->
+ SearchSingleUserResultItem(
+ modifier = Modifier.fillMaxWidth(),
+ searchResult = searchResult,
+ onClick = { onUserSelected(searchResult.matrixUser) }
+ )
+ if (index < users.lastIndex) {
+ Divider()
+ }
+ }
+ }
+ }
+ },
+ )
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt
new file mode 100644
index 0000000000..7d543261fc
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt
@@ -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.createroom.impl.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.createroom.impl.userlist.UserListEvents
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.features.createroom.impl.userlist.UserListStateProvider
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.SelectedUsersList
+
+@Composable
+fun UserListView(
+ state: UserListState,
+ modifier: Modifier = Modifier,
+ showBackButton: Boolean = true,
+ onUserSelected: (MatrixUser) -> Unit = {},
+ onUserDeselected: (MatrixUser) -> Unit = {},
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ SearchUserBar(
+ modifier = Modifier.fillMaxWidth(),
+ query = state.searchQuery,
+ state = state.searchResults,
+ selectedUsers = state.selectedUsers,
+ active = state.isSearchActive,
+ isMultiSelectionEnabled = state.isMultiSelectionEnabled,
+ showBackButton = showBackButton,
+ onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
+ onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
+ onUserSelected = {
+ state.eventSink(UserListEvents.AddToSelection(it))
+ onUserSelected(it)
+ },
+ onUserDeselected = {
+ state.eventSink(UserListEvents.RemoveFromSelection(it))
+ onUserDeselected(it)
+ },
+ )
+
+ if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
+ SelectedUsersList(
+ contentPadding = PaddingValues(16.dp),
+ selectedUsers = state.selectedUsers,
+ autoScroll = true,
+ onUserRemoved = {
+ state.eventSink(UserListEvents.RemoveFromSelection(it))
+ onUserDeselected(it)
+ },
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: UserListState) {
+ UserListView(state = state)
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt
new file mode 100644
index 0000000000..a020b387cb
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import io.element.android.features.createroom.impl.CreateRoomConfig
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+
+sealed interface ConfigureRoomEvents {
+ data class RoomNameChanged(val name: String) : ConfigureRoomEvents
+ data class TopicChanged(val topic: String) : ConfigureRoomEvents
+ data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents
+ data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
+ data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
+ data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
+ object CancelCreateRoom : ConfigureRoomEvents
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
new file mode 100644
index 0000000000..b09863b205
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.createroom.impl.di.CreateRoomScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.services.analytics.api.AnalyticsService
+
+@ContributesNode(CreateRoomScope::class)
+class ConfigureRoomNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: ConfigureRoomPresenter,
+ private val analyticsService: AnalyticsService,
+) : Node(buildContext, plugins = plugins) {
+
+ init {
+ lifecycle.subscribe(
+ onResume = {
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom))
+ }
+ )
+ }
+
+ interface Callback : Plugin {
+ fun onCreateRoomSuccess(roomId: RoomId)
+ }
+
+ private fun onRoomCreated(roomId: RoomId) {
+ plugins().forEach { it.onCreateRoomSuccess(roomId) }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ConfigureRoomView(
+ state = state,
+ modifier = modifier,
+ onBackPressed = this::navigateUp,
+ onRoomCreated = this::onRoomCreated,
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
new file mode 100644
index 0000000000..21d8f8d13f
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.createroom.impl.CreateRoomConfig
+import io.element.android.features.createroom.impl.CreateRoomDataStore
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
+import io.element.android.libraries.matrix.api.createroom.RoomPreset
+import io.element.android.libraries.matrix.api.createroom.RoomVisibility
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.mediapickers.api.PickerProvider
+import io.element.android.libraries.mediaupload.api.MediaPreProcessor
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class ConfigureRoomPresenter @Inject constructor(
+ private val dataStore: CreateRoomDataStore,
+ private val matrixClient: MatrixClient,
+ private val mediaPickerProvider: PickerProvider,
+ private val mediaPreProcessor: MediaPreProcessor,
+ private val analyticsService: AnalyticsService,
+) : Presenter {
+
+ @Composable
+ override fun present(): ConfigureRoomState {
+ val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
+
+ val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
+ onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
+ )
+ val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
+ onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) }
+ )
+
+ val avatarActions by remember(createRoomConfig.value.avatarUri) {
+ derivedStateOf {
+ listOfNotNull(
+ AvatarAction.TakePhoto,
+ AvatarAction.ChoosePhoto,
+ AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null },
+ ).toImmutableList()
+ }
+ }
+
+ val localCoroutineScope = rememberCoroutineScope()
+ val createRoomAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+
+ fun createRoom(config: CreateRoomConfig) {
+ createRoomAction.value = Async.Uninitialized
+ localCoroutineScope.createRoom(config, createRoomAction)
+ }
+
+ fun handleEvents(event: ConfigureRoomEvents) {
+ when (event) {
+ is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
+ is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
+ is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
+ is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
+ is ConfigureRoomEvents.CreateRoom -> createRoom(event.config)
+ is ConfigureRoomEvents.HandleAvatarAction -> {
+ when (event.action) {
+ AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
+ AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
+ AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
+ }
+ }
+
+ ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized
+ }
+ }
+
+ return ConfigureRoomState(
+ config = createRoomConfig.value,
+ avatarActions = avatarActions,
+ createRoomAction = createRoomAction.value,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ private fun CoroutineScope.createRoom(
+ config: CreateRoomConfig,
+ createRoomAction: MutableState>
+ ) = launch {
+ suspend {
+ val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
+ val params = CreateRoomParameters(
+ name = config.roomName,
+ topic = config.topic,
+ isEncrypted = config.privacy == RoomPrivacy.Private,
+ isDirect = false,
+ visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
+ preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
+ invite = config.invites.map { it.userId },
+ avatar = avatarUrl,
+ )
+ matrixClient.createRoom(params).getOrThrow()
+ .also {
+ dataStore.clearCachedData()
+ analyticsService.capture(CreatedRoom(isDM = false))
+ }
+ }.runCatchingUpdatingState(createRoomAction)
+ }
+
+ private suspend fun uploadAvatar(avatarUri: Uri): String {
+ val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
+ val byteArray = preprocessed.file.readBytes()
+ return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt
new file mode 100644
index 0000000000..5e52668a3d
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import io.element.android.libraries.matrix.api.user.MatrixUser
+
+data class ConfigureRoomPresenterArgs(
+ val selectedUsers: List,
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
new file mode 100644
index 0000000000..2e34f3bda2
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.configureroom
+
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.features.createroom.impl.CreateRoomConfig
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+
+data class ConfigureRoomState(
+ val config: CreateRoomConfig,
+ val avatarActions: ImmutableList,
+ val createRoomAction: Async,
+ val eventSink: (ConfigureRoomEvents) -> Unit
+) {
+ val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
new file mode 100644
index 0000000000..0e31e9e1c0
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.createroom.impl.CreateRoomConfig
+import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
+import io.element.android.libraries.architecture.Async
+import kotlinx.collections.immutable.persistentListOf
+
+open class ConfigureRoomStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aConfigureRoomState(),
+ aConfigureRoomState().copy(
+ config = CreateRoomConfig(
+ roomName = "Room 101",
+ topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines",
+ invites = aListOfSelectedUsers(),
+ privacy = RoomPrivacy.Public,
+ ),
+ ),
+ )
+}
+
+fun aConfigureRoomState() = ConfigureRoomState(
+ config = CreateRoomConfig(),
+ avatarActions = persistentListOf(),
+ createRoomAction = Async.Uninitialized,
+ eventSink = { },
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
new file mode 100644
index 0000000000..9ac8f0fbde
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt
@@ -0,0 +1,294 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import android.net.Uri
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.ModalBottomSheetValue
+import androidx.compose.material.rememberModalBottomSheetState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.createroom.impl.R
+import io.element.android.features.createroom.impl.components.RoomPrivacyOption
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.components.LabelledTextField
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasButtonText
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
+import io.element.android.libraries.matrix.ui.components.SelectedUsersList
+import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
+@Composable
+fun ConfigureRoomView(
+ state: ConfigureRoomState,
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+ onRoomCreated: (RoomId) -> Unit = {},
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val focusManager = LocalFocusManager.current
+ val itemActionsBottomSheetState = rememberModalBottomSheetState(
+ initialValue = ModalBottomSheetValue.Hidden,
+ )
+
+ if (state.createRoomAction is Async.Success) {
+ LaunchedEffect(state.createRoomAction) {
+ onRoomCreated(state.createRoomAction.data)
+ }
+ }
+
+ fun onAvatarClicked() {
+ focusManager.clearFocus()
+ coroutineScope.launch {
+ itemActionsBottomSheetState.show()
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.clearFocusOnTap(focusManager),
+ topBar = {
+ ConfigureRoomToolbar(
+ isNextActionEnabled = state.isCreateButtonEnabled,
+ onBackPressed = onBackPressed,
+ onNextPressed = {
+ focusManager.clearFocus()
+ state.eventSink(ConfigureRoomEvents.CreateRoom(state.config))
+ },
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .imePadding()
+ .verticalScroll(rememberScrollState())
+ .consumeWindowInsets(padding),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ RoomNameWithAvatar(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ avatarUri = state.config.avatarUri,
+ roomName = state.config.roomName.orEmpty(),
+ onAvatarClick = ::onAvatarClicked,
+ onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
+ )
+ RoomTopic(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ topic = state.config.topic.orEmpty(),
+ onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
+ )
+ if (state.config.invites.isNotEmpty()) {
+ SelectedUsersList(
+ modifier = Modifier.padding(bottom = 16.dp),
+ contentPadding = PaddingValues(horizontal = 24.dp),
+ selectedUsers = state.config.invites,
+ onUserRemoved = {
+ focusManager.clearFocus()
+ state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
+ },
+ )
+ }
+ RoomPrivacyOptions(
+ modifier = Modifier.padding(bottom = 40.dp),
+ selected = state.config.privacy,
+ onOptionSelected = {
+ focusManager.clearFocus()
+ state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy))
+ },
+ )
+ }
+ }
+
+ AvatarActionBottomSheet(
+ actions = state.avatarActions,
+ modalBottomSheetState = itemActionsBottomSheetState,
+ onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
+ )
+
+ when (state.createRoomAction) {
+ is Async.Loading -> {
+ ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
+ }
+
+ is Async.Failure -> {
+ RetryDialog(
+ content = stringResource(R.string.screen_create_room_error_creating_room),
+ onDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
+ onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
+ )
+ }
+
+ else -> Unit
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ConfigureRoomToolbar(
+ isNextActionEnabled: Boolean,
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+ onNextPressed: () -> Unit = {},
+) {
+ TopAppBar(
+ modifier = modifier,
+ title = {
+ Text(
+ text = stringResource(R.string.screen_create_room_title),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ },
+ navigationIcon = { BackButton(onClick = onBackPressed) },
+ actions = {
+ TextButton(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ enabled = isNextActionEnabled,
+ onClick = onNextPressed,
+ ) {
+ Text(
+ text = stringResource(CommonStrings.action_create),
+ style = ElementTheme.typography.aliasButtonText,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun RoomNameWithAvatar(
+ avatarUri: Uri?,
+ roomName: String,
+ modifier: Modifier = Modifier,
+ onAvatarClick: () -> Unit = {},
+ onRoomNameChanged: (String) -> Unit = {},
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ UnsavedAvatar(
+ avatarUri = avatarUri,
+ modifier = Modifier.clickable(onClick = onAvatarClick),
+ )
+
+ LabelledTextField(
+ label = stringResource(R.string.screen_create_room_room_name_label),
+ value = roomName,
+ placeholder = stringResource(CommonStrings.common_room_name_placeholder),
+ singleLine = true,
+ onValueChange = onRoomNameChanged,
+ )
+ }
+}
+
+@Composable
+fun RoomTopic(
+ topic: String,
+ modifier: Modifier = Modifier,
+ onTopicChanged: (String) -> Unit = {},
+) {
+ LabelledTextField(
+ modifier = modifier,
+ label = stringResource(R.string.screen_create_room_topic_label),
+ value = topic,
+ placeholder = stringResource(CommonStrings.common_topic_placeholder),
+ onValueChange = onTopicChanged,
+ maxLines = 3,
+ )
+}
+
+@Composable
+fun RoomPrivacyOptions(
+ selected: RoomPrivacy?,
+ modifier: Modifier = Modifier,
+ onOptionSelected: (RoomPrivacyItem) -> Unit = {},
+) {
+ val items = roomPrivacyItems()
+ Column(modifier = modifier.selectableGroup()) {
+ items.forEach { item ->
+ RoomPrivacyOption(
+ roomPrivacyItem = item,
+ isSelected = selected == item.privacy,
+ onOptionSelected = onOptionSelected,
+ )
+ }
+ }
+}
+
+private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
+ pointerInput(Unit) {
+ detectTapGestures(onTap = {
+ focusManager.clearFocus()
+ })
+ }
+
+@Preview
+@Composable
+fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: ConfigureRoomState) {
+ ConfigureRoomView(
+ state = state,
+ )
+}
+
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt
new file mode 100644
index 0000000000..5cb0cf25b4
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.createroom.impl.configureroom
+
+enum class RoomPrivacy {
+ Private,
+ Public,
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt
new file mode 100644
index 0000000000..462dedba00
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Lock
+import androidx.compose.material.icons.outlined.Public
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import io.element.android.features.createroom.impl.R
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+data class RoomPrivacyItem(
+ val privacy: RoomPrivacy,
+ val icon: ImageVector,
+ val title: String,
+ val description: String,
+)
+
+@Composable
+fun roomPrivacyItems(): ImmutableList {
+ return RoomPrivacy.values()
+ .map {
+ when (it) {
+ RoomPrivacy.Private -> RoomPrivacyItem(
+ privacy = it,
+ icon = Icons.Outlined.Lock,
+ title = stringResource(R.string.screen_create_room_private_option_title),
+ description = stringResource(R.string.screen_create_room_private_option_description),
+ )
+ RoomPrivacy.Public -> RoomPrivacyItem(
+ privacy = it,
+ icon = Icons.Outlined.Public,
+ title = stringResource(R.string.screen_create_room_public_option_title),
+ description = stringResource(R.string.screen_create_room_public_option_description),
+ )
+ }
+ }
+ .toImmutableList()
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt
new file mode 100644
index 0000000000..f6f50f67bf
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.di
+
+import com.squareup.anvil.annotations.ContributesTo
+import com.squareup.anvil.annotations.MergeSubcomponent
+import dagger.Subcomponent
+import io.element.android.libraries.architecture.NodeFactoriesBindings
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.SingleIn
+
+@SingleIn(CreateRoomScope::class)
+@MergeSubcomponent(CreateRoomScope::class)
+interface CreateRoomComponent : NodeFactoriesBindings {
+
+ @Subcomponent.Builder
+ interface Builder {
+ fun build(): CreateRoomComponent
+ }
+
+ @ContributesTo(SessionScope::class)
+ interface ParentBindings {
+ fun createRoomComponentBuilder(): Builder
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt
new file mode 100644
index 0000000000..c869536c56
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt
@@ -0,0 +1,19 @@
+/*
+ * 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.createroom.impl.di
+
+abstract class CreateRoomScope private constructor()
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt
new file mode 100644
index 0000000000..7d8211aea5
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.createroom.impl.root
+
+import io.element.android.libraries.matrix.api.user.MatrixUser
+
+sealed interface CreateRoomRootEvents {
+ data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
+ object CancelStartDM : CreateRoomRootEvents
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt
new file mode 100644
index 0000000000..597c5d0d01
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.createroom.impl.root
+
+import android.app.Activity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.services.analytics.api.AnalyticsService
+
+@ContributesNode(SessionScope::class)
+class CreateRoomRootNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: CreateRoomRootPresenter,
+ private val analyticsService: AnalyticsService,
+ private val inviteFriendsUseCase: InviteFriendsUseCase,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun onCreateNewRoom()
+ fun onStartChatSuccess(roomId: RoomId)
+ }
+
+ private val callback = object : Callback {
+ override fun onCreateNewRoom() {
+ plugins().forEach { it.onCreateNewRoom() }
+ }
+
+ override fun onStartChatSuccess(roomId: RoomId) {
+ plugins().forEach { it.onStartChatSuccess(roomId) }
+ }
+ }
+
+ init {
+ lifecycle.subscribe(
+ onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) }
+ )
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ val activity = LocalContext.current as Activity
+ CreateRoomRootView(
+ state = state,
+ modifier = modifier,
+ onClosePressed = this::navigateUp,
+ onNewRoomClicked = callback::onCreateNewRoom,
+ onOpenDM = callback::onStartChatSuccess,
+ onInviteFriendsClicked = { invitePeople(activity) }
+ )
+ }
+
+ private fun invitePeople(activity: Activity) {
+ inviteFriendsUseCase.execute(activity)
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
new file mode 100644
index 0000000000..20d3309a5f
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.createroom.impl.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.createroom.impl.userlist.SelectionMode
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.features.createroom.impl.userlist.UserListPresenter
+import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.usersearch.api.UserRepository
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class CreateRoomRootPresenter @Inject constructor(
+ private val presenterFactory: UserListPresenter.Factory,
+ private val userRepository: UserRepository,
+ private val userListDataStore: UserListDataStore,
+ private val matrixClient: MatrixClient,
+ private val analyticsService: AnalyticsService,
+ private val buildMeta: BuildMeta,
+) : Presenter {
+
+ private val presenter by lazy {
+ presenterFactory.create(
+ UserListPresenterArgs(
+ selectionMode = SelectionMode.Single,
+ ),
+ userRepository,
+ userListDataStore,
+ )
+ }
+
+ @Composable
+ override fun present(): CreateRoomRootState {
+ val userListState = presenter.present()
+
+ val localCoroutineScope = rememberCoroutineScope()
+ val startDmAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+
+ fun handleEvents(event: CreateRoomRootEvents) {
+ when (event) {
+ is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction)
+ CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
+ }
+ }
+
+ return CreateRoomRootState(
+ applicationName = buildMeta.applicationName,
+ userListState = userListState,
+ startDmAction = startDmAction.value,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ private fun CoroutineScope.startDm(matrixUser: MatrixUser, startDmAction: MutableState>) = launch {
+ suspend {
+ matrixClient.findDM(matrixUser.userId).use { existingDM ->
+ existingDM?.roomId ?: createDM(matrixUser)
+ }
+ }.runCatchingUpdatingState(startDmAction)
+ }
+
+ private suspend fun createDM(user: MatrixUser): RoomId {
+ return matrixClient
+ .createDM(user.userId)
+ .onSuccess {
+ analyticsService.capture(CreatedRoom(isDM = true))
+ }
+ .getOrThrow()
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt
new file mode 100644
index 0000000000..02f64a6c86
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.createroom.impl.root
+
+import io.element.android.features.createroom.impl.userlist.UserListState
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class CreateRoomRootState(
+ val applicationName: String,
+ val userListState: UserListState,
+ val startDmAction: Async,
+ val eventSink: (CreateRoomRootEvents) -> Unit,
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
new file mode 100644
index 0000000000..d1484b7a4f
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.createroom.impl.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.createroom.impl.userlist.aUserListState
+
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.usersearch.api.UserSearchResult
+import kotlinx.collections.immutable.persistentListOf
+
+open class CreateRoomRootStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aCreateRoomRootState(),
+ aCreateRoomRootState().copy(
+ startDmAction = Async.Loading(),
+ userListState = aMatrixUser().let {
+ aUserListState().copy(
+ searchQuery = it.userId.value,
+ searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
+ selectedUsers = persistentListOf(it),
+ isSearchActive = true,
+ )
+ }
+ ),
+ aCreateRoomRootState().copy(
+ startDmAction = Async.Failure(Throwable()),
+ userListState = aMatrixUser().let {
+ aUserListState().copy(
+ searchQuery = it.userId.value,
+ searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))),
+ selectedUsers = persistentListOf(it),
+ isSearchActive = true,
+ )
+ }
+ ),
+ )
+}
+
+fun aCreateRoomRootState() = CreateRoomRootState(
+ eventSink = {},
+ applicationName = "Element X Preview",
+ startDmAction = Async.Uninitialized,
+ userListState = aUserListState(),
+)
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
new file mode 100644
index 0000000000..ac8a05f448
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt
@@ -0,0 +1,221 @@
+/*
+ * 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.createroom.impl.root
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.createroom.impl.R
+import io.element.android.features.createroom.impl.components.UserListView
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.designsystem.R as DrawableR
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun CreateRoomRootView(
+ state: CreateRoomRootState,
+ modifier: Modifier = Modifier,
+ onClosePressed: () -> Unit = {},
+ onNewRoomClicked: () -> Unit = {},
+ onOpenDM: (RoomId) -> Unit = {},
+ onInviteFriendsClicked: () -> Unit = {},
+) {
+ if (state.startDmAction is Async.Success) {
+ LaunchedEffect(state.startDmAction) {
+ onOpenDM(state.startDmAction.data)
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.fillMaxWidth(),
+ topBar = {
+ if (!state.userListState.isSearchActive) {
+ CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
+ }
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ UserListView(
+ modifier = Modifier.fillMaxWidth(),
+ state = state.userListState,
+ onUserSelected = {
+ state.eventSink(CreateRoomRootEvents.StartDM(it))
+ },
+ )
+
+ if (!state.userListState.isSearchActive) {
+ CreateRoomActionButtonsList(
+ state = state,
+ onNewRoomClicked = onNewRoomClicked,
+ onInvitePeopleClicked = onInviteFriendsClicked,
+ )
+ }
+ }
+ }
+
+ when (state.startDmAction) {
+ is Async.Loading -> {
+ ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat))
+ }
+
+ is Async.Failure -> {
+ RetryDialog(
+ content = stringResource(id = R.string.screen_start_chat_error_starting_chat),
+ onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
+ onRetry = {
+ state.userListState.selectedUsers.firstOrNull()
+ ?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
+ // Cancel start DM if there is no more selected user (should not happen)
+ ?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
+ },
+ )
+ }
+
+ else -> Unit
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CreateRoomRootViewTopBar(
+ modifier: Modifier = Modifier,
+ onClosePressed: () -> Unit = {},
+) {
+ TopAppBar(
+ modifier = modifier,
+ title = {
+ Text(
+ text = stringResource(id = CommonStrings.action_start_chat),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClosePressed) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(id = CommonStrings.action_close),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ )
+}
+
+@Composable
+fun CreateRoomActionButtonsList(
+ state: CreateRoomRootState,
+ modifier: Modifier = Modifier,
+ onNewRoomClicked: () -> Unit = {},
+ onInvitePeopleClicked: () -> Unit = {},
+) {
+ Column(modifier = modifier) {
+ CreateRoomActionButton(
+ iconRes = DrawableR.drawable.ic_groups,
+ text = stringResource(id = R.string.screen_create_room_action_create_room),
+ onClick = onNewRoomClicked,
+ )
+ CreateRoomActionButton(
+ iconRes = DrawableR.drawable.ic_share,
+ text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
+ onClick = onInvitePeopleClicked,
+ )
+ }
+}
+
+@Composable
+fun CreateRoomActionButton(
+ @DrawableRes iconRes: Int,
+ text: String,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .clickable { onClick() }
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.secondary,
+ resourceId = iconRes,
+ contentDescription = null,
+ )
+ Text(
+ text = text,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ )
+ }
+}
+
+@Preview
+@Composable
+fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+fun CreateRoomRootViewDarkPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: CreateRoomRootState) {
+ CreateRoomRootView(
+ state = state,
+ )
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
new file mode 100644
index 0000000000..867fdc9a60
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.createroom.impl.userlist
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import com.squareup.anvil.annotations.ContributesBinding
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.usersearch.api.UserRepository
+import io.element.android.libraries.usersearch.api.UserSearchResult
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+class DefaultUserListPresenter @AssistedInject constructor(
+ @Assisted val args: UserListPresenterArgs,
+ @Assisted val userRepository: UserRepository,
+ @Assisted val userListDataStore: UserListDataStore,
+) : UserListPresenter {
+
+ @AssistedFactory
+ @ContributesBinding(SessionScope::class)
+ interface DefaultUserListFactory : UserListPresenter.Factory {
+ override fun create(
+ args: UserListPresenterArgs,
+ userRepository: UserRepository,
+ userListDataStore: UserListDataStore,
+ ): DefaultUserListPresenter
+ }
+
+ @Composable
+ override fun present(): UserListState {
+ var isSearchActive by rememberSaveable { mutableStateOf(false) }
+ val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
+ var searchQuery by rememberSaveable { mutableStateOf("") }
+ var searchResults: SearchBarResultState> by remember {
+ mutableStateOf(SearchBarResultState.NotSearching())
+ }
+
+ LaunchedEffect(searchQuery) {
+ searchResults = SearchBarResultState.NotSearching()
+
+ userRepository.search(searchQuery).collect {
+ searchResults = when {
+ it.isEmpty() -> SearchBarResultState.NoResults()
+ else -> SearchBarResultState.Results(it.toImmutableList())
+ }
+ }
+ }
+
+ return UserListState(
+ searchQuery = searchQuery,
+ searchResults = searchResults,
+ selectedUsers = selectedUsers.toImmutableList(),
+ isSearchActive = isSearchActive,
+ selectionMode = args.selectionMode,
+ eventSink = { event ->
+ when (event) {
+ is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
+ is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
+ is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
+ is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
+ }
+ },
+ )
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
new file mode 100644
index 0000000000..8de7be3114
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.userlist
+
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+
+class UserListDataStore @Inject constructor() {
+
+ private val selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList())
+
+ fun selectUser(user: MatrixUser) {
+ if (user !in selectedUsers.value) {
+ selectedUsers.tryEmit(selectedUsers.value.plus(user))
+ }
+ }
+
+ fun removeUserFromSelection(user: MatrixUser) {
+ selectedUsers.tryEmit(selectedUsers.value.minus(user))
+ }
+
+ fun selectedUsers(): Flow> = selectedUsers
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListEvents.kt
new file mode 100644
index 0000000000..6dc817d22c
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListEvents.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.userlist
+
+import io.element.android.libraries.matrix.api.user.MatrixUser
+
+sealed interface UserListEvents {
+ data class UpdateSearchQuery(val query: String) : UserListEvents
+ data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents
+ data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents
+ data class OnSearchActiveChanged(val active: Boolean) : UserListEvents
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenter.kt
new file mode 100644
index 0000000000..e5d68a2e28
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenter.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.createroom.impl.userlist
+
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.usersearch.api.UserRepository
+
+interface UserListPresenter : Presenter {
+
+ interface Factory {
+ fun create(
+ args: UserListPresenterArgs,
+ userRepository: UserRepository,
+ userListDataStore: UserListDataStore,
+ ): UserListPresenter
+ }
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenterArgs.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenterArgs.kt
new file mode 100644
index 0000000000..15c2a94a24
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenterArgs.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.createroom.impl.userlist
+
+data class UserListPresenterArgs(
+ val selectionMode: SelectionMode,
+)
+
+enum class SelectionMode {
+ Single,
+ Multiple,
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt
new file mode 100644
index 0000000000..60a5bea506
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.createroom.impl.userlist
+
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.usersearch.api.UserSearchResult
+import kotlinx.collections.immutable.ImmutableList
+
+data class UserListState(
+ val searchQuery: String,
+ val searchResults: SearchBarResultState>,
+ val selectedUsers: ImmutableList,
+ val isSearchActive: Boolean,
+ val selectionMode: SelectionMode,
+ val eventSink: (UserListEvents) -> Unit,
+) {
+ val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
+}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt
new file mode 100644
index 0000000000..31d1f6953a
--- /dev/null
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.createroom.impl.userlist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.libraries.usersearch.api.UserSearchResult
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+open class UserListStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aUserListState(),
+ aUserListState().copy(
+ isSearchActive = false,
+ selectedUsers = aListOfSelectedUsers(),
+ selectionMode = SelectionMode.Multiple,
+ ),
+ aUserListState().copy(isSearchActive = true),
+ aUserListState().copy(isSearchActive = true, searchQuery = "someone"),
+ aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
+ aUserListState().copy(
+ isSearchActive = true,
+ searchQuery = "@someone:matrix.org",
+ selectedUsers = aListOfSelectedUsers(),
+ searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
+ ),
+ aUserListState().copy(
+ isSearchActive = true,
+ searchQuery = "@someone:matrix.org",
+ selectionMode = SelectionMode.Multiple,
+ selectedUsers = aListOfSelectedUsers(),
+ searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
+ ),
+ aUserListState().copy(
+ isSearchActive = true,
+ searchQuery = "something-with-no-results",
+ searchResults = SearchBarResultState.NoResults()
+ ),
+ )
+}
+
+fun aUserListState() = UserListState(
+ isSearchActive = false,
+ searchQuery = "",
+ searchResults = SearchBarResultState.NotSearching(),
+ selectedUsers = persistentListOf(),
+ selectionMode = SelectionMode.Single,
+ eventSink = {}
+)
+
+fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..febd535cb0
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Nová místnost"
+ "Pozvat přátele do Elementu"
+ "Pozvat lidi"
+ "Při vytváření místnosti došlo k chybě"
+ "Zprávy v této místnosti jsou šifrované. Šifrování nelze později vypnout."
+ "Soukromá místnost (jen pro pozvané)"
+ "Zprávy nejsou šifrované a může si je přečíst kdokoli. Šifrování můžete povolit později."
+ "Veřejná místnost (kdokoli)"
+ "Název místnosti"
+ "Téma (nepovinné)"
+ "Při pokusu o zahájení chatu došlo k chybě"
+ "Vytvořit místnost"
+
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..abc2ef9d71
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Neuer Raum"
+ "Freunde zu Element einladen"
+ "Personen hinzufügen"
+ "Beim Erstellen des Raums ist ein Fehler aufgetreten"
+ "Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."
+ "Privater Raum (nur auf Einladung)"
+ "Nachrichten sind nicht verschlüsselt und jeder kann sie lesen. Du kannst die Verschlüsselung zu einem späteren Zeitpunkt aktivieren."
+ "Öffentlicher Raum (jeder)"
+ "Raumname"
+ "Thema (optional)"
+ "Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"
+ "Raum erstellen"
+
diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 0000000000..9a5d672fd4
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Nueva sala"
+ "Invitar gente"
+ "Añadir personas"
+ "Se ha producido un error al intentar iniciar un chat"
+ "Crear una sala"
+
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..6be7345c97
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Nouveau salon"
+ "Inviter des amis sur Element"
+ "Inviter des personnes"
+ "Une erreur s\'est produite lors de la création du salon"
+ "Les messages dans ce salon sont chiffrés. Une fopis activé, le chiffrement ne peut pas être désactivé."
+ "Salon privé (sur invitation uniquement)"
+ "Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."
+ "Salon public (n’importe qui)"
+ "Nom du salon"
+ "Sujet (optionnel)"
+ "Une erreur s\'est produite lors de la tentative de démarrage d\'une discussion"
+ "Créer un salon"
+
diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 0000000000..ceddb71154
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Nuova stanza"
+ "Invita persone"
+ "Aggiungi persone"
+ "Si è verificato un errore durante il tentativo di avviare una chat"
+ "Crea una stanza"
+
diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..9f68a006e5
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Cameră nouă"
+ "Invitați prieteni în Element"
+ "Invitați persoane"
+ "A apărut o eroare la crearea camerei"
+ "Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior."
+ "Cameră privată (doar pe bază de invitație)"
+ "Mesajele nu sunt criptate și oricine le poate citi. Puteți activa criptarea la o dată ulterioară."
+ "Cameră publică (oricine)"
+ "Numele camerei"
+ "Subiect (opțional)"
+ "A apărut o eroare la încercarea începerii conversației"
+ "Creați o cameră"
+
diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..831ac67369
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "Nová miestnosť"
+ "Pozvať priateľov na Element"
+ "Pozvať ľudí"
+ "Pri vytváraní miestnosti došlo k chybe"
+ "Správy v tejto miestnosti sú šifrované. Šifrovanie už potom nie je možné vypnúť."
+ "Súkromná miestnosť (len pre pozvaných)"
+ "Správy nie sú šifrované a môže si ich prečítať ktokoľvek. Šifrovanie môžete zapnúť neskôr."
+ "Verejná miestnosť (ktokoľvek)"
+ "Názov miestnosti"
+ "Téma (voliteľné)"
+ "Pri pokuse o spustenie konverzácie sa vyskytla chyba"
+ "Vytvoriť miestnosť"
+
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..68f318d385
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,15 @@
+
+
+ "New room"
+ "Invite friends to Element"
+ "Invite people"
+ "An error occurred when creating the room"
+ "Messages in this room are encrypted. Encryption can’t be disabled afterwards."
+ "Private room (invite only)"
+ "Messages are not encrypted and anyone can read them. You can enable encryption at a later date."
+ "Public room (anyone)"
+ "Room name"
+ "Topic (optional)"
+ "An error occurred when trying to start a chat"
+ "Create a room"
+
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
new file mode 100644
index 0000000000..fe8c7b7462
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.createroom.impl.addpeople
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.createroom.impl.CreateRoomDataStore
+import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.libraries.usersearch.test.FakeUserRepository
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class AddPeoplePresenterTests {
+
+ private lateinit var presenter: AddPeoplePresenter
+
+ @Before
+ fun setup() {
+ presenter = AddPeoplePresenter(
+ FakeUserListPresenterFactory(),
+ FakeUserRepository(),
+ CreateRoomDataStore(UserListDataStore())
+ )
+ }
+
+ @Test
+ fun `present - initial state`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ // TODO This doesn't actually test anything...
+ val initialState = awaitItem()
+ assertThat(initialState)
+ }
+ }
+}
+
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
new file mode 100644
index 0000000000..9b6ac2e067
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt
@@ -0,0 +1,297 @@
+/*
+ * 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.createroom.impl.configureroom
+
+import android.net.Uri
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.features.createroom.impl.CreateRoomConfig
+import io.element.android.features.createroom.impl.CreateRoomDataStore
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.test.AN_AVATAR_URL
+import io.element.android.libraries.matrix.test.A_MESSAGE
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.matrix.ui.media.AvatarAction
+import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaupload.api.MediaUploadInfo
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+
+private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
+private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
+
+@RunWith(RobolectricTestRunner::class)
+class ConfigureRoomPresenterTests {
+
+ private lateinit var presenter: ConfigureRoomPresenter
+ private lateinit var userListDataStore: UserListDataStore
+ private lateinit var createRoomDataStore: CreateRoomDataStore
+ private lateinit var fakeMatrixClient: FakeMatrixClient
+ private lateinit var fakePickerProvider: FakePickerProvider
+ private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
+ private lateinit var fakeAnalyticsService: FakeAnalyticsService
+
+ @Before
+ fun setup() {
+ fakeMatrixClient = FakeMatrixClient()
+ userListDataStore = UserListDataStore()
+ createRoomDataStore = CreateRoomDataStore(userListDataStore)
+ fakePickerProvider = FakePickerProvider()
+ fakeMediaPreProcessor = FakeMediaPreProcessor()
+ fakeAnalyticsService = FakeAnalyticsService()
+ presenter = ConfigureRoomPresenter(
+ dataStore = createRoomDataStore,
+ matrixClient = fakeMatrixClient,
+ mediaPickerProvider = fakePickerProvider,
+ mediaPreProcessor = fakeMediaPreProcessor,
+ analyticsService = fakeAnalyticsService,
+ )
+
+ mockkStatic(File::readBytes)
+ every { any().readBytes() } returns byteArrayOf()
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `present - initial state`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.config).isEqualTo(CreateRoomConfig())
+ assertThat(initialState.config.roomName).isNull()
+ assertThat(initialState.config.topic).isNull()
+ assertThat(initialState.config.invites).isEmpty()
+ assertThat(initialState.config.avatarUri).isNull()
+ assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private)
+ }
+ }
+
+ @Test
+ fun `present - create room button is enabled only if the required fields are completed`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ var config = initialState.config
+ assertThat(initialState.isCreateButtonEnabled).isFalse()
+
+ // Room name not empty
+ initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
+ var newState: ConfigureRoomState = awaitItem()
+ config = config.copy(roomName = A_ROOM_NAME)
+ assertThat(newState.config).isEqualTo(config)
+ assertThat(newState.isCreateButtonEnabled).isTrue()
+
+ // Clear room name
+ newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
+ newState = awaitItem()
+ config = config.copy(roomName = null)
+ assertThat(newState.config).isEqualTo(config)
+ assertThat(newState.isCreateButtonEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - state is updated when fields are changed`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ var expectedConfig = CreateRoomConfig()
+ assertThat(initialState.config).isEqualTo(expectedConfig)
+
+ // Select User
+ val selectedUser1 = aMatrixUser()
+ val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob")
+ userListDataStore.selectUser(selectedUser1)
+ skipItems(1)
+ userListDataStore.selectUser(selectedUser2)
+ var newState = awaitItem()
+ expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2))
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room name
+ initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room topic
+ newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(topic = A_MESSAGE)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room avatar
+ // Pick avatar
+ fakePickerProvider.givenResult(null)
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ // From gallery
+ val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
+ fakePickerProvider.givenResult(uriFromGallery)
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ // From camera
+ val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
+ fakePickerProvider.givenResult(uriFromCamera)
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ // Remove
+ newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(avatarUri = null)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Room privacy
+ newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(privacy = RoomPrivacy.Public)
+ assertThat(newState.config).isEqualTo(expectedConfig)
+
+ // Remove user
+ newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1))
+ newState = awaitItem()
+ expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
+ assertThat(newState.config).isEqualTo(expectedConfig)
+ }
+ }
+
+ @Test
+ fun `present - trigger create room action`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
+
+ fakeMatrixClient.givenCreateRoomResult(createRoomResult)
+
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Success::class.java)
+ assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull())
+ }
+ }
+
+ @Test
+ fun `present - record analytics when creating room`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
+
+ fakeMatrixClient.givenCreateRoomResult(createRoomResult)
+
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ skipItems(2)
+
+ val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance().firstOrNull()
+ assertThat(analyticsEvent).isNotNull()
+ assertThat(analyticsEvent?.isDM).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - trigger create room with upload error and retry`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
+ fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
+ fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
+
+ val initialState = awaitItem()
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
+ assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty()
+
+ fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java)
+ }
+ }
+
+ @Test
+ fun `present - trigger retry and cancel actions`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val createRoomResult = Result.failure(A_THROWABLE)
+
+ fakeMatrixClient.givenCreateRoomResult(createRoomResult)
+
+ // Create
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
+ assertThat((stateAfterCreateRoom.createRoomAction as? Async.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
+
+ // Retry
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterRetry = awaitItem()
+ assertThat(stateAfterRetry.createRoomAction).isInstanceOf(Async.Failure::class.java)
+ assertThat((stateAfterRetry.createRoomAction as? Async.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
+
+ // Cancel
+ stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java)
+ }
+ }
+}
+
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
new file mode 100644
index 0000000000..8976c77b8e
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt
@@ -0,0 +1,185 @@
+/*
+ * 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.createroom.impl.root
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import im.vector.app.features.analytics.plan.CreatedRoom
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
+import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
+import io.element.android.features.createroom.impl.userlist.UserListDataStore
+import io.element.android.features.createroom.impl.userlist.aUserListState
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.core.meta.BuildType
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.usersearch.test.FakeUserRepository
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class CreateRoomRootPresenterTests {
+
+ private lateinit var userRepository: FakeUserRepository
+ private lateinit var presenter: CreateRoomRootPresenter
+ private lateinit var fakeUserListPresenter: FakeUserListPresenter
+ private lateinit var fakeMatrixClient: FakeMatrixClient
+ private lateinit var fakeAnalyticsService: FakeAnalyticsService
+
+ @Before
+ fun setup() {
+ fakeUserListPresenter = FakeUserListPresenter()
+ fakeMatrixClient = FakeMatrixClient()
+ fakeAnalyticsService = FakeAnalyticsService()
+ userRepository = FakeUserRepository()
+ presenter = CreateRoomRootPresenter(
+ presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter),
+ userRepository = userRepository,
+ userListDataStore = UserListDataStore(),
+ matrixClient = fakeMatrixClient,
+ analyticsService = fakeAnalyticsService,
+ buildMeta = aBuildMeta(),
+ )
+ }
+
+ @Test
+ fun `present - initial state`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
+ assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName)
+ assertThat(initialState.userListState.selectedUsers).isEmpty()
+ assertThat(initialState.userListState.isSearchActive).isFalse()
+ assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - trigger create DM action`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ val createDmResult = Result.success(RoomId("!createDmResult:domain"))
+
+ fakeMatrixClient.givenFindDmResult(null)
+ fakeMatrixClient.givenCreateDmResult(createDmResult)
+
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterStartDM = awaitItem()
+ assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
+ assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
+ }
+ }
+
+ @Test
+ fun `present - creating a DM records analytics event`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ val createDmResult = Result.success(RoomId("!createDmResult:domain"))
+
+ fakeMatrixClient.givenFindDmResult(null)
+ fakeMatrixClient.givenCreateDmResult(createDmResult)
+
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ skipItems(2)
+
+ val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance().firstOrNull()
+ assertThat(analyticsEvent).isNotNull()
+ assertThat(analyticsEvent?.isDM).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - trigger retrieve DM action`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain"))
+
+ fakeMatrixClient.givenFindDmResult(fakeDmResult)
+
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ val stateAfterStartDM = awaitItem()
+ assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
+ assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
+ assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty()
+ }
+ }
+
+ @Test
+ fun `present - trigger retry create DM action`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val matrixUser = MatrixUser(UserId("@name:domain"))
+ val createDmResult = Result.success(RoomId("!createDmResult:domain"))
+ fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
+
+ fakeMatrixClient.givenFindDmResult(null)
+ fakeMatrixClient.givenCreateDmError(A_THROWABLE)
+ fakeMatrixClient.givenCreateDmResult(createDmResult)
+
+ // Failure
+ initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterStartDM = awaitItem()
+ assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
+ assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty()
+
+ // Cancel
+ stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
+ val stateAfterCancel = awaitItem()
+ assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
+
+ // Failure
+ stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterSecondAttempt = awaitItem()
+ assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
+ assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance()).isEmpty()
+
+ // Retry with success
+ fakeMatrixClient.givenCreateDmError(null)
+ stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
+ assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
+ val stateAfterRetryStartDM = awaitItem()
+ assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
+ assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
+ }
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
new file mode 100644
index 0000000000..745bdf74f9
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt
@@ -0,0 +1,216 @@
+/*
+ * 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.createroom.impl.userlist
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
+import io.element.android.libraries.matrix.ui.components.aMatrixUserList
+import io.element.android.libraries.usersearch.api.UserSearchResult
+import io.element.android.libraries.usersearch.test.FakeUserRepository
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultUserListPresenterTests {
+
+ private val userRepository = FakeUserRepository()
+
+ @Test
+ fun `present - initial state for single selection`() = runTest {
+ val presenter =
+ DefaultUserListPresenter(
+ UserListPresenterArgs(selectionMode = SelectionMode.Single),
+ userRepository,
+ UserListDataStore(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.searchQuery).isEmpty()
+ assertThat(initialState.isMultiSelectionEnabled).isFalse()
+ assertThat(initialState.isSearchActive).isFalse()
+ assertThat(initialState.selectedUsers).isEmpty()
+ assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
+ }
+ }
+
+ @Test
+ fun `present - initial state for multiple selection`() = runTest {
+ val presenter =
+ DefaultUserListPresenter(
+ UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
+ userRepository,
+ UserListDataStore(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.searchQuery).isEmpty()
+ assertThat(initialState.isMultiSelectionEnabled).isTrue()
+ assertThat(initialState.isSearchActive).isFalse()
+ assertThat(initialState.selectedUsers).isEmpty()
+ assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
+ }
+ }
+
+ @Test
+ fun `present - update search query`() = runTest {
+ val presenter =
+ DefaultUserListPresenter(
+ UserListPresenterArgs(selectionMode = SelectionMode.Single),
+ userRepository,
+ UserListDataStore(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+
+ initialState.eventSink(UserListEvents.OnSearchActiveChanged(true))
+ assertThat(awaitItem().isSearchActive).isTrue()
+
+ val matrixIdQuery = "@name:matrix.org"
+ initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
+ assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
+ assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery)
+ skipItems(1)
+
+ val notMatrixIdQuery = "name"
+ initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
+ assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
+ assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery)
+ skipItems(1)
+
+ initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
+ assertThat(awaitItem().isSearchActive).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - presents search results`() = runTest {
+ val presenter =
+ DefaultUserListPresenter(
+ UserListPresenterArgs(
+ selectionMode = SelectionMode.Single,
+ ),
+ userRepository,
+ UserListDataStore(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+
+ initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
+ assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
+ assertThat(userRepository.providedQuery).isEqualTo("alice")
+ skipItems(2)
+
+ // When the user repository emits a result, it's copied to the state
+ userRepository.emitResult(listOf(UserSearchResult(aMatrixUser())))
+ assertThat(awaitItem().searchResults).isEqualTo(
+ SearchBarResultState.Results(
+ persistentListOf(UserSearchResult(aMatrixUser()))
+ )
+ )
+
+ // When the user repository emits another result, it replaces the previous value
+ userRepository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
+ assertThat(awaitItem().searchResults).isEqualTo(
+ SearchBarResultState.Results(
+ aMatrixUserList().map { UserSearchResult(it) }
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - presents search results when not found`() = runTest {
+ val presenter =
+ DefaultUserListPresenter(
+ UserListPresenterArgs(
+ selectionMode = SelectionMode.Single,
+ ),
+ userRepository,
+ UserListDataStore(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+
+ initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
+ assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
+ assertThat(userRepository.providedQuery).isEqualTo("alice")
+ skipItems(2)
+
+ // When the results list is empty, the state is set to NoResults
+ userRepository.emitResult(emptyList())
+ assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
+ }
+ }
+
+ @Test
+ fun `present - select a user`() = runTest {
+ val presenter =
+ DefaultUserListPresenter(
+ UserListPresenterArgs(selectionMode = SelectionMode.Single),
+ userRepository,
+ UserListDataStore(),
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+
+ val userA = aMatrixUser("@userA:domain", "A")
+ val userB = aMatrixUser("@userB:domain", "B")
+ val userABis = aMatrixUser("@userA:domain", "A")
+ val userC = aMatrixUser("@userC:domain", "C")
+
+ initialState.eventSink(UserListEvents.AddToSelection(userA))
+ assertThat(awaitItem().selectedUsers).containsExactly(userA)
+
+ initialState.eventSink(UserListEvents.AddToSelection(userB))
+ assertThat(awaitItem().selectedUsers).containsExactly(userA, userB)
+
+ initialState.eventSink(UserListEvents.AddToSelection(userABis))
+ initialState.eventSink(UserListEvents.AddToSelection(userC))
+ // duplicated users should be ignored
+ assertThat(awaitItem().selectedUsers).containsExactly(userA, userB, userC)
+
+ initialState.eventSink(UserListEvents.RemoveFromSelection(userB))
+ assertThat(awaitItem().selectedUsers).containsExactly(userA, userC)
+ initialState.eventSink(UserListEvents.RemoveFromSelection(userA))
+ assertThat(awaitItem().selectedUsers).containsExactly(userC)
+ initialState.eventSink(UserListEvents.RemoveFromSelection(userC))
+ assertThat(awaitItem().selectedUsers).isEmpty()
+ }
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenter.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenter.kt
new file mode 100644
index 0000000000..45eb712dc1
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenter.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.createroom.impl.userlist
+
+import androidx.compose.runtime.Composable
+
+class FakeUserListPresenter : UserListPresenter {
+
+ private var state = aUserListState()
+
+ fun givenState(state: UserListState) {
+ this.state = state
+ }
+
+ @Composable
+ override fun present(): UserListState {
+ return state
+ }
+}
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenterFactory.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenterFactory.kt
new file mode 100644
index 0000000000..07697ce458
--- /dev/null
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenterFactory.kt
@@ -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.createroom.impl.userlist
+
+import io.element.android.libraries.usersearch.api.UserRepository
+
+class FakeUserListPresenterFactory(
+ private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
+) : UserListPresenter.Factory {
+
+ override fun create(
+ args: UserListPresenterArgs,
+ userRepository: UserRepository,
+ userListDataStore: UserListDataStore,
+ ): UserListPresenter = fakeUserListPresenter
+}
diff --git a/features/ftue/api/build.gradle.kts b/features/ftue/api/build.gradle.kts
new file mode 100644
index 0000000000..9fd36026b9
--- /dev/null
+++ b/features/ftue/api/build.gradle.kts
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.ftue.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt
new file mode 100644
index 0000000000..649a327f6e
--- /dev/null
+++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.ftue.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface FtueEntryPoint : FeatureEntryPoint {
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onFtueFlowFinished()
+ }
+}
diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt
new file mode 100644
index 0000000000..cd172669cc
--- /dev/null
+++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.ftue.api.state
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface FtueState {
+ val shouldDisplayFlow: StateFlow
+
+ suspend fun reset()
+}
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
new file mode 100644
index 0000000000..0dee792464
--- /dev/null
+++ b/features/ftue/impl/build.gradle.kts
@@ -0,0 +1,55 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.ftue.impl"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ api(projects.features.ftue.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.testtags)
+ implementation(projects.features.analytics.api)
+ implementation(projects.services.analytics.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.features.analytics.test)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt
new file mode 100644
index 0000000000..9c2f74f072
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.ftue.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.ftue.api.FtueEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
+
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : FtueEntryPoint.NodeBuilder {
+
+ override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
new file mode 100644
index 0000000000..0ff9c80d46
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -0,0 +1,154 @@
+/*
+ * 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.ftue.impl
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.replace
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.analytics.api.AnalyticsEntryPoint
+import io.element.android.features.ftue.api.FtueEntryPoint
+import io.element.android.features.ftue.impl.state.DefaultFtueState
+import io.element.android.features.ftue.impl.state.FtueStep
+import io.element.android.features.ftue.impl.welcome.WelcomeNode
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(AppScope::class)
+class FtueFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val ftueState: DefaultFtueState,
+ private val analyticsEntryPoint: AnalyticsEntryPoint,
+ private val analyticsService: AnalyticsService,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Placeholder,
+ savedStateMap = buildContext.savedStateMap,
+ backPressHandler = NoOpBackstackHandlerStrategy(),
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object Placeholder : NavTarget
+
+ @Parcelize
+ object WelcomeScreen : NavTarget
+
+ @Parcelize
+ object AnalyticsOptIn : NavTarget
+ }
+
+ private val callback = plugins.filterIsInstance().firstOrNull()
+
+ override fun onBuilt() {
+ super.onBuilt()
+
+ lifecycle.subscribe(onCreate = {
+ lifecycleScope.launch { moveToNextStep() }
+ })
+
+ analyticsService.didAskUserConsent()
+ .drop(1) // We only care about consent passing from not asked to asked state
+ .onEach { didAskUserConsent ->
+ if (didAskUserConsent) {
+ lifecycleScope.launch { moveToNextStep() }
+ }
+ }
+ .launchIn(lifecycleScope)
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Placeholder -> {
+ createNode(buildContext)
+ }
+ NavTarget.WelcomeScreen -> {
+ val callback = object : WelcomeNode.Callback {
+ override fun onContinueClicked() {
+ ftueState.setWelcomeScreenShown()
+ lifecycleScope.launch { moveToNextStep() }
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.AnalyticsOptIn -> {
+ analyticsEntryPoint.createNode(this, buildContext)
+ }
+ }
+ }
+
+ private suspend fun moveToNextStep() {
+ when (ftueState.getNextStep()) {
+ is FtueStep.WelcomeScreen -> {
+ backstack.newRoot(NavTarget.WelcomeScreen)
+ }
+ is FtueStep.AnalyticsOptIn -> {
+ backstack.replace(NavTarget.AnalyticsOptIn)
+ }
+ null -> callback?.onFtueFlowFinished()
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+
+ @ContributesNode(AppScope::class)
+ class PlaceholderNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ ) : Node(buildContext, plugins = plugins)
+}
+
+private class NoOpBackstackHandlerStrategy : BaseBackPressHandlerStrategy() {
+ override val canHandleBackPressFlow: StateFlow = MutableStateFlow(true)
+
+ override fun onBackPressed() {
+ // No-op
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
new file mode 100644
index 0000000000..52c8d90254
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.ftue.impl.state
+
+import androidx.annotation.VisibleForTesting
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.ftue.api.state.FtueState
+import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
+import io.element.android.libraries.di.AppScope
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultFtueState @Inject constructor(
+ private val coroutineScope: CoroutineScope,
+ private val analyticsService: AnalyticsService,
+ private val welcomeScreenState: WelcomeScreenState,
+) : FtueState {
+
+ override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
+
+ override suspend fun reset() {
+ welcomeScreenState.reset()
+ analyticsService.reset()
+ }
+
+ init {
+ analyticsService.didAskUserConsent()
+ .onEach { updateState() }
+ .launchIn(coroutineScope)
+ }
+
+ fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
+ when (currentStep) {
+ null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
+ FtueStep.WelcomeScreen
+ )
+ FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
+ FtueStep.AnalyticsOptIn
+ )
+ FtueStep.AnalyticsOptIn -> null
+ }
+
+ private fun isAnyStepIncomplete(): Boolean {
+ return listOf(
+ shouldDisplayWelcomeScreen(),
+ needsAnalyticsOptIn()
+ ).any { it }
+ }
+
+ private fun needsAnalyticsOptIn(): Boolean {
+ // We need this function to not be suspend, so we need to load the value through runBlocking
+ return runBlocking { analyticsService.didAskUserConsent().first().not() }
+ }
+
+ private fun shouldDisplayWelcomeScreen(): Boolean {
+ return welcomeScreenState.isWelcomeScreenNeeded()
+ }
+
+ fun setWelcomeScreenShown() {
+ welcomeScreenState.setWelcomeScreenShown()
+ updateState()
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun updateState() {
+ shouldDisplayFlow.value = isAnyStepIncomplete()
+ }
+}
+
+sealed interface FtueStep {
+ object WelcomeScreen : FtueStep
+ object AnalyticsOptIn : FtueStep
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt
new file mode 100644
index 0000000000..f4e0d9f640
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.ftue.impl.welcome
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.di.AppScope
+
+@ContributesNode(AppScope::class)
+class WelcomeNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val buildMeta: BuildMeta,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun onContinueClicked()
+ }
+
+ private fun onContinueClicked() {
+ plugins.filterIsInstance().forEach { it.onContinueClicked() }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ WelcomeView(
+ applicationName = buildMeta.applicationName,
+ onContinueClicked = ::onContinueClicked,
+ modifier = modifier
+ )
+ }
+
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt
new file mode 100644
index 0000000000..7397e5ecc5
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.ftue.impl.welcome
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddComment
+import androidx.compose.material.icons.outlined.Lock
+import androidx.compose.material.icons.outlined.NewReleases
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import io.element.android.features.ftue.impl.R
+import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
+import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
+import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
+import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.testtags.testTag
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun WelcomeView(
+ applicationName: String,
+ modifier: Modifier = Modifier,
+ onContinueClicked: () -> Unit,
+) {
+ BackHandler(onBack = onContinueClicked)
+ OnBoardingPage(
+ modifier = modifier
+ .systemBarsPadding()
+ .fillMaxSize(),
+ content = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(78.dp))
+ ElementLogoAtom(size = ElementLogoAtomSize.Medium)
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
+ text = stringResource(R.string.screen_welcome_title, applicationName),
+ style = ElementTheme.typography.fontHeadingMdBold,
+ color = ElementTheme.colors.textPrimary,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.screen_welcome_subtitle),
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textPrimary,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ InfoListOrganism(
+ items = listItems(),
+ textStyle = ElementTheme.typography.fontBodyMdMedium,
+ iconTint = ElementTheme.colors.iconSecondary,
+ backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f),
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ },
+ footer = {
+ Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
+ Text(text = stringResource(CommonStrings.action_continue))
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ )
+}
+
+@Composable
+private fun listItems() = persistentListOf(
+ InfoListItem(
+ message = stringResource(R.string.screen_welcome_bullet_1),
+ iconVector = Icons.Outlined.NewReleases,
+ ),
+ InfoListItem(
+ message = stringResource(R.string.screen_welcome_bullet_2),
+ iconVector = Icons.Outlined.Lock,
+ ),
+ InfoListItem(
+ message = stringResource(R.string.screen_welcome_bullet_3),
+ iconVector = Icons.Outlined.AddComment,
+ ),
+)
+
+@DayNightPreviews
+@Composable
+internal fun WelcomeViewPreview() {
+ ElementPreview {
+ WelcomeView(applicationName = "Element X", onContinueClicked = {})
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt
new file mode 100644
index 0000000000..6dbef47285
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.ftue.impl.welcome.state
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.DefaultPreferences
+import io.element.android.libraries.di.SingleIn
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+@SingleIn(AppScope::class)
+class AndroidWelcomeScreenState @Inject constructor(
+ @DefaultPreferences private val sharedPreferences: SharedPreferences,
+) : WelcomeScreenState {
+
+ companion object {
+ private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
+ }
+
+ override fun isWelcomeScreenNeeded(): Boolean {
+ return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not()
+ }
+
+ override fun setWelcomeScreenShown() {
+ sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
+ }
+
+ override fun reset() {
+ sharedPreferences.edit {
+ remove(IS_WELCOME_SCREEN_SHOWN)
+ }
+ }
+}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt
new file mode 100644
index 0000000000..d2be17fcbb
--- /dev/null
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.ftue.impl.welcome.state
+
+interface WelcomeScreenState {
+ fun isWelcomeScreenNeeded(): Boolean
+ fun setWelcomeScreenShown()
+ fun reset()
+}
diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..17999e7158
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,9 @@
+
+
+ "Calls, location sharing, search and more will be added later this year."
+ "Message history for encrypted rooms won’t be available in this update."
+ "We’d love to hear from you, let us know what you think via the settings page."
+ "Let\'s go!"
+ "Here’s what you need to know:"
+ "Welcome to %1$s!"
+
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
new file mode 100644
index 0000000000..ce1683e8e5
--- /dev/null
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.ftue.impl
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.features.ftue.impl.state.DefaultFtueState
+import io.element.android.features.ftue.impl.state.FtueStep
+import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultFtueStateTests {
+
+ @Test
+ fun `given any check being false, should display flow is true`() = runTest {
+ val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+ val state = createState(coroutineScope)
+
+ assertThat(state.shouldDisplayFlow.value).isTrue()
+
+ // Cleanup
+ coroutineScope.cancel()
+ }
+
+ @Test
+ fun `given all checks being true, should display flow is false`() = runTest {
+ val welcomeState = FakeWelcomeState()
+ val analyticsService = FakeAnalyticsService()
+ val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+
+ val state = createState(coroutineScope, welcomeState, analyticsService)
+
+ welcomeState.setWelcomeScreenShown()
+ analyticsService.setDidAskUserConsent()
+ state.updateState()
+
+ assertThat(state.shouldDisplayFlow.value).isFalse()
+
+ // Cleanup
+ coroutineScope.cancel()
+ }
+
+ @Test
+ fun `traverse flow`() = runTest {
+ val welcomeState = FakeWelcomeState()
+ val analyticsService = FakeAnalyticsService()
+ val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+
+ val state = createState(coroutineScope, welcomeState, analyticsService)
+ val steps = mutableListOf()
+
+ // First step, welcome screen
+ steps.add(state.getNextStep(steps.lastOrNull()))
+ welcomeState.setWelcomeScreenShown()
+
+ // Second step, analytics opt in
+ steps.add(state.getNextStep(steps.lastOrNull()))
+ analyticsService.setDidAskUserConsent()
+
+ // Final step (null)
+ steps.add(state.getNextStep(steps.lastOrNull()))
+
+ assertThat(steps).containsExactly(
+ FtueStep.WelcomeScreen,
+ FtueStep.AnalyticsOptIn,
+ null, // Final state
+ )
+
+ // Cleanup
+ coroutineScope.cancel()
+ }
+
+ @Test
+ fun `if a check for a step is true, start from the next one`() = runTest {
+ val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
+ val analyticsService = FakeAnalyticsService()
+ val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
+
+ state.setWelcomeScreenShown()
+ assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
+
+ analyticsService.setDidAskUserConsent()
+ assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
+
+ // Cleanup
+ coroutineScope.cancel()
+ }
+
+ private fun createState(
+ coroutineScope: CoroutineScope,
+ welcomeState: FakeWelcomeState = FakeWelcomeState(),
+ analyticsService: AnalyticsService = FakeAnalyticsService()
+ ) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
+
+}
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt
new file mode 100644
index 0000000000..e38d49db1c
--- /dev/null
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.ftue.impl.welcome.state
+
+class FakeWelcomeState : WelcomeScreenState {
+
+ private var isWelcomeScreenNeeded = true
+
+ override fun isWelcomeScreenNeeded(): Boolean {
+ return isWelcomeScreenNeeded
+ }
+
+ override fun setWelcomeScreenShown() {
+ isWelcomeScreenNeeded = false
+ }
+
+ override fun reset() {
+ isWelcomeScreenNeeded = true
+ }
+}
diff --git a/features/invitelist/api/build.gradle.kts b/features/invitelist/api/build.gradle.kts
new file mode 100644
index 0000000000..6ea2b8a49d
--- /dev/null
+++ b/features/invitelist/api/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.invitelist.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt
new file mode 100644
index 0000000000..790aac39be
--- /dev/null
+++ b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.invitelist.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.matrix.api.core.RoomId
+
+interface InviteListEntryPoint : FeatureEntryPoint {
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onBackClicked()
+
+ fun onInviteAccepted(roomId: RoomId)
+ }
+}
+
diff --git a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt
new file mode 100644
index 0000000000..ac143b8740
--- /dev/null
+++ b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.invitelist.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+
+interface SeenInvitesStore {
+ fun seenRoomIds(): Flow>
+ suspend fun markAsSeen(roomIds: Set)
+}
diff --git a/features/invitelist/impl/build.gradle.kts b/features/invitelist/impl/build.gradle.kts
new file mode 100644
index 0000000000..3f8f1a44ed
--- /dev/null
+++ b/features/invitelist/impl/build.gradle.kts
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
+@Suppress("DSL_SCOPE_VIOLATION")
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.invitelist.impl"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ api(projects.features.invitelist.api)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.services.analytics.api)
+ implementation(projects.libraries.push.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.push.test)
+ testImplementation(projects.features.invitelist.test)
+ testImplementation(projects.features.analytics.test)
+
+ ksp(libs.showkase.processor)
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt
new file mode 100644
index 0000000000..ef98bc0019
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.invitelist.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invitelist.api.InviteListEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultInviteListEntryPoint @Inject constructor() : InviteListEntryPoint {
+
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): InviteListEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : InviteListEntryPoint.NodeBuilder {
+
+ override fun callback(callback: InviteListEntryPoint.Callback): InviteListEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt
new file mode 100644
index 0000000000..848a4e2ba7
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.invitelist.impl
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.invitelist.api.SeenInvitesStore
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_seeninvites")
+private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
+
+
+@ContributesBinding(SessionScope::class)
+class DefaultSeenInvitesStore @Inject constructor(
+ @ApplicationContext context: Context
+) : SeenInvitesStore {
+
+ private val store = context.dataStore
+
+ override fun seenRoomIds(): Flow> =
+ store.data.map { prefs ->
+ prefs[seenInvitesKey]
+ .orEmpty()
+ .map { RoomId(it) }
+ .toSet()
+ }
+
+ override suspend fun markAsSeen(roomIds: Set) {
+ store.edit { prefs ->
+ prefs[seenInvitesKey] = roomIds.map { it.value }.toSet()
+ }
+ }
+
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt
new file mode 100644
index 0000000000..0b8f03b45a
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.invitelist.impl
+
+import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
+
+sealed interface InviteListEvents {
+
+ data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
+ data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents
+
+ object ConfirmDeclineInvite: InviteListEvents
+ object CancelDeclineInvite: InviteListEvents
+
+ object DismissAcceptError: InviteListEvents
+ object DismissDeclineError: InviteListEvents
+
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt
new file mode 100644
index 0000000000..2f67f83994
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.invitelist.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.core.plugin.plugins
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.invitelist.api.InviteListEntryPoint
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+
+@ContributesNode(SessionScope::class)
+class InviteListNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: InviteListPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ private fun onBackClicked() {
+ plugins().forEach { it.onBackClicked() }
+ }
+
+ private fun onInviteAccepted(roomId: RoomId) {
+ plugins().forEach { it.onInviteAccepted(roomId) }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ InviteListView(
+ state = state,
+ onBackClicked = ::onBackClicked,
+ onInviteAccepted = ::onInviteAccepted,
+ )
+ }
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
new file mode 100644
index 0000000000..21a57b48a7
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
@@ -0,0 +1,203 @@
+/*
+ * 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.invitelist.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import im.vector.app.features.analytics.plan.JoinedRoom
+import io.element.android.features.invitelist.api.SeenInvitesStore
+import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
+import io.element.android.features.invitelist.impl.model.InviteSender
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomSummary
+import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class InviteListPresenter @Inject constructor(
+ private val client: MatrixClient,
+ private val store: SeenInvitesStore,
+ private val analyticsService: AnalyticsService,
+ private val notificationDrawerManager: NotificationDrawerManager,
+) : Presenter {
+
+ @Composable
+ override fun present(): InviteListState {
+ val invites by client
+ .roomSummaryDataSource
+ .inviteRooms()
+ .collectAsState()
+
+ var seenInvites by remember { mutableStateOf>(emptySet()) }
+
+ LaunchedEffect(Unit) {
+ seenInvites = store.seenRoomIds().first()
+ }
+
+ LaunchedEffect(invites) {
+ store.markAsSeen(
+ invites
+ .filterIsInstance()
+ .map { it.details.roomId }
+ .toSet()
+ )
+ }
+
+ val localCoroutineScope = rememberCoroutineScope()
+ val acceptedAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+ val declinedAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+ val decliningInvite: MutableState = remember { mutableStateOf(null) }
+
+ fun handleEvent(event: InviteListEvents) {
+ when (event) {
+ is InviteListEvents.AcceptInvite -> {
+ acceptedAction.value = Async.Uninitialized
+ localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction)
+ }
+
+ is InviteListEvents.DeclineInvite -> {
+ decliningInvite.value = event.invite
+ }
+
+ is InviteListEvents.ConfirmDeclineInvite -> {
+ declinedAction.value = Async.Uninitialized
+ decliningInvite.value?.let {
+ localCoroutineScope.declineInvite(it.roomId, declinedAction)
+ }
+ decliningInvite.value = null
+ }
+
+ is InviteListEvents.CancelDeclineInvite -> {
+ decliningInvite.value = null
+ }
+
+ is InviteListEvents.DismissAcceptError -> {
+ acceptedAction.value = Async.Uninitialized
+ }
+
+ is InviteListEvents.DismissDeclineError -> {
+ declinedAction.value = Async.Uninitialized
+ }
+ }
+ }
+
+ val inviteList = remember(seenInvites, invites) {
+ invites
+ .filterIsInstance()
+ .map {
+ it.toInviteSummary(seenInvites.contains(it.details.roomId))
+ }
+ .toPersistentList()
+ }
+
+ return InviteListState(
+ inviteList = inviteList,
+ declineConfirmationDialog = decliningInvite.value?.let {
+ InviteDeclineConfirmationDialog.Visible(
+ isDirect = it.isDirect,
+ name = it.roomName,
+ )
+ } ?: InviteDeclineConfirmationDialog.Hidden,
+ acceptedAction = acceptedAction.value,
+ declinedAction = declinedAction.value,
+ eventSink = ::handleEvent
+ )
+ }
+
+ private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch {
+ suspend {
+ client.getRoom(roomId)?.use {
+ it.join().getOrThrow()
+ notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
+ }
+ roomId
+ }.runCatchingUpdatingState(acceptedAction)
+ }
+
+ private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState>) = launch {
+ suspend {
+ client.getRoom(roomId)?.use {
+ it.leave().getOrThrow()
+ notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId)
+ }
+ Unit
+ }.runCatchingUpdatingState(declinedAction)
+ }
+
+ private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
+ val i = inviter
+ val avatarData = if (isDirect && i != null)
+ AvatarData(
+ id = i.userId.value,
+ name = i.displayName,
+ url = i.avatarUrl,
+ size = AvatarSize.RoomInviteItem,
+ )
+ else
+ AvatarData(
+ id = roomId.value,
+ name = name,
+ url = avatarURLString,
+ size = AvatarSize.RoomInviteItem,
+ )
+
+ val alias = if (isDirect)
+ inviter?.userId?.value
+ else
+ canonicalAlias
+
+ InviteListInviteSummary(
+ roomId = roomId,
+ roomName = name,
+ roomAlias = alias,
+ roomAvatarData = avatarData,
+ isDirect = isDirect,
+ isNew = !seen,
+ sender = if (isDirect) null else inviter?.run {
+ InviteSender(
+ userId = userId,
+ displayName = displayName ?: "",
+ avatarData = AvatarData(
+ id = userId.value,
+ name = displayName,
+ url = avatarUrl,
+ size = AvatarSize.InviteSender,
+ ),
+ )
+ },
+ )
+ }
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt
new file mode 100644
index 0000000000..5a7761ebc0
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.invitelist.impl
+
+import androidx.compose.runtime.Immutable
+import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.collections.immutable.ImmutableList
+
+@Immutable
+data class InviteListState(
+ val inviteList: ImmutableList,
+ val declineConfirmationDialog: InviteDeclineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden,
+ val acceptedAction: Async = Async.Uninitialized,
+ val declinedAction: Async = Async.Uninitialized,
+ val eventSink: (InviteListEvents) -> Unit = {}
+)
+
+sealed interface InviteDeclineConfirmationDialog {
+ object Hidden : InviteDeclineConfirmationDialog
+ data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt
new file mode 100644
index 0000000000..d4d1f5c166
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.invitelist.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
+import io.element.android.features.invitelist.impl.model.InviteSender
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+open class InviteListStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aInviteListState(),
+ aInviteListState().copy(inviteList = persistentListOf()),
+ aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(true, "Alice")),
+ aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(false, "Some Room")),
+ aInviteListState().copy(acceptedAction = Async.Failure(Throwable("Whoops"))),
+ aInviteListState().copy(declinedAction = Async.Failure(Throwable("Whoops"))),
+ )
+}
+
+internal fun aInviteListState() = InviteListState(
+ inviteList = aInviteListInviteSummaryList(),
+)
+
+internal fun aInviteListInviteSummaryList(): ImmutableList {
+ return persistentListOf(
+ InviteListInviteSummary(
+ roomId = RoomId("!id1:example.com"),
+ roomName = "Room 1",
+ roomAlias = "#room:example.org",
+ sender = InviteSender(
+ userId = UserId("@alice:example.org"),
+ displayName = "Alice"
+ ),
+ ),
+ InviteListInviteSummary(
+ roomId = RoomId("!id2:example.com"),
+ roomName = "Room 2",
+ sender = InviteSender(
+ userId = UserId("@bob:example.org"),
+ displayName = "Bob"
+ ),
+ ),
+ InviteListInviteSummary(
+ roomId = RoomId("!id3:example.com"),
+ roomName = "Alice",
+ roomAlias = "@alice:example.com"
+ ),
+ )
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt
new file mode 100644
index 0000000000..e2e5927a66
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt
@@ -0,0 +1,188 @@
+/*
+ * 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.invitelist.impl
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.invitelist.impl.components.InviteSummaryRow
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.Divider
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun InviteListView(
+ state: InviteListState,
+ modifier: Modifier = Modifier,
+ onBackClicked: () -> Unit = {},
+ onInviteAccepted: (RoomId) -> Unit = {},
+) {
+ if (state.acceptedAction is Async.Success) {
+ LaunchedEffect(state.acceptedAction) {
+ onInviteAccepted(state.acceptedAction.data)
+ }
+ }
+
+ InviteListContent(
+ state = state,
+ modifier = modifier,
+ onBackClicked = onBackClicked,
+ )
+
+ if (state.declineConfirmationDialog is InviteDeclineConfirmationDialog.Visible) {
+ val contentResource = if (state.declineConfirmationDialog.isDirect)
+ R.string.screen_invites_decline_direct_chat_message
+ else
+ R.string.screen_invites_decline_chat_message
+
+ val titleResource = if (state.declineConfirmationDialog.isDirect)
+ R.string.screen_invites_decline_direct_chat_title
+ else
+ R.string.screen_invites_decline_chat_title
+
+ ConfirmationDialog(
+ content = stringResource(contentResource, state.declineConfirmationDialog.name),
+ title = stringResource(titleResource),
+ submitText = stringResource(CommonStrings.action_decline),
+ cancelText = stringResource(CommonStrings.action_cancel),
+ emphasizeSubmitButton = true,
+ onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) },
+ onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) }
+ )
+ }
+
+ if (state.acceptedAction is Async.Failure) {
+ ErrorDialog(
+ content = stringResource(CommonStrings.error_unknown),
+ title = stringResource(CommonStrings.common_error),
+ submitText = stringResource(CommonStrings.action_ok),
+ onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) }
+ )
+ }
+
+ if (state.declinedAction is Async.Failure) {
+ ErrorDialog(
+ content = stringResource(CommonStrings.error_unknown),
+ title = stringResource(CommonStrings.common_error),
+ submitText = stringResource(CommonStrings.action_ok),
+ onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun InviteListContent(
+ state: InviteListState,
+ modifier: Modifier = Modifier,
+ onBackClicked: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ navigationIcon = {
+ BackButton(onClick = onBackClicked)
+ },
+ title = {
+ Text(
+ text = stringResource(CommonStrings.action_invites_list),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ }
+ )
+ },
+ content = { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ ) {
+ if (state.inviteList.isEmpty()) {
+ Spacer(Modifier.size(80.dp))
+
+ Text(
+ text = stringResource(R.string.screen_invites_empty_list),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.fillMaxWidth()
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier.weight(1f)
+ ) {
+ itemsIndexed(
+ items = state.inviteList,
+ ) { index, invite ->
+ InviteSummaryRow(
+ invite = invite,
+ onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) },
+ onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) },
+ )
+
+ if (index != state.inviteList.lastIndex) {
+ Divider()
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+}
+
+@Preview
+@Composable
+internal fun InviteListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun InviteListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: InviteListState) {
+ InviteListView(state)
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt
new file mode 100644
index 0000000000..c2bbd5b023
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt
@@ -0,0 +1,206 @@
+/*
+ * 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.invitelist.impl.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.invitelist.impl.R
+import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
+import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider
+import io.element.android.features.invitelist.impl.model.InviteSender
+import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasButtonText
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+private val minHeight = 72.dp
+
+@Composable
+internal fun InviteSummaryRow(
+ invite: InviteListInviteSummary,
+ modifier: Modifier = Modifier,
+ onAcceptClicked: () -> Unit = {},
+ onDeclineClicked: () -> Unit = {},
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .heightIn(min = minHeight)
+ ) {
+ DefaultInviteSummaryRow(
+ invite = invite,
+ onAcceptClicked = onAcceptClicked,
+ onDeclineClicked = onDeclineClicked,
+ )
+ }
+}
+
+@Composable
+internal fun DefaultInviteSummaryRow(
+ invite: InviteListInviteSummary,
+ onAcceptClicked: () -> Unit = {},
+ onDeclineClicked: () -> Unit = {},
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .height(IntrinsicSize.Min),
+ verticalAlignment = Alignment.Top
+ ) {
+ Avatar(
+ invite.roomAvatarData,
+ )
+
+ Column(
+ modifier = Modifier
+ .padding(start = 16.dp, end = 4.dp)
+ .alignByBaseline()
+ .weight(1f)
+ ) {
+ val bonusPadding = if (invite.isNew) 12.dp else 0.dp
+
+ // Name
+ Text(
+ text = invite.roomName,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ modifier = Modifier.padding(end = bonusPadding),
+ )
+
+ // ID or Alias
+ invite.roomAlias?.let {
+ Text(
+ style = ElementTheme.typography.fontBodyMdRegular,
+ text = it,
+ color = MaterialTheme.colorScheme.secondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(end = bonusPadding),
+ )
+ }
+
+ // Sender
+ invite.sender?.let { sender ->
+ SenderRow(sender = sender)
+ }
+
+ // CTAs
+ Row(Modifier.padding(top = 12.dp)) {
+ OutlinedButton(
+ content = { Text(stringResource(CommonStrings.action_decline), style = ElementTheme.typography.aliasButtonText) },
+ onClick = onDeclineClicked,
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(max = 36.dp),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Button(
+ content = { Text(stringResource(CommonStrings.action_accept), style = ElementTheme.typography.aliasButtonText) },
+ onClick = onAcceptClicked,
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(max = 36.dp),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp),
+ )
+ }
+ }
+
+ UnreadIndicatorAtom(isVisible = invite.isNew)
+ }
+}
+
+@Composable
+private fun SenderRow(sender: InviteSender) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(top = 6.dp),
+ ) {
+ Avatar(
+ avatarData = sender.avatarData,
+ )
+ Text(
+ text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->
+ val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
+ AnnotatedString(
+ text = text,
+ spanStyles = listOf(
+ AnnotatedString.Range(
+ SpanStyle(
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary
+ ),
+ start = senderNameStart,
+ end = senderNameStart + sender.displayName.length
+ )
+ )
+ )
+ },
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ }
+}
+
+@Preview
+@Composable
+internal fun InviteSummaryRowLightPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) =
+ ElementPreviewLight { ContentToPreview(data) }
+
+@Preview
+@Composable
+internal fun InviteSummaryRowDarkPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) =
+ ElementPreviewDark { ContentToPreview(data) }
+
+@Composable
+private fun ContentToPreview(data: InviteListInviteSummary) {
+ InviteSummaryRow(data)
+}
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt
new file mode 100644
index 0000000000..cb695d4eda
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.invitelist.impl.model
+
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+
+@Immutable
+data class InviteListInviteSummary(
+ val roomId: RoomId,
+ val roomName: String = "",
+ val roomAlias: String? = null,
+ val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem),
+ val sender: InviteSender? = null,
+ val isDirect: Boolean = false,
+ val isNew: Boolean = false,
+)
+
+data class InviteSender constructor(
+ val userId: UserId,
+ val displayName: String,
+ val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
+)
diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt
new file mode 100644
index 0000000000..c872d05817
--- /dev/null
+++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.invitelist.impl.model
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+
+open class InviteListInviteSummaryProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aInviteListInviteSummary(),
+ aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com"),
+ aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com", isNew = true),
+ aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
+ aInviteListInviteSummary().copy(isNew = true)
+ )
+}
+
+fun aInviteListInviteSummary() = InviteListInviteSummary(
+ roomId = RoomId("!room1:example.com"),
+ roomName = "Some room with a long name that will truncate",
+ sender = InviteSender(
+ userId = UserId("@alice-with-a-long-mxid:example.org"),
+ displayName = "Alice with a long name"
+ ),
+)
diff --git a/features/invitelist/impl/src/main/res/values-cs/translations.xml b/features/invitelist/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..d4c60464b3
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Opravdu chcete odmítnout pozvánku do %1$s?"
+ "Odmítnout pozvání"
+ "Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"
+ "Odmítnout chat"
+ "Žádné pozvánky"
+ "%1$s (%2$s) vás pozval(a)"
+
diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invitelist/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..1e2fcc2e86
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Möchten Sie den Beitritt zu %1$s wirklich ablehnen?"
+ "Einladung ablehnen"
+ "Möchten Sie den Chat mit %1$s wirklich ablehnen?"
+ "Chat ablehnen"
+ "Keine Einladungen"
+ "%1$s (%2$s) hat dich eingeladen"
+
diff --git a/features/invitelist/impl/src/main/res/values-fr/translations.xml b/features/invitelist/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..677fadd539
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Voulez-vous vraiment refuser l‘invitation à rejoindre %1$s ?"
+ "Refuser l\'invitation"
+ "Voulez-vous vraiment refuser ce chat privé avec %1$s ?"
+ "Refuser le chat"
+ "Aucune invitation"
+ "%1$s (%2$s) vous a invité"
+
diff --git a/features/invitelist/impl/src/main/res/values-it/translations.xml b/features/invitelist/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 0000000000..5f31ef01ba
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "%1$s (%2$s) ti ha invitato"
+
diff --git a/features/invitelist/impl/src/main/res/values-ro/translations.xml b/features/invitelist/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..3f00d32337
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Sigur doriți să refuzați alăturarea la %1$s?"
+ "Refuzați invitația"
+ "Sigur doriți să refuzați conversațiile cu %1$s?"
+ "Refuzați conversația"
+ "Nicio invitație"
+ "%1$s (%2$s) v-a invitat."
+
diff --git a/features/invitelist/impl/src/main/res/values-sk/translations.xml b/features/invitelist/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..2875466fc7
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,9 @@
+
+
+ "Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"
+ "Odmietnuť pozvanie"
+ "Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"
+ "Odmietnuť konverzáciu"
+ "Žiadne pozvánky"
+ "%1$s (%2$s) vás pozval/a"
+
diff --git a/features/invitelist/impl/src/main/res/values/localazy.xml b/features/invitelist/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..7c2c019466
--- /dev/null
+++ b/features/invitelist/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,9 @@
+
+
+ "Are you sure you want to decline the invitation to join %1$s?"
+ "Decline invite"
+ "Are you sure you want to decline this private chat with %1$s?"
+ "Decline chat"
+ "No Invites"
+ "%1$s (%2$s) invited you"
+
diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
new file mode 100644
index 0000000000..1dd9068a1f
--- /dev/null
+++ b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
@@ -0,0 +1,501 @@
+/*
+ * 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.invitelist.impl
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.features.invitelist.api.SeenInvitesStore
+import io.element.android.features.invitelist.test.FakeSeenInvitesStore
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.RoomSummary
+import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
+import io.element.android.libraries.matrix.test.AN_AVATAR_URL
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.A_USER_NAME
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
+import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
+import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class InviteListPresenterTests {
+
+ @Test
+ fun `present - starts empty, adds invites when received`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource()
+ val presenter = createPresenter(
+ FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.inviteList).isEmpty()
+
+ roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary()))
+
+ val withInviteState = awaitItem()
+ Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
+ Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
+ Truth.assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
+ }
+ }
+
+ @Test
+ fun `present - uses user ID and avatar for direct invites`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
+ val presenter = createPresenter(
+ FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val withInviteState = awaitItem()
+ Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
+ Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
+ Truth.assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value)
+ Truth.assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
+ Truth.assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo(
+ AvatarData(
+ id = A_USER_ID.value,
+ name = A_USER_NAME,
+ url = AN_AVATAR_URL,
+ size = AvatarSize.RoomInviteItem,
+ )
+ )
+ Truth.assertThat(withInviteState.inviteList[0].sender).isNull()
+ }
+ }
+
+ @Test
+ fun `present - includes sender details for room invites`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val presenter = createPresenter(
+ FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val withInviteState = awaitItem()
+ Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
+ Truth.assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME)
+ Truth.assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID)
+ Truth.assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo(
+ AvatarData(
+ id = A_USER_ID.value,
+ name = A_USER_NAME,
+ url = AN_AVATAR_URL,
+ size = AvatarSize.InviteSender,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `present - shows confirm dialog for declining direct chat invites`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
+ val presenter = InviteListPresenter(
+ FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ ),
+ FakeSeenInvitesStore(),
+ FakeAnalyticsService(),
+ FakeNotificationDrawerManager()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0]))
+
+ val newState = awaitItem()
+ Truth.assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Visible::class.java)
+
+ val confirmDialog = newState.declineConfirmationDialog as InviteDeclineConfirmationDialog.Visible
+ Truth.assertThat(confirmDialog.isDirect).isTrue()
+ Truth.assertThat(confirmDialog.name).isEqualTo(A_ROOM_NAME)
+ }
+ }
+
+ @Test
+ fun `present - shows confirm dialog for declining room invites`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val presenter = createPresenter(
+ FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0]))
+
+ val newState = awaitItem()
+ Truth.assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Visible::class.java)
+
+ val confirmDialog = newState.declineConfirmationDialog as InviteDeclineConfirmationDialog.Visible
+ Truth.assertThat(confirmDialog.isDirect).isFalse()
+ Truth.assertThat(confirmDialog.name).isEqualTo(A_ROOM_NAME)
+ }
+ }
+
+ @Test
+ fun `present - hides confirm dialog when cancelling`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val presenter = createPresenter(
+ FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource)
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0]))
+
+ skipItems(1)
+
+ originalState.eventSink(InviteListEvents.CancelDeclineInvite)
+
+ val newState = awaitItem()
+ Truth.assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Hidden::class.java)
+ }
+ }
+
+ @Test
+ fun `present - declines invite after confirming`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
+ val client = FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
+ client.givenGetRoomResult(A_ROOM_ID, room)
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0]))
+
+ skipItems(1)
+
+ originalState.eventSink(InviteListEvents.ConfirmDeclineInvite)
+
+ skipItems(2)
+
+ Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - declines invite after confirming and sets state on error`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val client = FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(client)
+ val ex = Throwable("Ruh roh!")
+ room.givenLeaveRoomError(ex)
+ client.givenGetRoomResult(A_ROOM_ID, room)
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0]))
+
+ skipItems(1)
+
+ originalState.eventSink(InviteListEvents.ConfirmDeclineInvite)
+
+ skipItems(1)
+
+ val newState = awaitItem()
+
+ Truth.assertThat(newState.declinedAction).isEqualTo(Async.Failure(ex))
+ }
+ }
+
+ @Test
+ fun `present - dismisses declining error state`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val client = FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(client)
+ val ex = Throwable("Ruh roh!")
+ room.givenLeaveRoomError(ex)
+ client.givenGetRoomResult(A_ROOM_ID, room)
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0]))
+
+ skipItems(1)
+
+ originalState.eventSink(InviteListEvents.ConfirmDeclineInvite)
+
+ skipItems(2)
+
+ originalState.eventSink(InviteListEvents.DismissDeclineError)
+
+ val newState = awaitItem()
+
+ Truth.assertThat(newState.declinedAction).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - accepts invites and sets state on success`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val fakeNotificationDrawerManager = FakeNotificationDrawerManager()
+ val client = FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager)
+ client.givenGetRoomResult(A_ROOM_ID, room)
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0]))
+
+ val newState = awaitItem()
+
+ Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID))
+ Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - accepts invites and sets state on error`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val client = FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(client)
+ val ex = Throwable("Ruh roh!")
+ room.givenJoinRoomResult(Result.failure(ex))
+ client.givenGetRoomResult(A_ROOM_ID, room)
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0]))
+
+ Truth.assertThat(awaitItem().acceptedAction).isEqualTo(Async.Failure(ex))
+ }
+ }
+
+ @Test
+ fun `present - dismisses accepting error state`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
+ val client = FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ )
+ val room = FakeMatrixRoom()
+ val presenter = createPresenter(client)
+ val ex = Throwable("Ruh roh!")
+ room.givenJoinRoomResult(Result.failure(ex))
+ client.givenGetRoomResult(A_ROOM_ID, room)
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val originalState = awaitItem()
+ originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0]))
+
+ skipItems(1)
+
+ originalState.eventSink(InviteListEvents.DismissAcceptError)
+
+ val newState = awaitItem()
+ Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - stores seen invites when received`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource()
+ val store = FakeSeenInvitesStore()
+ val presenter = InviteListPresenter(
+ FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ ),
+ store,
+ FakeAnalyticsService(),
+ FakeNotificationDrawerManager()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem()
+
+ // When one invite is received, that ID is saved
+ roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary()))
+
+ awaitItem()
+ Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
+
+ // When a second is added, both are saved
+ roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
+
+ awaitItem()
+ Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
+
+ // When they're both dismissed, an empty set is saved
+ roomSummaryDataSource.postInviteRooms(listOf())
+
+ awaitItem()
+ Truth.assertThat(store.getProvidedRoomIds()).isEmpty()
+ }
+ }
+
+ @Test
+ fun `present - marks invite as new if they're unseen`() = runTest {
+ val roomSummaryDataSource = FakeRoomSummaryDataSource()
+ val store = FakeSeenInvitesStore()
+ store.publishRoomIds(setOf(A_ROOM_ID))
+ val presenter = InviteListPresenter(
+ FakeMatrixClient(
+ roomSummaryDataSource = roomSummaryDataSource,
+ ),
+ store,
+ FakeAnalyticsService(),
+ FakeNotificationDrawerManager()
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem()
+
+ roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
+ skipItems(1)
+
+ val withInviteState = awaitItem()
+ Truth.assertThat(withInviteState.inviteList.size).isEqualTo(2)
+ Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
+ Truth.assertThat(withInviteState.inviteList[0].isNew).isFalse()
+ Truth.assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2)
+ Truth.assertThat(withInviteState.inviteList[1].isNew).isTrue()
+ }
+ }
+
+ private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource {
+ postInviteRooms(
+ listOf(
+ RoomSummary.Filled(
+ RoomSummaryDetails(
+ roomId = A_ROOM_ID,
+ name = A_ROOM_NAME,
+ avatarURLString = null,
+ isDirect = false,
+ lastMessage = null,
+ lastMessageTimestamp = null,
+ unreadNotificationCount = 0,
+ inviter = RoomMember(
+ userId = A_USER_ID,
+ displayName = A_USER_NAME,
+ avatarUrl = AN_AVATAR_URL,
+ membership = RoomMembershipState.JOIN,
+ isNameAmbiguous = false,
+ powerLevel = 0,
+ normalizedPowerLevel = 0,
+ isIgnored = false,
+ )
+ )
+ )
+ )
+ )
+ return this
+ }
+
+ private suspend fun FakeRoomSummaryDataSource.withDirectChatInvitation(): FakeRoomSummaryDataSource {
+ postInviteRooms(
+ listOf(
+ RoomSummary.Filled(
+ RoomSummaryDetails(
+ roomId = A_ROOM_ID,
+ name = A_ROOM_NAME,
+ avatarURLString = null,
+ isDirect = true,
+ lastMessage = null,
+ lastMessageTimestamp = null,
+ unreadNotificationCount = 0,
+ inviter = RoomMember(
+ userId = A_USER_ID,
+ displayName = A_USER_NAME,
+ avatarUrl = AN_AVATAR_URL,
+ membership = RoomMembershipState.JOIN,
+ isNameAmbiguous = false,
+ powerLevel = 0,
+ normalizedPowerLevel = 0,
+ isIgnored = false,
+ )
+ )
+ )
+ )
+ )
+ return this
+ }
+
+ private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
+ RoomSummaryDetails(
+ roomId = id,
+ name = A_ROOM_NAME,
+ avatarURLString = null,
+ isDirect = false,
+ lastMessage = null,
+ lastMessageTimestamp = null,
+ unreadNotificationCount = 0,
+ )
+ )
+
+ private fun createPresenter(
+ client: MatrixClient,
+ seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
+ fakeAnalyticsService: AnalyticsService = FakeAnalyticsService(),
+ notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager()
+ ) = InviteListPresenter(
+ client,
+ seenInvitesStore,
+ fakeAnalyticsService,
+ notificationDrawerManager
+ )
+}
diff --git a/features/invitelist/test/build.gradle.kts b/features/invitelist/test/build.gradle.kts
new file mode 100644
index 0000000000..ce9b0dabe4
--- /dev/null
+++ b/features/invitelist/test/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.invitelist.test"
+}
+
+dependencies {
+ implementation(libs.coroutines.core)
+ implementation(projects.libraries.matrix.api)
+ api(projects.features.invitelist.api)
+}
diff --git a/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt b/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt
new file mode 100644
index 0000000000..486d3fb4a8
--- /dev/null
+++ b/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.invitelist.test
+
+import io.element.android.features.invitelist.api.SeenInvitesStore
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeSeenInvitesStore : SeenInvitesStore {
+
+ private val existing = MutableStateFlow(emptySet())
+ private var provided: Set? = null
+
+ fun publishRoomIds(invites: Set) {
+ existing.value = invites
+ }
+
+ fun getProvidedRoomIds() = provided
+
+ override fun seenRoomIds(): Flow> = existing
+
+ override suspend fun markAsSeen(roomIds: Set) {
+ provided = roomIds.toSet()
+ }
+}
diff --git a/features/leaveroom/api/build.gradle.kts b/features/leaveroom/api/build.gradle.kts
new file mode 100644
index 0000000000..83ca28b39a
--- /dev/null
+++ b/features/leaveroom/api/build.gradle.kts
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.features.leaveroom.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.matrix.api)
+ ksp(libs.showkase.processor)
+}
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt
new file mode 100644
index 0000000000..d1a3369ac6
--- /dev/null
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.leaveroom.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+sealed interface LeaveRoomEvent {
+ data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
+ object HideConfirmation : LeaveRoomEvent
+ data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
+ object HideError : LeaveRoomEvent
+}
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt
new file mode 100644
index 0000000000..dd1f83691e
--- /dev/null
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.leaveroom.api
+
+import androidx.compose.runtime.Composable
+import io.element.android.libraries.architecture.Presenter
+
+interface LeaveRoomPresenter : Presenter {
+ @Composable
+ override fun present(): LeaveRoomState
+}
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt
new file mode 100644
index 0000000000..7cb9926677
--- /dev/null
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.leaveroom.api
+
+import io.element.android.libraries.matrix.api.core.RoomId
+
+data class LeaveRoomState(
+ val confirmation: Confirmation = Confirmation.Hidden,
+ val progress: Progress = Progress.Hidden,
+ val error: Error = Error.Hidden,
+ val eventSink: (LeaveRoomEvent) -> Unit = {},
+) {
+ sealed interface Confirmation {
+ object Hidden : Confirmation
+ data class Generic(val roomId: RoomId) : Confirmation
+ data class PrivateRoom(val roomId: RoomId) : Confirmation
+ data class LastUserInRoom(val roomId: RoomId) : Confirmation
+ }
+
+ sealed interface Progress {
+ object Hidden : Progress
+ object Shown : Progress
+ }
+
+ sealed interface Error {
+ object Hidden : Error
+ object Shown : Error
+ }
+}
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt
new file mode 100644
index 0000000000..e9b08bcd18
--- /dev/null
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.leaveroom.api
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.matrix.api.core.RoomId
+
+class LeaveRoomStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ LeaveRoomState(
+ confirmation = LeaveRoomState.Confirmation.Hidden,
+ progress = LeaveRoomState.Progress.Hidden,
+ error = LeaveRoomState.Error.Hidden,
+ ),
+ LeaveRoomState(
+ confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID),
+ progress = LeaveRoomState.Progress.Hidden,
+ error = LeaveRoomState.Error.Hidden,
+ ),
+ LeaveRoomState(
+ confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID),
+ progress = LeaveRoomState.Progress.Hidden,
+ error = LeaveRoomState.Error.Hidden,
+ ),
+ LeaveRoomState(
+ confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID),
+ progress = LeaveRoomState.Progress.Hidden,
+ error = LeaveRoomState.Error.Hidden,
+ ),
+ LeaveRoomState(
+ confirmation = LeaveRoomState.Confirmation.Hidden,
+ progress = LeaveRoomState.Progress.Shown,
+ error = LeaveRoomState.Error.Hidden,
+ ),
+ LeaveRoomState(
+ confirmation = LeaveRoomState.Confirmation.Hidden,
+ progress = LeaveRoomState.Progress.Hidden,
+ error = LeaveRoomState.Error.Shown,
+ ),
+ )
+}
+
+private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt
new file mode 100644
index 0000000000..92cacd7fcb
--- /dev/null
+++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.leaveroom.api
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun LeaveRoomView(
+ state: LeaveRoomState
+) {
+ LeaveRoomConfirmationDialog(state)
+ LeaveRoomProgressDialog(state)
+ LeaveRoomErrorDialog(state)
+}
+
+@Composable
+private fun LeaveRoomConfirmationDialog(
+ state: LeaveRoomState,
+) {
+ when (state.confirmation) {
+ is LeaveRoomState.Confirmation.Hidden -> {}
+ is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
+ text = CommonStrings.leave_room_alert_private_subtitle,
+ roomId = state.confirmation.roomId,
+ eventSink = state.eventSink,
+ )
+
+ is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
+ text = CommonStrings.leave_room_alert_empty_subtitle,
+ roomId = state.confirmation.roomId,
+ eventSink = state.eventSink,
+ )
+
+ is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
+ text = CommonStrings.leave_room_alert_subtitle,
+ roomId = state.confirmation.roomId,
+ eventSink = state.eventSink,
+ )
+ }
+}
+
+@Composable
+private fun LeaveRoomConfirmationDialog(
+ @StringRes text: Int,
+ roomId: RoomId,
+ eventSink: (LeaveRoomEvent) -> Unit,
+) {
+ ConfirmationDialog(
+ title = stringResource(CommonStrings.action_leave_room),
+ content = stringResource(text),
+ submitText = stringResource(CommonStrings.action_leave),
+ onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
+ onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
+ )
+}
+
+@Composable
+private fun LeaveRoomProgressDialog(
+ state: LeaveRoomState,
+) {
+ when (state.progress) {
+ is LeaveRoomState.Progress.Hidden -> {}
+ is LeaveRoomState.Progress.Shown -> ProgressDialog(
+ text = stringResource(CommonStrings.common_leaving_room),
+ )
+ }
+}
+
+@Composable
+private fun LeaveRoomErrorDialog(
+ state: LeaveRoomState,
+) {
+ when (state.error) {
+ is LeaveRoomState.Error.Hidden -> {}
+ is LeaveRoomState.Error.Shown -> ErrorDialog(
+ content = stringResource(CommonStrings.error_unknown),
+ onDismiss = { state.eventSink(LeaveRoomEvent.HideError) }
+ )
+ }
+}
+
+@Preview
+@Composable
+internal fun LeaveRoomViewLightPreview(
+ @PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
+) = ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun LeaveRoomViewDarkPreview(
+ @PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
+) = ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: LeaveRoomState) {
+ Box(
+ modifier = Modifier.size(300.dp, 300.dp),
+ propagateMinConstraints = true,
+ ) {
+ LeaveRoomView(state = state)
+ }
+}
diff --git a/features/leaveroom/fake/build.gradle.kts b/features/leaveroom/fake/build.gradle.kts
new file mode 100644
index 0000000000..19a057d5ba
--- /dev/null
+++ b/features/leaveroom/fake/build.gradle.kts
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+}
+
+android {
+ namespace = "io.element.android.features.leaveroom.fake"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ api(projects.features.leaveroom.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.coroutines.core)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+}
diff --git a/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt
new file mode 100644
index 0000000000..28c12b54ba
--- /dev/null
+++ b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.leaveroom.fake
+
+import androidx.compose.runtime.Composable
+import io.element.android.features.leaveroom.api.LeaveRoomEvent
+import io.element.android.features.leaveroom.api.LeaveRoomPresenter
+import io.element.android.features.leaveroom.api.LeaveRoomState
+import javax.inject.Inject
+
+class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
+
+ val events = mutableListOf()
+
+ private fun handleEvent(event: LeaveRoomEvent) {
+ events += event
+ }
+
+ private var state = LeaveRoomState(eventSink = ::handleEvent)
+ set(value) {
+ field = value.copy(eventSink = ::handleEvent)
+ }
+
+ fun givenState(state: LeaveRoomState) {
+ this.state = state
+ }
+
+ @Composable
+ override fun present(): LeaveRoomState = state
+}
diff --git a/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt
new file mode 100644
index 0000000000..b20b88db1c
--- /dev/null
+++ b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt
@@ -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.leaveroom.fake
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import io.element.android.features.leaveroom.api.LeaveRoomPresenter
+import io.element.android.libraries.di.SessionScope
+
+@Module
+@ContributesTo(SessionScope::class)
+interface LeaveRoomPresenterFakeModule {
+ @Binds
+ fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterFake): LeaveRoomPresenter
+}
diff --git a/features/leaveroom/impl/build.gradle.kts b/features/leaveroom/impl/build.gradle.kts
new file mode 100644
index 0000000000..8d26ea9271
--- /dev/null
+++ b/features/leaveroom/impl/build.gradle.kts
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+}
+
+android {
+ namespace = "io.element.android.features.leaveroom.impl"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ api(projects.features.leaveroom.api)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.coroutines.core)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
+}
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt
new file mode 100644
index 0000000000..2ad35b38c9
--- /dev/null
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.leaveroom.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.features.leaveroom.api.LeaveRoomEvent
+import io.element.android.features.leaveroom.api.LeaveRoomPresenter
+import io.element.android.features.leaveroom.api.LeaveRoomState
+import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic
+import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom
+import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+class LeaveRoomPresenterImpl @Inject constructor(
+ private val client: MatrixClient,
+ private val roomMembershipObserver: RoomMembershipObserver,
+ private val dispatchers: CoroutineDispatchers,
+) : LeaveRoomPresenter {
+ @Composable
+ override fun present(): LeaveRoomState {
+ val scope = rememberCoroutineScope()
+ val confirmation = remember { mutableStateOf(LeaveRoomState.Confirmation.Hidden) }
+ val progress = remember { mutableStateOf(LeaveRoomState.Progress.Hidden) }
+ val error = remember { mutableStateOf(LeaveRoomState.Error.Hidden) }
+
+ return LeaveRoomState(
+ confirmation = confirmation.value,
+ progress = progress.value,
+ error = error.value,
+ ) { event ->
+ when (event) {
+ is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) {
+ showLeaveRoomAlert(
+ matrixClient = client,
+ roomId = event.roomId,
+ confirmation = confirmation,
+ )
+ }
+
+ is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden
+ is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
+ client.leaveRoom(
+ roomId = event.roomId,
+ roomMembershipObserver = roomMembershipObserver,
+ confirmation = confirmation,
+ progress = progress,
+ error = error,
+ )
+ }
+
+ is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden
+ }
+ }
+ }
+}
+
+private suspend fun showLeaveRoomAlert(
+ matrixClient: MatrixClient,
+ roomId: RoomId,
+ confirmation: MutableState,
+) {
+ matrixClient.getRoom(roomId)?.use { room ->
+ confirmation.value = when {
+ !room.isPublic -> PrivateRoom(roomId)
+ room.joinedMemberCount == 1L -> LastUserInRoom(roomId)
+ else -> Generic(roomId)
+ }
+ }
+}
+
+private suspend fun MatrixClient.leaveRoom(
+ roomId: RoomId,
+ roomMembershipObserver: RoomMembershipObserver,
+ confirmation: MutableState,
+ progress: MutableState,
+ error: MutableState,
+) {
+ confirmation.value = LeaveRoomState.Confirmation.Hidden
+ progress.value = LeaveRoomState.Progress.Shown
+ getRoom(roomId)?.use { room ->
+ room.leave().onSuccess {
+ roomMembershipObserver.notifyUserLeftRoom(room.roomId)
+ }.onFailure {
+ Timber.e(it, "Error while leaving room ${room.displayName} - ${room.roomId}")
+ error.value = LeaveRoomState.Error.Shown
+ }
+ }
+ progress.value = LeaveRoomState.Progress.Hidden
+}
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt
new file mode 100644
index 0000000000..65403adb60
--- /dev/null
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt
@@ -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.leaveroom.impl
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import io.element.android.features.leaveroom.api.LeaveRoomPresenter
+import io.element.android.libraries.di.SessionScope
+
+@Module
+@ContributesTo(SessionScope::class)
+interface LeaveRoomPresenterImplModule {
+ @Binds
+ fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterImpl): LeaveRoomPresenter
+}
diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt
new file mode 100644
index 0000000000..03eeb3adc6
--- /dev/null
+++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt
@@ -0,0 +1,217 @@
+/*
+ * 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.leaveroom.impl
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.leaveroom.api.LeaveRoomEvent
+import io.element.android.features.leaveroom.api.LeaveRoomPresenter
+import io.element.android.features.leaveroom.api.LeaveRoomState
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
+import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LeaveRoomPresenterImplTest {
+
+ @Test
+ fun `present - initial state hides all dialogs`() = runTest {
+ val presenter = createPresenter()
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden)
+ assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
+ assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden)
+ }
+ }
+
+ @Test
+ fun `present - show generic confirmation`() = runTest {
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom()
+ )
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID))
+ }
+ }
+
+ @Test
+ fun `present - show private room confirmation`() = runTest {
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom(isPublic = false),
+ )
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID))
+ }
+ }
+
+ @Test
+ fun `present - show last user in room confirmation`() = runTest {
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom(joinedMemberCount = 1),
+ )
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID))
+ }
+ }
+
+ @Test
+ fun `present - leaving a room leaves the room`() = runTest {
+ val roomMembershipObserver = RoomMembershipObserver()
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom(),
+ )
+ },
+ roomMembershipObserver = roomMembershipObserver
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
+ // Membership observer should receive a 'left room' change
+ assertThat(roomMembershipObserver.updates.first().change).isEqualTo(MembershipChange.LEFT)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - show error if leave room fails`() = runTest {
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom().apply {
+ givenLeaveRoomError(RuntimeException("Blimey!"))
+ },
+ )
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
+ skipItems(1) // Skip show progress state
+ val errorState = awaitItem()
+ assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - show progress indicator while leaving a room`() = runTest {
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom(),
+ )
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
+ val progressState = awaitItem()
+ assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown)
+ val finalState = awaitItem()
+ assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
+ }
+ }
+
+ @Test
+ fun `present - hide error hides the error`() = runTest {
+ val presenter = createPresenter(
+ client = FakeMatrixClient().apply {
+ givenGetRoomResult(
+ roomId = A_ROOM_ID,
+ result = FakeMatrixRoom().apply {
+ givenLeaveRoomError(RuntimeException("Blimey!"))
+ },
+ )
+ }
+ )
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
+ skipItems(1) // Skip show progress state
+ val errorState = awaitItem()
+ assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
+ skipItems(1) // Skip hide progress state
+ errorState.eventSink(LeaveRoomEvent.HideError)
+ val hiddenErrorState = awaitItem()
+ assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden)
+ }
+ }
+}
+
+private fun TestScope.createPresenter(
+ client: MatrixClient = FakeMatrixClient(),
+ roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
+): LeaveRoomPresenter = LeaveRoomPresenterImpl(
+ client = client,
+ roomMembershipObserver = roomMembershipObserver,
+ dispatchers = testCoroutineDispatchers(false),
+)
diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts
new file mode 100644
index 0000000000..6de297fe77
--- /dev/null
+++ b/features/location/api/build.gradle.kts
@@ -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.
+ */
+
+import java.util.Properties
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+fun readLocalProperty(name: String) = Properties().apply {
+ try {
+ load(rootProject.file("local.properties").reader())
+ } catch (ignored: java.io.IOException) {
+ }
+}[name]
+
+android {
+ namespace = "io.element.android.features.location.api"
+
+ defaultConfig {
+ resValue(
+ type = "string",
+ name = "maptiler_api_key",
+ value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
+ ?: readLocalProperty("services.maptiler.apikey") as? String
+ ?: ""
+ )
+ }
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.uiStrings)
+ implementation(libs.coil.compose)
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.test.truth)
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt
new file mode 100644
index 0000000000..8d801b37a8
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt
@@ -0,0 +1,44 @@
+/*
+ * 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
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+private const val GEO_URI_REGEX = """geo:(?-?\d+(?:\.\d+)?),(?-?\d+(?:\.\d+)?)(?:;u=(?\d+(?:\.\d+)?))?"""
+
+@Parcelize
+data class Location(
+ val lat: Double,
+ val lon: Double,
+ val accuracy: Float,
+) : Parcelable {
+ companion object {
+ fun fromGeoUri(geoUri: String): Location? {
+ val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
+ return Location(
+ lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
+ lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
+ accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
+ )
+ }
+ }
+
+ fun toGeoUri(): String {
+ return "geo:$lat,$lon;u=$accuracy"
+ }
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt
new file mode 100644
index 0000000000..a1b43d6a5c
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.location.api
+
+import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
+
+/**
+ * The "Send location" screen.
+ *
+ * Allows a user to share a location message within a room.
+ */
+interface SendLocationEntryPoint : SimpleFeatureEntryPoint
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt
new file mode 100644
index 0000000000..3c429dfa63
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt
@@ -0,0 +1,29 @@
+/*
+ * 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
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+
+interface ShowLocationEntryPoint : FeatureEntryPoint {
+
+ data class Inputs(val location: Location, val description: String?) : NodeInputs
+
+ fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
new file mode 100644
index 0000000000..14390d0f4f
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
@@ -0,0 +1,138 @@
+/*
+ * 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
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImagePainter
+import coil.compose.rememberAsyncImagePainter
+import coil.request.ImageRequest
+import io.element.android.features.location.api.internal.StaticMapPlaceholder
+import io.element.android.features.location.api.internal.staticMapUrl
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.text.toDp
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.theme.ElementTheme
+import timber.log.Timber
+import io.element.android.libraries.designsystem.R as DesignSystemR
+
+/**
+ * Shows a static map image downloaded via a third party service's static maps API.
+ */
+@Composable
+fun StaticMapView(
+ lat: Double,
+ lon: Double,
+ zoom: Double,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ darkMode: Boolean = !ElementTheme.isLightTheme,
+) {
+ // Using BoxWithConstraints to:
+ // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints.
+ // 2) Request the static map image of the exact required size in Px to fill the AsyncImage.
+ BoxWithConstraints(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ val context = LocalContext.current
+ var retryHash by remember { mutableStateOf(0) }
+ val painter = rememberAsyncImagePainter(
+ model = if (constraints.isZero) {
+ // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
+ null
+ } else {
+ ImageRequest.Builder(LocalContext.current)
+ .data(
+ staticMapUrl(
+ context = context,
+ lat = lat,
+ lon = lon,
+ zoom = zoom,
+ darkMode = darkMode,
+ // Size the map based on DP rather than pixels, as otherwise the features and attribution
+ // end up being illegibly tiny on high density displays.
+ width = constraints.maxWidth.toDp().value.toInt(),
+ height = constraints.maxHeight.toDp().value.toInt(),
+ )
+ )
+ .size(width = constraints.maxWidth, height = constraints.maxHeight)
+ .setParameter("retry_hash", retryHash, memoryCacheKey = null)
+ .build()
+ }.apply {
+ Timber.d("Static map image request: ${this?.data}")
+ }
+ )
+
+ if (painter.state is AsyncImagePainter.State.Success) {
+ Image(
+ painter = painter,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(width = maxWidth, height = maxHeight),
+ // The returned image can be smaller than the requested size due to the static maps API having
+ // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
+ // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
+ contentScale = ContentScale.Fit,
+ )
+ Icon(
+ resourceId = DesignSystemR.drawable.pin,
+ contentDescription = null,
+ tint = Color.Unspecified,
+ modifier = Modifier.align { size, space, _ ->
+ // Center bottom edge of pin (i.e. its arrow) to center of screen
+ IntOffset(
+ x = (space.width - size.width) / 2,
+ y = (space.height / 2) - size.height,
+ )
+ }
+ )
+ } else {
+ StaticMapPlaceholder(
+ showProgress = painter.state is AsyncImagePainter.State.Loading,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(width = maxWidth, height = maxHeight),
+ onLoadMapClick = { retryHash++ }
+ )
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+fun StaticMapViewPreview() = ElementPreview {
+ StaticMapView(
+ lat = 0.0,
+ lon = 0.0,
+ zoom = 0.0,
+ contentDescription = null,
+ modifier = Modifier.size(400.dp),
+ )
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt
new file mode 100644
index 0000000000..b6f21a4512
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt
@@ -0,0 +1,74 @@
+/*
+ * 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 androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import io.element.android.features.location.api.R
+import io.element.android.libraries.theme.ElementTheme
+
+/**
+ * Provides the URL to an image that contains a statically-generated map of the given location.
+ */
+fun staticMapUrl(
+ context: Context,
+ lat: Double,
+ lon: Double,
+ zoom: Double,
+ width: Int,
+ height: Int,
+ darkMode: Boolean,
+): String {
+ return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
+}
+
+/**
+ * Utility function to remember the tile server URL based on the current theme.
+ */
+@Composable
+fun rememberTileStyleUrl(): String {
+ val context = LocalContext.current
+ val darkMode = !ElementTheme.isLightTheme
+ return remember(darkMode) {
+ tileStyleUrl(
+ context = context,
+ darkMode = darkMode
+ )
+ }
+}
+
+/**
+ * Provides the URL to a MapLibre style document, used for rendering dynamic maps.
+ */
+private fun tileStyleUrl(
+ context: Context,
+ darkMode: Boolean,
+): String {
+ return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}"
+}
+
+private fun baseUrl(darkMode: Boolean) =
+ "https://api.maptiler.com/maps/" +
+ if (darkMode)
+ "dea61faf-292b-4774-9660-58fcef89a7f3"
+ else
+ "9bc819c8-e627-474a-a348-ec144fe3d810"
+
+private val Context.apiKey: String
+ get() = getString(R.string.maptiler_api_key)
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
new file mode 100644
index 0000000000..d36ead5b28
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
@@ -0,0 +1,96 @@
+/*
+ * 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 androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import io.element.android.features.location.api.R
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+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.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+internal fun StaticMapPlaceholder(
+ showProgress: Boolean,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ onLoadMapClick: () -> Unit,
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.blurred_map),
+ contentDescription = contentDescription,
+ modifier = modifier,
+ contentScale = ContentScale.FillBounds,
+ )
+ if (showProgress) {
+ CircularProgressIndicator()
+ } else {
+ Box(
+ modifier = modifier.clickable(onClick = onLoadMapClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = null
+ )
+ Text(text = stringResource(id = CommonStrings.action_static_map_load))
+ }
+ }
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+fun StaticMapPlaceholderPreview(
+ @PreviewParameter(BooleanParameterProvider::class) values: Boolean
+) = ElementPreview {
+ StaticMapPlaceholder(
+ showProgress = values,
+ contentDescription = null,
+ modifier = Modifier.size(400.dp),
+ onLoadMapClick = {},
+ )
+}
+
+internal class BooleanParameterProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(true, false)
+}
diff --git a/features/location/api/src/main/res/drawable-night/blurred_map.png b/features/location/api/src/main/res/drawable-night/blurred_map.png
new file mode 100644
index 0000000000..7e90d568f1
Binary files /dev/null and b/features/location/api/src/main/res/drawable-night/blurred_map.png differ
diff --git a/features/location/api/src/main/res/drawable/blurred_map.png b/features/location/api/src/main/res/drawable/blurred_map.png
new file mode 100644
index 0000000000..365cf96786
Binary files /dev/null and b/features/location/api/src/main/res/drawable/blurred_map.png differ
diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt
new file mode 100644
index 0000000000..f3d1f72f22
--- /dev/null
+++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt
@@ -0,0 +1,84 @@
+/*
+ * 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
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+internal class LocationKtTest {
+
+ @Test
+ fun `parseGeoUri - returns null for invalid urls`() {
+ assertThat(Location.fromGeoUri("")).isNull()
+ assertThat(Location.fromGeoUri("http://example.com/")).isNull()
+ assertThat(Location.fromGeoUri("geo:")).isNull()
+ assertThat(Location.fromGeoUri("geo:1.234")).isNull()
+ assertThat(Location.fromGeoUri("geo:1.234,")).isNull()
+ assertThat(Location.fromGeoUri("geo:,1.234")).isNull()
+ assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull()
+ assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull()
+ assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull()
+ assertThat(Location.fromGeoUri("geo:not,good")).isNull()
+ assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull()
+ assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull()
+ }
+
+ @Test
+ fun `parseGeoUri - returns location for valid urls`() {
+ assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
+ lat = 1.234,
+ lon = 5.678,
+ accuracy = 0f,
+ ))
+
+ assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
+ lat = 1.0,
+ lon = 5.0,
+ accuracy = 0f,
+ ))
+
+ assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
+ lat = 1.234,
+ lon = 5.678,
+ accuracy = 3000f,
+ ))
+
+ assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
+ lat = 1.0,
+ lon = 5.0,
+ accuracy = 3000f,
+ ))
+
+ assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
+ lat = -1.234,
+ lon = -5.678,
+ accuracy = 9.10f,
+ ))
+
+ assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
+ lat = -1.0,
+ lon = -5.0,
+ accuracy = 9.10f,
+ ))
+ }
+
+ @Test
+ fun `encode geoUri - returns geoUri from a Location`() {
+ assertThat(Location(1.0,2.0,3.0f).toGeoUri())
+ .isEqualTo("geo:1.0,2.0;u=3.0")
+ }
+}
diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts
new file mode 100644
index 0000000000..1158b5f152
--- /dev/null
+++ b/features/location/impl/build.gradle.kts
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.element.android.features.location.impl"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ api(projects.features.location.api)
+ implementation(projects.features.messages.api)
+ implementation(projects.libraries.maplibreCompose)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.di)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.services.analytics.api)
+ implementation(libs.accompanist.permission)
+ implementation(projects.libraries.uiStrings)
+ implementation(libs.dagger)
+ implementation(projects.anvilannotations)
+ implementation(projects.services.toolbox.api)
+ anvil(projects.anvilcodegen)
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(libs.test.truth)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.features.analytics.test)
+ testImplementation(projects.features.messages.test)
+}
diff --git a/features/location/impl/src/main/AndroidManifest.xml b/features/location/impl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..b4f5d8f271
--- /dev/null
+++ b/features/location/impl/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/AndroidLocationActions.kt
new file mode 100644
index 0000000000..da88598251
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/AndroidLocationActions.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.impl
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.annotation.VisibleForTesting
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.impl.show.LocationActions
+import io.element.android.libraries.androidutils.system.openAppSettingsPage
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import timber.log.Timber
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class AndroidLocationActions @Inject constructor(
+ @ApplicationContext private val context: Context
+) : LocationActions {
+ override fun share(location: Location, label: String?) {
+ runCatching {
+ val uri = Uri.parse(buildUrl(location, label))
+ val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
+ val chooserIntent = Intent.createChooser(showMapsIntent, null)
+ chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(chooserIntent)
+ }.onSuccess {
+ Timber.v("Open location succeed")
+ }.onFailure {
+ Timber.e(it, "Open location failed")
+ }
+ }
+
+ override fun openSettings() {
+ context.openAppSettingsPage()
+ }
+}
+
+@VisibleForTesting
+internal fun buildUrl(
+ location: Location,
+ label: String?,
+ urlEncoder: (String) -> String = Uri::encode
+): String {
+ // Ref: https://developer.android.com/guide/components/intents-common#ViewMap
+ val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
+ return if (label == null) {
+ base
+ } else {
+ "%s (%s)".format(base, urlEncoder(label))
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt
new file mode 100644
index 0000000000..ac99be1f59
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.impl
+
+import android.Manifest
+import android.view.Gravity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import com.mapbox.mapboxsdk.camera.CameraPosition
+import com.mapbox.mapboxsdk.geometry.LatLng
+import io.element.android.libraries.maplibre.compose.MapLocationSettings
+import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings
+import io.element.android.libraries.maplibre.compose.MapUiSettings
+import io.element.android.libraries.theme.ElementTheme
+
+/**
+ * Common configuration values for the map.
+ */
+object MapDefaults {
+ val uiSettings: MapUiSettings
+ @Composable
+ @ReadOnlyComposable
+ get() = MapUiSettings(
+ compassEnabled = false,
+ rotationGesturesEnabled = false,
+ scrollGesturesEnabled = true,
+ tiltGesturesEnabled = false,
+ zoomGesturesEnabled = true,
+ logoGravity = Gravity.TOP,
+ attributionGravity = Gravity.TOP,
+ attributionTintColor = ElementTheme.colors.iconPrimary
+ )
+
+ val symbolManagerSettings: MapSymbolManagerSettings
+ get() = MapSymbolManagerSettings(
+ iconAllowOverlap = true
+ )
+
+ val locationSettings: MapLocationSettings
+ get() = MapLocationSettings(
+ locationEnabled = false,
+ )
+
+ val centerCameraPosition = CameraPosition.Builder()
+ .target(LatLng(49.843, 9.902056))
+ .zoom(2.7)
+ .build()
+
+ const val DEFAULT_ZOOM = 15.0
+
+ val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsEvents.kt
new file mode 100644
index 0000000000..194bf31df7
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsEvents.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.impl.permissions
+
+sealed interface PermissionsEvents {
+ object RequestPermissions : PermissionsEvents
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenter.kt
new file mode 100644
index 0000000000..ccff16159e
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenter.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.impl.permissions
+
+import io.element.android.libraries.architecture.Presenter
+
+interface PermissionsPresenter : Presenter {
+ interface Factory {
+ fun create(permissions: List): PermissionsPresenter
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterImpl.kt
new file mode 100644
index 0000000000..85941ab7d3
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterImpl.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.impl.permissions
+
+import androidx.compose.runtime.Composable
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import com.squareup.anvil.annotations.ContributesBinding
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.di.AppScope
+
+class PermissionsPresenterImpl @AssistedInject constructor(
+ @Assisted private val permissions: List
+) : PermissionsPresenter {
+
+ @AssistedFactory
+ @ContributesBinding(AppScope::class)
+ interface Factory : PermissionsPresenter.Factory {
+ override fun create(permissions: List): PermissionsPresenterImpl
+ }
+
+ @OptIn(ExperimentalPermissionsApi::class)
+ @Composable
+ override fun present(): PermissionsState {
+ val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions)
+
+ fun handleEvents(event: PermissionsEvents) {
+ when (event) {
+ PermissionsEvents.RequestPermissions -> multiplePermissionsState.launchMultiplePermissionRequest()
+ }
+ }
+
+ return PermissionsState(
+ permissions = when {
+ multiplePermissionsState.allPermissionsGranted -> PermissionsState.Permissions.AllGranted
+ multiplePermissionsState.permissions.any { it.status.isGranted } -> PermissionsState.Permissions.SomeGranted
+ else -> PermissionsState.Permissions.NoneGranted
+ },
+ shouldShowRationale = multiplePermissionsState.shouldShowRationale,
+ eventSink = ::handleEvents,
+ )
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsState.kt
new file mode 100644
index 0000000000..626cf93c23
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.location.impl.permissions
+
+data class PermissionsState(
+ val permissions: Permissions = Permissions.NoneGranted,
+ val shouldShowRationale: Boolean = false,
+ val eventSink: (PermissionsEvents) -> Unit = {},
+) {
+ sealed interface Permissions {
+ object AllGranted : Permissions
+ object SomeGranted : Permissions
+ object NoneGranted : Permissions
+ }
+
+ val isAnyGranted: Boolean
+ get() = permissions is Permissions.SomeGranted || permissions is Permissions.AllGranted
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt
new file mode 100644
index 0000000000..9edf195e28
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.location.impl.send
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.location.api.SendLocationEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class SendLocationEntryPointImpl @Inject constructor() : SendLocationEntryPoint {
+ override fun createNode(
+ parentNode: Node, buildContext: BuildContext
+ ): SendLocationNode = parentNode.createNode(buildContext)
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt
new file mode 100644
index 0000000000..2f0686da27
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.impl.send
+
+import io.element.android.features.location.api.Location
+
+sealed interface SendLocationEvents {
+ data class SendLocation(
+ val cameraPosition: CameraPosition,
+ val location: Location?,
+ ) : SendLocationEvents {
+ data class CameraPosition(
+ val lat: Double,
+ val lon: Double,
+ val zoom: Double,
+ )
+ }
+
+ object SwitchToMyLocationMode : SendLocationEvents
+
+ object SwitchToPinLocationMode : SendLocationEvents
+
+ object DismissDialog : SendLocationEvents
+
+ object RequestPermissions : SendLocationEvents
+
+ object OpenAppSettings : SendLocationEvents
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt
new file mode 100644
index 0000000000..be4d3f0764
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.impl.send
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.libraries.di.RoomScope
+import io.element.android.services.analytics.api.AnalyticsService
+
+@ContributesNode(RoomScope::class)
+class SendLocationNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: SendLocationPresenter,
+ analyticsService: AnalyticsService,
+) : Node(buildContext, plugins = plugins) {
+
+ init {
+ lifecycle.subscribe(
+ onResume = {
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationSend))
+ }
+ )
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ SendLocationView(
+ state = presenter.present(),
+ modifier = modifier,
+ navigateUp = ::navigateUp,
+ )
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt
new file mode 100644
index 0000000000..d06124539c
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt
@@ -0,0 +1,164 @@
+/*
+ * 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.impl.send
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import im.vector.app.features.analytics.plan.Composer
+import io.element.android.features.location.impl.MapDefaults
+import io.element.android.features.location.impl.permissions.PermissionsEvents
+import io.element.android.features.location.impl.permissions.PermissionsPresenter
+import io.element.android.features.location.impl.permissions.PermissionsState
+import io.element.android.features.location.impl.show.LocationActions
+import io.element.android.features.messages.api.MessageComposerContext
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import kotlinx.coroutines.launch
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+
+class SendLocationPresenter @Inject constructor(
+ permissionsPresenterFactory: PermissionsPresenter.Factory,
+ private val room: MatrixRoom,
+ private val analyticsService: AnalyticsService,
+ private val messageComposerContext: MessageComposerContext,
+ private val locationActions: LocationActions,
+ private val systemClock: SystemClock,
+ private val buildMeta: BuildMeta,
+) : Presenter {
+
+ private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
+
+ @Composable
+ override fun present(): SendLocationState {
+ val permissionsState: PermissionsState = permissionsPresenter.present()
+ var mode: SendLocationState.Mode by remember {
+ mutableStateOf(
+ if (permissionsState.isAnyGranted) SendLocationState.Mode.SenderLocation
+ else SendLocationState.Mode.PinLocation
+ )
+ }
+ val appName by remember { derivedStateOf { buildMeta.applicationName } }
+ var permissionDialog: SendLocationState.Dialog by remember {
+ mutableStateOf(SendLocationState.Dialog.None)
+ }
+ val scope = rememberCoroutineScope()
+
+ LaunchedEffect(permissionsState.permissions) {
+ if (permissionsState.isAnyGranted) {
+ mode = SendLocationState.Mode.SenderLocation
+ permissionDialog = SendLocationState.Dialog.None
+ }
+ }
+
+ fun handleEvents(event: SendLocationEvents) {
+ when (event) {
+ is SendLocationEvents.SendLocation -> scope.launch {
+ sendLocation(event, mode)
+ }
+ SendLocationEvents.SwitchToMyLocationMode -> when {
+ permissionsState.isAnyGranted -> mode = SendLocationState.Mode.SenderLocation
+ permissionsState.shouldShowRationale -> permissionDialog = SendLocationState.Dialog.PermissionRationale
+ else -> permissionDialog = SendLocationState.Dialog.PermissionDenied
+ }
+ SendLocationEvents.SwitchToPinLocationMode -> mode = SendLocationState.Mode.PinLocation
+ SendLocationEvents.DismissDialog -> permissionDialog = SendLocationState.Dialog.None
+ SendLocationEvents.OpenAppSettings -> {
+ locationActions.openSettings()
+ permissionDialog = SendLocationState.Dialog.None
+ }
+ SendLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ }
+ }
+
+ return SendLocationState(
+ permissionDialog = permissionDialog,
+ mode = mode,
+ hasLocationPermission = permissionsState.isAnyGranted,
+ appName = appName,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ private suspend fun sendLocation(
+ event: SendLocationEvents.SendLocation,
+ mode: SendLocationState.Mode,
+ ) {
+ when (mode) {
+ SendLocationState.Mode.PinLocation -> {
+ val geoUri = event.cameraPosition.toGeoUri()
+ room.sendLocation(
+ body = generateBody(geoUri, systemClock.epochMillis()),
+ geoUri = geoUri,
+ description = null,
+ zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
+ assetType = AssetType.PIN
+ )
+ analyticsService.capture(
+ Composer(
+ inThread = messageComposerContext.composerMode.inThread,
+ isEditing = messageComposerContext.composerMode.isEditing,
+ isLocation = true,
+ isReply = messageComposerContext.composerMode.isReply,
+ locationType = Composer.LocationType.PinDrop,
+ )
+ )
+ }
+ SendLocationState.Mode.SenderLocation -> {
+ val geoUri = event.toGeoUri()
+ room.sendLocation(
+ body = generateBody(geoUri, systemClock.epochMillis()),
+ geoUri = geoUri,
+ description = null,
+ zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
+ assetType = AssetType.SENDER
+ )
+ analyticsService.capture(
+ Composer(
+ inThread = messageComposerContext.composerMode.inThread,
+ isEditing = messageComposerContext.composerMode.isEditing,
+ isLocation = true,
+ isReply = messageComposerContext.composerMode.isReply,
+ locationType = Composer.LocationType.MyLocation,
+ )
+ )
+ }
+ }
+ }
+}
+
+private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri()
+
+private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon"
+
+private fun generateBody(uri: String, epochMillis: Long): String {
+ val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
+ return "Location was shared at $uri as of $timestamp"
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt
new file mode 100644
index 0000000000..3aeec5f046
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.impl.send
+
+data class SendLocationState(
+ val permissionDialog: Dialog = Dialog.None,
+ val mode: Mode = Mode.PinLocation,
+ val hasLocationPermission: Boolean = false,
+ val appName: String = "AppName",
+ val eventSink: (SendLocationEvents) -> Unit = {},
+) {
+ sealed interface Mode {
+ object SenderLocation : Mode
+ object PinLocation : Mode
+ }
+
+ sealed interface Dialog {
+ object None : Dialog
+ object PermissionRationale : Dialog
+ object PermissionDenied : Dialog
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt
new file mode 100644
index 0000000000..15f16f593a
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.impl.send
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+private const val APP_NAME = "ApplicationName"
+
+class SendLocationStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ SendLocationState(
+ permissionDialog = SendLocationState.Dialog.None,
+ mode = SendLocationState.Mode.PinLocation,
+ hasLocationPermission = false,
+ appName = APP_NAME,
+ ),
+ SendLocationState(
+ permissionDialog = SendLocationState.Dialog.PermissionDenied,
+ mode = SendLocationState.Mode.PinLocation,
+ hasLocationPermission = false,
+ appName = APP_NAME,
+ ),
+ SendLocationState(
+ permissionDialog = SendLocationState.Dialog.PermissionRationale,
+ mode = SendLocationState.Mode.PinLocation,
+ hasLocationPermission = false,
+ appName = APP_NAME,
+ ),
+ SendLocationState(
+ permissionDialog = SendLocationState.Dialog.None,
+ mode = SendLocationState.Mode.PinLocation,
+ hasLocationPermission = true,
+ appName = APP_NAME,
+ ),
+ SendLocationState(
+ permissionDialog = SendLocationState.Dialog.None,
+ mode = SendLocationState.Mode.SenderLocation,
+ hasLocationPermission = true,
+ appName = APP_NAME,
+ ),
+ )
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt
new file mode 100644
index 0000000000..1a30c996a8
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt
@@ -0,0 +1,266 @@
+/*
+ * 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.impl.send
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.LocationSearching
+import androidx.compose.material.icons.filled.MyLocation
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.rememberBottomSheetScaffoldState
+import androidx.compose.material3.rememberStandardBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import com.mapbox.mapboxsdk.camera.CameraPosition
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.api.internal.rememberTileStyleUrl
+import io.element.android.features.location.impl.MapDefaults
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.preview.DayNightPreviews
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
+import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
+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.TopAppBar
+import io.element.android.libraries.maplibre.compose.CameraMode
+import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
+import io.element.android.libraries.maplibre.compose.MapboxMap
+import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.designsystem.R as DesignSystemR
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun SendLocationView(
+ state: SendLocationState,
+ modifier: Modifier = Modifier,
+ navigateUp: () -> Unit = {},
+) {
+ LaunchedEffect(Unit) {
+ state.eventSink(SendLocationEvents.RequestPermissions)
+ }
+
+ when (state.permissionDialog) {
+ SendLocationState.Dialog.None -> Unit
+ SendLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog(
+ onContinue = { state.eventSink(SendLocationEvents.OpenAppSettings) },
+ onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) },
+ appName = state.appName,
+ )
+ SendLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog(
+ onContinue = { state.eventSink(SendLocationEvents.RequestPermissions) },
+ onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) },
+ appName = state.appName,
+ )
+ }
+
+ val cameraPositionState = rememberCameraPositionState {
+ position = MapDefaults.centerCameraPosition
+ }
+
+ LaunchedEffect(state.mode) {
+ when (state.mode) {
+ SendLocationState.Mode.PinLocation -> {
+ cameraPositionState.cameraMode = CameraMode.NONE
+ }
+ SendLocationState.Mode.SenderLocation -> {
+ cameraPositionState.position = CameraPosition.Builder()
+ .zoom(MapDefaults.DEFAULT_ZOOM)
+ .build()
+ cameraPositionState.cameraMode = CameraMode.TRACKING
+ }
+ }
+ }
+
+ LaunchedEffect(cameraPositionState.isMoving) {
+ if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
+ state.eventSink(SendLocationEvents.SwitchToPinLocationMode)
+ }
+ }
+
+ // BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually.
+ val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+
+ BottomSheetScaffold(
+ sheetContent = {
+ Spacer(modifier = Modifier.height(16.dp))
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(
+ when (state.mode) {
+ SendLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action
+ SendLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action
+ }
+ )
+ )
+ },
+ modifier = Modifier.clickable(
+ // target is null when the map hasn't loaded (or api key is wrong) so we disable the button
+ enabled = cameraPositionState.position.target != null
+ ) {
+ state.eventSink(
+ SendLocationEvents.SendLocation(
+ cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
+ lat = cameraPositionState.position.target!!.latitude,
+ lon = cameraPositionState.position.target!!.longitude,
+ zoom = cameraPositionState.position.zoom,
+ ),
+ cameraPositionState.location?.let {
+ Location(
+ lat = it.latitude,
+ lon = it.longitude,
+ accuracy = it.accuracy,
+ )
+ }
+ )
+ )
+ navigateUp()
+ },
+ leadingContent = {
+ Icon(Icons.Default.LocationOn, null)
+ },
+ )
+ Spacer(modifier = Modifier.height(16.dp + navBarPadding))
+ },
+ modifier = modifier,
+ scaffoldState = rememberBottomSheetScaffoldState(
+ bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
+ ),
+ sheetDragHandle = {},
+ sheetSwipeEnabled = false,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(CommonStrings.screen_share_location_title),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ },
+ navigationIcon = {
+ BackButton(onClick = navigateUp)
+ },
+ )
+ },
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(it)
+ .consumeWindowInsets(it),
+ contentAlignment = Alignment.Center
+ ) {
+ MapboxMap(
+ styleUri = rememberTileStyleUrl(),
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ uiSettings = MapDefaults.uiSettings,
+ symbolManagerSettings = MapDefaults.symbolManagerSettings,
+ locationSettings = MapDefaults.locationSettings.copy(
+ locationEnabled = state.hasLocationPermission,
+ ),
+ )
+ Icon(
+ resourceId = DesignSystemR.drawable.pin,
+ contentDescription = null,
+ tint = Color.Unspecified,
+ modifier = Modifier.align { size, space, _ ->
+ // Center bottom edge of pin (i.e. its arrow) to center of screen
+ IntOffset(
+ x = (space.width - size.width) / 2,
+ y = (space.height / 2) - size.height,
+ )
+ }
+ )
+ FloatingActionButton(
+ onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 16.dp, bottom = 72.dp + navBarPadding),
+ ) {
+ when (state.mode) {
+ SendLocationState.Mode.PinLocation -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null)
+ SendLocationState.Mode.SenderLocation -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null)
+ }
+ }
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+fun SendLocationViewPreview(
+ @PreviewParameter(SendLocationStateProvider::class) state: SendLocationState
+) = ElementPreview {
+ SendLocationView(
+ state = state,
+ navigateUp = {},
+ )
+}
+
+@Composable
+private fun PermissionRationaleDialog(
+ onContinue: () -> Unit,
+ onDismiss: () -> Unit,
+ appName: String,
+) {
+ ConfirmationDialog(
+ content = stringResource(CommonStrings.error_missing_location_rationale_android, appName),
+ onSubmitClicked = onContinue,
+ onDismiss = onDismiss,
+ submitText = stringResource(CommonStrings.action_continue),
+ cancelText = stringResource(CommonStrings.action_cancel),
+ )
+}
+
+@Composable
+private fun PermissionDeniedDialog(
+ onContinue: () -> Unit,
+ onDismiss: () -> Unit,
+ appName: String,
+) {
+ ConfirmationDialog(
+ content = stringResource(CommonStrings.error_missing_location_auth_android, appName),
+ onSubmitClicked = onContinue,
+ onDismiss = onDismiss,
+ submitText = stringResource(CommonStrings.action_continue),
+ cancelText = stringResource(CommonStrings.action_cancel),
+ )
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LocationActions.kt
new file mode 100644
index 0000000000..d93b15e5c5
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LocationActions.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.impl.show
+
+import io.element.android.features.location.api.Location
+
+interface LocationActions {
+ fun share(location: Location, label: String?)
+ fun openSettings()
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt
new file mode 100644
index 0000000000..7dc1fc02f3
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.location.impl.show
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.location.api.ShowLocationEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class ShowLocationEntryPointImpl @Inject constructor() : ShowLocationEntryPoint {
+ override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ShowLocationEntryPoint.Inputs): Node {
+ return parentNode.createNode(buildContext, listOf(inputs))
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt
new file mode 100644
index 0000000000..b725ec6db7
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.impl.show
+
+sealed interface ShowLocationEvents {
+ object Share : ShowLocationEvents
+ data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt
new file mode 100644
index 0000000000..24094b03ca
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.impl.show
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import im.vector.app.features.analytics.plan.MobileScreen
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.location.api.ShowLocationEntryPoint
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.RoomScope
+import io.element.android.services.analytics.api.AnalyticsService
+
+@ContributesNode(RoomScope::class)
+class ShowLocationNode @AssistedInject constructor(
+ presenterFactory: ShowLocationPresenter.Factory,
+ analyticsService: AnalyticsService,
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext, plugins = plugins) {
+
+ init {
+ lifecycle.subscribe(
+ onResume = {
+ analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView))
+ }
+ )
+ }
+
+ private val inputs: ShowLocationEntryPoint.Inputs = inputs()
+ private val presenter = presenterFactory.create(inputs.location, inputs.description)
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ShowLocationView(
+ state = presenter.present(),
+ modifier = modifier,
+ onBackPressed = ::navigateUp
+ )
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
new file mode 100644
index 0000000000..3ac5d90bb6
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.impl.show
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.impl.MapDefaults
+import io.element.android.features.location.impl.permissions.PermissionsPresenter
+import io.element.android.features.location.impl.permissions.PermissionsState
+import io.element.android.libraries.architecture.Presenter
+
+class ShowLocationPresenter @AssistedInject constructor(
+ permissionsPresenterFactory: PermissionsPresenter.Factory,
+ private val actions: LocationActions,
+ @Assisted private val location: Location,
+ @Assisted private val description: String?
+) : Presenter {
+
+ @AssistedFactory
+ interface Factory {
+ fun create(location: Location, description: String?): ShowLocationPresenter
+ }
+
+ private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions)
+
+ @Composable
+ override fun present(): ShowLocationState {
+ val permissionsState: PermissionsState = permissionsPresenter.present()
+ var isTrackMyLocation by remember { mutableStateOf(false) }
+
+ fun handleEvents(event: ShowLocationEvents) {
+ when (event) {
+ ShowLocationEvents.Share -> actions.share(location, description)
+ is ShowLocationEvents.TrackMyLocation -> isTrackMyLocation = event.enabled
+ }
+ }
+
+ return ShowLocationState(
+ location = location,
+ description = description,
+ hasLocationPermission = permissionsState.isAnyGranted,
+ isTrackMyLocation = isTrackMyLocation,
+ eventSink = ::handleEvents,
+ )
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
new file mode 100644
index 0000000000..c567dd3c94
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.impl.show
+
+import io.element.android.features.location.api.Location
+
+data class ShowLocationState(
+ val location: Location,
+ val description: String?,
+ val hasLocationPermission: Boolean,
+ val isTrackMyLocation: Boolean,
+ val eventSink: (ShowLocationEvents) -> Unit,
+)
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
new file mode 100644
index 0000000000..1865c6b9a4
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
@@ -0,0 +1,62 @@
+/*
+ * 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.impl.show
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.location.api.Location
+
+class ShowLocationStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ ShowLocationState(
+ Location(1.23, 2.34, 4f),
+ description = null,
+ hasLocationPermission = false,
+ isTrackMyLocation = false,
+ eventSink = {},
+ ),
+ ShowLocationState(
+ Location(1.23, 2.34, 4f),
+ description = null,
+ hasLocationPermission = true,
+ isTrackMyLocation = false,
+ eventSink = {},
+ ),
+ ShowLocationState(
+ Location(1.23, 2.34, 4f),
+ description = null,
+ hasLocationPermission = true,
+ isTrackMyLocation = true,
+ eventSink = {},
+ ),
+ ShowLocationState(
+ Location(1.23, 2.34, 4f),
+ description = "My favourite place!",
+ hasLocationPermission = false,
+ isTrackMyLocation = false,
+ eventSink = {},
+ ),
+ ShowLocationState(
+ Location(1.23, 2.34, 4f),
+ description = "For some reason I decided to write a small essay in the location description. " +
+ "It is so long that it will wrap onto more than two lines!",
+ hasLocationPermission = false,
+ isTrackMyLocation = false,
+ eventSink = {},
+ ),
+ )
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
new file mode 100644
index 0000000000..7726bbf9cc
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
@@ -0,0 +1,193 @@
+/*
+ * 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.impl.show
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LocationSearching
+import androidx.compose.material.icons.filled.MyLocation
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import com.mapbox.mapboxsdk.camera.CameraPosition
+import com.mapbox.mapboxsdk.geometry.LatLng
+import io.element.android.features.location.api.internal.rememberTileStyleUrl
+import io.element.android.features.location.impl.MapDefaults
+import io.element.android.features.location.impl.send.SendLocationState
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.aliasScreenTitle
+import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.IconButton
+import io.element.android.libraries.designsystem.theme.components.Scaffold
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.maplibre.compose.CameraMode
+import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
+import io.element.android.libraries.maplibre.compose.IconAnchor
+import io.element.android.libraries.maplibre.compose.MapboxMap
+import io.element.android.libraries.maplibre.compose.Symbol
+import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
+import io.element.android.libraries.maplibre.compose.rememberSymbolState
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.theme.compound.generated.TypographyTokens
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.toImmutableMap
+import io.element.android.libraries.designsystem.R as DesignSystemR
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun ShowLocationView(
+ state: ShowLocationState,
+ modifier: Modifier = Modifier,
+ onBackPressed: () -> Unit = {},
+) {
+ val cameraPositionState = rememberCameraPositionState {
+ position = CameraPosition.Builder()
+ .target(LatLng(state.location.lat, state.location.lon))
+ .zoom(MapDefaults.DEFAULT_ZOOM)
+ .build()
+ }
+
+ LaunchedEffect(state.isTrackMyLocation) {
+ when (state.isTrackMyLocation) {
+ false -> cameraPositionState.cameraMode = CameraMode.NONE
+ true -> {
+ cameraPositionState.position = CameraPosition.Builder()
+ .zoom(MapDefaults.DEFAULT_ZOOM)
+ .build()
+ cameraPositionState.cameraMode = CameraMode.TRACKING
+ }
+ }
+ }
+
+ LaunchedEffect(cameraPositionState.isMoving) {
+ if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
+ state.eventSink(ShowLocationEvents.TrackMyLocation(false))
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(CommonStrings.screen_view_location_title),
+ style = ElementTheme.typography.aliasScreenTitle,
+ )
+ },
+ navigationIcon = {
+ BackButton(onClick = onBackPressed)
+ },
+ actions = {
+ IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) {
+ Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share))
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ if (state.hasLocationPermission) {
+ FloatingActionButton(
+ onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
+ ) {
+ when (state.isTrackMyLocation) {
+ false -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null)
+ true -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null)
+ }
+ }
+ }
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues)
+ .fillMaxSize(),
+ ) {
+ state.description?.let {
+ Text(
+ text = it,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = TypographyTokens.fontBodyMdRegular,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ )
+ }
+
+ MapboxMap(
+ styleUri = rememberTileStyleUrl(),
+ modifier = Modifier.fillMaxSize(),
+ images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(),
+ cameraPositionState = cameraPositionState,
+ uiSettings = MapDefaults.uiSettings,
+ symbolManagerSettings = MapDefaults.symbolManagerSettings,
+ locationSettings = MapDefaults.locationSettings.copy(
+ locationEnabled = state.hasLocationPermission,
+ ),
+ ) {
+ Symbol(
+ iconId = PIN_ID,
+ state = rememberSymbolState(
+ position = LatLng(state.location.lat, state.location.lon)
+ ),
+ iconAnchor = IconAnchor.BOTTOM,
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+internal fun ShowLocationViewLightPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) =
+ ElementPreviewLight { ContentToPreview(state) }
+
+@Preview
+@Composable
+internal fun ShowLocationViewDarkPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) =
+ ElementPreviewDark { ContentToPreview(state) }
+
+@Composable
+private fun ContentToPreview(state: ShowLocationState) {
+ ShowLocationView(
+ state = state,
+ onBackPressed = {},
+ )
+}
+
+private const val PIN_ID = "pin"
+
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterFake.kt
new file mode 100644
index 0000000000..a18e4cf2bf
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterFake.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.location.impl.permissions
+
+import androidx.compose.runtime.Composable
+
+class PermissionsPresenterFake : PermissionsPresenter {
+
+ val events = mutableListOf()
+
+ private fun handleEvent(event: PermissionsEvents) {
+ events += event
+ }
+
+ private var state = PermissionsState(eventSink = ::handleEvent)
+ set(value) {
+ field = value.copy(eventSink = ::handleEvent)
+ }
+
+ fun givenState(state: PermissionsState) {
+ this.state = state
+ }
+
+ @Composable
+ override fun present(): PermissionsState = state
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
new file mode 100644
index 0000000000..45c99b556a
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
@@ -0,0 +1,461 @@
+/*
+ * 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.impl.send
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import im.vector.app.features.analytics.plan.Composer
+import io.element.android.features.analytics.test.FakeAnalyticsService
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.impl.permissions.PermissionsEvents
+import io.element.android.features.location.impl.permissions.PermissionsPresenter
+import io.element.android.features.location.impl.permissions.PermissionsPresenterFake
+import io.element.android.features.location.impl.permissions.PermissionsState
+import io.element.android.features.location.impl.show.FakeLocationActions
+import io.element.android.features.messages.test.MessageComposerContextFake
+import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.SendLocationInvocation
+import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SendLocationPresenterTest {
+
+ private val permissionsPresenterFake = PermissionsPresenterFake()
+ private val fakeMatrixRoom = FakeMatrixRoom()
+ private val fakeAnalyticsService = FakeAnalyticsService()
+ private val messageComposerContextFake = MessageComposerContextFake()
+ private val fakeLocationActions = FakeLocationActions()
+ private val fakeSystemClock = SystemClock { 0L }
+ private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
+ private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
+ permissionsPresenterFactory = object : PermissionsPresenter.Factory {
+ override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake
+ },
+ room = fakeMatrixRoom,
+ analyticsService = fakeAnalyticsService,
+ messageComposerContext = messageComposerContextFake,
+ locationActions = fakeLocationActions,
+ systemClock = fakeSystemClock,
+ buildMeta = fakeBuildMeta,
+ )
+
+ @Test
+ fun `initial state with permissions granted`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
+
+ // Swipe the map to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(true)
+ }
+ }
+
+ @Test
+ fun `initial state with permissions partially granted`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.SomeGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
+
+ // Swipe the map to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(true)
+ }
+ }
+
+ @Test
+ fun `initial state with permissions denied`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
+
+ // Click on the button to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `initial state with permissions denied once`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = true,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
+
+ // Click on the button to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `rationale dialog dismiss`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = true,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ // Click on the button to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
+
+ // Dismiss the dialog
+ myLocationState.eventSink(SendLocationEvents.DismissDialog)
+ val dialogDismissedState = awaitItem()
+ Truth.assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(dialogDismissedState.hasLocationPermission).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `rationale dialog continue`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = true,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ // Click on the button to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
+
+ // Continue the dialog sends permission request to the permissions presenter
+ myLocationState.eventSink(SendLocationEvents.RequestPermissions)
+ Truth.assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions)
+ }
+ }
+
+ @Test
+ fun `permission denied dialog dismiss`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ // Click on the button to switch mode
+ initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
+ val myLocationState = awaitItem()
+ Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied)
+ Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false)
+
+ // Dismiss the dialog
+ myLocationState.eventSink(SendLocationEvents.DismissDialog)
+ val dialogDismissedState = awaitItem()
+ Truth.assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation)
+ Truth.assertThat(dialogDismissedState.hasLocationPermission).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `share sender location`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ // Send location
+ initialState.eventSink(
+ SendLocationEvents.SendLocation(
+ cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
+ lat = 0.0,
+ lon = 1.0,
+ zoom = 2.0,
+ ),
+ location = Location(
+ lat = 3.0,
+ lon = 4.0,
+ accuracy = 5.0f,
+ )
+ )
+ )
+
+ delay(1) // Wait for the coroutine to finish
+
+ Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
+ Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
+ SendLocationInvocation(
+ body = "Location was shared at geo:3.0,4.0;u=5.0 as of 1970-01-01T00:00:00Z",
+ geoUri = "geo:3.0,4.0;u=5.0",
+ description = null,
+ zoomLevel = 15,
+ assetType = AssetType.SENDER
+ )
+ )
+
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
+ Composer(
+ inThread = false,
+ isEditing = false,
+ isLocation = true,
+ isReply = false,
+ locationType = Composer.LocationType.MyLocation,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `share pin location`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ // Send location
+ initialState.eventSink(
+ SendLocationEvents.SendLocation(
+ cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
+ lat = 0.0,
+ lon = 1.0,
+ zoom = 2.0,
+ ),
+ location = Location(
+ lat = 3.0,
+ lon = 4.0,
+ accuracy = 5.0f,
+ )
+ )
+ )
+
+ delay(1) // Wait for the coroutine to finish
+
+ Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1)
+ Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo(
+ SendLocationInvocation(
+ body = "Location was shared at geo:0.0,1.0 as of 1970-01-01T00:00:00Z",
+ geoUri = "geo:0.0,1.0",
+ description = null,
+ zoomLevel = 15,
+ assetType = AssetType.PIN
+ )
+ )
+
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
+ Composer(
+ inThread = false,
+ isEditing = false,
+ isLocation = true,
+ isReply = false,
+ locationType = Composer.LocationType.PinDrop,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `composer context passes through analytics`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ )
+ )
+ messageComposerContextFake.apply {
+ composerMode = MessageComposerMode.Edit(
+ eventId = null, defaultContent = "", transactionId = null
+ )
+ }
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ // Send location
+ initialState.eventSink(
+ SendLocationEvents.SendLocation(
+ cameraPosition = SendLocationEvents.SendLocation.CameraPosition(
+ lat = 0.0,
+ lon = 1.0,
+ zoom = 2.0,
+ ),
+ location = null
+ )
+ )
+
+ delay(1) // Wait for the coroutine to finish
+
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1)
+ Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo(
+ Composer(
+ inThread = false,
+ isEditing = true,
+ isLocation = true,
+ isReply = false,
+ locationType = Composer.LocationType.PinDrop,
+ )
+ )
+ }
+ }
+
+ @Test
+ fun `open settings activity`() = runTest {
+ permissionsPresenterFake.givenState(
+ PermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ )
+ )
+ messageComposerContextFake.apply {
+ composerMode = MessageComposerMode.Edit(
+ eventId = null, defaultContent = "", transactionId = null
+ )
+ }
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ // Skip initial state
+ val initialState = awaitItem()
+
+ initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode)
+ val dialogShownState = awaitItem()
+
+ // Open settings
+ dialogShownState.eventSink(SendLocationEvents.OpenAppSettings)
+ val settingsOpenedState = awaitItem()
+
+ Truth.assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None)
+ Truth.assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `application name is in state`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ sendLocationPresenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.appName).isEqualTo("app name")
+ }
+ }
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/AndroidLocationActionsTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/AndroidLocationActionsTest.kt
new file mode 100644
index 0000000000..29c0ba4d58
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/AndroidLocationActionsTest.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.impl.show
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.impl.buildUrl
+import org.junit.Test
+import java.net.URLEncoder
+
+internal class AndroidLocationActionsTest {
+
+ // We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
+ private fun urlEncoder(input: String) = URLEncoder.encode(input, "US-ASCII")
+
+ @Test
+ fun `buildUrl - truncates excessive decimals to 6dp`() {
+ val location = Location(
+ lat = 1.234567890123,
+ lon = 123.456789012345,
+ accuracy = 0f
+ )
+
+ val actual = buildUrl(location, null, ::urlEncoder)
+ val expected = "geo:0,0?q=1.234568,123.456789"
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun `buildUrl - appends label if set`() {
+ val location = Location(
+ lat = 1.000001,
+ lon = 2.000001,
+ accuracy = 0f
+ )
+
+ val actual = buildUrl(location, "point", ::urlEncoder)
+ val expected = "geo:0,0?q=1.000001,2.000001 (point)"
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun `buildUrl - URL encodes label`() {
+ val location = Location(
+ lat = 1.000001,
+ lon = 2.000001,
+ accuracy = 0f
+ )
+
+ val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
+ val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
+
+ assertThat(actual).isEqualTo(expected)
+ }
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/FakeLocationActions.kt
new file mode 100644
index 0000000000..c54aab6f28
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/FakeLocationActions.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.location.impl.show
+
+import io.element.android.features.location.api.Location
+
+class FakeLocationActions : LocationActions {
+
+ var sharedLocation: Location? = null
+ private set
+
+ var sharedLabel: String? = null
+ private set
+
+ var openSettingsInvocationsCount = 0
+ private set
+
+ override fun share(location: Location, label: String?) {
+ sharedLocation = location
+ sharedLabel = label
+ }
+
+ override fun openSettings() {
+ openSettingsInvocationsCount++
+ }
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
new file mode 100644
index 0000000000..47f0e2ccea
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
@@ -0,0 +1,126 @@
+/*
+ * 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.impl.show
+
+import app.cash.molecule.RecompositionClock
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.impl.permissions.PermissionsPresenter
+import io.element.android.features.location.impl.permissions.PermissionsPresenterFake
+import io.element.android.features.location.impl.permissions.PermissionsState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class ShowLocationPresenterTest {
+
+ private val permissionsPresenterFake = PermissionsPresenterFake()
+ private val actions = FakeLocationActions()
+ private val location = Location(1.23, 4.56, 7.8f)
+ private val presenter = ShowLocationPresenter(
+ permissionsPresenterFactory = object : PermissionsPresenter.Factory {
+ override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake
+ },
+ actions,
+ location,
+ A_DESCRIPTION,
+ )
+
+ @Test
+ fun `emits initial state with no location permission`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.location).isEqualTo(location)
+ Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false)
+ Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `emits initial state with location permission`() = runTest {
+ permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.location).isEqualTo(location)
+ Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
+ Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `emits initial state with partial location permission`() = runTest {
+ permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.location).isEqualTo(location)
+ Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
+ Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
+ }
+ }
+
+ @Test
+ fun `uses action to share location`() = runTest {
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(ShowLocationEvents.Share)
+
+ Truth.assertThat(actions.sharedLocation).isEqualTo(location)
+ Truth.assertThat(actions.sharedLabel).isEqualTo(A_DESCRIPTION)
+ }
+ }
+
+ @Test
+ fun `centers on user location`() = runTest {
+ permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted))
+
+ moleculeFlow(RecompositionClock.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true)
+ Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false)
+
+ initialState.eventSink(ShowLocationEvents.TrackMyLocation(true))
+ val trackMyLocationState = awaitItem()
+
+ delay(1)
+
+ Truth.assertThat(trackMyLocationState.hasLocationPermission).isEqualTo(true)
+ Truth.assertThat(trackMyLocationState.isTrackMyLocation).isEqualTo(true)
+ }
+ }
+
+ companion object {
+ private const val A_DESCRIPTION = "My happy place"
+ }
+
+}
diff --git a/features/login/api/build.gradle.kts b/features/login/api/build.gradle.kts
new file mode 100644
index 0000000000..5b7bddb15f
--- /dev/null
+++ b/features/login/api/build.gradle.kts
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.login.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt
new file mode 100644
index 0000000000..07a546192d
--- /dev/null
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.login.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface LoginEntryPoint : FeatureEntryPoint {
+ data class Params(
+ val isAccountCreation: Boolean,
+ )
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun params(params: Params): NodeBuilder
+ fun build(): Node
+ }
+}
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginUserStory.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginUserStory.kt
new file mode 100644
index 0000000000..3a4cc54563
--- /dev/null
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginUserStory.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.login.api
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface LoginUserStory {
+ val loginFlowIsDone: StateFlow
+}
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcAction.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcAction.kt
new file mode 100644
index 0000000000..6e90a390c4
--- /dev/null
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcAction.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.login.api.oidc
+
+sealed interface OidcAction {
+ object GoBack : OidcAction
+ data class Success(val url: String) : OidcAction
+}
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcActionFlow.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcActionFlow.kt
new file mode 100644
index 0000000000..004e7c8a51
--- /dev/null
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcActionFlow.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.login.api.oidc
+
+interface OidcActionFlow {
+ fun post(oidcAction: OidcAction)
+}
diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcIntentResolver.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcIntentResolver.kt
new file mode 100644
index 0000000000..a6ecf26fca
--- /dev/null
+++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcIntentResolver.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.login.api.oidc
+
+import android.content.Intent
+
+interface OidcIntentResolver {
+ fun resolve(intent: Intent): OidcAction?
+}
diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts
new file mode 100644
index 0000000000..f9bbb390e6
--- /dev/null
+++ b/features/login/impl/build.gradle.kts
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+ kotlin("plugin.serialization") version "1.8.22"
+}
+
+android {
+ namespace = "io.element.android.features.login.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.network)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiStrings)
+ implementation(libs.androidx.browser)
+ implementation(libs.network.retrofit)
+ implementation(libs.serialization.json)
+ api(projects.features.login.api)
+ ksp(libs.showkase.processor)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
+
+ androidTestImplementation(libs.test.junitext)
+}
diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..172e8645c1
--- /dev/null
+++ b/features/login/impl/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt
new file mode 100644
index 0000000000..a4290825fb
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.login.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.login.api.LoginEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LoginEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : LoginEntryPoint.NodeBuilder {
+
+ override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder {
+ plugins += LoginFlowNode.Inputs(isAccountCreation = params.isAccountCreation)
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginUserStory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginUserStory.kt
new file mode 100644
index 0000000000..26b00068bc
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginUserStory.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.login.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.login.api.LoginUserStory
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class DefaultLoginUserStory @Inject constructor() : LoginUserStory {
+ // True by default, will be set to false when the login user story is started, and set to true again once it's done.
+ override val loginFlowIsDone: MutableStateFlow = MutableStateFlow(true)
+
+ fun setLoginFlowIsDone(value: Boolean) {
+ loginFlowIsDone.value = value
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
new file mode 100644
index 0000000000..fe47fb1b67
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.login.impl
+
+import android.app.Activity
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.push
+import com.bumble.appyx.navmodel.backstack.operation.singleTop
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
+import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
+import io.element.android.features.login.impl.oidc.webview.OidcNode
+import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
+import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
+import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
+import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
+import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
+import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.theme.ElementTheme
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(AppScope::class)
+class LoginFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
+ private val customTabHandler: CustomTabHandler,
+ private val accountProviderDataSource: AccountProviderDataSource,
+ private val defaultLoginUserStory: DefaultLoginUserStory,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.ConfirmAccountProvider,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ private var activity: Activity? = null
+ private var darkTheme: Boolean = false
+
+ data class Inputs(
+ val isAccountCreation: Boolean,
+ ) : NodeInputs
+
+ private val inputs: Inputs = inputs()
+
+ override fun onBuilt() {
+ super.onBuilt()
+ defaultLoginUserStory.setLoginFlowIsDone(false)
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ object ConfirmAccountProvider : NavTarget
+
+ @Parcelize
+ object ChangeAccountProvider : NavTarget
+
+ @Parcelize
+ object SearchAccountProvider : NavTarget
+
+ @Parcelize
+ object LoginPassword : NavTarget
+
+ @Parcelize
+ data class WaitList(val loginFormState: LoginFormState) : NavTarget
+
+ @Parcelize
+ data class OidcView(val oidcDetails: OidcDetails) : NavTarget
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.ConfirmAccountProvider -> {
+ val inputs = ConfirmAccountProviderNode.Inputs(
+ isAccountCreation = inputs.isAccountCreation
+ )
+ val callback = object : ConfirmAccountProviderNode.Callback {
+ override fun onOidcDetails(oidcDetails: OidcDetails) {
+ if (customTabAvailabilityChecker.supportCustomTab()) {
+ // In this case open a Chrome Custom tab
+ activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) }
+ } else {
+ // Fallback to WebView mode
+ backstack.push(NavTarget.OidcView(oidcDetails))
+ }
+ }
+
+ override fun onLoginPasswordNeeded() {
+ backstack.push(NavTarget.LoginPassword)
+ }
+
+ override fun onChangeAccountProvider() {
+ backstack.push(NavTarget.ChangeAccountProvider)
+ }
+ }
+ createNode(buildContext, plugins = listOf(inputs, callback))
+ }
+ NavTarget.ChangeAccountProvider -> {
+ val callback = object : ChangeAccountProviderNode.Callback {
+ override fun onDone() {
+ // Go back to the Account Provider screen
+ backstack.singleTop(NavTarget.ConfirmAccountProvider)
+ }
+
+ override fun onOtherClicked() {
+ backstack.push(NavTarget.SearchAccountProvider)
+ }
+ }
+
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ NavTarget.SearchAccountProvider -> {
+ val callback = object : SearchAccountProviderNode.Callback {
+ override fun onDone() {
+ // Go back to the Account Provider screen
+ backstack.singleTop(NavTarget.ConfirmAccountProvider)
+ }
+ }
+
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ NavTarget.LoginPassword -> {
+ val callback = object : LoginPasswordNode.Callback {
+ override fun onWaitListError(loginFormState: LoginFormState) {
+ backstack.newRoot(NavTarget.WaitList(loginFormState))
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ is NavTarget.OidcView -> {
+ val input = OidcNode.Inputs(navTarget.oidcDetails)
+ createNode(buildContext, plugins = listOf(input))
+ }
+ is NavTarget.WaitList -> {
+ val inputs = WaitListNode.Inputs(
+ loginFormState = navTarget.loginFormState,
+ )
+ val callback = object : WaitListNode.Callback {
+ override fun onCancelClicked() {
+ navigateUp()
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback, inputs))
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ activity = LocalContext.current as? Activity
+ darkTheme = !ElementTheme.isLightTheme
+ DisposableEffect(Unit) {
+ onDispose {
+ activity = null
+ accountProviderDataSource.reset()
+ }
+ }
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ // Animate transition to change server screen
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt
new file mode 100644
index 0000000000..b6aea81951
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+data class AccountProvider constructor(
+ val title: String,
+ val subtitle: String? = null,
+ val isPublic: Boolean = false,
+ val isMatrixOrg: Boolean = false,
+ val isValid: Boolean = false,
+ val supportSlidingSync: Boolean = false,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
new file mode 100644
index 0000000000..ea541285df
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.login.impl.accountprovider
+
+import io.element.android.features.login.impl.util.defaultAccountProvider
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+
+@SingleIn(AppScope::class)
+class AccountProviderDataSource @Inject constructor(
+) {
+ private val accountProvider: MutableStateFlow = MutableStateFlow(
+ defaultAccountProvider
+ )
+
+ fun flow(): StateFlow {
+ return accountProvider.asStateFlow()
+ }
+
+ fun reset() {
+ accountProvider.tryEmit(defaultAccountProvider)
+ }
+
+ fun userSelection(data: AccountProvider) {
+ accountProvider.tryEmit(data)
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
new file mode 100644
index 0000000000..71e1abd591
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.login.impl.accountprovider
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class AccountProviderProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ anAccountProvider(),
+ anAccountProvider().copy(subtitle = null),
+ anAccountProvider().copy(subtitle = null, title = "no.sliding.sync", supportSlidingSync = false),
+ anAccountProvider().copy(subtitle = null, title = "invalid", isValid = false, supportSlidingSync = false),
+ anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false),
+ // Add other state here
+ )
+}
+
+fun anAccountProvider() = AccountProvider(
+ title = "matrix.org",
+ subtitle = "Matrix.org is an open network for secure, decentralized communication.",
+ isPublic = true,
+ isMatrixOrg = true,
+ isValid = true,
+ supportSlidingSync = true,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt
new file mode 100644
index 0000000000..3362beac40
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.login.impl.accountprovider
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.login.impl.R
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
+import io.element.android.libraries.designsystem.preview.ElementPreviewDark
+import io.element.android.libraries.designsystem.preview.ElementPreviewLight
+import io.element.android.libraries.designsystem.theme.components.Divider
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.ElementTheme
+
+/**
+ * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
+ */
+@Composable
+fun AccountProviderView(
+ item: AccountProvider,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ Column(modifier = modifier
+ .fillMaxWidth()
+ .clickable { onClick() }) {
+ Divider()
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp, horizontal = 16.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 44.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (item.isMatrixOrg) {
+ RoundedIconAtom(
+ size = RoundedIconAtomSize.Medium,
+ resourceId = R.drawable.ic_matrix,
+ tint = Color.Unspecified,
+ )
+ } else {
+ RoundedIconAtom(
+ size = RoundedIconAtomSize.Medium,
+ imageVector = Icons.Filled.Search,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ Text(
+ modifier = Modifier
+ .padding(start = 16.dp)
+ .weight(1f),
+ text = item.title,
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ if (item.isPublic) {
+ Icon(
+ modifier = Modifier
+ .padding(start = 10.dp)
+ .size(16.dp),
+ resourceId = R.drawable.ic_public,
+ contentDescription = null,
+ tint = Color.Unspecified,
+ )
+ }
+ }
+ if (item.subtitle != null) {
+ Text(
+ modifier = Modifier
+ .padding(start = 46.dp, bottom = 12.dp, end = 26.dp),
+ text = item.subtitle,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) =
+ ElementPreviewLight { ContentToPreview(item) }
+
+@Preview
+@Composable
+fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) =
+ ElementPreviewDark { ContentToPreview(item) }
+
+@Composable
+private fun ContentToPreview(item: AccountProvider) {
+ AccountProviderView(
+ item = item,
+ onClick = { }
+ )
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt
new file mode 100644
index 0000000000..cd1cb7b4ce
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.login.impl.changeserver
+
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+
+sealed interface ChangeServerEvents {
+ data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
+ object ClearError : ChangeServerEvents
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
new file mode 100644
index 0000000000..ef9f9e2441
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt
@@ -0,0 +1,76 @@
+/*
+ * 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.login.impl.changeserver
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.features.login.impl.accountprovider.AccountProvider
+import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
+import io.element.android.features.login.impl.error.ChangeServerError
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import java.net.URL
+import javax.inject.Inject
+
+class ChangeServerPresenter @Inject constructor(
+ private val authenticationService: MatrixAuthenticationService,
+ private val accountProviderDataSource: AccountProviderDataSource,
+) : Presenter