Merge branch 'develop' of https://github.com/vector-im/element-x-android into dla/feature/connect_sdk_to_global_notifications_ui

This commit is contained in:
David Langley 2023-09-12 16:30:36 +01:00
commit c3fbac4678
686 changed files with 7212 additions and 2257 deletions

View file

@ -27,7 +27,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}-{1}', matrix.variant, github.sha) || format('build-{0}-{1}', matrix.variant, github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881

View file

@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
name: Danger main check
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger

View file

@ -8,7 +8,7 @@ jobs:
update-gradle-wrapper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Update Gradle Wrapper
uses: gradle-update/update-gradle-wrapper-action@v1
# Skip in forks

View file

@ -11,5 +11,5 @@ jobs:
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v1

View file

@ -24,7 +24,7 @@ jobs:
group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -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.4.1
- uses: mobile-dev-inc/action-maestro-cloud@v1.5.0
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):

View file

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use JDK 17
uses: actions/setup-java@v3
with:

View file

@ -55,7 +55,7 @@ jobs:
name: Dependency analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use JDK 17
uses: actions/setup-java@v3
with:

View file

@ -17,7 +17,7 @@ jobs:
name: Search for forbidden patterns
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Run code quality check suite
run: ./tools/check/check_code_quality.sh
@ -29,7 +29,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -52,12 +52,6 @@ jobs:
name: linting-report
path: |
*/build/reports/**/*.*
- name: 🔊 Publish results to Sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
- name: Prepare Danger
if: always()
run: |

View file

@ -18,7 +18,7 @@ jobs:
group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use JDK 17
uses: actions/setup-java@v3
with:

View file

