mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
Merge branch 'develop' into feature/fga/user_status_last_pieces
This commit is contained in:
@@ -51,7 +51,7 @@ jobs:
|
|||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
- name: Clone submodules
|
- name: Clone submodules
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: ⏬ Checkout with LFS
|
- name: ⏬ Checkout with LFS
|
||||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
name: Use JDK 21
|
name: Use JDK 21
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
# https://github.com/actions/checkout/issues/881
|
# https://github.com/actions/checkout/issues/881
|
||||||
ref: ${{ github.ref }}
|
ref: ${{ github.ref }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
name: Use JDK 21
|
name: Use JDK 21
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||||
|
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -183,7 +183,7 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -228,7 +228,7 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -269,7 +269,7 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -336,7 +336,7 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||||
|
|
||||||
upload_reports:
|
upload_reports:
|
||||||
name: Project Check Suite
|
name: Project Check Suite
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: ☕️ Use JDK 21
|
- name: ☕️ Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Clone submodules
|
- name: Clone submodules
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -126,7 +126,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Use JDK 21
|
- name: Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive
|
||||||
- name: ☕️ Use JDK 21
|
- name: ☕️ Use JDK 21
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
|
|||||||
- Click on OK
|
- Click on OK
|
||||||
4. Configure generate-module-from-template plugin :
|
4. Configure generate-module-from-template plugin :
|
||||||
- Navigate to AS/Settings/Tools/Module Template Settings
|
- Navigate to AS/Settings/Tools/Module Template Settings
|
||||||
|
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
|
||||||
|
imported template and will not pick up changes made to `FeatureModule.json` on its own
|
||||||
- Click on + / Import From File
|
- Click on + / Import From File
|
||||||
- Pick the `tools/templates/FeatureModule.json`
|
- Pick the `tools/templates/FeatureModule.json`
|
||||||
|
|
||||||
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
|
|||||||
5. The modules api/impl should be created under `features/roomdetails` directory.
|
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).
|
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`.
|
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`.
|
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
|
||||||
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
|
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
|
||||||
|
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
|
||||||
|
`PreferencesRoot` in the `features/preferences/impl/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
|
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
|
||||||
|
|||||||
+14
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
|
|||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
|
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
|
||||||
},
|
},
|
||||||
|
onResume = ::onResume,
|
||||||
onDestroy = {
|
onDestroy = {
|
||||||
linkMobileHandlerJob?.cancel()
|
linkMobileHandlerJob?.cancel()
|
||||||
linkDesktopHandlerJob?.cancel()
|
linkDesktopHandlerJob?.cancel()
|
||||||
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
|
|||||||
.launchIn(sessionCoroutineScope)
|
.launchIn(sessionCoroutineScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onResume() = sessionCoroutineScope.launch {
|
||||||
|
// Application is resumed, if the step is waiting for auth, send the confirmation
|
||||||
|
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
|
||||||
|
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
|
||||||
|
it.continuationMessageSender.confirm()
|
||||||
|
}
|
||||||
|
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
|
||||||
|
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
|
||||||
|
it.continuationMessageSender.confirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun navigateToError(errorType: ErrorType) {
|
private fun navigateToError(errorType: ErrorType) {
|
||||||
// Map the error to an error screen
|
// Map the error to an error screen
|
||||||
val error = when (errorType) {
|
val error = when (errorType) {
|
||||||
|
|||||||
+9
-2
@@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap
|
|||||||
import io.element.android.libraries.core.uri.ensureProtocol
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.core.uri.isValidUrl
|
import io.element.android.libraries.core.uri.isValidUrl
|
||||||
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
|
||||||
|
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||||
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -34,8 +36,13 @@ class HomeserverResolver(
|
|||||||
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
|
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
|
||||||
val flowContext = currentCoroutineContext()
|
val flowContext = currentCoroutineContext()
|
||||||
val trimmedUserInput = userInput.trim()
|
val trimmedUserInput = userInput.trim()
|
||||||
if (trimmedUserInput.length < 4) return@flow
|
val searchTerm = when {
|
||||||
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
|
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
|
||||||
|
trimmedUserInput.startsWith("@") -> return@flow
|
||||||
|
else -> trimmedUserInput
|
||||||
|
}
|
||||||
|
if (searchTerm.length < 4) return@flow
|
||||||
|
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
|
||||||
val list = getUrlCandidates(candidateBase)
|
val list = getUrlCandidates(candidateBase)
|
||||||
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
|
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
|
||||||
// Run all the requests in parallel
|
// Run all the requests in parallel
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.resolver
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
|
||||||
|
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HomeserverResolverTest {
|
||||||
|
@Test
|
||||||
|
fun `resolve - a server name is used as is`() = runTest {
|
||||||
|
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||||
|
val sut = createHomeserverResolver(checkResult)
|
||||||
|
sut.resolve("example.org").test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
checkResult.assertions().isCalledExactly(1)
|
||||||
|
.withSequence(
|
||||||
|
listOf(value("https://example.org")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve - an url is used as is`() = runTest {
|
||||||
|
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||||
|
val sut = createHomeserverResolver(checkResult)
|
||||||
|
sut.resolve("https://example.org").test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
checkResult.assertions().isCalledExactly(1)
|
||||||
|
.withSequence(
|
||||||
|
listOf(value("https://example.org")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve - a user id resolves the server name of the user id`() = runTest {
|
||||||
|
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
|
||||||
|
val sut = createHomeserverResolver(checkResult)
|
||||||
|
sut.resolve("@alice:example.org").test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
checkResult.assertions().isCalledExactly(1)
|
||||||
|
.withSequence(
|
||||||
|
listOf(value("https://example.org")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
|
||||||
|
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
|
||||||
|
val sut = createHomeserverResolver(checkResult)
|
||||||
|
sut.resolve("@alice:example.org:8448").test {
|
||||||
|
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
checkResult.assertions().isCalledExactly(1)
|
||||||
|
.withSequence(
|
||||||
|
listOf(value("https://example.org:8448")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
|
||||||
|
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
|
||||||
|
val sut = createHomeserverResolver(checkResult)
|
||||||
|
sut.resolve("@alice").test {
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
sut.resolve("@alice:").test {
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
checkResult.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
|
||||||
|
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
|
||||||
|
val sut = createHomeserverResolver(checkResult)
|
||||||
|
sut.resolve("@alice:a.b").test {
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
checkResult.assertions().isNeverCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createHomeserverResolver(
|
||||||
|
checkResult: (String) -> Result<Boolean>,
|
||||||
|
) = HomeserverResolver(
|
||||||
|
dispatchers = testCoroutineDispatchers(),
|
||||||
|
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
|
||||||
|
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||||
|
)
|
||||||
|
}
|
||||||
+13
-1
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
|
|||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.features.messages.impl.MessagesEvent
|
||||||
import io.element.android.features.messages.impl.MessagesNavigator
|
import io.element.android.features.messages.impl.MessagesNavigator
|
||||||
import io.element.android.features.messages.impl.MessagesPresenter
|
import io.element.android.features.messages.impl.MessagesPresenter
|
||||||
import io.element.android.features.messages.impl.MessagesState
|
import io.element.android.features.messages.impl.MessagesState
|
||||||
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||||||
import io.element.android.features.messages.impl.timeline.TimelineController
|
import io.element.android.features.messages.impl.timeline.TimelineController
|
||||||
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
import io.element.android.features.messages.impl.timeline.TimelineEvent
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
|
||||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
|
|||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||||
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
|
|||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
private val appNavigationStateService: AppNavigationStateService,
|
private val appNavigationStateService: AppNavigationStateService,
|
||||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||||
|
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val threadRootEventId: ThreadId,
|
val threadRootEventId: ThreadId,
|
||||||
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
|
|||||||
onViewAllPinnedMessagesClick = {},
|
onViewAllPinnedMessagesClick = {},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
knockRequestsBannerView = {},
|
knockRequestsBannerView = {},
|
||||||
customReactionBottomSheet = {},
|
customReactionBottomSheet = {
|
||||||
|
CustomReactionBottomSheet(
|
||||||
|
state = state.customReactionState,
|
||||||
|
onSelectEmoji = { uniqueId, emoji ->
|
||||||
|
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
|
||||||
|
},
|
||||||
|
emojiPickerRenderer = emojiPickerRenderer,
|
||||||
|
)
|
||||||
|
},
|
||||||
onThreadsListClick = {},
|
onThreadsListClick = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -801,15 +801,15 @@ private fun MessageEventBubbleContent(
|
|||||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
|
|
||||||
val talkbackCompatModifier = if (isTalkbackActive()) {
|
val talkbackCompatModifier = when {
|
||||||
// Use z-index to make the replied to text being read after the message
|
// Use z-index to make the replied to text being read after the message
|
||||||
// Usually, you'd use traversalIndex for that, but it's not working for some reason
|
// Usually, you'd use traversalIndex for that, but it's not working for some reason
|
||||||
inReplyToModifier.zIndex(1f)
|
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
|
||||||
} else {
|
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
|
||||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
else -> inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
val contentHasError = currentContentValidationState.hasError()
|
val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
|
||||||
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
|
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
|
||||||
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
+17
@@ -8,11 +8,16 @@
|
|||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
import androidx.compose.material3.rememberBottomSheetState
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.emojibasebindings.Emoji
|
import io.element.android.emojibasebindings.Emoji
|
||||||
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
) {
|
) {
|
||||||
|
ExpandSheetWhenImeIsVisible(sheetState)
|
||||||
emojiPickerRenderer.Render(
|
emojiPickerRenderer.Render(
|
||||||
state = state.target.emojiPickerState,
|
state = state.target.emojiPickerState,
|
||||||
onSelectEmoji = ::onEmojiSelectedDismiss,
|
onSelectEmoji = ::onEmojiSelectedDismiss,
|
||||||
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
|
||||||
|
val isImeVisible = WindowInsets.isImeVisible
|
||||||
|
LaunchedEffect(isImeVisible) {
|
||||||
|
if (isImeVisible) {
|
||||||
|
sheetState.expand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-6
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateSetOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
@@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService
|
|||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.mapState
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
|
||||||
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
import io.element.android.libraries.matrix.api.media.MediaPreviewService
|
||||||
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
|
|||||||
private val mediaPreviewService: MediaPreviewService,
|
private val mediaPreviewService: MediaPreviewService,
|
||||||
private val room: BaseRoom,
|
private val room: BaseRoom,
|
||||||
private val contentScannerService: ContentScannerService,
|
private val contentScannerService: ContentScannerService,
|
||||||
|
private val timelineProtectionStore: TimelineProtectionStore,
|
||||||
) : Presenter<TimelineProtectionState> {
|
) : Presenter<TimelineProtectionState> {
|
||||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): TimelineProtectionState {
|
override fun present(): TimelineProtectionState {
|
||||||
val mediaPreviewValue = remember {
|
val mediaPreviewValue = remember {
|
||||||
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
|
|||||||
if (isPreviewEnabled) {
|
if (isPreviewEnabled) {
|
||||||
ProtectionState.RenderAll
|
ProtectionState.RenderAll
|
||||||
} else {
|
} else {
|
||||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
|
|||||||
fun handleEvent(event: TimelineProtectionEvent) {
|
fun handleEvent(event: TimelineProtectionEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is TimelineProtectionEvent.ShowContent -> {
|
is TimelineProtectionEvent.ShowContent -> {
|
||||||
allowedEvents += setOfNotNull(event.eventId)
|
event.eventId?.let(timelineProtectionStore::allowEvent)
|
||||||
}
|
}
|
||||||
is TimelineProtectionEvent.ValidateContent -> {
|
is TimelineProtectionEvent.ValidateContent -> {
|
||||||
contentScannerService.scan(event.mediaSources, event.validationState)
|
contentScannerService.scan(event.mediaSources, event.validationState)
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.protection
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateSetOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateSet
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
|
||||||
|
interface TimelineProtectionStore {
|
||||||
|
val allowedEventIds: SnapshotStateSet<EventId>
|
||||||
|
fun allowEvent(eventId: EventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(SessionScope::class)
|
||||||
|
@SingleIn(SessionScope::class)
|
||||||
|
class DefaultTimelineProtectionStore : TimelineProtectionStore {
|
||||||
|
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||||
|
|
||||||
|
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
|
||||||
|
|
||||||
|
override fun allowEvent(eventId: EventId) {
|
||||||
|
allowedEvents += eventId
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onFirst
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollToIndex
|
import androidx.compose.ui.test.performScrollToIndex
|
||||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||||
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
|
|||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||||
|
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||||
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
|
|||||||
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||||
|
setTimelineView(
|
||||||
|
state = aTimelineState(
|
||||||
|
timelineItems = persistentListOf(
|
||||||
|
aTimelineItemEvent(
|
||||||
|
content = aTimelineItemTextContent(),
|
||||||
|
inReplyTo = InReplyToDetails.Ready(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
senderId = A_USER_ID,
|
||||||
|
senderProfile = aProfileDetailsReady(),
|
||||||
|
eventContent = MessageContent(
|
||||||
|
body = "Replied message",
|
||||||
|
inReplyTo = null,
|
||||||
|
isEdited = false,
|
||||||
|
threadInfo = null,
|
||||||
|
type = TextMessageType("Replied message", null),
|
||||||
|
),
|
||||||
|
textContent = "Replied message",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
eventsRecorder.clear()
|
||||||
|
|
||||||
|
onAllNodesWithText("Replied message").onFirst().performClick()
|
||||||
|
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
|
||||||
|
val eventsRecorder = EventsRecorder<TimelineEvent>()
|
||||||
|
setTimelineView(
|
||||||
|
state = aTimelineState(
|
||||||
|
timelineItems = persistentListOf(
|
||||||
|
aTimelineItemEvent(
|
||||||
|
content = aTimelineItemTextContent(),
|
||||||
|
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
onMessageClick = {},
|
||||||
|
)
|
||||||
|
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
|
||||||
|
eventsRecorder.clear()
|
||||||
|
|
||||||
|
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
|
||||||
|
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
|
||||||
|
eventsRecorder.assertEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Ignore(
|
@Ignore(
|
||||||
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
|
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
|
||||||
"This needs to be reworked to use a different approach to check the LoadMore event was emitted."
|
"This needs to be reworked to use a different approach to check the LoadMore event was emitted."
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class)
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() {
|
||||||
|
@Test
|
||||||
|
fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||||
|
val sheetState = renderWithIme(isImeVisible = false)
|
||||||
|
assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||||
|
val sheetState = renderWithIme(isImeVisible = true)
|
||||||
|
assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidComposeUiTest<ComponentActivity>.renderWithIme(isImeVisible: Boolean): SheetState {
|
||||||
|
lateinit var sheetState: SheetState
|
||||||
|
lateinit var view: View
|
||||||
|
setContent {
|
||||||
|
view = LocalView.current
|
||||||
|
sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
|
||||||
|
ExpandSheetWhenImeIsVisible(sheetState)
|
||||||
|
}
|
||||||
|
runOnIdle {
|
||||||
|
ViewCompat.dispatchApplyWindowInsets(
|
||||||
|
view,
|
||||||
|
WindowInsetsCompat.Builder()
|
||||||
|
.setVisible(WindowInsetsCompat.Type.ime(), isImeVisible)
|
||||||
|
.setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
waitForIdle()
|
||||||
|
return sheetState
|
||||||
|
}
|
||||||
+20
@@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - shown content is restored when the presenter is recreated`() = runTest {
|
||||||
|
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
|
||||||
|
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
|
||||||
|
val timelineProtectionStore = DefaultTimelineProtectionStore()
|
||||||
|
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
|
||||||
|
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `present - validate media scans the media source`() = runTest {
|
fun `present - validate media scans the media source`() = runTest {
|
||||||
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
|
|||||||
room: BaseRoom = FakeBaseRoom(),
|
room: BaseRoom = FakeBaseRoom(),
|
||||||
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||||
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
|
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
|
||||||
|
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
|
||||||
) = TimelineProtectionPresenter(
|
) = TimelineProtectionPresenter(
|
||||||
mediaPreviewService = mediaPreviewService,
|
mediaPreviewService = mediaPreviewService,
|
||||||
room = room,
|
room = room,
|
||||||
contentScannerService = contentScannerService,
|
contentScannerService = contentScannerService,
|
||||||
|
timelineProtectionStore = timelineProtectionStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-11
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers
|
|||||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
||||||
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
import kotlinx.collections.immutable.persistentMapOf
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -58,6 +59,7 @@ class RoomMemberListPresenter(
|
|||||||
override fun present(): RoomMemberListState {
|
override fun present(): RoomMemberListState {
|
||||||
val searchQuery = rememberTextFieldState()
|
val searchQuery = rememberTextFieldState()
|
||||||
val membersState by room.membersStateFlow.collectAsState()
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
|
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||||
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
|
val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() }
|
||||||
val roomModerationState = roomMembersModerationPresenter.present()
|
val roomModerationState = roomMembersModerationPresenter.present()
|
||||||
|
|
||||||
@@ -72,13 +74,18 @@ class RoomMemberListPresenter(
|
|||||||
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
|
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
|
||||||
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||||
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||||
|
val activeCallParticipants = roomInfo.activeRoomCallParticipants
|
||||||
|
|
||||||
|
val comparator = remember(activeCallParticipants) {
|
||||||
|
roomMemberListComparator(activeCallParticipants)
|
||||||
|
}
|
||||||
|
|
||||||
// Update the room members when the screen is loaded
|
// Update the room members when the screen is loaded
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
room.updateMembers()
|
room.updateMembers()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(membersState, roomMemberIdentityStates) {
|
LaunchedEffect(membersState, roomMemberIdentityStates, activeCallParticipants) {
|
||||||
if (membersState is RoomMembersState.Unknown) {
|
if (membersState is RoomMembersState.Unknown) {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
@@ -90,23 +97,23 @@ class RoomMemberListPresenter(
|
|||||||
}
|
}
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
||||||
val info = room.info()
|
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < roomInfo.joinedMembersCount / 2) {
|
||||||
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
|
|
||||||
// Don't display initial room member list if we have less than half of the joined members:
|
// Don't display initial room member list if we have less than half of the joined members:
|
||||||
// This result will come from the timeline loading membership events and it'll be wrong.
|
// This result will come from the timeline loading membership events, and it'll be wrong.
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val result = RoomMembers(
|
val result = RoomMembers(
|
||||||
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
||||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||||
.toImmutableList(),
|
.toImmutableList(),
|
||||||
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
||||||
.sortedWith(powerLevelRoomMemberComparator)
|
.sortedWith(comparator)
|
||||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
// .sortedWith(powerLevelRoomMemberComparator)
|
||||||
|
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||||
.toImmutableList(),
|
.toImmutableList(),
|
||||||
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
|
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
|
||||||
.sortedBy { it.userId.value }
|
.sortedBy { it.userId.value }
|
||||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
.map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) }
|
||||||
.toImmutableList(),
|
.toImmutableList(),
|
||||||
)
|
)
|
||||||
roomMembers = if (membersState is RoomMembersState.Pending) {
|
roomMembers = if (membersState is RoomMembersState.Pending) {
|
||||||
@@ -150,12 +157,29 @@ class RoomMemberListPresenter(
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
private suspend fun RoomMember.withIdentityState(
|
||||||
|
identityStates: ImmutableMap<UserId, IdentityState>,
|
||||||
|
activeCallParticipants: ImmutableList<UserId>
|
||||||
|
): RoomMemberWithIdentityState {
|
||||||
return if (room.info().isEncrypted != true) {
|
return if (room.info().isEncrypted != true) {
|
||||||
RoomMemberWithIdentityState(this, null)
|
RoomMemberWithIdentityState(this, null, userId in activeCallParticipants)
|
||||||
} else {
|
} else {
|
||||||
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull()
|
val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull()
|
||||||
RoomMemberWithIdentityState(this, identityState)
|
RoomMemberWithIdentityState(this, identityState, userId in activeCallParticipants)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun roomMemberListComparator(
|
||||||
|
activeCallParticipants: List<UserId>,
|
||||||
|
): Comparator<RoomMember> =
|
||||||
|
Comparator { a, b ->
|
||||||
|
val aInCall = a.userId in activeCallParticipants
|
||||||
|
val bInCall = b.userId in activeCallParticipants
|
||||||
|
|
||||||
|
when {
|
||||||
|
aInCall && !bInCall -> -1
|
||||||
|
!aInCall && bInCall -> 1
|
||||||
|
else -> powerLevelRoomMemberComparator.compare(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -66,4 +66,5 @@ data class RoomMembers(
|
|||||||
data class RoomMemberWithIdentityState(
|
data class RoomMemberWithIdentityState(
|
||||||
val roomMember: RoomMember,
|
val roomMember: RoomMember,
|
||||||
val identityState: IdentityState?,
|
val identityState: IdentityState?,
|
||||||
|
val isInCall: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-2
@@ -78,7 +78,7 @@ private fun aLoadedRoomMembers() = AsyncData.Success(
|
|||||||
joined = persistentListOf(
|
joined = persistentListOf(
|
||||||
anAlice().withIdentity(identityState = IdentityState.Verified),
|
anAlice().withIdentity(identityState = IdentityState.Verified),
|
||||||
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
||||||
aCarol().withIdentity(),
|
aCarol().withIdentity(isInCall = true),
|
||||||
aDavid().withIdentity(),
|
aDavid().withIdentity(),
|
||||||
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
|
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
|
||||||
),
|
),
|
||||||
@@ -174,4 +174,8 @@ fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), USER_N
|
|||||||
|
|
||||||
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN)
|
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN)
|
||||||
|
|
||||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
private fun RoomMember.withIdentity(identityState: IdentityState? = null, isInCall: Boolean = false) = RoomMemberWithIdentityState(
|
||||||
|
this,
|
||||||
|
identityState,
|
||||||
|
isInCall
|
||||||
|
)
|
||||||
|
|||||||
+9
@@ -338,6 +338,15 @@ private fun RoomMemberListItem(
|
|||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (roomMemberWithIdentity.isInCall) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
imageVector = CompoundIcons.VideoCallSolid(),
|
||||||
|
contentDescription = stringResource(CommonStrings.action_video_call),
|
||||||
|
tint = ElementTheme.colors.iconSuccessPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+32
@@ -191,6 +191,38 @@ class RoomMemberListPresenterTest {
|
|||||||
awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor()))
|
awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `call participants are shown first in joined members`() = runTest {
|
||||||
|
val room = createFakeJoinedRoom().apply {
|
||||||
|
givenRoomInfo(
|
||||||
|
aRoomInfo(
|
||||||
|
joinedMembersCount = 5,
|
||||||
|
activeRoomCallParticipants = listOf(
|
||||||
|
aCarol().userId
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val presenter = createPresenter(joinedRoom = room)
|
||||||
|
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
val joined = loadedState.filteredRoomMembers.dataOrNull()!!.joined
|
||||||
|
|
||||||
|
assertThat(joined.first().roomMember.userId)
|
||||||
|
.isEqualTo(aCarol().userId)
|
||||||
|
|
||||||
|
assertThat(joined.first().isInCall).isTrue()
|
||||||
|
|
||||||
|
assertThat(joined.count { it.isInCall }).isEqualTo(1)
|
||||||
|
assertThat(joined.drop(1).all { !it.isInCall }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createFakeJoinedRoom(
|
private fun createFakeJoinedRoom(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ firebaseAppDistribution = "5.3.0"
|
|||||||
core = "1.18.0"
|
core = "1.18.0"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
constraintlayout = "2.2.2"
|
constraintlayout = "2.2.2"
|
||||||
constraintlayout_compose = "1.1.1"
|
constraintlayout_compose = "1.1.2"
|
||||||
lifecycle = "2.11.0"
|
lifecycle = "2.11.0"
|
||||||
activity = "1.13.0"
|
activity = "1.13.0"
|
||||||
media3 = "1.10.1"
|
media3 = "1.10.1"
|
||||||
@@ -199,7 +199,7 @@ serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-jso
|
|||||||
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.1"
|
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.1"
|
||||||
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
|
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
|
||||||
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
|
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
|
||||||
jsoup = "org.jsoup:jsoup:1.22.2"
|
jsoup = "org.jsoup:jsoup:1.23.1"
|
||||||
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
|
||||||
molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0"
|
molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0"
|
||||||
timber = "com.jakewharton.timber:timber:5.0.1"
|
timber = "com.jakewharton.timber:timber:5.0.1"
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ dependencies {
|
|||||||
implementation(libs.matrix.emojibase.bindings)
|
implementation(libs.matrix.emojibase.bindings)
|
||||||
|
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs, includeTestComposeView = true)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
|
|||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
modifier = Modifier.padding(bottom = 10.dp),
|
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
|
||||||
queryState = state.searchQuery,
|
queryState = state.searchQuery,
|
||||||
resultState = state.searchResults,
|
resultState = state.searchResults,
|
||||||
active = state.isSearchActive,
|
active = state.isSearchActive,
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalTestApi::class)
|
||||||
|
|
||||||
|
package io.element.android.libraries.emoji.impl.picker
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsEqualTo
|
||||||
|
import androidx.compose.ui.test.getBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.hasScrollToIndexAction
|
||||||
|
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.emojibasebindings.Emoji
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
|
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentSetOf
|
||||||
|
import org.junit.Test
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
private val PICKER_HEIGHT = 600.dp
|
||||||
|
|
||||||
|
class EmojiPickerViewTest : RobolectricTest() {
|
||||||
|
@Config(qualifiers = "w400dp-h800dp")
|
||||||
|
@Test
|
||||||
|
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||||
|
setEmojiPickerView()
|
||||||
|
onNode(hasScrollToIndexAction())
|
||||||
|
.getBoundsInRoot()
|
||||||
|
.bottom
|
||||||
|
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
|
||||||
|
setContent {
|
||||||
|
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
|
||||||
|
EmojiPickerView(
|
||||||
|
state = aDefaultEmojiPickerState(
|
||||||
|
isSearchActive = true,
|
||||||
|
searchQuery = "smile",
|
||||||
|
searchResults = SearchBarResultState.Results(
|
||||||
|
persistentListOf(
|
||||||
|
Emoji(
|
||||||
|
hexcode = "0x00",
|
||||||
|
label = "grinning face",
|
||||||
|
tags = persistentListOf("grinning"),
|
||||||
|
shortcodes = persistentListOf("smile"),
|
||||||
|
unicode = "😀",
|
||||||
|
skins = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSelectEmoji = {},
|
||||||
|
selectedEmojis = persistentSetOf(),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth
|
|||||||
import io.element.android.libraries.matrix.api.BuildConfig
|
import io.element.android.libraries.matrix.api.BuildConfig
|
||||||
|
|
||||||
object OAuthConfig {
|
object OAuthConfig {
|
||||||
|
// This *can* be null if the build time config field is not set
|
||||||
|
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||||
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
|
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
|
||||||
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
|
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
|
||||||
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
|
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.linknewdevice
|
||||||
|
|
||||||
|
interface ContinuationMessageSender {
|
||||||
|
suspend fun cancel(): Result<Unit>
|
||||||
|
suspend fun confirm(): Result<Unit>
|
||||||
|
}
|
||||||
+2
@@ -18,8 +18,10 @@ interface LinkDesktopHandler {
|
|||||||
sealed interface LinkDesktopStep {
|
sealed interface LinkDesktopStep {
|
||||||
data object Uninitialized : LinkDesktopStep
|
data object Uninitialized : LinkDesktopStep
|
||||||
data object Starting : LinkDesktopStep
|
data object Starting : LinkDesktopStep
|
||||||
|
|
||||||
data class WaitingForAuth(
|
data class WaitingForAuth(
|
||||||
val verificationUri: String,
|
val verificationUri: String,
|
||||||
|
val continuationMessageSender: ContinuationMessageSender,
|
||||||
) : LinkDesktopStep
|
) : LinkDesktopStep
|
||||||
|
|
||||||
data class EstablishingSecureChannel(
|
data class EstablishingSecureChannel(
|
||||||
|
|||||||
+6
-1
@@ -22,7 +22,12 @@ sealed interface LinkMobileStep {
|
|||||||
data object Starting : LinkMobileStep
|
data object Starting : LinkMobileStep
|
||||||
data class QrReady(val data: String) : LinkMobileStep
|
data class QrReady(val data: String) : LinkMobileStep
|
||||||
data object QrRotating : LinkMobileStep
|
data object QrRotating : LinkMobileStep
|
||||||
data class WaitingForAuth(val verificationUri: String) : LinkMobileStep
|
|
||||||
|
data class WaitingForAuth(
|
||||||
|
val verificationUri: String,
|
||||||
|
val continuationMessageSender: ContinuationMessageSender,
|
||||||
|
) : LinkMobileStep
|
||||||
|
|
||||||
data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
|
data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
|
||||||
data class Error(val errorType: ErrorType) : LinkMobileStep
|
data class Error(val errorType: ErrorType) : LinkMobileStep
|
||||||
data object SyncingSecrets : LinkMobileStep
|
data object SyncingSecrets : LinkMobileStep
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.linknewdevice
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender
|
||||||
|
|
||||||
|
class RustContinuationMessageSender(
|
||||||
|
private val inner: FfiContinuationMessageSender,
|
||||||
|
private val sessionDispatcher: CoroutineDispatcher,
|
||||||
|
) : ContinuationMessageSender {
|
||||||
|
override suspend fun cancel(): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
inner.cancel()
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Error cancelling continuation message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun confirm(): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
inner.confirm()
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Error confirming continuation message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -75,6 +75,10 @@ class RustLinkDesktopHandler(
|
|||||||
GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets
|
GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets
|
||||||
is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth(
|
is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth(
|
||||||
verificationUri = verificationUri,
|
verificationUri = verificationUri,
|
||||||
|
continuationMessageSender = RustContinuationMessageSender(
|
||||||
|
inner = continuationSender,
|
||||||
|
sessionDispatcher = sessionDispatcher,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel(
|
is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel(
|
||||||
checkCode = checkCode,
|
checkCode = checkCode,
|
||||||
|
|||||||
+7
-1
@@ -84,7 +84,13 @@ class RustLinkMobileHandler(
|
|||||||
)
|
)
|
||||||
GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting
|
GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting
|
||||||
GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets
|
GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets
|
||||||
is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri)
|
is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(
|
||||||
|
verificationUri = verificationUri,
|
||||||
|
continuationMessageSender = RustContinuationMessageSender(
|
||||||
|
inner = continuationSender,
|
||||||
|
sessionDispatcher = sessionDispatcher,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.linknewdevice
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
|
||||||
|
class FakeContinuationMessageSender(
|
||||||
|
private val cancelResult: () -> Result<Unit> = { lambdaError() },
|
||||||
|
private val confirmResult: () -> Result<Unit> = { lambdaError() },
|
||||||
|
) : ContinuationMessageSender {
|
||||||
|
override suspend fun cancel(): Result<Unit> = simulateLongTask {
|
||||||
|
cancelResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun confirm(): Result<Unit> = simulateLongTask {
|
||||||
|
confirmResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-7
@@ -16,6 +16,8 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
|
|||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
||||||
import io.element.android.libraries.matrix.test.QR_CODE_DATA
|
import io.element.android.libraries.matrix.test.QR_CODE_DATA
|
||||||
|
import io.element.android.tests.testutils.ExpectedResult
|
||||||
|
import io.element.android.tests.testutils.match
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -50,16 +52,18 @@ class RustLinkDesktopHandlerTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
// progress from the handler is mapped and emitted
|
// progress from the handler is mapped and emitted
|
||||||
listOf(
|
listOf(
|
||||||
GrantQrLoginProgress.Starting to LinkDesktopStep.Starting,
|
GrantQrLoginProgress.Starting to ExpectedResult(LinkDesktopStep.Starting),
|
||||||
GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets,
|
GrantQrLoginProgress.SyncingSecrets to ExpectedResult(LinkDesktopStep.SyncingSecrets),
|
||||||
GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
|
GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
|
||||||
to LinkDesktopStep.WaitingForAuth("aVerificationUri"),
|
to ExpectedResult(
|
||||||
|
resultClass = LinkDesktopStep.WaitingForAuth::class.java,
|
||||||
|
),
|
||||||
GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1")
|
GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1")
|
||||||
to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"),
|
to ExpectedResult(LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1")),
|
||||||
GrantQrLoginProgress.Done to LinkDesktopStep.Done,
|
GrantQrLoginProgress.Done to ExpectedResult(LinkDesktopStep.Done),
|
||||||
).forEach { (progress, expectedStep) ->
|
).forEach { (progress, expectedResult) ->
|
||||||
handler.emitScanProgress(progress)
|
handler.emitScanProgress(progress)
|
||||||
assertThat(awaitItem()).isEqualTo(expectedStep)
|
assertThat(awaitItem()).match(expectedResult)
|
||||||
}
|
}
|
||||||
// scan returns, no new event is emitted
|
// scan returns, no new event is emitted
|
||||||
completable.complete(Unit)
|
completable.complete(Unit)
|
||||||
|
|||||||
+10
-8
@@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati
|
|||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
|
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
|
||||||
import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
|
import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
|
||||||
|
import io.element.android.tests.testutils.ExpectedResult
|
||||||
|
import io.element.android.tests.testutils.match
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -51,18 +53,18 @@ class RustLinkMobileHandlerTest {
|
|||||||
runCurrent()
|
runCurrent()
|
||||||
// progress from the handler is mapped and emitted
|
// progress from the handler is mapped and emitted
|
||||||
listOf(
|
listOf(
|
||||||
GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java,
|
GrantGeneratedQrLoginProgress.Starting to ExpectedResult(LinkMobileStep.Starting),
|
||||||
GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java,
|
GrantGeneratedQrLoginProgress.SyncingSecrets to ExpectedResult(LinkMobileStep.SyncingSecrets),
|
||||||
GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
|
GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender())
|
||||||
to LinkMobileStep.WaitingForAuth::class.java,
|
to ExpectedResult(LinkMobileStep.WaitingForAuth::class.java),
|
||||||
GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender())
|
GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender())
|
||||||
to LinkMobileStep.QrScanned::class.java,
|
to ExpectedResult(LinkMobileStep.QrScanned::class.java),
|
||||||
GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE }))
|
GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE }))
|
||||||
to LinkMobileStep.QrReady::class.java,
|
to ExpectedResult(LinkMobileStep.QrReady::class.java),
|
||||||
GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java,
|
GrantGeneratedQrLoginProgress.Done to ExpectedResult(LinkMobileStep.Done),
|
||||||
).forEach { (progress, expectedStepClass) ->
|
).forEach { (progress, expectedResult) ->
|
||||||
handler.emitGenerateProgress(progress)
|
handler.emitGenerateProgress(progress)
|
||||||
assertThat(awaitItem()).isInstanceOf(expectedStepClass)
|
assertThat(awaitItem()).match(expectedResult)
|
||||||
}
|
}
|
||||||
// generate returns, no new event is emitted
|
// generate returns, no new event is emitted
|
||||||
completable.complete(Unit)
|
completable.complete(Unit)
|
||||||
|
|||||||
+4
-4
@@ -8,12 +8,12 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.matrix.ui.components
|
package io.element.android.libraries.matrix.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -40,8 +40,8 @@ fun CheckableUserRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(role = Role.Checkbox, enabled = enabled) {
|
.toggleable(value = checked, role = Role.Checkbox, enabled = enabled) {
|
||||||
onCheckedChange(!checked)
|
onCheckedChange(it)
|
||||||
},
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -66,7 +66,7 @@ fun CheckableUserRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Checkbox(
|
Checkbox(
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = null,
|
||||||
checked = checked,
|
checked = checked,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
|
|||||||
+9
-7
@@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun ContentErrorView(
|
internal fun ContentErrorView(
|
||||||
title: String,
|
title: String,
|
||||||
message: String,
|
message: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||||
@@ -51,12 +51,14 @@ internal fun ContentErrorView(
|
|||||||
color = ElementTheme.colors.textCriticalPrimary,
|
color = ElementTheme.colors.textCriticalPrimary,
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
)
|
)
|
||||||
Text(
|
if (message != null) {
|
||||||
text = message,
|
Text(
|
||||||
color = ElementTheme.colors.textSecondary,
|
text = message,
|
||||||
style = ElementTheme.typography.fontBodySmRegular,
|
color = ElementTheme.colors.textSecondary,
|
||||||
onTextLayout = onTextLayout,
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
)
|
onTextLayout = onTextLayout,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-18
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||||
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView
|
||||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||||
@@ -77,7 +77,7 @@ fun InReplyToView(
|
|||||||
else -> ReplyToLoadingContent(modifier = modifier)
|
else -> ReplyToLoadingContent(modifier = modifier)
|
||||||
}
|
}
|
||||||
is InReplyToDetails.Error ->
|
is InReplyToDetails.Error ->
|
||||||
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
|
ReplyToErrorContent(modifier = modifier)
|
||||||
is InReplyToDetails.Loading ->
|
is InReplyToDetails.Loading ->
|
||||||
ReplyToLoadingContent(modifier = modifier)
|
ReplyToLoadingContent(modifier = modifier)
|
||||||
}
|
}
|
||||||
@@ -150,24 +150,15 @@ private fun ReplyToLoadingContent(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyToErrorContent(
|
private fun ReplyToErrorContent(
|
||||||
data: InReplyToDetails.Error,
|
|
||||||
maxLines: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
ContentErrorView(
|
||||||
Row(
|
title = stringResource(CommonStrings.error_message_not_found),
|
||||||
modifier
|
message = null,
|
||||||
.background(ElementTheme.colors.bgCanvasDefault)
|
modifier = modifier,
|
||||||
.padding(paddings)
|
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
|
||||||
) {
|
onTextLayout = null,
|
||||||
Text(
|
)
|
||||||
text = data.message,
|
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
|
||||||
color = ElementTheme.colors.textCriticalPrimary,
|
|
||||||
maxLines = maxLines,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ object BuildTimeConfig {
|
|||||||
val URL_ACCEPTABLE_USE: String? = null
|
val URL_ACCEPTABLE_USE: String? = null
|
||||||
val URL_PRIVACY: String? = null
|
val URL_PRIVACY: String? = null
|
||||||
val URL_POLICY: String? = null
|
val URL_POLICY: String? = null
|
||||||
val OAUTH_CLIENT_URL_PATH: String? = null
|
val OAUTH_CLIENT_URL_PATH: String? = "apps/android"
|
||||||
val SERVICES_MAPTILER_BASE_URL: String? = null
|
val SERVICES_MAPTILER_BASE_URL: String? = null
|
||||||
val SERVICES_MAPTILER_APIKEY: String? = null
|
val SERVICES_MAPTILER_APIKEY: String? = null
|
||||||
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
|
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.tests.testutils
|
||||||
|
|
||||||
|
import com.google.common.truth.Subject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper class to assert expected results in tests. It can be used to assert either the type of result or its value.
|
||||||
|
*/
|
||||||
|
data class ExpectedResult<T : Any>(
|
||||||
|
val resultClass: Class<out T>,
|
||||||
|
val result: T? = null,
|
||||||
|
) {
|
||||||
|
constructor(result: T) : this(result::class.java, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> Subject.match(expectedResult: ExpectedResult<T>) {
|
||||||
|
if (expectedResult.result != null) {
|
||||||
|
isEqualTo(expectedResult.result)
|
||||||
|
} else {
|
||||||
|
isInstanceOf(expectedResult.resultClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +1 @@
|
|||||||
{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":"","BUILD_GRADLE_API":"build.gradle.kts","BUILD_GRADLE_IMPL":"build.gradle.kts"},"fileTemplates":{"${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","${BUILD_GRADLE_API}":"Template Module Feature Build Gradle API","${BUILD_GRADLE_IMPL}":"Template Module Feature Build Gradle Impl","${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_API}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_IMPL}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false}
|
{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":""},"fileTemplates":{"api/${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","impl/Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","api/build.gradle.kts":"Template Module Feature Build Gradle API","impl/build.gradle.kts":"Template Module Feature Build Gradle Impl","impl/${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"build.gradle.kts","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"build.gradle.kts","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false}
|
||||||
Reference in New Issue
Block a user