Merge branch 'develop' of https://github.com/vector-im/element-x-android into dla/feature/custom_room_notification_settings_list
35
.github/ISSUE_TEMPLATE/story.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: User story issue
|
||||
description: Second-level planning issue template. A story should take about a week or a sprint to finish.
|
||||
title: "[Story] <title>"
|
||||
labels: [T-Story]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Story
|
||||
description: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day.
|
||||
value: |
|
||||
As a user…
|
||||
I want to…
|
||||
so that I can…
|
||||
|
||||
## Scope
|
||||
<!--These should be a list of technical tasks which take ½-1 day to complete-->
|
||||
```[tasklist]
|
||||
### Tasklist
|
||||
- [ ] Task 1
|
||||
```
|
||||
|
||||
- [ ] QA signoff on completion
|
||||
- [ ] Design signoff on completion
|
||||
- [ ] Product signoff on completion
|
||||
|
||||
|
||||
## Stretch goals
|
||||
None at this time
|
||||
<!--or add a tasklist-->
|
||||
|
||||
## Out of scope
|
||||
-
|
||||
validations:
|
||||
required: false
|
||||
4
.github/workflows/build.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
name: elementx-debug
|
||||
path: |
|
||||
app/build/outputs/apk/debug/*.apk
|
||||
- uses: rnkdsh/action-upload-diawi@v1.5.1
|
||||
- uses: rnkdsh/action-upload-diawi@v1.5.3
|
||||
id: diawi
|
||||
# Do not fail the whole build if Diawi upload fails
|
||||
continue-on-error: true
|
||||
|
||||
2
.github/workflows/danger.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- run: |
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
- name: Danger
|
||||
uses: danger/danger-js@11.2.8
|
||||
uses: danger/danger-js@11.3.0
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||
env:
|
||||
|
||||
2
.github/workflows/maestro.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
- uses: mobile-dev-inc/action-maestro-cloud@v1.5.0
|
||||
- uses: mobile-dev-inc/action-maestro-cloud@v1.6.0
|
||||
with:
|
||||
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
|
||||
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
|
||||
|
||||
2
.github/workflows/nightlyReports.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
|
||||
4
.github/workflows/quality.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run code quality check suite
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
yarn add danger-plugin-lint-report --dev
|
||||
- name: Danger lint
|
||||
if: always()
|
||||
uses: danger/danger-js@11.2.8
|
||||
uses: danger/danger-js@11.3.0
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||
env:
|
||||
|
||||
2
.github/workflows/recordScreenshots.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
java-version: '17'
|
||||
# Add gradle cache, this should speed up the process
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
|
||||
2
.github/workflows/release.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
- name: Create app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
|
||||
2
.github/workflows/sonar.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: 🔊 Publish results to Sonar
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '17'
|
||||
- name: Configure gradle
|
||||
uses: gradle/gradle-build-action@v2.8.0
|
||||
uses: gradle/gradle-build-action@v2.9.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
|
||||
1
.idea/dictionaries/shared.xml
generated
@@ -4,6 +4,7 @@
|
||||
<w>backstack</w>
|
||||
<w>ftue</w>
|
||||
<w>homeserver</w>
|
||||
<w>konsist</w>
|
||||
<w>kover</w>
|
||||
<w>measurables</w>
|
||||
<w>onboarding</w>
|
||||
|
||||
@@ -10,9 +10,9 @@ appId: ${APP_ID}
|
||||
- tapOn:
|
||||
id: "change_server-server"
|
||||
# Test server that does not support sliding sync.
|
||||
- inputText: "gnuradio"
|
||||
- inputText: "https://kieranml.ems-support.element.dev"
|
||||
- hideKeyboard
|
||||
- tapOn: "gnuradio.org"
|
||||
- tapOn: "kieranml.ems-support.element.dev"
|
||||
- extendedWaitUntil:
|
||||
visible: "This server currently doesn’t support sliding sync."
|
||||
timeout: 10000
|
||||
|
||||
63
CHANGES.md
@@ -1,3 +1,66 @@
|
||||
Changes in Element X v0.2.4 (2023-10-12)
|
||||
========================================
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- [Rich text editor] Add full screen mode ([#1447](https://github.com/vector-im/element-x-android/issues/1447))
|
||||
- Improve rendering of m.emote. ([#1497](https://github.com/vector-im/element-x-android/issues/1497))
|
||||
- Improve deleted session behavior. ([#1520](https://github.com/vector-im/element-x-android/issues/1520))
|
||||
|
||||
Bugfixes 🐛
|
||||
----------
|
||||
- WebP images can't be sent as media. ([#1483](https://github.com/vector-im/element-x-android/issues/1483))
|
||||
- Fix back button not working in bottom sheets. ([#1517](https://github.com/vector-im/element-x-android/issues/1517))
|
||||
- Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/vector-im/element-x-android/issues/1539))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/vector-im/element-x-android/issues/1457))
|
||||
- Add some Konsist tests. ([#1526](https://github.com/vector-im/element-x-android/issues/1526))
|
||||
|
||||
|
||||
Changes in Element X v0.2.3 (2023-09-27)
|
||||
========================================
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- Handle installation of Apks from the media viewer. ([#1432](https://github.com/vector-im/element-x-android/pull/1432))
|
||||
- Integrate SDK 0.1.58 ([#1437](https://github.com/vector-im/element-x-android/pull/1437))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Element call: add custom parameters to Element Call urls. ([#1434](https://github.com/vector-im/element-x-android/issues/1434))
|
||||
|
||||
|
||||
Changes in Element X v0.2.2 (2023-09-21)
|
||||
========================================
|
||||
|
||||
Bugfixes 🐛
|
||||
----------
|
||||
- Add animation when rendering the timeline to avoid glitches. ([#1323](https://github.com/vector-im/element-x-android/issues/1323))
|
||||
- Fix crash when trying to take a photo or record a video. ([#1395](https://github.com/vector-im/element-x-android/issues/1395))
|
||||
|
||||
|
||||
Changes in Element X v0.2.1 (2023-09-20)
|
||||
========================================
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- Bump Rust SDK to `v0.1.56`
|
||||
- [Rich text editor] Add link support to rich text editor ([#1309](https://github.com/vector-im/element-x-android/issues/1309))
|
||||
- Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers) ([#1382](https://github.com/vector-im/element-x-android/issues/1382))
|
||||
|
||||
Bugfixes 🐛
|
||||
----------
|
||||
- Fix ANR on RoomList when notification settings change. ([#1370](https://github.com/vector-im/element-x-android/issues/1370))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Element Call: support scheme `io.element.call` ([#1377](https://github.com/vector-im/element-x-android/issues/1377))
|
||||
- [DI] Rework how dagger components are created and provided. ([#1378](https://github.com/vector-im/element-x-android/issues/1378))
|
||||
- Remove usage of async-uniffi as it leads to a deadlocks and memory leaks. ([#1381](https://github.com/vector-im/element-x-android/issues/1381))
|
||||
|
||||
|
||||
Changes in Element X v0.2.0 (2023-09-18)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* [knit](#knit)
|
||||
* [lint](#lint)
|
||||
* [Unit tests](#unit-tests)
|
||||
* [konsist](#konsist)
|
||||
* [Tests](#tests)
|
||||
* [Accessibility](#accessibility)
|
||||
* [Jetpack Compose](#jetpack-compose)
|
||||
@@ -156,6 +157,10 @@ Make sure the following commands execute without any error:
|
||||
./gradlew test
|
||||
</pre>
|
||||
|
||||
#### konsist
|
||||
|
||||
[konsist](https://github.com/LemonAppDev/konsist) is setup in the project to check that the architecture and the naming rules are followed. Konsist tests are classical Unit tests.
|
||||
|
||||
### Tests
|
||||
|
||||
Element X is currently supported on Android Marshmallow (API 23+): please test your change on an Android device (or Android emulator) running with API 23. Many issues can happen (including crashes) on older devices.
|
||||
@@ -171,11 +176,11 @@ For instance, when updating the image `src` of an ImageView, please also conside
|
||||
|
||||
### Jetpack Compose
|
||||
|
||||
When adding or editing `@Composable`, make sure that you create an internal function annotated with `@DayNightPreviews`, with a name suffixed by `Preview`, and having `ElementPreview` as the root composable.
|
||||
When adding or editing `@Composable`, make sure that you create an internal function annotated with `@PreviewsDayNight`, with a name suffixed by `Preview`, and having `ElementPreview` as the root composable.
|
||||
|
||||
Example:
|
||||
```kotlin
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun PinIconPreview() = ElementPreview {
|
||||
PinIcon()
|
||||
|
||||
@@ -20,14 +20,16 @@
|
||||
<!-- To be able to install APK from the application -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- Do not enable enableOnBackInvokedCallback until https://issuetracker.google.com/issues/271303558 is fixed -->
|
||||
<application
|
||||
android:name=".ElementXApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ElementX"
|
||||
@@ -81,11 +83,12 @@
|
||||
tools:node="remove" />
|
||||
|
||||
<provider
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_providers" />
|
||||
</provider>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 263 KiB |
@@ -34,7 +34,7 @@ import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
|
||||
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.x.di.AppBindings
|
||||
import io.element.android.x.intent.SafeUriHandler
|
||||
|
||||
@@ -18,7 +18,7 @@ package io.element.android.x.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.tracing.TracingService
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.DefaultPreferences
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
35
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
|
||||
<!-- By default, do not allow clearText traffic -->
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Allow clearText traffic on some specified host -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<!-- Localhost -->
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<!-- Localhost for Android emulator -->
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<!-- Onion services -->
|
||||
<domain includeSubdomains="true">onion</domain>
|
||||
|
||||
<!-- Domains that are used for LANs -->
|
||||
<!-- These are IANA recognized special use domain names, see https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml -->
|
||||
<domain includeSubdomains="true">home.arpa</domain>
|
||||
<domain includeSubdomains="true">local</domain> <!-- Note this has been reserved for use with mDNS -->
|
||||
<domain includeSubdomains="true">test</domain>
|
||||
<!-- These are observed in the wild either by convention or RFCs that have not been accepted, and are not currently TLDs -->
|
||||
<domain includeSubdomains="true">home</domain>
|
||||
<domain includeSubdomains="true">lan</domain>
|
||||
<domain includeSubdomains="true">localdomain</domain>
|
||||
</domain-config>
|
||||
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
</network-security-config>
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
package io.element.android.appnav
|
||||
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
|
||||
@@ -26,8 +26,10 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.composable.PermanentChild
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
@@ -48,14 +50,18 @@ import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.invitelist.api.InviteListEntryPoint
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.features.lockscreen.api.LockScreenState
|
||||
import io.element.android.features.lockscreen.api.LockScreenStateService
|
||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.waitForChildAttached
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
||||
@@ -68,6 +74,7 @@ import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -86,6 +93,8 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val ftueState: FtueState,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
private val lockScreenStateService: LockScreenStateService,
|
||||
private val matrixClient: MatrixClient,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
||||
@@ -93,6 +102,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
initialElement = NavTarget.RoomList,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
permanentNavModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget.LoggedInPermanent, NavTarget.LockPermanent),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
@@ -121,9 +134,19 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
backstack.push(NavTarget.Ftue)
|
||||
}
|
||||
},
|
||||
onStop = {
|
||||
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
|
||||
onResume = {
|
||||
coroutineScope.launch {
|
||||
lockScreenStateService.entersForeground()
|
||||
}
|
||||
},
|
||||
onPause = {
|
||||
coroutineScope.launch {
|
||||
lockScreenStateService.entersBackground()
|
||||
}
|
||||
},
|
||||
onStop = {
|
||||
coroutineScope.launch {
|
||||
//Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
|
||||
syncService.stopSync()
|
||||
}
|
||||
},
|
||||
@@ -159,7 +182,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Permanent : NavTarget
|
||||
data object LoggedInPermanent : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LockPermanent : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomList : NavTarget
|
||||
@@ -188,9 +214,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Permanent -> {
|
||||
NavTarget.LoggedInPermanent -> {
|
||||
createNode<LoggedInNode>(buildContext)
|
||||
}
|
||||
NavTarget.LockPermanent -> {
|
||||
lockScreenEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.RoomList -> {
|
||||
val callback = object : RoomListEntryPoint.Callback {
|
||||
override fun onRoomClicked(roomId: RoomId) {
|
||||
@@ -308,30 +337,37 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = Modifier,
|
||||
// Animate navigation to settings and to a room
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
|
||||
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
|
||||
|
||||
if (!isFtueDisplayed) {
|
||||
PermanentChild(navTarget = NavTarget.Permanent)
|
||||
}
|
||||
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
|
||||
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
|
||||
backstack.singleTop(NavTarget.RoomList)
|
||||
backstack.push(NavTarget.InviteList)
|
||||
waitForChildAttached<Node, NavTarget> { navTarget ->
|
||||
navTarget is NavTarget.InviteList
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun attachRoom(deeplinkData: DeeplinkData.Room) {
|
||||
backstack.push(NavTarget.Room(deeplinkData.roomId))
|
||||
}
|
||||
|
||||
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) {
|
||||
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
|
||||
backstack.push(NavTarget.InviteList)
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
val lockScreenState by lockScreenStateService.state.collectAsState()
|
||||
when (lockScreenState) {
|
||||
LockScreenState.Unlocked -> {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = Modifier,
|
||||
// Animate navigation to settings and to a room
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
|
||||
if (!isFtueDisplayed) {
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
|
||||
}
|
||||
}
|
||||
LockScreenState.Locked -> {
|
||||
MoveActivityToBackgroundBackHandler()
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
fun MoveActivityToBackgroundBackHandler(enabled: Boolean = true) {
|
||||
|
||||
fun Context.findActivity(): ComponentActivity? = when (this) {
|
||||
is ComponentActivity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
BackHandler(enabled = enabled) {
|
||||
context.findActivity()?.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import io.element.android.appnav.root.RootView
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -54,6 +55,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -69,6 +71,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||
private val matrixClientsHolder: MatrixClientsHolder,
|
||||
private val presenter: RootPresenter,
|
||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||
private val signedOutEntryPoint: SignedOutEntryPoint,
|
||||
private val intentResolver: IntentResolver,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
) : BackstackNode<RootFlowNode.NavTarget>(
|
||||
@@ -97,13 +100,20 @@ class RootFlowNode @AssistedInject constructor(
|
||||
.distinctUntilChanged()
|
||||
.onEach { navState ->
|
||||
Timber.v("navState=$navState")
|
||||
if (navState.isLoggedIn) {
|
||||
tryToRestoreLatestSession(
|
||||
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
|
||||
onFailure = { switchToNotLoggedInFlow() }
|
||||
)
|
||||
} else {
|
||||
switchToNotLoggedInFlow()
|
||||
when (navState.loggedInState) {
|
||||
is LoggedInState.LoggedIn -> {
|
||||
if (navState.loggedInState.isTokenValid) {
|
||||
tryToRestoreLatestSession(
|
||||
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
|
||||
onFailure = { switchToNotLoggedInFlow() }
|
||||
)
|
||||
} else {
|
||||
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
|
||||
}
|
||||
}
|
||||
LoggedInState.NotLoggedIn -> {
|
||||
switchToNotLoggedInFlow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
@@ -118,6 +128,10 @@ class RootFlowNode @AssistedInject constructor(
|
||||
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
||||
}
|
||||
|
||||
private fun switchToSignedOutFlow(sessionId: SessionId) {
|
||||
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
|
||||
}
|
||||
|
||||
private suspend fun restoreSessionIfNeeded(
|
||||
sessionId: SessionId,
|
||||
onFailure: () -> Unit = {},
|
||||
@@ -179,6 +193,11 @@ class RootFlowNode @AssistedInject constructor(
|
||||
val navId: Int
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class SignedOutFlow(
|
||||
val sessionId: SessionId
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object BugReport : NavTarget
|
||||
}
|
||||
@@ -198,6 +217,15 @@ class RootFlowNode @AssistedInject constructor(
|
||||
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
}
|
||||
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext)
|
||||
is NavTarget.SignedOutFlow -> {
|
||||
signedOutEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(
|
||||
SignedOutEntryPoint.Params(
|
||||
sessionId = navTarget.sessionId
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
NavTarget.SplashScreen -> splashNode(buildContext)
|
||||
NavTarget.BugReport -> {
|
||||
val callback = object : BugReportEntryPoint.Callback {
|
||||
@@ -234,7 +262,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||
.apply {
|
||||
when (deeplinkData) {
|
||||
is DeeplinkData.Root -> attachRoot()
|
||||
is DeeplinkData.Room -> attachRoom(deeplinkData)
|
||||
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
|
||||
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ class LoggedInNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val loggedInPresenter: LoggedInPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
) : Node(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
||||
@Composable
|
||||
@@ -47,7 +47,7 @@ fun LoggedInView(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
|
||||
LoggedInView(
|
||||
|
||||
@@ -33,7 +33,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
@@ -81,7 +81,7 @@ fun SyncStateView(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SyncStateViewPreview() = ElementPreview {
|
||||
// Add a box to see the shadow
|
||||
|
||||
@@ -34,7 +34,7 @@ import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorVi
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
@@ -100,7 +100,7 @@ private fun LoadingRoomTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview {
|
||||
LoadingRoomNodeView(
|
||||
|
||||
@@ -46,6 +46,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -60,6 +61,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||
private val messagesEntryPoint: MessagesEntryPoint,
|
||||
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
roomComponentFactory: RoomComponentFactory,
|
||||
roomMembershipObserver: RoomMembershipObserver,
|
||||
) : BackstackNode<RoomLoadedFlowNode.NavTarget>(
|
||||
@@ -91,6 +93,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
|
||||
fetchRoomMembers()
|
||||
},
|
||||
onResume = {
|
||||
appCoroutineScope.launch {
|
||||
inputs.room.subscribeToSync()
|
||||
}
|
||||
},
|
||||
onPause = {
|
||||
appCoroutineScope.launch {
|
||||
inputs.room.unsubscribeFromSync()
|
||||
}
|
||||
},
|
||||
onDestroy = {
|
||||
Timber.v("OnDestroy")
|
||||
appNavigationStateService.onLeavingRoom(id)
|
||||
@@ -169,9 +181,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
||||
// because this node enters 'onDestroy' before his children, so it can leads to
|
||||
// using the room in a child node where it's already closed.
|
||||
DisposableEffect(Unit) {
|
||||
inputs.room.subscribeToSync()
|
||||
onDispose {
|
||||
inputs.room.unsubscribeFromSync()
|
||||
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
|
||||
inputs.room.destroy()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
package io.element.android.appnav.root
|
||||
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
|
||||
/**
|
||||
* [RootNavState] produced by [RootNavStateFlowFactory].
|
||||
*/
|
||||
@@ -26,7 +28,7 @@ data class RootNavState(
|
||||
*/
|
||||
val cacheIndex: Int,
|
||||
/**
|
||||
* true if we are currently loggedIn.
|
||||
* LoggedInState.
|
||||
*/
|
||||
val isLoggedIn: Boolean
|
||||
val loggedInState: LoggedInState,
|
||||
)
|
||||
|
||||
@@ -22,9 +22,9 @@ import io.element.android.appnav.di.MatrixClientsHolder
|
||||
import io.element.android.features.login.api.LoginUserStory
|
||||
import io.element.android.features.preferences.api.CacheService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
@@ -47,9 +47,14 @@ class RootNavStateFlowFactory @Inject constructor(
|
||||
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
|
||||
return combine(
|
||||
cacheIndexFlow(savedStateMap),
|
||||
isUserLoggedInFlow(),
|
||||
) { cacheIndex, isLoggedIn ->
|
||||
RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn)
|
||||
authenticationService.loggedInStateFlow(),
|
||||
loginUserStory.loginFlowIsDone,
|
||||
) { cacheIndex, loggedInState, loginFlowIsDone ->
|
||||
if (loginFlowIsDone) {
|
||||
RootNavState(cacheIndex = cacheIndex, loggedInState = loggedInState)
|
||||
} else {
|
||||
RootNavState(cacheIndex = cacheIndex, loggedInState = LoggedInState.NotLoggedIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,16 +77,6 @@ class RootNavStateFlowFactory @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isUserLoggedInFlow(): Flow<Boolean> {
|
||||
return combine(
|
||||
authenticationService.isLoggedIn(),
|
||||
loginUserStory.loginFlowIsDone
|
||||
) { isLoggedIn, loginFlowIsDone ->
|
||||
isLoggedIn && loginFlowIsDone
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a flow of integer that increments the value by one each time a new element is emitted upstream.
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@ import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
|
||||
import io.element.android.features.rageshake.api.crash.CrashDetectionView
|
||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
|
||||
import io.element.android.features.rageshake.api.detection.RageshakeDetectionView
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.services.apperror.impl.AppErrorView
|
||||
@@ -66,7 +66,7 @@ fun RootView(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RootPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
|
||||
RootView(rootState) {
|
||||
|
||||
@@ -35,6 +35,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@@ -85,6 +87,7 @@ class RoomFlowNodeTest {
|
||||
plugins: List<Plugin>,
|
||||
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
|
||||
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
|
||||
coroutineScope: CoroutineScope,
|
||||
) = RoomLoadedFlowNode(
|
||||
buildContext = BuildContext.root(savedStateMap = null),
|
||||
plugins = plugins,
|
||||
@@ -92,16 +95,21 @@ class RoomFlowNodeTest {
|
||||
roomDetailsEntryPoint = roomDetailsEntryPoint,
|
||||
appNavigationStateService = FakeAppNavigationStateService(),
|
||||
roomMembershipObserver = RoomMembershipObserver(),
|
||||
appCoroutineScope = coroutineScope,
|
||||
roomComponentFactory = FakeRoomComponentFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `given a room flow node when initialized then it loads messages entry point`() {
|
||||
fun `given a room flow node when initialized then it loads messages entry point`() = runTest {
|
||||
// GIVEN
|
||||
val room = FakeMatrixRoom()
|
||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||
val inputs = RoomLoadedFlowNode.Inputs(room)
|
||||
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint)
|
||||
val roomFlowNode = aRoomFlowNode(
|
||||
plugins = listOf(inputs),
|
||||
messagesEntryPoint = fakeMessagesEntryPoint,
|
||||
coroutineScope = this
|
||||
)
|
||||
// WHEN
|
||||
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
|
||||
|
||||
@@ -113,13 +121,18 @@ class RoomFlowNodeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() {
|
||||
fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
|
||||
// GIVEN
|
||||
val room = FakeMatrixRoom()
|
||||
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
|
||||
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
|
||||
val inputs = RoomLoadedFlowNode.Inputs(room)
|
||||
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint)
|
||||
val roomFlowNode = aRoomFlowNode(
|
||||
plugins = listOf(inputs),
|
||||
messagesEntryPoint = fakeMessagesEntryPoint,
|
||||
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
|
||||
coroutineScope = this
|
||||
)
|
||||
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
|
||||
// WHEN
|
||||
fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()
|
||||
|
||||
@@ -42,7 +42,7 @@ class RootPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createRootPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -54,7 +54,7 @@ class RootPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - passes app error state`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
val presenter = createRootPresenter(
|
||||
appErrorService = DefaultAppErrorStateService().apply {
|
||||
showError("Bad news", "Something bad happened")
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class RootPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
private fun createRootPresenter(
|
||||
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
|
||||
): RootPresenter {
|
||||
val crashDataStore = FakeCrashDataStore()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.appnav.di
|
||||
|
||||
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class MatrixClientsHolderTest {
|
||||
@Test
|
||||
fun `test getOrNull`() {
|
||||
val fakeAuthenticationService = FakeAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getOrRestore`() = runTest {
|
||||
val fakeAuthenticationService = FakeAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
// Do it again to it the cache
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test remove`() = runTest {
|
||||
val fakeAuthenticationService = FakeAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
// Remove
|
||||
matrixClientsHolder.remove(A_SESSION_ID)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test remove all`() = runTest {
|
||||
val fakeAuthenticationService = FakeAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
// Remove all
|
||||
matrixClientsHolder.removeAll()
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test save and restore`() = runTest {
|
||||
val fakeAuthenticationService = FakeAuthenticationService()
|
||||
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
|
||||
val fakeMatrixClient = FakeMatrixClient()
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
matrixClientsHolder.getOrRestore(A_SESSION_ID)
|
||||
val savedStateMap = MutableSavedStateMapImpl { true }
|
||||
matrixClientsHolder.saveIntoSavedState(savedStateMap)
|
||||
assertThat(savedStateMap.size).isEqualTo(1)
|
||||
// Test Restore with non-empty map
|
||||
matrixClientsHolder.restoreWithSavedState(savedStateMap)
|
||||
// Empty the map
|
||||
matrixClientsHolder.removeAll()
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
|
||||
// Restore again
|
||||
matrixClientsHolder.restoreWithSavedState(savedStateMap)
|
||||
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ class LoggedInPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
val presenter = createLoggedInPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -54,7 +54,7 @@ class LoggedInPresenterTest {
|
||||
@Test
|
||||
fun `present - show sync spinner`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createPresenter(roomListService, NetworkStatus.Online)
|
||||
val presenter = createLoggedInPresenter(roomListService, NetworkStatus.Online)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -66,7 +66,7 @@ class LoggedInPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
private fun createLoggedInPresenter(
|
||||
roomListService: RoomListService = FakeRoomListService(),
|
||||
networkStatus: NetworkStatus = NetworkStatus.Offline
|
||||
): LoggedInPresenter {
|
||||
|
||||
@@ -45,7 +45,7 @@ plugins {
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean").configure {
|
||||
delete(rootProject.buildDir)
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
allprojects {
|
||||
@@ -62,7 +62,7 @@ allprojects {
|
||||
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
||||
}
|
||||
dependencies {
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.2.3")
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.3.0")
|
||||
}
|
||||
|
||||
// KtLint
|
||||
@@ -86,7 +86,7 @@ allprojects {
|
||||
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
|
||||
}
|
||||
filter {
|
||||
exclude { element -> element.file.path.contains("$buildDir/generated/") }
|
||||
exclude { element -> element.file.path.contains("${layout.buildDirectory.asFile.get()}/generated/") }
|
||||
}
|
||||
}
|
||||
// Dependency check
|
||||
@@ -176,10 +176,13 @@ koverMerged {
|
||||
"*_ModuleKt",
|
||||
"anvil.hint.binding.io.element.*",
|
||||
"anvil.hint.merge.*",
|
||||
"anvil.hint.multibinding.io.element.*",
|
||||
"anvil.module.*",
|
||||
"com.airbnb.android.showkase*",
|
||||
"io.element.android.libraries.designsystem.showkase.*",
|
||||
"io.element.android.x.di.DaggerAppComponent*",
|
||||
"*_Factory",
|
||||
"*_Factory_Impl",
|
||||
"*_Factory$*",
|
||||
"*_Module",
|
||||
"*_Module$*",
|
||||
@@ -228,11 +231,11 @@ koverMerged {
|
||||
name = "Global minimum code coverage."
|
||||
target = kotlinx.kover.api.VerificationTarget.ALL
|
||||
bound {
|
||||
minValue = 55
|
||||
minValue = 60
|
||||
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
|
||||
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
|
||||
// minValue to 25 and maxValue to 35.
|
||||
maxValue = 65
|
||||
maxValue = 70
|
||||
counter = kotlinx.kover.api.CounterType.INSTRUCTION
|
||||
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
|
||||
}
|
||||
@@ -247,6 +250,10 @@ koverMerged {
|
||||
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
|
||||
// Some options can't be tested at the moment
|
||||
excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
|
||||
// Temporary until we have actually something to test.
|
||||
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter"
|
||||
excludes += "io.element.android.features.lockscreen.impl.auth.PinAuthenticationPresenter$*"
|
||||
excludes += "io.element.android.features.lockscreen.impl.create.CreatePinPresenter"
|
||||
}
|
||||
bound {
|
||||
minValue = 85
|
||||
@@ -354,7 +361,7 @@ subprojects {
|
||||
subprojects {
|
||||
tasks.withType<KspTask>() {
|
||||
doLast {
|
||||
fileTree(buildDir).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file ->
|
||||
fileTree(layout.buildDirectory).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file ->
|
||||
ReplaceRegExp().apply {
|
||||
setMatch("^public fun Showkase.getMetadata")
|
||||
setReplace("@Suppress(\"DEPRECATION\") public fun Showkase.getMetadata")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[Rich text editor] Add link support to rich text editor
|
||||
@@ -1 +0,0 @@
|
||||
Fix ANR on RoomList when notification settings change.
|
||||
@@ -1 +0,0 @@
|
||||
[DI] Rework how dagger components are created and provided.
|
||||
@@ -1 +0,0 @@
|
||||
Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers)
|
||||
1
changelog.d/1481.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Always register the pusher when application starts
|
||||
1
changelog.d/1519.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Ensure screen does not turn off when playing a video
|
||||
1
changelog.d/1563.misc
Normal file
@@ -0,0 +1 @@
|
||||
Remove usage of blocking methods.
|
||||
@@ -117,21 +117,16 @@ You can also have access to the aars through the [release](https://github.com/ma
|
||||
|
||||
#### Build the SDK locally
|
||||
|
||||
Easiest way: run the script [./tools/sdk/build_rust_sdk.sh](./tools/sdk/build_rust_sdk.sh) and just answer the questions.
|
||||
|
||||
Legacy way:
|
||||
|
||||
If you need to locally build the sdk-android you can use
|
||||
the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script.
|
||||
|
||||
For this, you first need to ensure to setup :
|
||||
For this please check the [prerequisites](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/README.md#prerequisites) from the repo.
|
||||
|
||||
- rust environment (check https://rust-lang.github.io/rustup/ if needed)
|
||||
- cargo-ndk < 2.12.0
|
||||
```shell
|
||||
cargo install cargo-ndk --version 2.11.0
|
||||
```
|
||||
- android targets
|
||||
```shell
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
|
||||
```
|
||||
- checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories
|
||||
Checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories
|
||||
```shell
|
||||
git clone git@github.com:matrix-org/matrix-rust-sdk.git
|
||||
git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git
|
||||
@@ -151,14 +146,10 @@ So for example to build the sdk against aarch64-linux-android target and copy th
|
||||
./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar
|
||||
```
|
||||
|
||||
Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`:
|
||||
|
||||
```groovy
|
||||
dependencies {
|
||||
api(projects.libraries.rustsdk) // <- use the local version of the sdk. Uncomment this line.
|
||||
//implementation(libs.matrix.sdk) // <- use the released version. Comment this line.
|
||||
}
|
||||
```
|
||||
Troubleshooting:
|
||||
- You may need to set `ANDROID_NDK_HOME` e.g `export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk`.
|
||||
- If you get the error `thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', .cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-ndk-2.11.0/src/cli.rs:345:18` try updating your Cargo NDK version. In this case, 2.11.0 is too old so `cargo install cargo-ndk` to install a newer version.
|
||||
- If you get the error `Unsupported class file major version 64` try changing your JVM version. In this case, Java 20 is not supported in Gradle yet, so downgrade to an earlier version (Java 17 worked in this case).
|
||||
|
||||
You are good to test your local rust development now!
|
||||
|
||||
@@ -280,11 +271,12 @@ Follow these steps to install and configure the plugin and templates:
|
||||
|
||||
1. Install the AS plugin for generating modules :
|
||||
[Generate Module from Template](https://plugins.jetbrains.com/plugin/13586-generate-module-from-template)
|
||||
2. Import file templates in AS :
|
||||
2. Run the script `tools/templates/generate_templates.sh` to generate the template zip file
|
||||
3. Import file templates in AS :
|
||||
- Navigate to File/Manage IDE Settings/Import Settings
|
||||
- Pick the `tools/templates/file_templates.zip` files
|
||||
- Pick the `tmp/file_templates.zip` files
|
||||
- Click on OK
|
||||
3. Configure generate-module-from-template plugin :
|
||||
4. Configure generate-module-from-template plugin :
|
||||
- Navigate to AS/Settings/Tools/Module Template Settings
|
||||
- Click on + / Import From File
|
||||
- Pick the `tools/templates/FeatureModule.json`
|
||||
|
||||
22
docs/debug_proxying.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Setup a debug mitm proxy to inspect all the app's network traffic
|
||||
|
||||
1) Install mitmproxy: `brew install mitmproxy`.
|
||||
1) Launch `mitmweb` from a terminal. It will pop up mitmproxy's web interface in a web browser.
|
||||
1) Configure Android Emulator.
|
||||
1) Launch your android emulator.
|
||||
1) Open its settings page and go to Settings -> Proxy (nb this tab isn't visible when running the emu inside the Android Studio window, you need to set it so it runs in its own window).
|
||||
1) Disable "Use Android Studio HTTP proxy settings" and pick "Manual proxy configuration".
|
||||
1) Set `127.0.0.1` as "Host name" and `8080` as "Port number".
|
||||
1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window.
|
||||
<img width="932" alt="Screenshot 2023-10-04 at 14 48 47" src="https://github.com/vector-im/element-x-android/assets/1273124/bf99a053-20b0-42a4-91d3-9602f709f684">
|
||||
1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk).
|
||||
1) Open the emulator Chrome browser app
|
||||
1) Go to the url `mitm.it`
|
||||
1) Follow the instructions to install the CA cert on Android devices.
|
||||
<img width="606" alt="Screenshot 2023-10-04 at 14 51 27" src="https://github.com/vector-im/element-x-android/assets/1273124/5f2b6f27-6958-4ea7-97fe-c7f06d105da5">
|
||||
1) Slightly modify the Element X app source code.
|
||||
1) Go to the `RustMatrixClientFactory.create()` method.
|
||||
1) Add `.disableSslVerification()` in the `ClientBuilder` method chain.
|
||||
1) Build and run the Element X app.
|
||||
1) Enjoy, you will see all the traffic in mitmproxy's web interface.
|
||||
<img width="1110" alt="Screenshot 2023-10-04 at 14 50 03" src="https://github.com/vector-im/element-x-android/assets/1273124/5d039efd-448d-426c-a384-dbbceb9f33ac">
|
||||
2
fastlane/metadata/android/en-US/changelogs/40002010.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: Element Call, design update, bugfixes
|
||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||
2
fastlane/metadata/android/en-US/changelogs/40002020.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: bugfixes
|
||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||
2
fastlane/metadata/android/en-US/changelogs/40002030.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: bugfixes.
|
||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||
2
fastlane/metadata/android/en-US/changelogs/40002040.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: bugfixes.
|
||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1024 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 934 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -24,7 +24,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.libraries.designsystem.components.LINK_TAG
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
@@ -70,7 +70,7 @@ fun AnalyticsPreferencesView(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) =
|
||||
ElementPreview {
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Poll
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -45,10 +44,10 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||
import io.element.android.features.analytics.api.Config
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.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.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
@@ -56,6 +55,7 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -147,7 +147,7 @@ private fun CheckIcon(modifier: Modifier = Modifier) {
|
||||
.size(20.dp)
|
||||
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
|
||||
.padding(2.dp),
|
||||
imageVector = Icons.Rounded.Check,
|
||||
resourceId = CommonDrawables.ic_compound_check,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
@@ -209,7 +209,7 @@ private fun AnalyticsOptInFooter(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreview {
|
||||
AnalyticsOptInView(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_prompt_data_usage">"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"Teilen Sie anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Bedingungen lesen%1$s."</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Bedingungen lesen %1$s."</string>
|
||||
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
|
||||
<string name="screen_analytics_prompt_settings">"Du kannst diese Funktion jederzeit deaktivieren"</string>
|
||||
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben deine Daten nicht an Dritte weiter"</string>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_analytics_prompt_data_usage">"我們不會紀錄或剖繪您的個人資料"</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"分享匿名的使用數據以協助我們釐清問題"</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"您可以到 %1$s 閱讀我們的條款。"</string>
|
||||
<string name="screen_analytics_prompt_help_us_improve">"分享匿名的使用數據以協助我們釐清問題。"</string>
|
||||
<string name="screen_analytics_prompt_read_terms">"您可以到%1$s閱讀我們的條款。"</string>
|
||||
<string name="screen_analytics_prompt_read_terms_content_link">"這裡"</string>
|
||||
<string name="screen_analytics_prompt_settings">"您可以在任何時候關閉它"</string>
|
||||
<string name="screen_analytics_prompt_third_party_sharing">"我們不會和第三方分享您的資料"</string>
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
|
||||
<data android:host="call.element.io" />
|
||||
@@ -53,6 +52,14 @@
|
||||
<data android:scheme="element" />
|
||||
<data android:host="call" />
|
||||
</intent-filter>
|
||||
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="io.element.call" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
<service android:name=".CallForegroundService" android:enabled="true" android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
@@ -17,29 +17,88 @@
|
||||
package io.element.android.features.call
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.URLDecoder
|
||||
import javax.inject.Inject
|
||||
|
||||
object CallIntentDataParser {
|
||||
class CallIntentDataParser @Inject constructor() {
|
||||
|
||||
private val validHttpSchemes = sequenceOf("http", "https")
|
||||
private val validHttpSchemes = sequenceOf("https")
|
||||
|
||||
fun parse(data: String?): String? {
|
||||
val parsedUrl = data?.let { Uri.parse(data) } ?: return null
|
||||
val scheme = parsedUrl.scheme
|
||||
return when {
|
||||
scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> data
|
||||
scheme in validHttpSchemes && parsedUrl.host == "call.element.io" -> parsedUrl
|
||||
scheme == "element" && parsedUrl.host == "call" -> {
|
||||
// We use this custom scheme to load arbitrary URLs for other instances of Element Call,
|
||||
// so we can only verify it's an HTTP/HTTPs URL with a non-empty host
|
||||
parsedUrl.getQueryParameter("url")
|
||||
?.let { URLDecoder.decode(it, "utf-8") }
|
||||
?.takeIf {
|
||||
val internalUri = Uri.parse(it)
|
||||
internalUri.scheme in validHttpSchemes && !internalUri.host.isNullOrBlank()
|
||||
}
|
||||
parsedUrl.getUrlParameter()
|
||||
}
|
||||
scheme == "io.element.call" && parsedUrl.host == null -> {
|
||||
// We use this custom scheme to load arbitrary URLs for other instances of Element Call,
|
||||
// so we can only verify it's an HTTP/HTTPs URL with a non-empty host
|
||||
parsedUrl.getUrlParameter()
|
||||
}
|
||||
// This should never be possible, but we still need to take into account the possibility
|
||||
else -> null
|
||||
}
|
||||
}?.withCustomParameters()
|
||||
}
|
||||
|
||||
private fun Uri.getUrlParameter(): Uri? {
|
||||
return getQueryParameter("url")
|
||||
?.let { urlParameter ->
|
||||
Uri.parse(urlParameter).takeIf { uri ->
|
||||
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the uri has the following parameters and value in the fragment:
|
||||
* - appPrompt=false
|
||||
* - confineToRoom=true
|
||||
* to ensure that the rendering will bo correct on the embedded Webview.
|
||||
*/
|
||||
private fun Uri.withCustomParameters(): String {
|
||||
val builder = buildUpon()
|
||||
// Remove the existing query parameters
|
||||
builder.clearQuery()
|
||||
queryParameterNames.forEach {
|
||||
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
|
||||
builder.appendQueryParameter(it, getQueryParameter(it))
|
||||
}
|
||||
// Remove the existing fragment parameters, and build the new fragment
|
||||
val currentFragment = fragment ?: ""
|
||||
// Reset the current fragment
|
||||
builder.fragment("")
|
||||
val queryFragmentPosition = currentFragment.lastIndexOf("?")
|
||||
val newFragment = if (queryFragmentPosition == -1) {
|
||||
// No existing query, build it.
|
||||
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
|
||||
} else {
|
||||
buildString {
|
||||
append(currentFragment.substring(0, queryFragmentPosition + 1))
|
||||
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
|
||||
// Replace the existing parameters
|
||||
val newQueryFragment = queryFragment
|
||||
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
|
||||
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
|
||||
append(newQueryFragment)
|
||||
// Ensure the parameters are there
|
||||
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
|
||||
if (newQueryFragment.isNotEmpty()) {
|
||||
append("&")
|
||||
}
|
||||
append("$APP_PROMPT_PARAMETER=false")
|
||||
}
|
||||
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
|
||||
append("&$CONFINE_TO_ROOM_PARAMETER=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
// We do not want to encode the Fragment part, so append it manually
|
||||
return builder.build().toString() + "#" + newFragment
|
||||
}
|
||||
|
||||
private const val APP_PROMPT_PARAMETER = "appPrompt"
|
||||
private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"
|
||||
|
||||
@@ -24,8 +24,6 @@ import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -33,10 +31,11 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
|
||||
typealias RequestPermissionCallback = (Array<String>) -> Unit
|
||||
@@ -58,7 +57,7 @@ internal fun CallScreenView(
|
||||
title = { Text(stringResource(R.string.element_call)) },
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
imageVector = Icons.Default.Close,
|
||||
resourceId = CommonDrawables.ic_compound_close,
|
||||
onClick = onClose
|
||||
)
|
||||
}
|
||||
@@ -138,7 +137,7 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CallScreenViewPreview() {
|
||||
ElementTheme {
|
||||
|
||||
@@ -39,6 +39,7 @@ import javax.inject.Inject
|
||||
class ElementCallActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var userAgentProvider: UserAgentProvider
|
||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
|
||||
@@ -129,7 +130,7 @@ class ElementCallActivity : ComponentActivity() {
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
|
||||
private fun parseUrl(url: String?): String? = CallIntentDataParser.parse(url)
|
||||
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
|
||||
|
||||
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
|
||||
return registerForActivityResult(
|
||||
|
||||
6
features/call/src/main/res/values-cs/translations.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="call_foreground_service_channel_title_android">"Probíhající hovor"</string>
|
||||
<string name="call_foreground_service_message_android">"Klepněte pro návrat k hovoru"</string>
|
||||
<string name="call_foreground_service_title_android">"☎️ Probíhá hovor"</string>
|
||||
</resources>
|
||||
6
features/call/src/main/res/values-ru/translations.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="call_foreground_service_channel_title_android">"Текущий вызов"</string>
|
||||
<string name="call_foreground_service_message_android">"Коснитесь, чтобы вернуться к вызову"</string>
|
||||
<string name="call_foreground_service_title_android">"Идёт вызов"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="call_foreground_service_message_android">"點擊以返回到通話頁面"</string>
|
||||
<string name="call_foreground_service_title_android">"☎️ 通話中"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.net.URLEncoder
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CallIntentDataParserTest {
|
||||
|
||||
private val callIntentDataParser = CallIntentDataParser()
|
||||
|
||||
@Test
|
||||
fun `a null data returns null`() {
|
||||
val url: String? = null
|
||||
assertThat(callIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty data returns null`() {
|
||||
doTest("", null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid data returns null`() {
|
||||
doTest("!", null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data with no scheme returns null`() {
|
||||
doTest("test", null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call http urls returns null`() {
|
||||
doTest("http://call.element.io", null)
|
||||
doTest("http://call.element.io/some-actual-call?with=parameters", null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call urls will be returned as is`() {
|
||||
doTest(
|
||||
url = "https://call.element.io",
|
||||
expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url param gets url extracted`() {
|
||||
doTest(
|
||||
url = VALID_CALL_URL_WITH_PARAM,
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP and HTTPS urls that don't come from EC return null`() {
|
||||
doTest("http://app.element.io", null)
|
||||
doTest("https://app.element.io", null, testEmbedded = false)
|
||||
doTest("http://", null)
|
||||
doTest("https://", null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with no url returns null`() {
|
||||
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
|
||||
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
|
||||
val url = "io.element.call:/?no_url=$encodedUrl"
|
||||
assertThat(callIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element scheme with no call host returns null`() {
|
||||
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
|
||||
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
|
||||
val url = "element://no-call?url=$encodedUrl"
|
||||
assertThat(callIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element scheme with no data returns null`() {
|
||||
val url = "element://call?url="
|
||||
assertThat(callIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with no data returns null`() {
|
||||
val url = "io.element.call:/?url="
|
||||
assertThat(callIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element invalid scheme returns null`() {
|
||||
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
|
||||
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
|
||||
val url = "bad.scheme:/?url=$encodedUrl"
|
||||
assertThat(callIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url extra param appPrompt gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}&appPrompt=true",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#?appPrompt=true",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#?appPrompt=true&otherParam=maybe",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}&confineToRoom=false",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#?confineToRoom=false",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#?confineToRoom=false&otherParam=maybe",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url fragment gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#fragment",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url fragment with params gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#fragment?otherParam=maybe",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with url fragment with other params gets url extracted`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#?otherParam=maybe",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with empty fragment`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call url with empty fragment query`() {
|
||||
doTest(
|
||||
url = "${VALID_CALL_URL_WITH_PARAM}#?",
|
||||
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
|
||||
)
|
||||
}
|
||||
|
||||
private fun doTest(url: String, expectedResult: String?, testEmbedded: Boolean = true) {
|
||||
// Test direct parsing
|
||||
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
|
||||
|
||||
if (testEmbedded) {
|
||||
// Test embedded url, scheme 1
|
||||
val encodedUrl = URLEncoder.encode(url, "utf-8")
|
||||
val urlScheme1 = "element://call?url=$encodedUrl"
|
||||
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
|
||||
|
||||
// Test embedded url, scheme 2
|
||||
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
|
||||
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
|
||||
const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.call
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.net.URLEncoder
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CallIntentDataParserTests {
|
||||
|
||||
@Test
|
||||
fun `a null data returns null`() {
|
||||
val url: String? = null
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty data returns null`() {
|
||||
val url = ""
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid data returns null`() {
|
||||
val url = "!"
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data with no scheme returns null`() {
|
||||
val url = "test"
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Element Call urls will be returned as is`() {
|
||||
val httpBaseUrl = "http://call.element.io"
|
||||
val httpCallUrl = "http://call.element.io/some-actual-call?with=parameters"
|
||||
val httpsBaseUrl = "https://call.element.io"
|
||||
val httpsCallUrl = "https://call.element.io/some-actual-call?with=parameters"
|
||||
assertThat(CallIntentDataParser.parse(httpBaseUrl)).isEqualTo(httpBaseUrl)
|
||||
assertThat(CallIntentDataParser.parse(httpCallUrl)).isEqualTo(httpCallUrl)
|
||||
assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isEqualTo(httpsBaseUrl)
|
||||
assertThat(CallIntentDataParser.parse(httpsCallUrl)).isEqualTo(httpsCallUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HTTP and HTTPS urls that don't come from EC return null`() {
|
||||
val httpBaseUrl = "http://app.element.io"
|
||||
val httpsBaseUrl = "https://app.element.io"
|
||||
val httpInvalidUrl = "http://"
|
||||
val httpsInvalidUrl = "http://"
|
||||
assertThat(CallIntentDataParser.parse(httpBaseUrl)).isNull()
|
||||
assertThat(CallIntentDataParser.parse(httpsBaseUrl)).isNull()
|
||||
assertThat(CallIntentDataParser.parse(httpInvalidUrl)).isNull()
|
||||
assertThat(CallIntentDataParser.parse(httpsInvalidUrl)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element scheme with call host and url param gets url extracted`() {
|
||||
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
|
||||
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
|
||||
val url = "element://call?url=$encodedUrl"
|
||||
assertThat(CallIntentDataParser.parse(url)).isEqualTo(embeddedUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element scheme with call host and no url param returns null`() {
|
||||
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
|
||||
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
|
||||
val url = "element://call?no-url=$encodedUrl"
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element scheme with no call host returns null`() {
|
||||
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
|
||||
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
|
||||
val url = "element://no-call?url=$encodedUrl"
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `element scheme with no data returns null`() {
|
||||
val url = "element://call?url="
|
||||
assertThat(CallIntentDataParser.parse(url)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ dependencies {
|
||||
implementation(projects.libraries.deeplink)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.usersearch.impl)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.coil.compose)
|
||||
@@ -64,6 +65,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import io.element.android.features.createroom.impl.components.UserListView
|
||||
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
@@ -84,7 +84,7 @@ fun AddPeopleView(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddPeopleViewTopBar(
|
||||
private fun AddPeopleViewTopBar(
|
||||
hasSelectedUsers: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
@@ -109,7 +109,7 @@ fun AddPeopleViewTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AddPeopleViewPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreview {
|
||||
AddPeopleView(state = state)
|
||||
|
||||
@@ -31,7 +31,7 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
|
||||
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.RadioButton
|
||||
@@ -57,7 +57,7 @@ fun RoomPrivacyOption(
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
imageVector = roomPrivacyItem.icon,
|
||||
resourceId = roomPrivacyItem.icon,
|
||||
contentDescription = "",
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
@@ -90,7 +90,7 @@ fun RoomPrivacyOption(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomPrivacyOptionPreview() = ElementPreview {
|
||||
val aRoomPrivacyItem = roomPrivacyItems().first()
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
@@ -76,7 +76,7 @@ fun UserListView(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UserListViewPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreview {
|
||||
UserListView(state = state)
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -40,6 +41,8 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -52,10 +55,15 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val analyticsService: AnalyticsService,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<ConfigureRoomState> {
|
||||
|
||||
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||
private var pendingPermissionRequest = false
|
||||
|
||||
@Composable
|
||||
override fun present(): ConfigureRoomState {
|
||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
|
||||
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||
@@ -75,6 +83,13 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
|
||||
pendingPermissionRequest = false
|
||||
cameraPhotoPicker.launch()
|
||||
}
|
||||
}
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
|
||||
|
||||
@@ -93,7 +108,12 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
is ConfigureRoomEvents.HandleAvatarAction -> {
|
||||
when (event.action) {
|
||||
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
|
||||
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
|
||||
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
|
||||
cameraPhotoPicker.launch()
|
||||
} else {
|
||||
pendingPermissionRequest = true
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +126,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
config = createRoomConfig.value,
|
||||
avatarActions = avatarActions,
|
||||
createRoomAction = createRoomAction.value,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ConfigureRoomState(
|
||||
val config: CreateRoomConfig,
|
||||
val avatarActions: ImmutableList<AvatarAction>,
|
||||
val createRoomAction: Async<RoomId>,
|
||||
val cameraPermissionState: PermissionsState,
|
||||
val eventSink: (ConfigureRoomEvents) -> Unit
|
||||
) {
|
||||
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
|
||||
@@ -41,5 +42,6 @@ fun aConfigureRoomState() = ConfigureRoomState(
|
||||
config = CreateRoomConfig(),
|
||||
avatarActions = persistentListOf(),
|
||||
createRoomAction = Async.Uninitialized,
|
||||
cameraPermissionState = aPermissionsState(showDialog = false),
|
||||
eventSink = { },
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ import io.element.android.libraries.designsystem.components.LabelledTextField
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
@@ -65,6 +65,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
|
||||
import io.element.android.libraries.permissions.api.PermissionsView
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -73,9 +74,9 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun ConfigureRoomView(
|
||||
state: ConfigureRoomState,
|
||||
onBackPressed: () -> Unit,
|
||||
onRoomCreated: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
onRoomCreated: (RoomId) -> Unit = {},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -172,11 +173,15 @@ fun ConfigureRoomView(
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
PermissionsView(
|
||||
state = state.cameraPermissionState,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfigureRoomToolbar(
|
||||
private fun ConfigureRoomToolbar(
|
||||
isNextActionEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackPressed: () -> Unit = {},
|
||||
@@ -202,7 +207,7 @@ fun ConfigureRoomToolbar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoomNameWithAvatar(
|
||||
private fun RoomNameWithAvatar(
|
||||
avatarUri: Uri?,
|
||||
roomName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -230,7 +235,7 @@ fun RoomNameWithAvatar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoomTopic(
|
||||
private fun RoomTopic(
|
||||
topic: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onTopicChanged: (String) -> Unit = {},
|
||||
@@ -249,7 +254,7 @@ fun RoomTopic(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoomPrivacyOptions(
|
||||
private fun RoomPrivacyOptions(
|
||||
selected: RoomPrivacy?,
|
||||
modifier: Modifier = Modifier,
|
||||
onOptionSelected: (RoomPrivacyItem) -> Unit = {},
|
||||
@@ -273,10 +278,12 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
|
||||
})
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {
|
||||
ConfigureRoomView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
onRoomCreated = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,19 +16,17 @@
|
||||
|
||||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class RoomPrivacyItem(
|
||||
val privacy: RoomPrivacy,
|
||||
val icon: ImageVector,
|
||||
@DrawableRes val icon: Int,
|
||||
val title: String,
|
||||
val description: String,
|
||||
)
|
||||
@@ -40,13 +38,13 @@ fun roomPrivacyItems(): ImmutableList<RoomPrivacyItem> {
|
||||
when (it) {
|
||||
RoomPrivacy.Private -> RoomPrivacyItem(
|
||||
privacy = it,
|
||||
icon = Icons.Outlined.Lock,
|
||||
icon = CommonDrawables.ic_compound_lock,
|
||||
title = stringResource(R.string.screen_create_room_private_option_title),
|
||||
description = stringResource(R.string.screen_create_room_private_option_description),
|
||||
)
|
||||
RoomPrivacy.Public -> RoomPrivacyItem(
|
||||
privacy = it,
|
||||
icon = Icons.Outlined.Public,
|
||||
icon = CommonDrawables.ic_compound_public,
|
||||
title = stringResource(R.string.screen_create_room_public_option_title),
|
||||
description = stringResource(R.string.screen_create_room_public_option_description),
|
||||
)
|
||||
|
||||
@@ -27,8 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -41,17 +39,17 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.R
|
||||
import io.element.android.features.createroom.impl.components.UserListView
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.VectorIcons
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -128,7 +126,7 @@ fun CreateRoomRootView(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateRoomRootViewTopBar(
|
||||
private fun CreateRoomRootViewTopBar(
|
||||
modifier: Modifier = Modifier,
|
||||
onClosePressed: () -> Unit = {},
|
||||
) {
|
||||
@@ -141,19 +139,16 @@ fun CreateRoomRootViewTopBar(
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClosePressed) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(id = CommonStrings.action_close),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
BackButton(
|
||||
resourceId = CommonDrawables.ic_compound_close,
|
||||
onClick = onClosePressed,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRoomActionButtonsList(
|
||||
private fun CreateRoomActionButtonsList(
|
||||
state: CreateRoomRootState,
|
||||
modifier: Modifier = Modifier,
|
||||
onNewRoomClicked: () -> Unit = {},
|
||||
@@ -161,12 +156,12 @@ fun CreateRoomActionButtonsList(
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
CreateRoomActionButton(
|
||||
iconRes = VectorIcons.Groups,
|
||||
iconRes = CommonDrawables.ic_groups,
|
||||
text = stringResource(id = R.string.screen_create_room_action_create_room),
|
||||
onClick = onNewRoomClicked,
|
||||
)
|
||||
CreateRoomActionButton(
|
||||
iconRes = VectorIcons.Share,
|
||||
iconRes = CommonDrawables.ic_compound_share_android,
|
||||
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
|
||||
onClick = onInvitePeopleClicked,
|
||||
)
|
||||
@@ -174,7 +169,7 @@ fun CreateRoomActionButtonsList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateRoomActionButton(
|
||||
private fun CreateRoomActionButton(
|
||||
@DrawableRes iconRes: Int,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -202,7 +197,7 @@ fun CreateRoomActionButton(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) =
|
||||
ElementPreview {
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<string name="screen_create_room_action_invite_people">"邀請朋友使用 Element"</string>
|
||||
<string name="screen_create_room_add_people_title">"邀請夥伴"</string>
|
||||
<string name="screen_create_room_error_creating_room">"建立聊天室時發生錯誤"</string>
|
||||
<string name="screen_create_room_private_option_description">"聊天室裡的訊息會被加密。聊天室建立後,無法停用加密功能。"</string>
|
||||
<string name="screen_create_room_private_option_title">"私密聊天室(僅限邀請)"</string>
|
||||
<string name="screen_create_room_public_option_description">"訊息未加密,任何人都可以查看。您可以在之後啟用加密功能。"</string>
|
||||
<string name="screen_create_room_public_option_title">"公開聊天室(任何人)"</string>
|
||||
<string name="screen_create_room_room_name_label">"聊天室名稱"</string>
|
||||
<string name="screen_create_room_topic_label">"主題(非必填)"</string>
|
||||
<string name="screen_create_room_title">"建立聊天室"</string>
|
||||
|
||||
@@ -37,6 +37,8 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.mockk.every
|
||||
@@ -55,6 +57,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
|
||||
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
|
||||
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
|
||||
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@@ -70,6 +73,7 @@ class ConfigureRoomPresenterTests {
|
||||
private lateinit var fakePickerProvider: FakePickerProvider
|
||||
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
|
||||
private lateinit var fakeAnalyticsService: FakeAnalyticsService
|
||||
private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
@@ -79,12 +83,14 @@ class ConfigureRoomPresenterTests {
|
||||
fakePickerProvider = FakePickerProvider()
|
||||
fakeMediaPreProcessor = FakeMediaPreProcessor()
|
||||
fakeAnalyticsService = FakeAnalyticsService()
|
||||
fakePermissionsPresenter = FakePermissionsPresenter()
|
||||
presenter = ConfigureRoomPresenter(
|
||||
dataStore = createRoomDataStore,
|
||||
matrixClient = fakeMatrixClient,
|
||||
mediaPickerProvider = fakePickerProvider,
|
||||
mediaPreProcessor = fakeMediaPreProcessor,
|
||||
analyticsService = fakeAnalyticsService,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter),
|
||||
)
|
||||
|
||||
mockkStatic(File::readBytes)
|
||||
@@ -170,8 +176,6 @@ class ConfigureRoomPresenterTests {
|
||||
// Room avatar
|
||||
// Pick avatar
|
||||
fakePickerProvider.givenResult(null)
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
// From gallery
|
||||
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
|
||||
fakePickerProvider.givenResult(uriFromGallery)
|
||||
@@ -182,10 +186,23 @@ class ConfigureRoomPresenterTests {
|
||||
// From camera
|
||||
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
|
||||
fakePickerProvider.givenResult(uriFromCamera)
|
||||
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
newState = awaitItem()
|
||||
assertThat(newState.cameraPermissionState.showDialog).isTrue()
|
||||
fakePermissionsPresenter.setPermissionGranted()
|
||||
newState = awaitItem()
|
||||
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
|
||||
newState = awaitItem()
|
||||
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
|
||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||
// Do it again, no permission is requested
|
||||
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
|
||||
fakePickerProvider.givenResult(uriFromCamera2)
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
newState = awaitItem()
|
||||
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
|
||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||
// Remove
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
newState = awaitItem()
|
||||
|
||||
@@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
||||
@Composable
|
||||
@@ -45,7 +45,7 @@ fun MigrationScreenView(
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MigrationViewPreview() = ElementPreview {
|
||||
MigrationScreenView(
|
||||
|
||||
@@ -65,7 +65,7 @@ class NotificationsOptInPresenter @AssistedInject constructor(
|
||||
if (notificationsPermissionsState.permissionGranted) {
|
||||
callback.onNotificationsOptInFinished()
|
||||
} else {
|
||||
notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog)
|
||||
notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
}
|
||||
NotificationsOptInEvents.NotNowClicked -> {
|
||||
|
||||
@@ -28,6 +28,6 @@ open class NotificationsOptInStateProvider : PreviewParameterProvider<Notificati
|
||||
}
|
||||
|
||||
fun aNotificationsOptInState() = NotificationsOptInState(
|
||||
notificationsPermissionState = aPermissionsState(),
|
||||
notificationsPermissionState = aPermissionsState(showDialog = false),
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,11 +43,12 @@ import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@@ -80,7 +79,7 @@ private fun NotificationsOptInHeader(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.screen_notification_optin_title),
|
||||
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
|
||||
iconImageVector = Icons.Default.Notifications,
|
||||
iconResourceId = CommonDrawables.ic_compound_notifications_solid,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,7 +182,7 @@ private fun NotificationRow(
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun NotificationsOptInViewPreview(
|
||||
@PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState
|
||||
|
||||