@ -1,4 +1,4 @@
name: Code Quality Checks
name: Sonar
on:
workflow_dispatch:
@ -10,18 +10,18 @@ on:
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --no-daemon --warn
jobs:
sonar:
name: Project Check Suite
name: Sonar Quality Checks
runs-on: ubuntu-latest
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('sonar-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('sonar-develop-{0}', github.sha) || format('sonar-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@ -41,9 +41,3 @@ jobs:
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
- name: Prepare Danger
if: always()
run: |
npm install --save-dev @babel/core
npm install --save-dev @babel/plugin-transform-flow-strip-types
yarn add danger-plugin-lint-report --dev

View file

@ -11,7 +11,7 @@ jobs:
# Skip in forks
if: github.repository == 'vector-im/element-x-android'
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:

View file

@ -22,6 +22,16 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
cancel-in-progress: true
steps:
# Increase swapfile size to prevent screenshot tests getting terminated
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
- name: 💽 Increase swapfile size
run: |
sudo swapoff -a
sudo fallocate -l 8G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
sudo swapon --show
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
with:

View file

@ -0,0 +1,13 @@
appId: ${APP_ID}
---
- takeScreenshot: build/maestro/530-Timeline
- tapOn: "Add attachment"
- tapOn: "Poll"
- tapOn: "What is the poll about?"
- inputText: "I am a poll"
- tapOn: "Option 1"
- inputText: "Answer 1"
- tapOn: "Option 2"
- inputText: "Answer 2"
- tapOn: "Create"
- takeScreenshot: build/maestro/531-Timeline

View file

@ -5,5 +5,6 @@ appId: ${APP_ID}
- takeScreenshot: build/maestro/500-Timeline
- runFlow: messages/text.yaml
- runFlow: messages/location.yaml
- runFlow: messages/poll.yaml
- back
- runFlow: ../../assertions/assertHomeDisplayed.yaml

View file

@ -1,3 +1,26 @@
Changes in Element X v0.1.6 (2023-09-04)
========================================
Features ✨
----------
- Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/vector-im/element-x-android/issues/1196))
- Create poll. ([#1143](https://github.com/vector-im/element-x-android/issues/1143))
Bugfixes 🐛
----------
- Ensure notification for Event from encrypted room get decrypted content. ([#1178](https://github.com/vector-im/element-x-android/issues/1178))
- Make sure Snackbars are only displayed once. ([#928](https://github.com/vector-im/element-x-android/issues/928))
- Fix the orientation of sent images. ([#1135](https://github.com/vector-im/element-x-android/issues/1135))
- Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/vector-im/element-x-android/issues/1168))
- Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/vector-im/element-x-android/issues/1177))
- Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/vector-im/element-x-android/issues/1198))
- Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/vector-im/element-x-android/issues/1995))
Other changes
-------------
- Remove unnecessary year in copyright mention. ([#1187](https://github.com/vector-im/element-x-android/issues/1187))
Changes in Element X v0.1.5 (2023-08-28)
========================================

View file

@ -218,7 +218,7 @@ dependencies {
implementation(libs.network.okhttp.logging)
implementation(libs.serialization.json)
implementation(libs.vanniktech.emoji)
implementation(libs.matrix.emojibase.bindings)
implementation(libs.dagger)
kapt(libs.dagger.compiler)

View file

@ -22,7 +22,7 @@
<application
android:name=".ElementXApplication"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View file

@ -23,7 +23,6 @@ import io.element.android.x.di.AppComponent
import io.element.android.x.di.DaggerAppComponent
import io.element.android.x.info.logApplicationInfo
import io.element.android.x.initializer.CrashInitializer
import io.element.android.x.initializer.EmojiInitializer
import io.element.android.x.initializer.TracingInitializer
class ElementXApplication : Application(), DaggerComponentOwner {
@ -39,7 +38,6 @@ class ElementXApplication : Application(), DaggerComponentOwner {
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
initializeComponent(TracingInitializer::class.java)
initializeComponent(EmojiInitializer::class.java)
}
logApplicationInfo()
}

View file

@ -23,6 +23,8 @@ import androidx.preference.PreferenceManager
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.features.messages.impl.timeline.components.customreaction.DefaultEmojibaseProvider
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@ -105,4 +107,10 @@ object AppModule {
fun provideSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher()
}
@Provides
@SingleIn(AppScope::class)
fun providesEmojibaseProvider(@ApplicationContext context: Context): EmojibaseProvider {
return DefaultEmojibaseProvider(context)
}
}

View file

@ -17,7 +17,11 @@
package io.element.android.x.initializer
import android.content.Context
import android.system.Os
import androidx.preference.PreferenceManager
import androidx.startup.Initializer
import io.element.android.features.preferences.impl.developer.tracing.SharedPrefTracingConfigurationStore
import io.element.android.features.preferences.impl.developer.tracing.TargetLogLevelMapBuilder
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations
@ -34,8 +38,11 @@ class TracingInitializer : Initializer<Unit> {
val bugReporter = appBindings.bugReporter()
Timber.plant(tracingService.createTimberTree())
val tracingConfiguration = if (BuildConfig.DEBUG) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val store = SharedPrefTracingConfigurationStore(prefs)
val builder = TargetLogLevelMapBuilder(store)
TracingConfiguration(
filterConfiguration = TracingFilterConfigurations.debug,
filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()),
writesToLogcat = true,
writesToFilesConfiguration = WriteToFilesConfiguration.Disabled
)
@ -51,6 +58,8 @@ class TracingInitializer : Initializer<Unit> {
}
bugReporter.cleanLogDirectoryIfNeeded()
tracingService.setupTracing(tracingConfiguration)
// Also set env variable for rust back trace
Os.setenv("RUST_BACKTRACE", "1", true)
}
override fun dependencies(): List<Class<out Initializer<*>>> = mutableListOf()

View file

@ -15,15 +15,8 @@
-->
<!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
All backup is disabled since it would clash with encryption.
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
<exclude domain="root" path="." />
</full-backup-content>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2022 New Vector Ltd
~ 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.
@ -15,21 +15,13 @@
-->
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
All backup is disabled since it would clash with encryption.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
<exclude domain="root" path="." />
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
<exclude domain="root" path="." />
</device-transfer>
-->
</data-extraction-rules>

View file

@ -48,8 +48,6 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(libs.coil)

View file

@ -32,6 +32,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.onboarding.api.OnBoardingEntryPoint
import io.element.android.features.preferences.api.ConfigureTracingEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.di.AppScope
@ -43,6 +44,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val onBoardingEntryPoint: OnBoardingEntryPoint,
private val configureTracingEntryPoint: ConfigureTracingEntryPoint,
private val loginEntryPoint: LoginEntryPoint,
private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory,
) : BackstackNode<NotLoggedInFlowNode.NavTarget>(
@ -70,6 +72,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
data class LoginFlow(
val isAccountCreation: Boolean,
) : NavTarget
@Parcelize
data object ConfigureTracing : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -83,6 +88,10 @@ class NotLoggedInFlowNode @AssistedInject constructor(
override fun onSignIn() {
backstack.push(NavTarget.LoginFlow(isAccountCreation = false))
}
override fun onOpenDeveloperSettings() {
backstack.push(NavTarget.ConfigureTracing)
}
}
onBoardingEntryPoint
.nodeBuilder(this, buildContext)
@ -94,6 +103,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
.params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation))
.build()
}
NavTarget.ConfigureTracing -> {
configureTracingEntryPoint.createNode(this, buildContext)
}
}
}

View file

