Merge branch 'release/26.07.1'

This commit is contained in:
Jorge Martín
2026-07-15 15:46:59 +02:00
857 changed files with 13950 additions and 3233 deletions
+4 -4
View File
@@ -69,18 +69,18 @@ body:
description: |
Trigger the defect, then click on the menu from the room list then "Report a bug". Describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
options:
- 'Yes'
- 'No'
- 'Yes'
validations:
required: true
- type: dropdown
id: pr
attributes:
label: Are you willing to provide a PR?
label: Will you submit a Pull Request?
description: |
Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
Submitting a Pull Request can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :).
options:
- 'Yes'
- 'No'
- 'Yes'
validations:
required: true
+2 -2
View File
@@ -1,11 +1,11 @@
name: Enhancement request
description: Do you have a suggestion or feature request?
description: Do you have a suggestion or feature request? Please open issue on the repo element-hq/element-meta if the request is global to all Element clients.
labels: [T-Enhancement]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/element-hq/element-meta/discussions/new?category=ideas).
Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please open an issue on the [element-meta repository](https://github.com/element-hq/element-meta/issues/new?template=enhancement.yml) instead.
- type: textarea
id: usecase
attributes:
+13 -2
View File
@@ -51,7 +51,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -104,7 +104,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources
if: ${{ matrix.variant == 'release' }}
run: ./gradlew bundleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
run: |
./gradlew assembleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
./gradlew bundleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Run aapt dump
if: ${{ matrix.variant == 'release' }}
run: |
$ANDROID_HOME/build-tools/37.0.0/aapt dump badging \
./app/build/outputs/apk/gplay/release/app-gplay-universal-release.apk \
> /tmp/currentAaptDump.txt
- name: Check manifest has not changed
if: ${{ matrix.variant == 'release' }}
run: diff /tmp/currentAaptDump.txt ./tools/manifest/gplay/release/aaptDump.txt
- name: Compile nightly sources
if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@58355dc2b274a87699c8b7deac01baba16d6f061 # 13.0.8
uses: danger/danger-js@e9b58820039524318198d8059ec98b0c02b7875c # 13.0.10
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
persist-credentials: false
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -80,7 +80,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+7 -7
View File
@@ -69,7 +69,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
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' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
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' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
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' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
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' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
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' }}
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -360,7 +360,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@58355dc2b274a87699c8b7deac01baba16d6f061 # 13.0.8
uses: danger/danger-js@e9b58820039524318198d8059ec98b0c02b7875c # 13.0.10
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
with:
persist-credentials: false
- name: ☕️ Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+3 -3
View File
@@ -38,7 +38,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -82,7 +82,7 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -126,7 +126,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+2 -1
View File
@@ -17,7 +17,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -39,6 +39,7 @@ jobs:
./tools/localazy/downloadStrings.sh --all
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
./tools/manifest/aaptDump.sh
- name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
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
- name: ☕️ Use JDK 21
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
@@ -0,0 +1,21 @@
appId: ${MAESTRO_APP_ID}
---
# Clean up the room list if it was left in a bad state by previous tests
- runFlow:
when:
visible: 'aRoomName'
commands:
- longPressOn: 'aRoomName'
- tapOn:
text: "Leave room"
index: 0
- tapOn: "Leave"
- runFlow:
when:
visible: ${MAESTRO_INVITEE1_MXID}
commands:
- longPressOn: ${MAESTRO_INVITEE1_MXID}
- tapOn:
text: "Leave room"
index: 0
- tapOn: "Leave"
+10 -1
View File
@@ -7,7 +7,16 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
text: ${MAESTRO_INVITEE1_MXID}
index: 1
- tapOn: "Continue"
- runFlow:
when:
visible: 'Start a chat with this new contact?'
commands:
- tapOn: 'Continue'
- runFlow:
when:
visible: 'Send invite?'
commands:
- tapOn: 'Send invite'
- takeScreenshot: build/maestro/330-createAndDeleteDM
- tapOn: "maestroelement2"
- scroll
@@ -15,6 +15,12 @@ appId: ${MAESTRO_APP_ID}
text: ${MAESTRO_INVITEE1_MXID}
index: 1
- tapOn: "Finish"
- runFlow:
when:
visible: 'Invite new contact to this room?'
commands:
- tapOn:
id: "confirm_invite_unknown"
- tapOn: "aRoomName"
- tapOn: "Invite"
# assert there's 1 member and 1 invitee
@@ -23,6 +29,10 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
text: ${MAESTRO_INVITEE2_MXID}
index: 1
# Hide keyboard by tapping on an external element in case it's displayed, otherwise it will 'eat' the back button press
- tapOn:
# Navigation title
text: "Invite people"
- tapOn: "Invite"
- runFlow:
when:
@@ -30,10 +40,6 @@ appId: ${MAESTRO_APP_ID}
commands:
- tapOn:
id: "confirm_invite_unknown"
# Close the keyboard if it's still open
- tapOn: "Back"
# Go back to the room details screen
- tapOn: "Back"
- scrollUntilVisible:
direction: DOWN
element:
+1
View File
@@ -1,5 +1,6 @@
appId: ${MAESTRO_APP_ID}
---
- runFlow: cleanUpRoomList.yaml
- runFlow: searchRoomList.yaml
- takeScreenshot: build/maestro/300-RoomList
- runFlow: timeline/timeline.yaml
+49
View File
@@ -1,3 +1,52 @@
Changes in Element X v26.07.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.07.0 -->
## What's Changed
### ✨ Features
* Add read all messages feature by @hghgrtut in https://github.com/element-hq/element-x-android/pull/7049
### 🐛 Bugfixes
* Detect RTL text in events and adjust the text rendering by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6994
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7071
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7115
### 🧱 Build
* Fix Sonar with AGP 9 (some workarounds) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7055
* Disable periodically generating the screenshot gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7110
* Try a different workaround for flaky `ScanQrCodeViewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7109
### 🚧 In development 🚧
* Protect link new device by @bmarty in https://github.com/element-hq/element-x-android/pull/6992
### Dependency upgrades
* Update metro to v1.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7057
* Update gradle/actions action to v6.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7063
* Update roborazzi to v1.64.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7068
* Update rnkdsh/action-upload-diawi action to v1.5.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7067
* Update dependency io.element.android:element-call-embedded to v0.20.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7075
* Update dependency com.github.matrix-org:matrix-analytics-events to v0.37.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7079
* Update actions/setup-java action to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7088
* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7052
* Update firebaseAppDistribution to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7096
* Update dependency com.google.firebase:firebase-bom to v34.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7089
* Update dependency com.google.crypto.tink:tink-android to v1.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7105
* Update actions/checkout action to v7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7098
* Update lifecycle to v2.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7097
* Update danger/danger-js action to v13.0.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7104
* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.25 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7107
* Update dependency androidx.compose:compose-bom to v2026.06.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7095
* Update dependency io.element.android:element-call-embedded to v0.20.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7117
* Update zizmorcore/zizmor-action action to v0.5.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7116
* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.30 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7127
* Update dependency com.posthog:posthog-android to v3.51.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7128
### Others
* Fix small code smell by @bmarty in https://github.com/element-hq/element-x-android/pull/7058
* Hide message notification content when the app is locked by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6902
## New Contributors
* @hghgrtut made their first contribution in https://github.com/element-hq/element-x-android/pull/7049
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.06.4...v26.07.0
Changes in Element X v26.06.4
=============================
-3
View File
@@ -8,9 +8,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".ElementXApplication"
android:allowBackup="false"
@@ -15,7 +15,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
@ContributesBinding(AppScope::class)
class DefaultSessionGraphFactory(
private val appGraph: AppGraph
private val appGraph: AppGraph,
) : SessionGraphFactory {
override fun create(client: MatrixClient): Any {
return appGraph.sessionGraphFactory.create(client)
+2
View File
@@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.oauth.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
@@ -61,6 +62,7 @@ dependencies {
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
@@ -12,4 +12,7 @@ sealed interface LoggedInEvents {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
data object CheckSlidingSyncProxyAvailability : LoggedInEvents
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents
data object DismissLocalNetworkPermissionPrompt : LoggedInEvents
data object RequestLocationNetworkPermission : LoggedInEvents
}
@@ -8,6 +8,7 @@
package io.element.android.appnav.loggedin
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -15,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -36,6 +38,10 @@ import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService
@@ -58,7 +64,12 @@ class LoggedInPresenter(
private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter =
permissionsPresenterFactory.create(Manifest.permission.ACCESS_LOCAL_NETWORK)
@Composable
override fun present(): LoggedInState {
val coroutineScope = rememberCoroutineScope()
@@ -117,6 +128,21 @@ class LoggedInPresenter(
}
}
val localNetworkPermissionState = localNetworkPermissionsPresenter.present()
var localNetworkPromptDismissedThisSession by remember { mutableStateOf(false) }
val advisorRequestsPrompt by produceState(initialValue = false, localNetworkPermissionState.permissionGranted) {
value = localNetworkPermissionAdvisor.shouldRequestPermissionFor(matrixClient.homeserverUrl)
}
LaunchedEffect(localNetworkPermissionState.permissionGranted) {
if (localNetworkPermissionState.permissionGranted) localNetworkPromptDismissedThisSession = false
}
val localNetworkPermissionDialog = when {
!advisorRequestsPrompt -> LocalNetworkPermissionDialog.None
localNetworkPromptDismissedThisSession -> LocalNetworkPermissionDialog.None
localNetworkPermissionState.shouldShowRationale -> LocalNetworkPermissionDialog.Rationale
else -> LocalNetworkPermissionDialog.Settings
}
fun handleEvent(event: LoggedInEvents) {
when (event) {
is LoggedInEvents.CloseErrorDialog -> {
@@ -134,6 +160,17 @@ class LoggedInPresenter(
// Force the logout since Native Sliding Sync is already enforced by the SDK
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
}
LoggedInEvents.DismissLocalNetworkPermissionPrompt -> {
localNetworkPromptDismissedThisSession = true
}
LoggedInEvents.RequestLocationNetworkPermission -> {
if (localNetworkPermissionDialog == LocalNetworkPermissionDialog.Settings) {
localNetworkPermissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
} else {
localNetworkPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
localNetworkPromptDismissedThisSession = true
}
}
}
@@ -143,6 +180,7 @@ class LoggedInPresenter(
ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = buildMeta.applicationName,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = ::handleEvent,
)
}
@@ -9,6 +9,7 @@
package io.element.android.appnav.loggedin
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class LoggedInState(
val showSyncSpinner: Boolean,
@@ -16,5 +17,6 @@ data class LoggedInState(
val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean,
val appName: String,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (LoggedInEvents) -> Unit,
)
@@ -10,6 +10,7 @@ package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.push.api.PusherRegistrationFailure
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
@@ -19,6 +20,8 @@ open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
aLoggedInState(showSyncSpinner = true),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
aLoggedInState(forceNativeSlidingSyncMigration = true),
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Settings),
)
}
@@ -27,11 +30,13 @@ fun aLoggedInState(
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false,
appName: String = "Element X",
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = appName,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = {},
)
@@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.ui.strings.CommonStrings
@@ -82,6 +83,12 @@ fun LoggedInView(
}
)
}
LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog,
onSubmit = { state.eventSink(LoggedInEvents.RequestLocationNetworkPermission) },
onDismiss = { state.eventSink(LoggedInEvents.DismissLocalNetworkPermissionPrompt) },
)
}
private fun Throwable.getReason(): String? {
@@ -36,6 +36,8 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.push.test.FakePushService
@@ -124,6 +126,8 @@ class LoggedInPresenterTest {
encryptionService = encryptionService,
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
@@ -368,6 +372,8 @@ class LoggedInPresenterTest {
encryptionService = encryptionService,
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
}
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements, including fixing the local network permission usage in Android 17.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -31,12 +31,12 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.visuallist.VisualList
import io.element.android.libraries.designsystem.components.visuallist.VisualListItemData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
@@ -129,22 +129,21 @@ private fun AnalyticsOptInContent() {
verticalBias = -0.4f
)
) {
InfoListOrganism(
VisualList(
items = persistentListOf(
InfoListItem(
VisualListItemData(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
iconVector = CompoundIcons.CheckCircle(),
),
InfoListItem(
VisualListItemData(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
iconVector = CompoundIcons.CheckCircle(),
),
InfoListItem(
VisualListItemData(
message = stringResource(id = R.string.screen_analytics_prompt_settings),
iconVector = CompoundIcons.CheckCircle(),
),
),
textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.iconSuccessPrimary,
)
}
@@ -36,10 +36,10 @@ import io.element.android.features.announcement.impl.AnnouncementState
import io.element.android.features.announcement.impl.AnnouncementStateProvider
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.visuallist.VisualList
import io.element.android.libraries.designsystem.components.visuallist.VisualListItemData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -134,11 +134,9 @@ private fun FullscreenAnnouncementContent(
Column(
modifier = modifier.fillMaxSize(),
) {
InfoListOrganism(
VisualList(
modifier = Modifier.fillMaxWidth(),
items = announcement.items(),
textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.iconSecondary,
iconSize = 24.dp
)
announcement.notice()?.let { notice ->
@@ -186,25 +184,25 @@ private fun Announcement.Fullscreen.icon() = when (this) {
}
@Composable
private fun Announcement.Fullscreen.items(): ImmutableList<InfoListItem> = when (this) {
private fun Announcement.Fullscreen.items(): ImmutableList<VisualListItemData> = when (this) {
Announcement.Fullscreen.Space -> persistentListOf(
InfoListItem(
VisualListItemData(
message = "View spaces you\'ve created or joined",
iconVector = CompoundIcons.VisibilityOn(),
),
InfoListItem(
VisualListItemData(
message = "Accept or decline invites to spaces",
iconVector = CompoundIcons.Email(),
),
InfoListItem(
VisualListItemData(
message = "Discover any rooms you can join in your spaces",
iconVector = CompoundIcons.Search(),
),
InfoListItem(
VisualListItemData(
message = "Join public spaces",
iconVector = CompoundIcons.Explore(),
),
InfoListItem(
VisualListItemData(
message = "Leave any spaces you’ve joined",
iconVector = CompoundIcons.Leave(),
),
@@ -14,6 +14,7 @@
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -22,7 +22,6 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
@@ -84,7 +83,7 @@ class CallScreenPresenter(
var ignoreWebViewError by rememberSaveable { mutableStateOf(false) }
var webViewError by remember { mutableStateOf<String?>(null) }
val languageTag = languageTagProvider.provideLanguageTag()
val theme = if (ElementTheme.isLightTheme) "light" else "dark"
val theme = "dark"
DisposableEffect(Unit) {
coroutineScope.launch {
@@ -38,6 +38,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
@@ -143,24 +144,28 @@ class ElementCallActivity :
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive) {
if (state.isCallActive) {
setCallIsActive()
ForcedDarkElementTheme(
colors = colors,
) {
val state = presenter.present()
eventSink = state.eventSink
LaunchedEffect(state.isCallActive) {
if (state.isCallActive) {
setCallIsActive()
}
}
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {
consoleMessageLogger.log("ElementCall", it)
},
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
}
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {
consoleMessageLogger.log("ElementCall", it)
},
requestPermissions = { permissions, callback ->
requestPermissionCallback = callback
requestPermissionsLauncher.launch(permissions)
}
)
}
}
}
@@ -0,0 +1,23 @@
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.api"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,35 @@
/*
* 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.
*/
import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.impl"
}
setupDependencyInjection()
dependencies {
api(projects.features.contentscanner.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -43,6 +43,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.invitepeople.api)
implementation(projects.features.enterprise.api)
api(projects.features.createroom.api)
testCommonDependencies(libs, true)
@@ -53,4 +54,5 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.enterprise.test)
}
@@ -24,6 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -73,6 +74,7 @@ class ConfigureRoomPresenter(
private val featureFlagService: FeatureFlagService,
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val sessionEnterpriseService: SessionEnterpriseService,
) : Presenter<ConfigureRoomState> {
@AssistedFactory
interface Factory {
@@ -256,7 +258,7 @@ class ConfigureRoomPresenter(
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = true,
isEncrypted = !sessionEnterpriseService.isEncryptionDisabledByHomeserver(),
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
@@ -11,7 +11,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -85,9 +86,9 @@ internal fun SelectParentSpaceOptions(
)
if (displaySelectSpaceBottomSheet) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { true },
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
)
ModalBottomSheet(
sheetState = sheetState,
@@ -3,14 +3,34 @@
<string name="screen_create_room_action_create_room">"Ruangan baru"</string>
<string name="screen_create_room_add_people_title">"Undang orang-orang"</string>
<string name="screen_create_room_error_creating_room">"Terjadi kesalahan saat membuat ruangan"</string>
<string name="screen_create_room_error_creating_space">"Ruang tersebut tidak dapat dibuat karena kesalahan yang tidak diketahui. Coba lagi nanti."</string>
<string name="screen_create_room_name_placeholder">"Tambahkan nama…"</string>
<string name="screen_create_room_new_room_title">"Ruangan baru"</string>
<string name="screen_create_room_new_space_title">"Ruang baru"</string>
<string name="screen_create_room_private_option_description">"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."</string>
<string name="screen_create_room_private_option_title">"Pribadi"</string>
<string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string>
<string name="screen_create_room_public_option_short_description">"Siapa pun bisa bergabung."</string>
<string name="screen_create_room_public_option_title">"Umum"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Siapa pun dapat meminta untuk bergabung dengan ijin administrator atau moderator"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Minta untuk bergabung"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Siapa pun di %1$s dapat bergabung, tetapi semua orang lain harus meminta akses."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Ajukan permohonan untuk bergabung"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Pribadi"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Siapa pun dapat bergabung dengan ruangan ini"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Umum"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Siapa pun di %1$s dapat bergabung."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standar"</string>
<string name="screen_create_room_room_access_section_title">"Yang memiliki akses"</string>
<string name="screen_create_room_room_address_section_footer">"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."</string>
<string name="screen_create_room_room_address_section_title">"Alamat ruangan"</string>
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
<string name="screen_create_room_space_selection_no_space_description">"(ruang sudah penuh)"</string>
<string name="screen_create_room_space_selection_no_space_option">"Jangan menambahkan sesuatu ke ruang yang sudah ada."</string>
<string name="screen_create_room_space_selection_no_space_title">"Tidak ada ruang yang dipilih"</string>
<string name="screen_create_room_space_selection_sheet_title">"Tambahkan ke ruang"</string>
<string name="screen_create_room_topic_label">"Topik (opsional)"</string>
<string name="screen_create_room_topic_placeholder">"Tambahkan deskripsi…"</string>
</resources>
@@ -23,7 +23,7 @@
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s にいる全員が参加することができます。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"スタンダード"</string>
<string name="screen_create_room_room_access_section_title">"参加できるユーザー"</string>
<string name="screen_create_room_room_access_section_title">"参加可能なユーザー"</string>
<string name="screen_create_room_room_address_section_footer">"公開ディレクトリで自分を見つけられるようにするには、アドレスが必要です。"</string>
<string name="screen_create_room_room_address_section_title">"アドレス"</string>
<string name="screen_create_room_room_visibility_section_title">"ルームの公開度"</string>
@@ -20,11 +20,13 @@ import io.element.android.features.createroom.impl.configureroom.CreateRoomConfi
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
@@ -56,6 +58,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.matching
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.mockk.mockk
@@ -517,6 +520,30 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
}
}
@Test
fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest {
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
val matrixClient = createMatrixClient().apply {
createRoomResult = createRoomLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient,
sessionEnterpriseService = sessionEnterpriseService,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private))
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction.isLoading()).isTrue()
assertThat(awaitItem().createRoomAction.isSuccess()).isTrue()
createRoomLambda.assertions().isCalledOnce().with(matching<CreateRoomParameters> { !it.isEncrypted })
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
@@ -552,6 +579,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }),
) = ConfigureRoomPresenter(
isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
@@ -566,5 +594,6 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
sessionEnterpriseService = sessionEnterpriseService,
)
}
@@ -48,11 +48,11 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.deactivation.impl.R
import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.list.SwitchListItem
import io.element.android.libraries.designsystem.components.visuallist.VisualList
import io.element.android.libraries.designsystem.components.visuallist.VisualListItemData
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -175,9 +175,9 @@ private fun Content(
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
InfoListOrganism(
VisualList(
items = persistentListOf(
InfoListItem(
VisualListItemData(
message = buildAnnotatedStringWithStyledPart(
R.string.screen_deactivate_account_list_item_1,
R.string.screen_deactivate_account_list_item_1_bold_part,
@@ -194,7 +194,7 @@ private fun Content(
)
},
),
InfoListItem(
VisualListItemData(
message = stringResource(R.string.screen_deactivate_account_list_item_2),
iconComposable = {
Icon(
@@ -205,7 +205,7 @@ private fun Content(
)
},
),
InfoListItem(
VisualListItemData(
message = stringResource(R.string.screen_deactivate_account_list_item_3),
iconComposable = {
Icon(
@@ -216,7 +216,7 @@ private fun Content(
)
},
),
InfoListItem(
VisualListItemData(
message = stringResource(R.string.screen_deactivate_account_list_item_4),
iconComposable = {
Icon(
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekreft at du vil deaktivere kontoen din. Denne handlingen kan ikke angres."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Bekreft at du vil slette kontoen din. Denne handlingen kan ikke angres."</string>
<string name="screen_deactivate_account_delete_all_messages">"Slett alle meldingene mine"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Advarsel: Fremtidige brukere vil kunne se ufullstendige samtaler."</string>
<string name="screen_deactivate_account_description">"Deaktivering av kontoen din er %1$s , det vil:"</string>
<string name="screen_deactivate_account_description">"Hvis %1$s sletter kontoen din, vil følgende skje:"</string>
<string name="screen_deactivate_account_description_bold_part">"irreversibel"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s kontoen din (du kan ikke logge på igjen, og ID-en din kan ikke brukes på nytt)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Deaktiver permanent"</string>
<string name="screen_deactivate_account_list_item_2">"Fjern deg fra alle chatterom."</string>
<string name="screen_deactivate_account_list_item_3">"Slett kontoinformasjonen din fra vår identitetsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Meldingene dine vil fortsatt være synlige for registrerte brukere, men vil ikke være tilgjengelige for nye eller uregistrerte brukere hvis du velger å slette dem."</string>
<string name="screen_deactivate_account_title">"Deaktiver kontoen"</string>
<string name="screen_deactivate_account_title">"Slett kontoen"</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Iltimos, hisobingizni o‘chirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib bo‘lmaydi."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Iltimos, hisobingizni oʻchirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib boʻlmaydi."</string>
<string name="screen_deactivate_account_delete_all_messages">"Barcha xabarlarimni o‘chirib tashlang"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Ogohlantirish: Kelgusi foydalanuvchilar chala suhbatlarni ko‘rishi mumkin."</string>
<string name="screen_deactivate_account_description">"Hisobingiz %1$s faolsizlantirilmoqda, u quyidagilarni bajaradi:"</string>
@@ -12,5 +12,7 @@ interface SessionEnterpriseService {
suspend fun isElementCallAvailable(): Boolean
suspend fun tweakMasUrl(url: String): String
suspend fun isEncryptionDisabledByHomeserver(): Boolean
suspend fun init()
}
@@ -17,4 +17,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun init() = Unit
override suspend fun tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true
override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false
}
@@ -15,6 +15,7 @@ import io.element.android.tests.testutils.simulateLongTask
class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val tweakMasUrlResult: (String) -> String = { lambdaError() },
private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
@@ -26,4 +27,8 @@ class FakeSessionEnterpriseService(
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}
override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
return isEncryptionDisabledResult()
}
}
@@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"نمی‌توانید تأیید کنید؟"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"ایجاد کلید بازیابی جدید"</string>
<string name="screen_identity_confirmation_subtitle">"تأیید این افزاره برای برپایی پیام‌رسانی امن."</string>
<string name="screen_identity_confirmation_title">"تأیید هویتتان"</string>
<string name="screen_identity_confirmation_subtitle">"گزینش چگونگی تأیید برای برپایی پیام‌رسانی امن."</string>
<string name="screen_identity_confirmation_title">"تأیید هویت دیجیتالیتان"</string>
<string name="screen_identity_confirmation_use_another_device">"استفاده از افزاره‌ای دیگر"</string>
<string name="screen_identity_confirmation_use_recovery_key">"استفاده از کلید بازیابی"</string>
<string name="screen_identity_confirmed_subtitle">"اکنون می‌توانید پیام‌ها را به صورت امن فرستاده و بگیرید و هرکسی که با او گپ می‌زنید نیز می‌تواند به این افزاره اعتماد کند."</string>
@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Kan du ikke bekrefte?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Opprett en ny gjenopprettingsnøkkel"</string>
<string name="screen_identity_confirmation_subtitle">"Verifiser denne enheten for å sette opp sikker meldingsutveksling."</string>
<string name="screen_identity_confirmation_subtitle">"Velg hvordan du vil verifisere deg for å sette opp sikker meldingsutveksling."</string>
<string name="screen_identity_confirmation_title">"Bekreft din digitale identitet"</string>
<string name="screen_identity_confirmation_use_another_device">"Bruk en annen enhet"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Bruk gjenopprettingsnøkkel"</string>
@@ -106,7 +106,7 @@ private fun RoomListModalBottomSheetContent(
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_read),
text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
)
},
@@ -200,7 +200,7 @@ private fun RoomListModalBottomSheetContent(
@Composable
internal fun RoomListContextMenuPreview(
@PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown
) = ElementPreview {
) = ElementPreview(fillMaxSize = true) {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = true,
@@ -33,6 +33,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -112,10 +114,12 @@ private fun RoomListSearchContent(
title = {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.query,
lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
@@ -132,7 +136,7 @@ private fun RoomListSearchContent(
IconButton(onClick = { state.eventSink(RoomListSearchEvent.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel)
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
)
}
}
@@ -24,7 +24,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -57,8 +57,9 @@ fun SpaceFiltersView(
modifier: Modifier = Modifier
) {
val isSelecting by rememberUpdatedState(state is SpaceFiltersState.Selecting)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
confirmValueChange = { sheetValueTarget ->
// This ensures the hide animation is not cancelled
when (sheetValueTarget) {
@@ -187,6 +188,6 @@ private fun SpaceFilterItem(
@PreviewsDayNight
@Composable
internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview {
internal fun SpaceFiltersViewPreview(@PreviewParameter(SpaceFiltersStateProvider::class) state: SpaceFiltersState) = ElementPreview(fillMaxSize = true) {
SpaceFiltersView(state = state)
}
@@ -3,6 +3,8 @@
<string name="banner_battery_optimization_content_android">"Nonaktifkan pengoptimalan baterai untuk aplikasi ini, untuk memastikan semua notifikasi diterima."</string>
<string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string>
<string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</string>
<string name="banner_new_sound_message">"Pemberitahuan Anda telah diperbarui—lebih jelas, lebih cepat, dan tidak mengganggu."</string>
<string name="banner_new_sound_title">"Kami telah memperbarui suara Anda."</string>
<string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string>
<string name="banner_set_up_recovery_submit">"Siapkan Kunci pemulihan"</string>
<string name="banner_set_up_recovery_title">"Cadangkan obrolan Anda"</string>
@@ -48,6 +50,7 @@ Anda tidak memiliki pesan yang belum dibaca!"</string>
<string name="screen_roomlist_mark_as_read">"Tandai sebagai dibaca"</string>
<string name="screen_roomlist_mark_as_unread">"Tandai sebagai belum dibaca"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ruangan ini telah ditingkatkan"</string>
<string name="screen_roomlist_your_spaces">"Ruang Anda"</string>
<string name="session_verification_banner_message">"Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya."</string>
<string name="session_verification_banner_title">"Verifikasi bahwa ini Anda"</string>
</resources>
@@ -5,9 +5,9 @@
<string name="banner_battery_optimization_title_android">"Kommer ikke varslene frem?"</string>
<string name="banner_new_sound_message">"Varslingssignalet ditt er oppdatert – tydeligere, raskere og mindre forstyrrende."</string>
<string name="banner_new_sound_title">"Vi har oppdatert lydene dine"</string>
<string name="banner_set_up_recovery_content">"Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter."</string>
<string name="banner_set_up_recovery_content">"Chattene dine sikkerhetskopieres automatisk med ende-til-ende-kryptering. For å gjenopprette denne sikkerhetskopien og beholde din digitale identitet når du mister tilgang til alle enhetene dine, trenger du gjenopprettingsnøkkelen din."</string>
<string name="banner_set_up_recovery_submit">"Hent gjenopprettingsnøkkel"</string>
<string name="banner_set_up_recovery_title">"Konfigurer gjenoppretting for å beskytte kontoen din"</string>
<string name="banner_set_up_recovery_title">"Sikkerhetskopier chattene dine"</string>
<string name="confirm_recovery_key_banner_message">"Verifiser gjenopprettingsnøkkelen for å opprettholde tilgangen til nøkkellageret og meldingshistorikken."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Skriv inn gjenopprettingsnøkkelen din"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Har du glemt din gjenopprettingsnøkkel?"</string>
@@ -34,7 +34,7 @@ class RoomListContextMenuTest : RobolectricTest() {
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
clickOn(R.string.screen_roomlist_mark_as_read)
clickOn(CommonStrings.action_mark_as_read)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideContextMenu,
@@ -302,13 +302,13 @@ private fun InvitePeopleConfirmModal(
text = stringResource(CommonStrings.action_remove),
onClick = onRemove,
leadingIcon = IconSource.Vector(CompoundIcons.Close()),
modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
modifier = Modifier.weight(1f),
)
Button(
text = stringResource(CommonStrings.action_invite),
onClick = onInvite,
leadingIcon = IconSource.Vector(CompoundIcons.Check()),
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
)
}
}
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Sudah menjadi anggota"</string>
<string name="screen_invite_users_already_invited">"Sudah diundang"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Saat ini Anda belum memiliki obrolan dengan kontak-kontak ini. Konfirmasikan undangan untuk mengundang mereka ke ruang obrolan ini sebelum melanjutkan."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Saat ini Anda belum memiliki obrolan apa pun dengan kontak ini. Konfirmasikan undangan ke ruang obrolan ini sebelum melanjutkan."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Ingin mengundang kontak baru ke ruang ini?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Ingin mengundang kontak baru ke ruang ini?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Allaqachon a\'zo"</string>
<string name="screen_invite_users_already_invited">"Allaqachon taklif qilingan"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Hozirda ushbu kontaktlar bilan hech qanday chatingiz yoʻq. Davom etishdan oldin ularni ushbu xonaga taklif qilishni tasdiqlang."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Hozirda bu kontakt bilan hech qanday chatingiz yoʻq. Davom etishdan oldin ularni ushbu xonaga taklif qilishni tasdiqlang."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Bu xonaga yangi kontaktlar taklif qilinsinmi?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Bu xonaga yangi kontakt taklif qilinsinmi?"</string>
</resources>
@@ -13,6 +13,8 @@
<string name="screen_link_new_device_mobile_step2">"گزینش %1$s"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"اکنون چه؟"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"آزمودن ورود دوباره با کد پاس در صورت مشکل شبکه"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"آزمون شبکهٔ وای‌فایی دیگر یا استفاده از دادهٔ همراهتان به جای وای‌فای در صورت مواجه دوباره با مشکل"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"ورود دستی در صورت کار نکردنش"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"اتّصال ناامن"</string>
<string name="screen_qr_code_login_device_code_subtitle">"از شما خواسته خواهد شد که دو رقم نشان داده روی این افزاره را وارد کنید."</string>
@@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Pindai kode QR"</string>
<string name="screen_link_new_device_desktop_step1">"Buka %1$s di laptop atau komputer desktop."</string>
<string name="screen_link_new_device_desktop_step3">"Pindai kode QR dengan perangkat ini"</string>
<string name="screen_link_new_device_desktop_submit">"Siap untuk memindai"</string>
<string name="screen_link_new_device_desktop_title">"Buka %1$s di komputer desktop untuk mendapatkan kode QR."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Angkanya tidak cocok"</string>
<string name="screen_link_new_device_enter_number_notice">"Masukkan kode 2 digit"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ini akan memverifikasi bahwa koneksi ke perangkat Anda yang lain aman."</string>
<string name="screen_link_new_device_enter_number_title">"Masukkan nomor yang ditampilkan di perangkat Anda yang lain"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Penyedia akun Anda tidak mendukung %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s tidak didukung"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Penyedia akun Anda tidak mendukung masuk ke perangkat baru menggunakan kode QR."</string>
<string name="screen_link_new_device_error_not_supported_title">"Kode QR tidak didukung"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Proses masuk dibatalkan di perangkat lain."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_link_new_device_mobile_step1">"Buka %1$s di perangkat lain."</string>
<string name="screen_link_new_device_mobile_step2">"Pilih %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"Masuk dengan kode QR"</string>
<string name="screen_link_new_device_mobile_step3">"Pindai kode QR yang ditampilkan di sini dengan perangkat lain."</string>
<string name="screen_link_new_device_mobile_title">"Buka %1$s di perangkat lain."</string>
<string name="screen_link_new_device_root_desktop_computer">"Komputer desktop"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Memuat kode QR…"</string>
<string name="screen_link_new_device_root_mobile_device">"Perangkat seluler"</string>
<string name="screen_link_new_device_root_title">"Jenis perangkat apa yang ingin Anda tautkan?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Silakan coba lagi dan pastikan Anda telah memasukkan kode 2 digit dengan benar. Jika angka-angka tersebut masih tidak cocok, hubungi penyedia akun Anda."</string>
<string name="screen_link_new_device_wrong_number_title">"Angkanya tidak cocok"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
@@ -23,6 +40,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Permintaan masuk dibatalkan"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Proses masuk ditolak di perangkat lain."</string>
<string name="screen_qr_code_login_error_declined_title">"Proses masuk ditolak"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Anda tidak perlu melakukan hal lain."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Perangkat Anda yang lain sudah masuk"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Masa masuk kedaluwarsa. Silakan coba lagi."</string>
<string name="screen_qr_code_login_error_expired_title">"Proses masuk tidak selesai tepat waktu"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR.
@@ -30,7 +30,7 @@ import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -86,7 +86,9 @@ fun MapBottomSheetScaffold(
customMapStyleUrl: AsyncData<String?>,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
)
),
cameraState: CameraState = rememberCameraState(),
mapOptions: MapOptions = MapDefaults.options,
@@ -20,7 +20,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -105,7 +105,9 @@ fun ShareLocationView(
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded,
)
)
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
@@ -21,7 +21,7 @@ import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -92,7 +92,9 @@ fun ShowLocationView(
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded,
)
)
LaunchedEffect(state.isSheetDraggable) {
if (!state.isSheetDraggable) {
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Vaša povijest lokacije uživo bit će pohranjena u sobi i vidljiva članovima nakon završetka sesije."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Odaberite koliko dugo želite dijeliti svoju lokaciju uživo."</string>
<string name="screen_share_location_live_location_missing_permissions">"Nemate dopuštenja za dijeljenje svoje lokacije uživo u ovoj sobi"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Riwayat lokasi langsung Anda akan disimpan di ruang obrolan dan dapat dilihat oleh anggota setelah sesi berakhir."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Pilih berapa lama Anda ingin membagikan lokasi langsung Anda."</string>
<string name="screen_share_location_live_location_missing_permissions">"Anda tidak memiliki izin untuk membagikan lokasi real-time Anda di ruang ini"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_duration_picker_title">"Velg hvor lenge du vil dele din sanntidslokasjon."</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Jonli joylashuv tarixingiz chat-xonada saqlanadi va sessiya tugaganidan keyin homiylarga ko‘rinadi."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Jonli joylashuvingiz qancha vaqt ulashilishini tanlang."</string>
<string name="screen_share_location_live_location_missing_permissions">"Bu xonada jonli joylashuvingizni ulashish uchun sizda ruxsat yoʻq"</string>
</resources>
@@ -15,15 +15,23 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -32,6 +40,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.pinDigitBg
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PinEntryTextField(
@@ -39,16 +49,25 @@ fun PinEntryTextField(
isSecured: Boolean,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
initialIsFocus: Boolean = false,
) {
var isFocused by remember { mutableStateOf(initialIsFocus) }
val filledCount = pinEntry.digits.count { it is PinDigit.Filled }
val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field)
val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount)
BasicTextField(
modifier = modifier,
modifier = modifier
.onFocusChanged { isFocused = it.isFocused }
.clearAndSetSemantics {
contentDescription = "$pinFieldLabel, $digitsEnteredLabel"
},
value = pinEntry.toText(),
onValueChange = {
onValueChange(it)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
decorationBox = {
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused)
}
)
}
@@ -58,13 +77,19 @@ fun PinEntryTextField(
private fun PinEntryRow(
pinEntry: PinEntry,
isSecured: Boolean,
isFocused: Boolean,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
for (digit in pinEntry.digits) {
PinDigitView(digit = digit, isSecured = isSecured)
val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty }
pinEntry.digits.forEachIndexed { index, digit ->
PinDigitView(
digit = digit,
isSecured = isSecured,
isFocused = isFocused && index == focusedIndex
)
}
}
}
@@ -73,11 +98,16 @@ private fun PinEntryRow(
private fun PinDigitView(
digit: PinDigit,
isSecured: Boolean,
isFocused: Boolean,
) {
val shape = RoundedCornerShape(8.dp)
val appearanceModifier = when (digit) {
PinDigit.Empty -> {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
if (isFocused) {
Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape)
} else {
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
}
}
is PinDigit.Filled -> {
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
@@ -108,18 +138,19 @@ private fun PinDigitView(
internal fun PinEntryTextFieldPreview() {
ElementPreview {
val pinEntry = PinEntry.createEmpty(4).fillWith("12")
Column {
PinEntryTextField(
pinEntry = pinEntry,
isSecured = true,
onValueChange = {},
)
Spacer(modifier = Modifier.size(16.dp))
PinEntryTextField(
pinEntry = pinEntry,
isSecured = false,
onValueChange = {},
)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
listOf(true, false).forEach { isSecured ->
listOf(true, false).forEach { initialIsFocus ->
PinEntryTextField(
pinEntry = pinEntry,
isSecured = isSecured,
initialIsFocus = initialIsFocus,
onValueChange = {},
)
}
}
}
}
}
@@ -13,4 +13,6 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChangeServerEvents {
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
data object ClearError : ChangeServerEvents
data object RequestLocalNetworkPermission : ChangeServerEvents
data object DismissLocalNetworkPermission : ChangeServerEvents
}
@@ -12,50 +12,55 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
class ChangeServerPresenter(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
) : Presenter<ChangeServerState> {
@Composable
override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val changeServerAction: MutableState<AsyncData<Unit>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
val gateState = localNetworkPermissionGate.present<AccountProvider>(
urlOf = { provider -> provider.url },
onProceed = { provider -> changeServer(provider, changeServerAction) },
)
fun handleEvent(event: ChangeServerEvents) {
when (event) {
is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction)
ChangeServerEvents.ClearError -> changeServerAction.value = AsyncData.Uninitialized
is ChangeServerEvents.ChangeServer -> gateState.submit(event.accountProvider)
ChangeServerEvents.DismissLocalNetworkPermission -> gateState.abort()
ChangeServerEvents.RequestLocalNetworkPermission -> gateState.requestPermission()
}
}
return ChangeServerState(
changeServerAction = changeServerAction.value,
localNetworkPermissionDialog = gateState.dialog,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.changeServer(
private suspend fun changeServer(
data: AccountProvider,
changeServerAction: MutableState<AsyncData<Unit>>,
) = launch {
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = data.title,
@@ -9,8 +9,10 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class ChangeServerState(
val changeServerAction: AsyncData<Unit>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (ChangeServerEvents) -> Unit
)
@@ -11,6 +11,7 @@ package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
override val values: Sequence<ChangeServerState>
@@ -39,12 +40,15 @@ open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerStat
ChangeServerError.UnsupportedServer
)
),
aChangeServerState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
)
}
fun aChangeServerState(
changeServerAction: AsyncData<Unit> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = ChangeServerState(
changeServerAction = changeServerAction,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = {}
)
@@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -121,6 +122,15 @@ fun ChangeServerView(
}
AsyncData.Uninitialized -> Unit
}
LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog,
onSubmit = {
eventSink.invoke(ChangeServerEvents.RequestLocalNetworkPermission)
},
onDismiss = {
eventSink.invoke(ChangeServerEvents.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter
interface LoginModule {
@Binds
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter<ChangeServerState>
@Binds
fun bindLoginModePresenter(presenter: LoginModePresenter): Presenter<LoginModeState>
}
@@ -0,0 +1,96 @@
/*
* 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.localnetwork
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import kotlinx.coroutines.launch
@Inject
class LocalNetworkPermissionGate(
private val advisor: LocalNetworkPermissionAdvisor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) {
private val permissionsPresenter: PermissionsPresenter =
permissionsPresenterFactory.create(Manifest.permission.ACCESS_LOCAL_NETWORK)
@Composable
fun <T : Any> present(
urlOf: (T) -> String,
onProceed: suspend (T) -> Unit,
): LocalNetworkPermissionGateState<T> {
val coroutineScope = rememberCoroutineScope()
val permissionsState = permissionsPresenter.present()
var pendingSubmit by remember { mutableStateOf<T?>(null) }
val latestUrlOf by rememberUpdatedState(urlOf)
val latestOnProceed by rememberUpdatedState(onProceed)
LaunchedEffect(permissionsState.permissionGranted, pendingSubmit) {
val pending = pendingSubmit
if (pending != null && permissionsState.permissionGranted) {
coroutineScope.launch { latestOnProceed(pending) }
pendingSubmit = null
}
}
val dialog by rememberUpdatedState(
when {
pendingSubmit == null -> LocalNetworkPermissionDialog.None
permissionsState.permissionGranted -> LocalNetworkPermissionDialog.None
permissionsState.shouldShowRationale || !permissionsState.permissionAlreadyAsked -> LocalNetworkPermissionDialog.Rationale
else -> LocalNetworkPermissionDialog.Settings
}
)
fun submit(value: T) {
coroutineScope.launch {
if (advisor.shouldRequestPermissionFor(latestUrlOf(value))) {
pendingSubmit = value
} else {
latestOnProceed(value)
}
}
}
fun requestPermission() {
when (dialog) {
LocalNetworkPermissionDialog.Settings -> {
permissionsState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
}
LocalNetworkPermissionDialog.Rationale -> {
permissionsState.eventSink(PermissionsEvent.ForceRequestPermissions)
}
else -> Unit
}
}
fun abort() {
pendingSubmit = null
}
return LocalNetworkPermissionGateState(
dialog = dialog,
submit = ::submit,
requestPermission = ::requestPermission,
abort = ::abort,
)
}
}
@@ -0,0 +1,17 @@
/*
* 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.localnetwork
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class LocalNetworkPermissionGateState<T>(
val dialog: LocalNetworkPermissionDialog,
val submit: (T) -> Unit,
val requestPermission: () -> Unit,
val abort: () -> Unit,
)
@@ -1,128 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests.
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* and [ChooseAccountProviderPresenter].
*/
@Inject
class LoginHelper(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) {
private val loginModeState: MutableState<AsyncData<LoginMode>> = mutableStateOf(AsyncData.Uninitialized)
@Composable
fun collectLoginMode(): State<AsyncData<LoginMode>> {
LaunchedEffect(Unit) {
oAuthActionFlow.collect { oAuthAction ->
if (oAuthAction != null) {
onOAuthAction(oAuthAction)
}
}
}
return loginModeState
}
fun clearError() {
loginModeState.value = AsyncData.Uninitialized
}
suspend fun submit(
isAccountCreation: Boolean,
homeserverUrl: String,
resolvedHomeserverUrl: String?,
loginHint: String?,
) {
suspend {
authenticationService.setHomeserver(homeserverUrl).recoverCatching {
// No .well-known file?
// If the homeserver is not reachable, try using resolvedHomeserverUrl.
if (resolvedHomeserverUrl != null && resolvedHomeserverUrl != homeserverUrl) {
authenticationService.setHomeserver(resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
if (matrixHomeServerDetails.supportsOAuthLogin) {
// Retrieve the details right now
val oAuthPrompt = if (isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = loginHint).getOrThrow()
)
} else if (isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
LoginMode.AccountCreation(url)
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginMode.PasswordLogin
} else {
error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginModeState,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun onOAuthAction(oAuthAction: OAuthAction) {
if (oAuthAction is OAuthAction.GoBack && oAuthAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
// This can happen if there is an error, for instance attempt to login again on the same account.
return
}
loginModeState.value = AsyncData.Loading()
when (oAuthAction) {
is OAuthAction.GoBack -> {
authenticationService.cancelOAuthLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized
}
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
is OAuthAction.Success -> {
authenticationService.loginWithOAuth(oAuthAction.url)
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
}
oAuthActionFlow.reset()
}
}
@@ -0,0 +1,23 @@
/*
* 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.login
sealed interface LoginModeEvent {
data class Submit(
val isAccountCreation: Boolean,
val homeserverUrl: String,
val resolvedHomeserverUrl: String?,
val loginHint: String?,
) : LoginModeEvent
data object ClearError : LoginModeEvent
data object RequestLocalNetworkPermission : LoginModeEvent
data object DismissLocalNetworkPermission : LoginModeEvent
}
@@ -0,0 +1,127 @@
/*
* 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.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter
import io.element.android.features.login.impl.screens.classic.loginwithclassic.LoginWithClassicPresenter
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.api.OAuthActionFlow
/**
* Presenter responsible for managing the login flow, including handling OAuth actions and
* submitting login requests.
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter],
* [ChooseAccountProviderPresenter] and [LoginWithClassicPresenter].
*/
@Inject
class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
) : Presenter<LoginModeState> {
@Composable
override fun present(): LoginModeState {
val loginMode: MutableState<AsyncData<LoginMode>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val gateState = localNetworkPermissionGate.present<LoginModeEvent.Submit>(
urlOf = { it.homeserverUrl },
onProceed = { request -> performSubmit(request, loginMode) },
)
// Forward OAuth navigation events to the presenter's own loginMode state.
LaunchedEffect(Unit) {
oAuthActionFlow.collect { action ->
if (action != null) handleOAuthAction(action, loginMode)
}
}
fun handleEvent(event: LoginModeEvent) {
when (event) {
LoginModeEvent.ClearError -> loginMode.value = AsyncData.Uninitialized
is LoginModeEvent.Submit -> gateState.submit(event)
LoginModeEvent.DismissLocalNetworkPermission -> gateState.abort()
LoginModeEvent.RequestLocalNetworkPermission -> gateState.requestPermission()
}
}
return LoginModeState(
loginMode = loginMode.value,
localNetworkPermissionDialog = gateState.dialog,
eventSink = ::handleEvent,
)
}
private suspend fun performSubmit(request: LoginModeEvent.Submit, loginMode: MutableState<AsyncData<LoginMode>>) {
suspend {
authenticationService.setHomeserver(request.homeserverUrl).recoverCatching {
// Fallback to the well-known-resolved URL if the primary URL failed and the caller supplied one.
if (request.resolvedHomeserverUrl != null && request.resolvedHomeserverUrl != request.homeserverUrl) {
authenticationService.setHomeserver(request.resolvedHomeserverUrl).getOrThrow()
} else {
throw it
}
}.map { matrixHomeServerDetails ->
when {
matrixHomeServerDetails.supportsOAuthLogin -> {
val oAuthPrompt = if (request.isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
LoginMode.OAuth(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
}
request.isAccountCreation -> LoginMode.AccountCreation(
webClientUrlForAuthenticationRetriever.retrieve(request.homeserverUrl)
)
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported login flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
state = loginMode,
errorTransform = {
when (it) {
is AccountCreationNotSupported -> it
else -> ChangeServerError.from(it)
}
}
)
}
private suspend fun handleOAuthAction(action: OAuthAction, loginMode: MutableState<AsyncData<LoginMode>>) {
if (action is OAuthAction.GoBack && action.toUnblock && loginMode.value !is AsyncData.Loading) {
// Ignore GoBack that isn't tied to an in-flight login — it comes from LoginFlowNode
// when the user backs out after a completed login attempt.
return
}
loginMode.value = AsyncData.Loading()
when (action) {
is OAuthAction.GoBack -> authenticationService.cancelOAuthLogin()
.onSuccess { loginMode.value = AsyncData.Uninitialized }
.onFailure { loginMode.value = AsyncData.Failure(it) }
is OAuthAction.Success -> authenticationService.loginWithOAuth(action.url)
.onFailure { loginMode.value = AsyncData.Failure(it) }
}
oAuthActionFlow.reset()
}
}
@@ -1,6 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
* 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.
@@ -8,7 +7,15 @@
package io.element.android.features.login.impl.login
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
data class LoginModeState(
val loginMode: AsyncData<LoginMode>,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (LoginModeEvent) -> Unit,
)
sealed interface LoginMode {
data object PasswordLogin : LoginMode
@@ -0,0 +1,21 @@
/*
* 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.login
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
fun aLoginModeState(
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
eventSink: (LoginModeEvent) -> Unit = {},
) = LoginModeState(
loginMode = loginMode,
localNetworkPermissionDialog = localNetworkPermissionDialog,
eventSink = eventSink,
)
@@ -14,6 +14,7 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@@ -28,6 +29,7 @@ import java.util.Collections
class HomeserverResolver(
private val dispatchers: CoroutineDispatchers,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
) {
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
@@ -39,13 +41,17 @@ class HomeserverResolver(
// Run all the requests in parallel
withContext(dispatchers.io) {
list.parallelMap { url ->
val isValid = homeServerLoginCompatibilityChecker.check(url)
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
.getOrNull()
?: return@parallelMap
// Skip the compatibility probe if we'd need ACCESS_LOCAL_NETWORK first —
// otherwise the probe hangs on the TCP timeout for ~30s. Emit the URL as a
// candidate directly; the actual sign-in flow triggers the permission prompt.
val shouldRequestPermissionOrIsValid = localNetworkPermissionAdvisor.shouldRequestPermissionFor(url) ||
homeServerLoginCompatibilityChecker.check(url)
.onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") }
.getOrNull()
?: return@parallelMap
// Emit the list as soon as possible
if (isValid) {
if (shouldRequestPermissionOrIsValid) {
currentList.add(HomeserverData(homeserverUrl = url))
withContext(flowContext) {
emit(currentList.toList())
@@ -12,50 +12,49 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Inject
class ChooseAccountProviderPresenter(
private val enterpriseService: EnterpriseService,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
) : Presenter<ChooseAccountProviderState> {
@Composable
override fun present(): ChooseAccountProviderState {
val localCoroutineScope = rememberCoroutineScope()
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
var selectedAccountProvider: AccountProvider? by remember { mutableStateOf(null) }
fun handleEvent(event: ChooseAccountProviderEvents) {
when (event) {
ChooseAccountProviderEvents.Continue -> localCoroutineScope.launch {
selectedAccountProvider?.let {
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = it.url,
resolvedHomeserverUrl = null,
loginHint = null,
ChooseAccountProviderEvents.Continue -> {
selectedAccountProvider?.let { provider ->
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = provider.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
)
}
}
is ChooseAccountProviderEvents.SelectAccountProvider -> {
// Ensure that the user do not change the server during processing
if (loginMode is AsyncData.Uninitialized) {
// Ensure that the user does not change the server during processing.
if (loginModeState.loginMode is AsyncData.Uninitialized) {
selectedAccountProvider = event.accountProvider
}
}
ChooseAccountProviderEvents.ClearError -> loginHelper.clearError()
ChooseAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
}
}
@@ -77,7 +76,7 @@ class ChooseAccountProviderPresenter(
return ChooseAccountProviderState(
accountProviders = staticAccountProviderList,
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = ::handleEvent,
)
}
@@ -9,16 +9,17 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
data class ChooseAccountProviderState(
val accountProviders: ImmutableList<AccountProvider>,
val selectedAccountProvider: AccountProvider?,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (ChooseAccountProviderEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
get() = selectedAccountProvider != null &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
}
@@ -11,7 +11,8 @@ package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.toImmutableList
@@ -58,7 +59,7 @@ open class ChooseAccountProviderStateProvider : PreviewParameterProvider<ChooseA
server3,
),
selectedAccountProvider = server2,
loginMode = AsyncData.Loading(),
loginModeState = aLoginModeState(loginMode = AsyncData.Loading()),
),
// Add other state here
)
@@ -69,11 +70,11 @@ fun aChooseAccountProviderState(
anAccountProvider()
),
selectedAccountProvider: AccountProvider? = null,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (ChooseAccountProviderEvents) -> Unit = {},
) = ChooseAccountProviderState(
accountProviders = accountProviders.toImmutableList(),
selectedAccountProvider = selectedAccountProvider,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = eventSink,
)
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -44,6 +45,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -56,9 +58,9 @@ fun ChooseAccountProviderView(
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
@@ -124,7 +126,7 @@ fun ChooseAccountProviderView(
Spacer(modifier = Modifier.height(48.dp))
}
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
state.eventSink(ChooseAccountProviderEvents.ClearError)
},
@@ -135,6 +137,15 @@ fun ChooseAccountProviderView(
)
}
}
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -20,7 +20,8 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.ElementClassicConnectionState
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -32,7 +33,7 @@ import kotlinx.coroutines.launch
class LoginWithClassicPresenter(
@Assisted private val userId: UserId,
@Assisted private val navigator: LoginWithClassicNavigator,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
private val elementClassicConnection: ElementClassicConnection,
private val accountProviderDataSource: AccountProviderDataSource,
private val buildMeta: BuildMeta,
@@ -51,7 +52,7 @@ class LoginWithClassicPresenter(
var loginWithClassicAction by remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
val elementClassicConnectionState by elementClassicConnection.stateFlow.collectAsState()
fun handleEvent(event: LoginWithClassicEvent) {
@@ -69,11 +70,13 @@ class LoginWithClassicPresenter(
val elementClassicUserId = currentState.elementClassicSession.userId
val accountProvider = elementClassicUserId.domainName.orEmpty().ensureProtocol()
accountProviderDataSource.setUrl(accountProvider)
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = accountProvider,
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
loginHint = "mxid:" + elementClassicUserId.value,
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = accountProvider,
resolvedHomeserverUrl = currentState.elementClassicSession.homeserverUrl,
loginHint = "mxid:" + elementClassicUserId.value,
)
)
}
}
@@ -83,7 +86,7 @@ class LoginWithClassicPresenter(
}
LoginWithClassicEvent.ClearError -> {
loginWithClassicAction = AsyncAction.Uninitialized
loginHelper.clearError()
loginModeState.eventSink(LoginModeEvent.ClearError)
}
}
}
@@ -94,7 +97,7 @@ class LoginWithClassicPresenter(
userId = userId,
displayName = elementClassicReady?.displayName,
avatar = elementClassicReady?.avatar,
loginMode = loginMode,
loginModeState = loginModeState,
loginWithClassicAction = loginWithClassicAction,
eventSink = ::handleEvent,
)
@@ -9,9 +9,8 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic
import android.graphics.Bitmap
import androidx.compose.runtime.Stable
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
@Stable
@@ -21,6 +20,6 @@ data class LoginWithClassicState(
val displayName: String?,
val avatar: Bitmap?,
val loginWithClassicAction: AsyncAction<Unit>,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (LoginWithClassicEvent) -> Unit,
)
@@ -9,9 +9,9 @@ package io.element.android.features.login.impl.screens.classic.loginwithclassic
import android.graphics.Bitmap
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
@@ -29,7 +29,7 @@ fun aLoginWithClassicState(
displayName: String? = null,
avatar: Bitmap? = null,
loginWithClassicAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (LoginWithClassicEvent) -> Unit = {},
) = LoginWithClassicState(
isElementPro = isElementPro,
@@ -37,6 +37,6 @@ fun aLoginWithClassicState(
displayName = displayName,
avatar = avatar,
loginWithClassicAction = loginWithClassicAction,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = eventSink,
)
@@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@@ -50,6 +51,7 @@ import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -65,9 +67,9 @@ fun LoginWithClassicView(
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
@@ -195,7 +197,7 @@ fun LoginWithClassicView(
}
)
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
state.eventSink(LoginWithClassicEvent.ClearError)
},
@@ -204,6 +206,15 @@ fun LoginWithClassicView(
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -11,20 +11,19 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch
@AssistedInject
class ConfirmAccountProviderPresenter(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
@@ -38,28 +37,26 @@ class ConfirmAccountProviderPresenter(
@Composable
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow.collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
fun handleEvent(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> localCoroutineScope.launch {
loginHelper.submit(
ConfirmAccountProviderEvents.Continue -> loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = params.isAccountCreation,
homeserverUrl = accountProvider.url,
resolvedHomeserverUrl = null,
loginHint = null,
)
}
ConfirmAccountProviderEvents.ClearError -> loginHelper.clearError()
)
ConfirmAccountProviderEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
}
}
return ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = params.isAccountCreation,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = ::handleEvent,
)
}
@@ -9,14 +9,16 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
data class ConfirmAccountProviderState(
val accountProvider: AccountProvider,
val isAccountCreation: Boolean,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
val submitEnabled: Boolean
get() = accountProvider.url.isNotEmpty() &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
}
@@ -11,7 +11,8 @@ package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.architecture.AsyncData
@@ -24,7 +25,7 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
),
aConfirmAccountProviderState(
isAccountCreation = true,
loginMode = AsyncData.Failure(AccountCreationNotSupported())
loginModeState = aLoginModeState(loginMode = AsyncData.Failure(AccountCreationNotSupported())),
),
)
}
@@ -32,11 +33,11 @@ open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<Confir
private fun aConfirmAccountProviderState(
accountProvider: AccountProvider = anAccountProvider(),
isAccountCreation: Boolean = false,
loginMode: AsyncData<LoginMode> = AsyncData.Uninitialized,
loginModeState: LoginModeState = aLoginModeState(),
eventSink: (ConfirmAccountProviderEvents) -> Unit = {},
) = ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = isAccountCreation,
loginMode = loginMode,
loginModeState = loginModeState,
eventSink = eventSink
)
@@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeView
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
@@ -31,6 +32,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -45,9 +47,9 @@ fun ConfirmAccountProviderView(
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginMode) {
val isLoading by remember(state.loginModeState.loginMode) {
derivedStateOf {
state.loginMode is AsyncData.Loading
state.loginModeState.loginMode is AsyncData.Loading
}
}
val eventSink = state.eventSink
@@ -98,7 +100,7 @@ fun ConfirmAccountProviderView(
}
) {
LoginModeView(
loginMode = state.loginMode,
loginMode = state.loginModeState.loginMode,
onClearError = {
eventSink(ConfirmAccountProviderEvents.ClearError)
},
@@ -108,6 +110,16 @@ fun ConfirmAccountProviderView(
onCreateAccountContinue = onCreateAccountContinue,
)
}
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
onSubmit = {
state.loginModeState.eventSink(LoginModeEvent.RequestLocalNetworkPermission)
},
onDismiss = {
state.loginModeState.eventSink(LoginModeEvent.DismissLocalNetworkPermission)
}
)
}
@PreviewsDayNight
@@ -25,7 +25,8 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -41,7 +42,7 @@ class OnBoardingPresenter(
private val enterpriseService: EnterpriseService,
private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val loginHelper: LoginHelper,
private val loginModePresenter: Presenter<LoginModeState>,
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
private val accountProviderDataSource: AccountProviderDataSource,
@@ -98,21 +99,23 @@ class OnBoardingPresenter(
value = sessionStore.numberOfSessions() > 0
}
val loginMode by loginHelper.collectLoginMode()
val loginModeState = loginModePresenter.present()
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
// Ensure that the current account provider is set
accountProviderDataSource.setUrl(event.defaultAccountProvider)
loginHelper.submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
)
}
OnBoardingEvents.ClearError -> loginHelper.clearError()
OnBoardingEvents.ClearError -> loginModeState.eventSink(LoginModeEvent.ClearError)
OnBoardingEvents.OnVersionClick -> {
if (canReportBug) {
if (multipleTapToUnlock.unlock(localCoroutineScope)) {
@@ -133,7 +136,7 @@ class OnBoardingPresenter(
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug && showReportBug,
loginMode = loginMode,
loginModeState = loginModeState,
version = buildMeta.versionName,
onBoardingLogoResId = onBoardingLogoResId,
eventSink = ::handleEvent,
@@ -9,7 +9,7 @@
package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
@@ -25,9 +25,10 @@ data class OnBoardingState(
val version: String,
@DrawableRes
val onBoardingLogoResId: Int?,
val loginMode: AsyncData<LoginMode>,
val loginModeState: LoginModeState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean
get() = defaultAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading)
get() = defaultAccountProvider != null &&
(loginModeState.loginMode is AsyncData.Uninitialized || loginModeState.loginMode is AsyncData.Loading)
}

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