@ -16,44 +16,26 @@
package io.element.android.appnav.loggedin
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.delay
import javax.inject.Inject
private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
) : Presenter<LoggedInState> {
private val postNotificationPermissionsPresenter by lazy {
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else {
NoopPermissionsPresenter()
}
}
@Composable
override fun present(): LoggedInState {
LaunchedEffect(Unit) {
@ -64,25 +46,15 @@ class LoggedInPresenter @Inject constructor(
pushService.registerWith(matrixClient, pushProvider, distributor)
}
val roomListState by matrixClient.roomListService.state.collectAsState()
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState()
val permissionsState = postNotificationPermissionsPresenter.present()
var showSyncSpinner by remember {
mutableStateOf(false)
}
LaunchedEffect(roomListState, networkStatus) {
showSyncSpinner = when {
networkStatus == NetworkStatus.Offline -> false
roomListState == RoomListService.State.Running -> false
else -> {
delay(DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS)
true
}
val showSyncSpinner by remember {
derivedStateOf {
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
}
}
return LoggedInState(
showSyncSpinner = showSyncSpinner,
permissionsState = permissionsState,
)
}
}

View file

@ -16,9 +16,6 @@
package io.element.android.appnav.loggedin
import io.element.android.libraries.permissions.api.PermissionsState
data class LoggedInState(
val showSyncSpinner: Boolean,
val permissionsState: PermissionsState,
)

View file

@ -17,7 +17,6 @@
package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
@ -32,5 +31,4 @@ fun aLoggedInState(
showSyncSpinner: Boolean = true,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
permissionsState = createDummyPostNotificationPermissionsState(),
)

View file

@ -23,21 +23,16 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.permissions.api.PermissionsView
@Composable
fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Box(
modifier = modifier
.fillMaxSize()
@ -49,10 +44,6 @@ fun LoggedInView(
.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner,
)
PermissionsView(
state = state.permissionsState,
openSystemSettings = context::openAppSettingsPage
)
}
}

View file

@ -31,10 +31,16 @@ import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolde
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class RootPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()

View file

@ -26,16 +26,21 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LoggedInPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
@ -43,7 +48,7 @@ class LoggedInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionsState.permission).isEmpty()
assertThat(initialState.showSyncSpinner).isFalse()
}
}
@ -68,11 +73,6 @@ class LoggedInPresenterTest {
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return NoopPermissionsPresenter()
}
},
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = object : PushService {
override fun notificationStyleChanged() {

View file

@ -62,7 +62,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.2.1")
detektPlugins("io.nlopez.compose.rules:detekt:0.2.2")
}
// KtLint
@ -143,22 +143,6 @@ sonar {
}
}
allprojects {
val projectDir = projectDir.toString()
sonar {
properties {
// Note: folders `kotlin` are not supported (yet), I asked on their side: https://community.sonarsource.com/t/82824
// As a workaround provide the path in `sonar.sources` property.
if (File("$projectDir/src/main/kotlin").exists()) {
property("sonar.sources", "src/main/kotlin")
}
if (File("$projectDir/src/test/kotlin").exists()) {
property("sonar.tests", "src/test/kotlin")
}
}
}
}
allprojects {
tasks.withType<Test> {
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
@ -261,6 +245,8 @@ koverMerged {
includes += "*Presenter"
excludes += "*Fake*Presenter"
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$*"
}
bound {
minValue = 85

View file

@ -0,0 +1 @@
Bump Rust SDK to `v0.1.49`

View file

@ -1 +0,0 @@
Create poll.

View file

@ -1 +0,0 @@
Bug reporter crashes when 'send logs' is disabled.

2
changelog.d/1172.feature Normal file
View file

@ -0,0 +1,2 @@
[Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor.

1
changelog.d/1173.bugfix Normal file
View file

@ -0,0 +1 @@
Reply action: harmonize conditions in bottom sheet and swipe to reply.

View file

@ -1 +0,0 @@
Add missing link to the terms on the analytics setting screen.

View file

@ -1 +0,0 @@
Remove unnecessary year in copyright mention.

1
changelog.d/1191.misc Normal file
View file

@ -0,0 +1 @@
Exclude some groups related to analytics to be included.

1
changelog.d/1217.feature Normal file
View file

@ -0,0 +1 @@
Implement Bloom effect modifier.

1
changelog.d/1222.bugfix Normal file
View file

@ -0,0 +1 @@
Fix system bar color after login on light theme.

1
changelog.d/1224.feature Normal file
View file

@ -0,0 +1 @@
Set color on display name and default avatar in the timeline.

1
changelog.d/1241.bugfix Normal file
View file

@ -0,0 +1 @@
Enable polls in release build.

1
changelog.d/1244.misc Normal file
View file

@ -0,0 +1 @@
Use the new SyncIndicator API.

1
changelog.d/1251.misc Normal file
View file

@ -0,0 +1 @@
Improve RoomSummary mapping by using RoomInfo.

1
changelog.d/1261.feature Normal file
View file

@ -0,0 +1 @@
[Rich text editor] Add formatting menu (accessible via the '+' button)

1
changelog.d/1269.misc Normal file
View file

@ -0,0 +1 @@
Ensure Posthog data are sent to "https://posthog.element.io"

1
changelog.d/612.bugfix Normal file
View file

@ -0,0 +1 @@
Make links in room topic clickable

1
changelog.d/897.feature Normal file
View file

@ -0,0 +1 @@
Add a notification permission screen to the initial flow.

View file

@ -1 +0,0 @@
Make sure Snackbars are only displayed once.

View file

@ -0,0 +1,69 @@
# Continuous integration strategy
<!--- TOC -->
* [Introduction](#introduction)
* [CI tools](#ci-tools)
* [Rules](#rules)
* [What is the CI checking](#what-is-the-ci-checking)
* [What is the CI reporting](#what-is-the-ci-reporting)
* [Current choices](#current-choices)
* [R8 task](#r8-task)
* [Android test (connected test)](#android-test-connected-test)
<!--- END -->
## Introduction
This document gives some information about how we take advantage of the continuous integration (CI).
## CI tools
We use GitHub Actions to configure and perform the CI.
## Rules
We want:
1. The CI to detect as soon as possible any issue in the code
2. The CI to be fast - it's run on all the Pull Requests, and developers do not like to wait too long
3. The CI to be reliable - it should not fail randomly
4. The CI to generate artifacts which can be used by the team and the community
5. The CI to generate useful logs and reports, not too verbose, not too short
6. The developer to be able to run the CI locally - to help with this we have [a script](../tools/check/check_code_quality.sh) the can be run locally and which does more checks that just building and deploying the app.
7. The CI to be used as a common environment for the team: generate the screenshots image for the screenshot test, build the release build (unsigned)
8. The CI to run repeated tasks, like building the nightly builds, integrating data from external tools (translations, etc.)
9. The CI to upgrade our dependencies (Renovate)
10. The CI to do some issue triaging
## What is the CI checking
The CI checks that:
1. The code is compiling, without any warnings, for all the app build types and variants and for the minimal app
2. The tests are passing
3. The code quality is good (detekt, ktlint, lint)
4. The code is running and smoke tests are passing (maestro)
5. The PullRequest itself is good (with danger)
6. Files that must be added with git-lfs are added with git-lfs
## What is the CI reporting
The CI reports:
1. Code coverage reports
2. Sonar reports
## Current choices
### R8 task
The CI does not run R8 because it's too slow, and it breaks rule 2.
The drawback is that the nightly build can fail, as well as the release build.
Since the nightly build is failing, the team can detect the failure quite fast and react to it.
### Android test (connected test)
We limit the number of connected tests (tests under folder `androidTest`), because it often break rule 2 and 3.

View file

@ -0,0 +1,64 @@
# Installing Element X Android from a Github Release
This document explains how to install Element X Android from a Github Release.
<!--- TOC -->
* [Requirements](#requirements)
* [Steps](#steps)
* [I already have the application on my phone](#i-already-have-the-application-on-my-phone)
<!--- END -->
## Requirements
The Github release will contain an Android App Bundle (with `aab` extension) file, unlike in the Element Android project where releases directly provide the APKs. So there are some steps to perform to generate and sign App Bundle APKs. An APK suitable for the targeted device will then be generated.
The easiest way to do that is to use the debug signature that is shared between the developers and stored in the Element X Android project. So we recommend to clone the project first, to be able to use the debug signature it contains. But note that you can use any other signature. You don't need to install Android Studio, you will only need a shell terminal.
You can clone the project by running:
```bash
git clone git@github.com:vector-im/element-x-android.git
```
or
```bash
git clone https://github.com/vector-im/element-x-android.git
```
You will also need to install [bundletool](https://developer.android.com/studio/command-line/bundletool). On MacOS, you can run the following command:
```bash
brew install bundletool
```
## Steps
1. Open the GitHub release that you want to install from https://github.com/vector-im/element-x-android/releases
2. Download the asset `app-release-signed.aab`
3. Navigate to the folder where you cloned the project and run the following command:
```bash
bundletool build-apks --bundle=<PATH_TO_YOUR_APP-RELEASE-SIGNED.AAB_FILE> --output=./tmp/elementx.apks \
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
--overwrite
```
For instance:
```bash
bundletool build-apks --bundle=./tmp/Element/0.1.5/app-release-signed.aab --output=./tmp/elementx.apks \
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
--overwrite
```
4. Run an Android emulator, or connect a real device to your computer
5. Install the APKs on the device:
```bash
bundletool install-apks --apks=./tmp/elementx.apks
```
That's it, the application should be installed on your device, you can start it from the launcher icon.
## I already have the application on my phone
If the application was already installed on your phone, there are several cases:
- it was installed from the PlayStore, you will have to uninstall it first because the signature will not match.
- it was installed from a previous GitHub release, this is like an application upgrade, so no need to uninstall the existing app.
- it was installed from a more recent GitHub release, you will have to uninstall it first.

View file

@ -0,0 +1,2 @@
Main changes in this version: bugfixes.
Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -51,4 +51,5 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
}

View file

@ -23,11 +23,18 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AnalyticsOptInPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - enable`() = runTest {
val analyticsService = FakeAnalyticsService(isEnabled = false)

View file

@ -23,10 +23,17 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AnalyticsPreferencesPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state available`() = runTest {
val presenter = DefaultAnalyticsPreferencesPresenter(

View file

@ -65,6 +65,7 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -23,6 +23,7 @@ import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.toImmutableList
open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListState> {
@ -36,7 +37,11 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
selectionMode = SelectionMode.Multiple,
),
aUserListState().copy(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList()
.mapIndexed { index, matrixUser ->
UserSearchResult(matrixUser, index % 2 == 0)
}
.toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,

View file

@ -24,12 +24,18 @@ import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AddPeoplePresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private lateinit var presenter: AddPeoplePresenter
@Before

View file

@ -38,6 +38,7 @@ 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.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@ -47,6 +48,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@ -58,6 +60,10 @@ private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
class ConfigureRoomPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore

View file

@ -35,13 +35,19 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CreateRoomRootPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter

View file

@ -25,12 +25,18 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultUserListPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val userRepository = FakeUserRepository()
@Test

View file

@ -43,6 +43,10 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api)
implementation(projects.services.toolbox.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -51,6 +55,9 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -35,6 +35,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
@ -79,6 +80,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object WelcomeScreen : NavTarget
@Parcelize
data object NotificationsOptIn : NavTarget
@Parcelize
data object AnalyticsOptIn : NavTarget
}
@ -124,6 +128,14 @@ class FtueFlowNode @AssistedInject constructor(
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
@ -138,6 +150,9 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn)
}
FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
sealed interface NotificationsOptInEvents {
data object ContinueClicked : NotificationsOptInEvents
data object NotNowClicked : NotificationsOptInEvents
}

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class NotificationsOptInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: NotificationsOptInPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback: NodeInputs {
fun onNotificationsOptInFinished()
}
private val callback = inputs<Callback>()
private val presenter: NotificationsOptInPresenter by lazy {
presenterFactory.create(callback)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
NotificationsOptInView(
state = state,
onBack = { callback.onNotificationsOptInFinished() },
modifier = modifier
)
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class NotificationsOptInPresenter @AssistedInject constructor(
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback,
private val appCoroutineScope: CoroutineScope,
private val permissionStateProvider: PermissionStateProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : Presenter<NotificationsOptInState> {
@AssistedFactory
interface Factory {
fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
}
private val postNotificationPermissionsPresenter by lazy {
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else {
NoopPermissionsPresenter()
}
}
@Composable
override fun present(): NotificationsOptInState {
val notificationsPermissionsState = postNotificationPermissionsPresenter.present()
fun handleEvents(event: NotificationsOptInEvents) {
when (event) {
NotificationsOptInEvents.ContinueClicked -> {
if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished()
} else {
notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog)
}
}
NotificationsOptInEvents.NotNowClicked -> {
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
appCoroutineScope.setPermissionDenied()
}
callback.onNotificationsOptInFinished()
}
}
}
LaunchedEffect(notificationsPermissionsState) {
if (notificationsPermissionsState.permissionGranted
|| notificationsPermissionsState.permissionAlreadyDenied) {
callback.onNotificationsOptInFinished()
}
}
return NotificationsOptInState(
notificationsPermissionState = notificationsPermissionsState,
eventSink = ::handleEvents
)
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun CoroutineScope.setPermissionDenied() = launch {
permissionStateProvider.setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS, true)
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import io.element.android.libraries.permissions.api.PermissionsState
data class NotificationsOptInState(
val notificationsPermissionState: PermissionsState,
val eventSink: (NotificationsOptInEvents) -> Unit
)

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.aPermissionsState
open class NotificationsOptInStateProvider : PreviewParameterProvider<NotificationsOptInState> {
override val values: Sequence<NotificationsOptInState>
get() = sequenceOf(
aNotificationsOptInState(),
// Add other states here
)
}
fun aNotificationsOptInState() = NotificationsOptInState(
notificationsPermissionState = aPermissionsState(),
eventSink = {}
)

View file

@ -0,0 +1,197 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
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.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.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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun NotificationsOptInView(
state: NotificationsOptInState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
HeaderFooterPage(
modifier = modifier
.systemBarsPadding()
.fillMaxSize(),
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { NotificationsOptInFooter(state) },
) {
NotificationsOptInContent(modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun NotificationsOptInHeader(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = Icons.Default.Notifications,
)
}
@Composable
private fun NotificationsOptInFooter(state: NotificationsOptInState) {
ButtonColumnMolecule {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ok),
onClick = {
state.eventSink(NotificationsOptInEvents.ContinueClicked)
}
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_not_now),
onClick = {
state.eventSink(NotificationsOptInEvents.NotNowClicked)
}
)
}
}
@Composable
private fun NotificationsOptInContent(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
verticalArrangement = Arrangement.spacedBy(
16.dp,
alignment = Alignment.CenterVertically
)
) {
NotificationRow(
avatarLetter = "M",
avatarColorsId = "5",
firstRowPercent = 1f,
secondRowPercent = 0.4f
)
NotificationRow(
avatarLetter = "A",
avatarColorsId = "1",
firstRowPercent = 1f,
secondRowPercent = 1f
)
NotificationRow(
avatarLetter = "T",
avatarColorsId = "4",
firstRowPercent = 0.65f,
secondRowPercent = 0f
)
}
}
}
@Composable
private fun NotificationRow(
avatarLetter: String,
avatarColorsId: String,
firstRowPercent: Float,
secondRowPercent: Float,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = ElementTheme.colors.bgCanvasDisabled,
shape = RoundedCornerShape(14.dp),
shadowElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = AvatarData(id = avatarColorsId, name = avatarLetter, size = AvatarSize.NotificationsOptIn),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.clip(CircleShape)
.fillMaxWidth(firstRowPercent)
.height(10.dp)
.background(ElementTheme.colors.borderInteractiveSecondary)
)
if (secondRowPercent > 0f) {
Box(
modifier = Modifier
.clip(CircleShape)
.fillMaxWidth(secondRowPercent)
.height(10.dp)
.background(ElementTheme.colors.borderInteractiveSecondary)
)
}
}
}
}
}
@DayNightPreviews
@Composable
internal fun NotificationsOptInViewPreview(
@PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState
) {
ElementPreview {
NotificationsOptInView(
onBack = {},
state = state,
)
}
}

View file

@ -16,6 +16,8 @@
package io.element.android.features.ftue.impl.state
import android.Manifest
import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
@ -23,7 +25,9 @@ import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@ -34,10 +38,12 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider,
private val matrixClient: MatrixClient,
) : FtueState {
@ -47,6 +53,9 @@ class DefaultFtueState @Inject constructor(
welcomeScreenState.reset()
analyticsService.reset()
migrationScreenStore.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
}
}
init {
@ -63,7 +72,10 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
FtueStep.NotificationsOptIn
)
FtueStep.NotificationsOptIn -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
@ -73,6 +85,7 @@ class DefaultFtueState @Inject constructor(
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
shouldAskNotificationPermissions(),
needsAnalyticsOptIn()
).any { it }
}
@ -90,6 +103,15 @@ class DefaultFtueState @Inject constructor(
return welcomeScreenState.isWelcomeScreenNeeded()
}
private fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
!isPermissionGranted && !isPermissionDenied
} else false
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
@ -104,5 +126,6 @@ class DefaultFtueState @Inject constructor(
sealed interface FtueStep {
data object MigrationScreen : FtueStep
data object WelcomeScreen : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string>
<string name="screen_migration_message">"Jedná se o jednorázový proces, prosíme o strpení."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>
<string name="screen_welcome_bullet_2">"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."</string>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare."</string>
<string name="screen_migration_title">"Contul dumneavoastră se configurează"</string>
<string name="screen_welcome_bullet_1">"Apelurile, sondajele, căutare și multe altele vor fi adăugate în cursul acestui an."</string>
<string name="screen_welcome_bullet_2">"Istoricul mesajelor pentru camerele criptate nu va fi disponibil în această actualizare."</string>
<string name="screen_welcome_bullet_3">"Ne-ar plăcea să auzim de la dumneavoastră, spuneți-ne ce părere aveți prin intermediul paginii de setări."</string>
<string name="screen_welcome_button">"Să începem!"</string>
<string name="screen_welcome_subtitle">"Iată ce trebuie să știți:"</string>
<string name="screen_welcome_title">"Bun venit la%1$s!"</string>
</resources>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -16,6 +16,7 @@
package io.element.android.features.ftue.impl
import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
@ -25,8 +26,10 @@ import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@ -51,13 +54,21 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
@ -71,9 +82,16 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
)
val steps = mutableListOf<FtueStep?>()
// First step, migration screen
@ -84,7 +102,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Third step, analytics opt in
// Third step, notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
// Fourth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@ -94,6 +116,7 @@ class DefaultFtueStateTests {
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.AnalyticsOptIn,
null, // Final state
)
@ -107,11 +130,40 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
)
// Skip first 3 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val state = createState(
sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
@ -132,12 +184,16 @@ class DefaultFtueStateTests {
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
matrixClient = matrixClient,
)
}

View file

@ -25,10 +25,17 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
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.roomlist.FakeRoomListService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class MigrationScreenPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial`() = runTest {
val presenter = createPresenter()

View file

@ -0,0 +1,148 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import android.os.Build
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class NotificationsOptInPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private var isFinished = false
@Test
fun `initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.notificationsPermissionState.showDialog).isFalse()
}
}
@Test
fun `show dialog on continue clicked`() = runTest {
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(permissionPresenter)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
Truth.assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue()
}
}
@Test
fun `finish flow on continue clicked with permission already granted`() = runTest {
val permissionPresenter = FakePermissionsPresenter().apply {
setPermissionGranted()
}
val presenter = createPresenter(permissionPresenter)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
Truth.assertThat(isFinished).isTrue()
}
}
@Test
fun `finish flow on not now clicked`() = runTest {
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
permissionsPresenter = permissionPresenter,
sdkIntVersion = Build.VERSION_CODES.M
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
Truth.assertThat(isFinished).isTrue()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `set permission denied on not now clicked in API 33`() = runTest(StandardTestDispatcher()) {
val permissionPresenter = FakePermissionsPresenter()
val permissionStateProvider = FakePermissionStateProvider()
val presenter = createPresenter(
permissionsPresenter = permissionPresenter,
permissionStateProvider = permissionStateProvider,
sdkIntVersion = Build.VERSION_CODES.TIRAMISU
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
// Allow background coroutines to run
runCurrent()
val isPermissionDenied = runBlocking {
permissionStateProvider.isPermissionDenied("notifications").first()
}
Truth.assertThat(isPermissionDenied).isTrue()
}
}
private fun TestScope.createPresenter(
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = NotificationsOptInPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
isFinished = true
}
},
appCoroutineScope = this,
permissionStateProvider = permissionStateProvider,
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
)
}

View file

@ -54,6 +54,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -44,10 +44,15 @@ import io.element.android.libraries.push.api.notifications.NotificationDrawerMan
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class InviteListPresenterTests {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - starts empty, adds invites when received`() = runTest {

View file

@ -29,14 +29,20 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LeaveRoomPresenterImplTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()

View file

@ -57,4 +57,5 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils)
}

View file

@ -26,10 +26,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@ -119,9 +119,8 @@ class SendLocationPresenter @Inject constructor(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.PinDrop,
messageType = Composer.MessageType.LocationPin,
)
)
}
@ -138,9 +137,8 @@ class SendLocationPresenter @Inject constructor(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isLocation = true,
isReply = messageComposerContext.composerMode.isReply,
locationType = Composer.LocationType.MyLocation,
messageType = Composer.MessageType.LocationUser,
)
)
}

View file

@ -34,12 +34,18 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SendLocationPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService()
@ -302,9 +308,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = false,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.MyLocation,
messageType = Composer.MessageType.LocationUser,
)
)
}
@ -359,9 +364,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = false,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.PinDrop,
messageType = Composer.MessageType.LocationPin,
)
)
}
@ -406,9 +410,8 @@ class SendLocationPresenterTest {
Composer(
inThread = false,
isEditing = true,
isLocation = true,
isReply = false,
locationType = Composer.LocationType.PinDrop,
messageType = Composer.MessageType.LocationPin,
)
)
}

View file

@ -27,12 +27,18 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ShowLocationPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")

View file

@ -5,10 +5,11 @@
<string name="screen_account_provider_form_notice">"Introduceţi un termen de căutare sau o adresă de domeniu."</string>
<string name="screen_account_provider_form_subtitle">"Căutați o companie, o comunitate sau un server privat."</string>
<string name="screen_account_provider_form_title">"Găsiți un furnizor de cont"</string>
<string name="screen_account_provider_signin_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signin_subtitle">"Aici vor trăi conversațiile dumneavoastră - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signin_title">"Sunteți pe cale să vă conectați la %s"</string>
<string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile dumneavoastră - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signup_title">"Sunteți pe cale să creați un cont pe %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org este un server mare și gratuit din rețeaua publică Matrix pentru comunicații sigure și descentralizate, administrat de Fundația Matrix.org."</string>
<string name="screen_change_account_provider_other">"Altul"</string>
<string name="screen_change_account_provider_subtitle">"Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."</string>
<string name="screen_change_account_provider_title">"Schimbați furnizorul contului"</string>

View file

@ -9,6 +9,7 @@
<string name="screen_account_provider_signin_title">"Вы собираетесь войти в %s"</string>
<string name="screen_account_provider_signup_subtitle">"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем."</string>
<string name="screen_account_provider_signup_title">"Вы собираетесь создать учетную запись на %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org — это большой бесплатный сервер в общедоступной сети Matrix для безопасной децентрализованной связи, управляемый Matrix.org Foundation."</string>
<string name="screen_change_account_provider_other">"Другое"</string>
<string name="screen_change_account_provider_subtitle">"Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись."</string>
<string name="screen_change_account_provider_title">"Сменить поставщика учетной записи"</string>

View file

@ -26,10 +26,17 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChangeServerPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = ChangeServerPresenter(

View file

@ -27,11 +27,18 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class OidcPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = OidcPresenter(

View file

@ -24,10 +24,17 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ChangeAccountProviderPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val changeServerPresenter = ChangeServerPresenter(

View file

@ -31,11 +31,18 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class ConfirmAccountProviderPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial test`() = runTest {
val presenter = createConfirmAccountProviderPresenter()

View file

@ -31,10 +31,17 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LoginPasswordPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService()

View file

@ -30,11 +30,18 @@ import io.element.android.features.login.impl.resolver.network.WellKnownSlidingS
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class SearchAccountProviderPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val fakeWellknownRequest = FakeWellknownRequest()

View file

@ -30,10 +30,17 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class WaitListPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val authenticationService = FakeAuthenticationService().apply {

View file

@ -48,4 +48,5 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
}

View file

@ -25,10 +25,17 @@ import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LogoutPreferencePresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(

View file

@ -25,5 +25,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.libraries.textcomposer)
api(projects.libraries.textcomposer.impl)
}

View file

@ -41,7 +41,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.textcomposer.impl)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
@ -61,7 +61,7 @@ dependencies {
implementation(libs.accompanist.systemui)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.emoji)
implementation(libs.matrix.emojibase.bindings)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -76,6 +76,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.textcomposer.test)
testImplementation(libs.test.mockk)
ksp(libs.showkase.processor)

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -59,7 +60,6 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -93,6 +94,7 @@ class MessagesPresenter @AssistedInject constructor(
private val messageSummaryFormatter: MessageSummaryFormatter,
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
private val analyticsService: AnalyticsService,
@Assisted private val navigator: MessagesNavigator,
) : Presenter<MessagesState> {
@ -176,7 +178,7 @@ class MessagesPresenter @AssistedInject constructor(
snackbarMessage = snackbarMessage,
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
eventSink = ::handleEvents
eventSink = { handleEvents(it) }
)
}
@ -202,6 +204,7 @@ class MessagesPresenter @AssistedInject constructor(
TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent)
TimelineItemAction.Forward -> handleForwardAction(targetEvent)
TimelineItemAction.ReportContent -> handleReportAction(targetEvent)
TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent)
}
}
@ -214,7 +217,8 @@ class MessagesPresenter @AssistedInject constructor(
}
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) {
suspend {
inviteProgress.value = Async.Loading()
runCatching {
room.updateMembers()
val memberList = when (val memberState = room.membersStateFlow.value) {
@ -227,7 +231,14 @@ class MessagesPresenter @AssistedInject constructor(
room.inviteUserById(member.userId).onFailure { t ->
Timber.e(t, "Failed to reinvite DM partner")
}.getOrThrow()
}.runCatchingUpdatingState(inviteProgress)
}.fold(
onSuccess = {
inviteProgress.value = Async.Success(Unit)
},
onFailure = {
inviteProgress.value = Async.Failure(it)
}
)
}
private suspend fun handleActionRedact(event: TimelineItem.Event) {
@ -242,7 +253,9 @@ class MessagesPresenter @AssistedInject constructor(
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
val composerMode = MessageComposerMode.Edit(
targetEvent.eventId,
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
(targetEvent.content as? TimelineItemTextBasedContent)?.let {
it.htmlBody ?: it.body
}.orEmpty(),
targetEvent.transactionId,
)
composerState.eventSink(
@ -310,6 +323,13 @@ class MessagesPresenter @AssistedInject constructor(
navigator.onReportContentClicked(event.eventId, event.senderId)
}
private suspend fun handleEndPollAction(event: TimelineItem.Event) {
event.eventId?.let {
room.endPoll(it, "The poll with event id: $it has ended.")
analyticsService.capture(PollEnd())
}
}
private suspend fun handleCopyContents(event: TimelineItem.Event) {
val content = when (event.content) {
is TimelineItemTextBasedContent -> event.content.body

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
@ -54,7 +55,9 @@ fun aMessagesState() = MessagesState(
userHasPermissionToSendMessage = true,
userHasPermissionToRedact = false,
composerState = aMessageComposerState().copy(
text = "Hello",
richTextEditorState = RichTextEditorState("Hello", fake = true).apply {
requestFocus()
},
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),
@ -67,7 +70,7 @@ fun aMessagesState() = MessagesState(
),
actionListState = anActionListState(),
customReactionState = CustomReactionState(
selectedEventId = null,
target = CustomReactionState.Target.None,
eventSink = {},
selectedEmoji = persistentSetOf(),
),

View file

@ -123,7 +123,13 @@ fun MessagesView(
fun onMessageLongClicked(event: TimelineItem.Event) {
Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event, state.userHasPermissionToRedact))
state.actionListState.eventSink(
ActionListEvents.ComputeForMessage(
event = event,
canRedact = state.userHasPermissionToRedact,
canSendMessage = state.userHasPermissionToSendMessage,
)
)
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
@ -141,7 +147,7 @@ fun MessagesView(
}
fun onMoreReactionsClicked(event: TimelineItem.Event) {
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event))
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
}
Scaffold(
@ -194,18 +200,17 @@ fun MessagesView(
state = state.actionListState,
onActionSelected = ::onActionSelected,
onCustomReactionClicked = { event ->
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event))
if (event.eventId == null) return@ActionListView
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
},
onEmojiReactionClicked = ::onEmojiReactionClicked,
)
CustomReactionBottomSheet(
state = state.customReactionState,
onEmojiSelected = { emoji ->
state.customReactionState.selectedEventId?.let { eventId ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
}
onEmojiSelected = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
}
)

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