Merge branch 'release/25.04.0' into main

This commit is contained in:
Benoit Marty 2025-04-08 15:49:39 +02:00
commit 4361139c05
834 changed files with 6033 additions and 3466 deletions

View file

@ -1,53 +0,0 @@
name: Build and release Enterprise nightly application
on:
workflow_dispatch:
schedule:
# Every nights at 4
- cron: "0 4 * * *"
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx9g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
jobs:
nightly:
name: Build and publish Enterprise nightly bundle to Firebase
runs-on: ubuntu-latest
if: ${{ github.repository == 'element-hq/element-x-android' }}
steps:
- uses: actions/checkout@v4
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Build and upload Nightly application
run: |
./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES
env:
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 }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
- name: Additionally upload Nightly APK to browserstack for testing
continue-on-error: true # don't block anything by this upload failing (for now)
run: |
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/gplay/nightly/app-gplay-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
env:
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}

View file

@ -1,6 +1,6 @@
name: Pull Request name: Pull Request
on: on:
pull_request: pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ] types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call: workflow_call:
secrets: secrets:

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.10" /> <option name="version" value="2.1.20" />
</component> </component>
</project> </project>

View file

@ -1,3 +1,21 @@
Changes in Element X v25.03.4
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.03.4 -->
## What's Changed
### 🙌 Improvements
* Change : composer suggestions by @ganfra in https://github.com/element-hq/element-x-android/pull/4485
### 🧱 Build
* Fix flaky incoming verification tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4479
### Dependency upgrades
* fix(deps): update dagger to v2.56.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4472
* fix(deps): update dependencyanalysis to v2.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4473
* Upgrade embedded EC version to `v0.9.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4489
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.3...v25.03.4
Changes in Element X v25.03.3 Changes in Element X v25.03.3
============================= =============================

View file

@ -20,6 +20,7 @@ import extension.allEnterpriseImpl
import extension.allFeaturesImpl import extension.allFeaturesImpl
import extension.allLibrariesImpl import extension.allLibrariesImpl
import extension.allServicesImpl import extension.allServicesImpl
import extension.buildConfigFieldStr
import extension.koverDependencies import extension.koverDependencies
import extension.locales import extension.locales
import extension.setupAnvil import extension.setupAnvil
@ -102,7 +103,7 @@ android {
} }
val baseAppName = BuildTimeConfig.APPLICATION_NAME val baseAppName = BuildTimeConfig.APPLICATION_NAME
logger.warnInBox("Building $baseAppName") logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName)")
buildTypes { buildTypes {
getByName("debug") { getByName("debug") {
@ -170,13 +171,13 @@ android {
create("gplay") { create("gplay") {
dimension = "store" dimension = "store"
isDefault = true isDefault = true
buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"G\"") buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "G")
buildConfigField("String", "FLAVOR_DESCRIPTION", "\"GooglePlay\"") buildConfigFieldStr("FLAVOR_DESCRIPTION", "GooglePlay")
} }
create("fdroid") { create("fdroid") {
dimension = "store" dimension = "store"
buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"F\"") buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "F")
buildConfigField("String", "FLAVOR_DESCRIPTION", "\"FDroid\"") buildConfigFieldStr("FLAVOR_DESCRIPTION", "FDroid")
} }
} }
} }
@ -291,8 +292,8 @@ tasks.withType<GenerateBuildConfig>().configureEach {
outputs.upToDateWhen { false } outputs.upToDateWhen { false }
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get() val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get() val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
android.defaultConfig.buildConfigField("String", "GIT_REVISION", "\"$gitRevision\"") android.defaultConfig.buildConfigFieldStr("GIT_REVISION", gitRevision)
android.defaultConfig.buildConfigField("String", "GIT_BRANCH_NAME", "\"$gitBranchName\"") android.defaultConfig.buildConfigFieldStr("GIT_BRANCH_NAME", gitBranchName)
} }
licensee { licensee {

View file

@ -9,6 +9,8 @@ package io.element.android.x
import android.app.Application import android.app.Application
import androidx.startup.AppInitializer import androidx.startup.AppInitializer
import io.element.android.appconfig.RageshakeConfig
import io.element.android.appconfig.isEnabled
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent import io.element.android.x.di.AppComponent
@ -23,7 +25,9 @@ class ElementXApplication : Application(), DaggerComponentOwner {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AppInitializer.getInstance(this).apply { AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java) if (RageshakeConfig.isEnabled) {
initializeComponent(CrashInitializer::class.java)
}
initializeComponent(PlatformInitializer::class.java) initializeComponent(PlatformInitializer::class.java)
initializeComponent(CacheCleanerInitializer::class.java) initializeComponent(CacheCleanerInitializer::class.java)
} }

View file

@ -37,6 +37,7 @@ class PlatformInitializer : Initializer<Unit> {
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter), writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
logLevel = logLevel, logLevel = logLevel,
extraTargets = listOf(ELEMENT_X_TARGET), extraTargets = listOf(ELEMENT_X_TARGET),
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
) )
bugReporter.setCurrentTracingLogLevel(logLevel.name) bugReporter.setCurrentTracingLogLevel(logLevel.name)
platformService.init(tracingConfiguration) platformService.init(tracingConfiguration)

View file

@ -5,8 +5,8 @@
~ Please see LICENSE files in the repository root for full details. ~ Please see LICENSE files in the repository root for full details.
--> -->
<resources> <resources>
<!-- Must be equal to DarkDesignTokens.colorThemeBg --> <!-- Must be equal to DarkColorTokens.colorThemeBg -->
<color name="splashscreen_bg_dark">#FF101317</color> <color name="splashscreen_bg_dark">#FF101317</color>
<!-- Must be equal to LightDesignTokens.colorThemeBg --> <!-- Must be equal to LightColorTokens.colorThemeBg -->
<color name="splashscreen_bg_light">#FFFFFFFF</color> <color name="splashscreen_bg_light">#FFFFFFFF</color>
</resources> </resources>

View file

@ -1,3 +1,6 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
/* /*
* Copyright 2022-2024 New Vector Ltd. * Copyright 2022-2024 New Vector Ltd.
* *
@ -10,6 +13,37 @@ plugins {
android { android {
namespace = "io.element.android.appconfig" namespace = "io.element.android.appconfig"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "URL_POLICY",
value = if (isEnterpriseBuild) {
BuildTimeConfig.URL_POLICY ?: ""
} else {
"https://element.io/cookie-policy"
},
)
buildConfigFieldStr(
name = "BUG_REPORT_URL",
value = if (isEnterpriseBuild) {
BuildTimeConfig.BUG_REPORT_URL ?: ""
} else {
"https://riot.im/bugreports/submit"
},
)
buildConfigFieldStr(
name = "BUG_REPORT_APP_NAME",
value = if (isEnterpriseBuild) {
BuildTimeConfig.BUG_REPORT_APP_NAME ?: ""
} else {
"element-x-android"
},
)
}
} }
dependencies { dependencies {

View file

@ -8,5 +8,5 @@
package io.element.android.appconfig package io.element.android.appconfig
object AnalyticsConfig { object AnalyticsConfig {
const val POLICY_LINK = "https://element.io/cookie-policy" const val POLICY_LINK = BuildConfig.URL_POLICY
} }

View file

@ -11,17 +11,23 @@ object RageshakeConfig {
/** /**
* The URL to submit bug reports to. * The URL to submit bug reports to.
*/ */
const val BUG_REPORT_URL = "https://riot.im/bugreports/submit" const val BUG_REPORT_URL = BuildConfig.BUG_REPORT_URL
/** /**
* As per https://github.com/matrix-org/rageshake: * As per https://github.com/matrix-org/rageshake:
* Identifier for the application (eg 'riot-web'). * Identifier for the application (eg 'riot-web').
* Should correspond to a mapping configured in the configuration file for github issue reporting to work. * Should correspond to a mapping configured in the configuration file for github issue reporting to work.
*/ */
const val BUG_REPORT_APP_NAME = "element-x-android" const val BUG_REPORT_APP_NAME = BuildConfig.BUG_REPORT_APP_NAME
/** /**
* The maximum size of the upload request. Default value is just below CloudFlare's max request size. * The maximum size of the upload request. Default value is just below CloudFlare's max request size.
*/ */
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
} }
/**
* Whether the rageshake feature is enabled.
*/
val RageshakeConfig.isEnabled: Boolean
get() = BUG_REPORT_URL.isNotEmpty() && BUG_REPORT_APP_NAME.isNotEmpty()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

View file

@ -10,25 +10,28 @@ package io.element.android.appicon.element
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview @Preview
@Composable @Composable
internal fun IconPreview() { internal fun IconPreview() {
Box { Box {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null) Image(
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null) modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
)
Image(
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
contentDescription = null,
)
} }
} }
@ -36,8 +39,15 @@ internal fun IconPreview() {
@Composable @Composable
internal fun RoundIconPreview() { internal fun RoundIconPreview() {
Box(modifier = Modifier.clip(shape = CircleShape)) { Box(modifier = Modifier.clip(shape = CircleShape)) {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null) Image(
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null) modifier = Modifier.matchParentSize(),
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
)
Image(
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
contentDescription = null,
)
} }
} }
@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
internal fun MonochromeIconPreview() { internal fun MonochromeIconPreview() {
Box( Box(
modifier = Modifier modifier = Modifier
.size(108.dp) .background(Color(0xFF2F3133)),
.background(Color(0xFF2F3133))
.clip(shape = RoundedCornerShape(32.dp)),
contentAlignment = Alignment.Center
) { ) {
Image( Image(
painter = painterResource(id = R.mipmap.ic_launcher_monochrome), painter = painterResource(id = R.mipmap.ic_launcher_monochrome),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -1,2 +1,10 @@
<bitmap xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_launcher_background" /> android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#010302"
android:fillType="evenOdd"
android:pathData="m0,0h108v108h-108z" />
</vector>

View file

@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this) .launchIn(this)
} }
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember { val showSyncSpinner by remember {
derivedStateOf { derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show isOnline && syncIndicator == RoomListService.SyncIndicator.Show

View file

@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
} }
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier -> private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView( LoadingRoomNodeView(
state = LoadingRoomState.Loading, state = LoadingRoomState.Loading,
hasNetworkConnection = isOnline, hasNetworkConnection = isOnline,

View file

@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.isOnline
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier -> private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState() val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
val isOnline by syncService.isOnline().collectAsState() val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView( LoadingRoomNodeView(
state = loadingRoomState, state = loadingRoomState,
hasNetworkConnection = isOnline, hasNetworkConnection = isOnline,

View file

@ -241,7 +241,7 @@ class IntentResolverTest {
} }
private fun createIntentResolver( private fun createIntentResolver(
permalinkParserResult: () -> PermalinkData = { lambdaError() } permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
): IntentResolver { ): IntentResolver {
return IntentResolver( return IntentResolver(
deeplinkParser = DeeplinkParser(), deeplinkParser = DeeplinkParser(),

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -1,38 +1,28 @@
Element X brings you both sovereign & seamless collaboration built on Matrix. Freedom to communicate on your own terms
The collaboration capabilities include chat & video calls with the modern set of features such as: For individuals and communities - private communication between family, friends, hobby groups, clubs, etc.
• public & private channels
• room moderation & access control
• replies, reactions, polls, read receipts, pinned messages, etc.
• simultaneous chat & calls (picture in picture)
• decentralized & federated communication across organizations
All this comes in a secure & sovereign fashion without compromising responsiveness or overall usability of the app: Element X gives you fast, secure and private instant messaging and video calls built on Matrix, the open standard for real-time communication. This is a free and open-source app maintained at https://github.com/element-hq/element-x-android.
• enterprise-grade single sign-on
• easy & secure login & device verification via QR-code
• end to end encryption & zero trust
• protection against MITM & other cyber attacks
If youre a new user, use the new Element X app from the start. Compared to the current Element app you will get: Stay in touch with friends, family and communities with:
• greatly enhanced performance, sleek user interface and overall better user experience • Real time messaging & video calls
• enterprise-grade support for single sign-on (OIDC) • Public rooms for open group communication
• QR-code based login & device verification • Private rooms for closed group communication
• natively integrated Element Call for video calls • Rich messaging features: emoji reactions, replies, polls, pinned messages and more.
• continuous improvements, bug fixes and new features • Video calling while browsing messages.
• Interoperability with other Matrix-based apps such as FluffyChat, Cinny and many more.
If youre an existing user, using the current Element app - check out the new Element X and start planning your transition. The current Element app will be phased out and will only get critical security updates. <b>Privacy-first<b>
Unlike some other messengers from Big Tech companies, we dont mine your data or monitor your communications.
<b>Own your data</b> <b>Own your conversations</b>
Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; its your data. Youre not the product. Youre in control. Choose where to host your data - from any public server (the largest free server is matrix.org, but there are plenty of others to choose from) to creating your own personal server and hosting it on your own domain. This ability to choose a server is a large part of what differentiates us from other real time communication apps. However you host, you have ownership; its your data. Youre not the product. Youre in control.
<b>Interoperate natively</b> <b>Communicate in real time, all the time</b>
Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends, partners or customers are on a different Matrix-based app - you can still connect. Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io
<b>Encrypt your data</b> <b>Element X is our next-generation app</b>
Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages. If youre using the original Element app, its time to try Element X! Its faster, easier to use, and more powerful than the original app. Its better in every way and were adding new features all the time.
<b>Chat across multiple devices</b>
Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app. The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

View file

@ -13,12 +13,17 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
override val values: Sequence<AnalyticsPreferencesState> override val values: Sequence<AnalyticsPreferencesState>
get() = sequenceOf( get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true), aAnalyticsPreferencesState().copy(isEnabled = true),
aAnalyticsPreferencesState().copy(isEnabled = true, policyUrl = ""),
) )
} }
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState( fun aAnalyticsPreferencesState(
applicationName = "Element X", applicationName: String = "Element X",
isEnabled = false, isEnabled: Boolean = false,
policyUrl = "https://element.io", policyUrl: String = "https://element.io",
) = AnalyticsPreferencesState(
applicationName = applicationName,
isEnabled = isEnabled,
policyUrl = policyUrl,
eventSink = {} eventSink = {}
) )

View file

@ -36,11 +36,6 @@ fun AnalyticsPreferencesView(
id = R.string.screen_analytics_settings_help_us_improve, id = R.string.screen_analytics_settings_help_us_improve,
state.applicationName state.applicationName
) )
val linkText = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_settings_read_terms,
R.string.screen_analytics_settings_read_terms_content_link,
tagAndLink = LINK_TAG to state.policyUrl,
)
Column(modifier) { Column(modifier) {
ListItem( ListItem(
headlineContent = { headlineContent = {
@ -57,7 +52,14 @@ fun AnalyticsPreferencesView(
onEnabledChanged(!state.isEnabled) onEnabledChanged(!state.isEnabled)
} }
) )
ListSupportingText(annotatedString = linkText) if (state.policyUrl.isNotEmpty()) {
val linkText = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_settings_read_terms,
R.string.screen_analytics_settings_read_terms_content_link,
tagAndLink = LINK_TAG to state.policyUrl,
)
ListSupportingText(annotatedString = linkText)
}
} }
} }

View file

@ -9,6 +9,7 @@ package io.element.android.features.analytics.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@ -36,6 +37,7 @@ class AnalyticsOptInPresenter @Inject constructor(
return AnalyticsOptInState( return AnalyticsOptInState(
applicationName = buildMeta.applicationName, applicationName = buildMeta.applicationName,
hasPolicyLink = AnalyticsConfig.POLICY_LINK.isNotEmpty(),
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -11,5 +11,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
data class AnalyticsOptInState( data class AnalyticsOptInState(
val applicationName: String, val applicationName: String,
val hasPolicyLink: Boolean,
val eventSink: (AnalyticsOptInEvents) -> Unit val eventSink: (AnalyticsOptInEvents) -> Unit
) )

View file

@ -14,10 +14,14 @@ open class AnalyticsOptInStateProvider @Inject constructor() : PreviewParameterP
override val values: Sequence<AnalyticsOptInState> override val values: Sequence<AnalyticsOptInState>
get() = sequenceOf( get() = sequenceOf(
aAnalyticsOptInState(), aAnalyticsOptInState(),
aAnalyticsOptInState(hasPolicyLink = false),
) )
} }
fun aAnalyticsOptInState() = AnalyticsOptInState( fun aAnalyticsOptInState(
hasPolicyLink: Boolean = true,
) = AnalyticsOptInState(
applicationName = "Element X", applicationName = "Element X",
hasPolicyLink = hasPolicyLink,
eventSink = {} eventSink = {}
) )

View file

@ -95,25 +95,27 @@ private fun AnalyticsOptInHeader(
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart()) iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
) )
val text = buildAnnotatedStringWithStyledPart( if (state.hasPolicyLink) {
R.string.screen_analytics_prompt_read_terms, val text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms_content_link, R.string.screen_analytics_prompt_read_terms,
color = Color.Unspecified, R.string.screen_analytics_prompt_read_terms_content_link,
underline = false, color = Color.Unspecified,
bold = true, underline = false,
tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK, bold = true,
) tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
ClickableLinkText( )
annotatedString = text, ClickableLinkText(
onClick = { onClickTerms() }, annotatedString = text,
modifier = Modifier onClick = { onClickTerms() },
.padding(8.dp), modifier = Modifier
style = ElementTheme.typography.fontBodyMdRegular .padding(8.dp),
.copy( style = ElementTheme.typography.fontBodyMdRegular
color = ElementTheme.colors.textSecondary, .copy(
textAlign = TextAlign.Center, color = ElementTheme.colors.textSecondary,
) textAlign = TextAlign.Center,
) )
)
}
} }
} }

View file

@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
@Composable @Composable
override fun present(): AnalyticsPreferencesState { override fun present(): AnalyticsPreferencesState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val isEnabled = analyticsService.getUserConsent() val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
.collectAsState(initial = false)
fun handleEvents(event: AnalyticsOptInEvents) { fun handleEvents(event: AnalyticsOptInEvents) {
when (event) { when (event) {

View file

@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsent().first()).isFalse() assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
assertThat(analyticsService.didAskUserConsent().first()).isTrue() assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.getUserConsent().first()).isTrue() assertThat(analyticsService.userConsentFlow.first()).isTrue()
} }
} }
@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsent().first()).isFalse() assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
assertThat(analyticsService.didAskUserConsent().first()).isTrue() assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.getUserConsent().first()).isFalse() assertThat(analyticsService.userConsentFlow.first()).isFalse()
} }
} }
} }

View file

@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@ -35,7 +36,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue() assertThat(initialState.isEnabled).isTrue()
assertThat(initialState.policyUrl).isNotEmpty() assertThat(initialState.policyUrl).isEqualTo(AnalyticsConfig.POLICY_LINK)
} }
} }

View file

@ -32,7 +32,7 @@ interface ElementCallEntryPoint {
* @param notificationChannelId The id of the notification channel to use for the call notification. * @param notificationChannelId The id of the notification channel to use for the call notification.
* @param textContent The text content of the notification. If null the default content from the system will be used. * @param textContent The text content of the notification. If null the default content from the system will be used.
*/ */
fun handleIncomingCall( suspend fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,

View file

@ -1,3 +1,4 @@
import extension.buildConfigFieldStr
import extension.readLocalProperty import extension.readLocalProperty
import extension.setupAnvil import extension.setupAnvil
@ -26,45 +27,35 @@ android {
} }
defaultConfig { defaultConfig {
buildConfigField( buildConfigFieldStr(
type = "String",
name = "SENTRY_DSN", name = "SENTRY_DSN",
value = (System.getenv("ELEMENT_CALL_SENTRY_DSN") value = System.getenv("ELEMENT_CALL_SENTRY_DSN")
?: readLocalProperty("features.call.sentry.dsn") ?: readLocalProperty("features.call.sentry.dsn")
?: "" ?: ""
).let { "\"$it\"" }
) )
buildConfigField( buildConfigFieldStr(
type = "String",
name = "POSTHOG_USER_ID", name = "POSTHOG_USER_ID",
value = (System.getenv("ELEMENT_CALL_POSTHOG_USER_ID") value = System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
?: readLocalProperty("features.call.posthog.userid") ?: readLocalProperty("features.call.posthog.userid")
?: "" ?: ""
).let { "\"$it\"" }
) )
buildConfigField( buildConfigFieldStr(
type = "String",
name = "POSTHOG_API_HOST", name = "POSTHOG_API_HOST",
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_HOST") value = System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
?: readLocalProperty("features.call.posthog.api.host") ?: readLocalProperty("features.call.posthog.api.host")
?: "" ?: ""
).let { "\"$it\"" }
) )
buildConfigField( buildConfigFieldStr(
type = "String",
name = "POSTHOG_API_KEY", name = "POSTHOG_API_KEY",
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_KEY") value = System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
?: readLocalProperty("features.call.posthog.api.key") ?: readLocalProperty("features.call.posthog.api.key")
?: "" ?: ""
).let { "\"$it\"" }
) )
buildConfigField( buildConfigFieldStr(
type = "String",
name = "RAGESHAKE_URL", name = "RAGESHAKE_URL",
value = (System.getenv("ELEMENT_CALL_RAGESHAKE_URL") value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
?: readLocalProperty("features.call.regeshake.url") ?: readLocalProperty("features.call.regeshake.url")
?: "" ?: ""
).let { "\"$it\"" }
) )
} }
} }

View file

@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
context.startActivity(IntentProvider.createIntent(context, callType)) context.startActivity(IntentProvider.createIntent(context, callType))
} }
override fun handleIncomingCall( override suspend fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,

View file

@ -16,6 +16,8 @@ import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -27,10 +29,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
} }
@Inject @Inject
lateinit var activeCallManager: ActiveCallManager lateinit var activeCallManager: ActiveCallManager
@Inject
lateinit var appCoroutineScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
?: return ?: return
context.bindings<CallBindings>().inject(this) context.bindings<CallBindings>().inject(this)
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
}
} }
} }

View file

@ -62,6 +62,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val activeCallManager: ActiveCallManager, private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider, private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -87,7 +88,7 @@ class CallScreenPresenter @AssistedInject constructor(
coroutineScope.launch { coroutineScope.launch {
// Sets the call as joined // Sets the call as joined
activeCallManager.joinedCall(callType) activeCallManager.joinedCall(callType)
loadUrl( fetchRoomCallUrl(
inputs = callType, inputs = callType,
urlState = urlState, urlState = urlState,
callWidgetDriver = callWidgetDriver, callWidgetDriver = callWidgetDriver,
@ -96,7 +97,7 @@ class CallScreenPresenter @AssistedInject constructor(
) )
} }
onDispose { onDispose {
activeCallManager.hungUpCall(callType) appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
} }
} }
@ -187,7 +188,7 @@ class CallScreenPresenter @AssistedInject constructor(
) )
} }
private suspend fun loadUrl( private suspend fun fetchRoomCallUrl(
inputs: CallType, inputs: CallType,
urlState: MutableState<AsyncData<String>>, urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>, callWidgetDriver: MutableState<MatrixWidgetDriver?>,

View file

@ -24,9 +24,11 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -55,6 +57,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject @Inject
lateinit var buildMeta: BuildMeta lateinit var buildMeta: BuildMeta
@Inject
lateinit var appCoroutineScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -102,6 +107,8 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() { private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return val activeCall = activeCallManager.activeCall.value ?: return
activeCallManager.hungUpCall(callType = activeCall.callType) appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = activeCall.callType)
}
} }
} }

View file

@ -8,8 +8,11 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
@ -17,6 +20,7 @@ import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -55,25 +61,26 @@ interface ActiveCallManager {
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification. * Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification. * @param notificationData The data for the incoming call notification.
*/ */
fun registerIncomingCall(notificationData: CallNotificationData) suspend fun registerIncomingCall(notificationData: CallNotificationData)
/** /**
* Called when the active call has been hung up. It will remove any existing UI and the active call. * Called when the active call has been hung up. It will remove any existing UI and the active call.
* @param callType The type of call that the user hung up, either an external url one or a room one. * @param callType The type of call that the user hung up, either an external url one or a room one.
*/ */
fun hungUpCall(callType: CallType) suspend fun hungUpCall(callType: CallType)
/** /**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
* *
* @param callType The type of call that the user joined, either an external url one or a room one. * @param callType The type of call that the user joined, either an external url one or a room one.
*/ */
fun joinedCall(callType: CallType) suspend fun joinedCall(callType: CallType)
} }
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor( class DefaultActiveCallManager @Inject constructor(
@ApplicationContext context: Context,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler, private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator, private val ringingCallNotificationCreator: RingingCallNotificationCreator,
@ -83,33 +90,47 @@ class DefaultActiveCallManager @Inject constructor(
) : ActiveCallManager { ) : ActiveCallManager {
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
private val mutex = Mutex()
init { init {
observeRingingCall() observeRingingCall()
observeCurrentCall() observeCurrentCall()
} }
override fun registerIncomingCall(notificationData: CallNotificationData) { override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) { mutex.withLock {
displayMissedCallNotification(notificationData) if (activeCall.value != null) {
Timber.w("Already have an active call, ignoring incoming call: $notificationData") displayMissedCallNotification(notificationData)
return Timber.w("Already have an active call, ignoring incoming call: $notificationData")
} return
activeCall.value = ActiveCall( }
callType = CallType.RoomCall( activeCall.value = ActiveCall(
sessionId = notificationData.sessionId, callType = CallType.RoomCall(
roomId = notificationData.roomId, sessionId = notificationData.sessionId,
), roomId = notificationData.roomId,
callState = CallState.Ringing(notificationData), ),
) callState = CallState.Ringing(notificationData),
)
timedOutCallJob = coroutineScope.launch { timedOutCallJob = coroutineScope.launch {
showIncomingCallNotification(notificationData) showIncomingCallNotification(notificationData)
// Wait for the ringing call to time out // Wait for the ringing call to time out
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds) delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
incomingCallTimedOut(displayMissedCallNotification = true) incomingCallTimedOut(displayMissedCallNotification = true)
}
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
if (activeWakeLock?.isHeld == false) {
activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
}
} }
} }
@ -117,10 +138,13 @@ class DefaultActiveCallManager @Inject constructor(
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification. * Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/ */
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun incomingCallTimedOut(displayMissedCallNotification: Boolean) { suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
val previousActiveCall = activeCall.value ?: return val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null activeCall.value = null
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
cancelIncomingCallNotification() cancelIncomingCallNotification()
@ -129,18 +153,24 @@ class DefaultActiveCallManager @Inject constructor(
} }
} }
override fun hungUpCall(callType: CallType) { override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
if (activeCall.value?.callType != callType) { if (activeCall.value?.callType != callType) {
Timber.w("Call type $callType does not match the active call type, ignoring") Timber.w("Call type $callType does not match the active call type, ignoring")
return return
} }
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
timedOutCallJob?.cancel() timedOutCallJob?.cancel()
activeCall.value = null activeCall.value = null
} }
override fun joinedCall(callType: CallType) { override suspend fun joinedCall(callType: CallType) = mutex.withLock {
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
timedOutCallJob?.cancel() timedOutCallJob?.cancel()
activeCall.value = ActiveCall( activeCall.value = ActiveCall(
@ -201,6 +231,7 @@ class DefaultActiveCallManager @Inject constructor(
?.getRoom(callType.roomId) ?.getRoom(callType.roomId)
?.roomInfoFlow ?.roomInfoFlow
?.map { ?.map {
Timber.d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants) it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
} }
?: flowOf() ?: flowOf()

View file

@ -20,16 +20,21 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest { class DefaultElementCallEntryPointTest {
@Test @Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() { fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint() val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() { fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {} val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda) val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager) val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
@ -57,10 +63,12 @@ class DefaultElementCallEntryPointTest {
textContent = "textContent", textContent = "textContent",
) )
advanceTimeBy(1.seconds)
registerIncomingCallLambda.assertions().isCalledOnce() registerIncomingCallLambda.assertions().isCalledOnce()
} }
private fun createEntryPoint( private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint( ) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,

View file

@ -44,12 +44,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class CallScreenPresenterTest { @OptIn(ExperimentalCoroutinesApi::class) class CallScreenPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -66,7 +68,8 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
// Wait until the URL is loaded // Wait until the URL is loaded
skipItems(1) advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull() assertThat(initialState.webViewError).isNull()
@ -101,16 +104,23 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
// Wait until the URL is loaded // Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(1) skipItems(1)
joinedCallLambda.assertions().isCalledOnce() joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue() assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1) assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall)) analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
sendCallNotificationIfNeededLambda.assertions().isCalledOnce() sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
// Wait until the WidgetDriver is loaded
skipItems(1)
assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java)
} }
} }
@ -126,6 +136,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@ -141,7 +154,6 @@ class CallScreenPresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
@ -158,11 +170,15 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup) initialState.eventSink(CallScreenEvents.Hangup)
// Let background coroutines run // Let background coroutines run and the widget drive be received
runCurrent() runCurrent()
assertThat(navigator.closeCalled).isTrue() assertThat(navigator.closeCalled).isTrue()
@ -172,7 +188,6 @@ class CallScreenPresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
@ -189,11 +204,16 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""") messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run // Let background coroutines run
advanceTimeBy(1.seconds)
runCurrent() runCurrent()
assertThat(navigator.closeCalled).isTrue() assertThat(navigator.closeCalled).isTrue()
@ -218,7 +238,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@ -235,7 +257,7 @@ class CallScreenPresenterTest {
} }
""".trimIndent() """.trimIndent()
) )
skipItems(1) skipItems(2)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isCallActive).isTrue() assertThat(finalState.isCallActive).isTrue()
} }
@ -300,7 +322,8 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
// Wait until the URL is loaded // Wait until the URL is loaded
skipItems(1) advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem() val finalState = awaitItem()
@ -329,6 +352,8 @@ class CallScreenPresenterTest {
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.webViewError).isNull() assertThat(finalState.webViewError).isNull()
cancelAndIgnoreRemainingEvents()
} }
} }
@ -361,6 +386,7 @@ class CallScreenPresenterTest {
screenTracker = screenTracker, screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"), languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
appCoroutineScope = backgroundScope,
) )
} }
} }

View file

@ -7,7 +7,9 @@
package io.element.android.features.call.utils package io.element.android.features.call.utils
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
@ -49,6 +51,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest { class DefaultActiveCallManagerTest {
@ -57,10 +60,12 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest { fun `registerIncomingCall - sets the incoming call as active`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope { inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData() val callNotificationData = aCallNotificationData()
@ -78,6 +83,7 @@ class DefaultActiveCallManagerTest {
runCurrent() runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) } verify { notificationManagerCompat.notify(notificationId, any()) }
} }
} }
@ -128,6 +134,7 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest { fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope { inCancellableScope {
@ -138,11 +145,13 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true) manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1) advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce() addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
@ -150,6 +159,7 @@ class DefaultActiveCallManagerTest {
@Test @Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest { fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed // Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope { inCancellableScope {
@ -158,9 +168,11 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData() val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData) manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
@ -168,6 +180,7 @@ class DefaultActiveCallManagerTest {
@Test @Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed // Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope { inCancellableScope {
@ -175,9 +188,11 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com")) manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
} }
@ -284,12 +299,19 @@ class DefaultActiveCallManagerTest {
} }
} }
private fun setupShadowPowerManager() {
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
}
}
private fun CoroutineScope.createActiveCallManager( private fun CoroutineScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(), onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true), notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
coroutineScope: CoroutineScope = this, coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager( ) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler, onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator( ringingCallNotificationCreator = RingingCallNotificationCreator(

View file

@ -11,6 +11,7 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager( class FakeActiveCallManager(
@ -20,15 +21,15 @@ class FakeActiveCallManager(
) : ActiveCallManager { ) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) { override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
registerIncomingCallResult(notificationData) registerIncomingCallResult(notificationData)
} }
override fun hungUpCall(callType: CallType) { override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType) hungUpCallResult(callType)
} }
override fun joinedCall(callType: CallType) { override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType) joinedCallResult(callType)
} }

View file

@ -30,7 +30,7 @@ class FakeElementCallEntryPoint(
startCallResult(callType) startCallResult(callType)
} }
override fun handleIncomingCall( override suspend fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,

View file

@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
} }
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine( val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers(), selectedUserListDataStore.selectedUsers,
createRoomConfigFlow, createRoomConfigFlow,
) { selectedUsers, config -> ) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList()) config.copy(invites = selectedUsers.toImmutableList())

View file

@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig()) val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false) val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(initial = false)
val roomAddressValidity = remember { val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown) mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
} }

View file

@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false) val isRoomDirectorySearchEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
}.collectAsState(initial = false)
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {

View file

@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms() recentDirectRooms = matrixClient.getRecentDirectRooms()
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember { var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.Initial()) mutableStateOf(SearchBarResultState.Initial())

View file

@ -8,22 +8,22 @@
package io.element.android.features.createroom.impl.userlist package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject import javax.inject.Inject
class UserListDataStore @Inject constructor() { class UserListDataStore @Inject constructor() {
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList()) private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) { fun selectUser(user: MatrixUser) {
if (!selectedUsers.value.contains(user)) { if (!_selectedUsers.value.contains(user)) {
selectedUsers.tryEmit(selectedUsers.value.plus(user)) _selectedUsers.tryEmit(_selectedUsers.value.plus(user))
} }
} }
fun removeUserFromSelection(user: MatrixUser) { fun removeUserFromSelection(user: MatrixUser) {
selectedUsers.tryEmit(selectedUsers.value.minus(user)) _selectedUsers.tryEmit(_selectedUsers.value.minus(user))
} }
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers val selectedUsers = _selectedUsers.asStateFlow()
} }

View file

@ -18,4 +18,6 @@ Gelaren ezarpenetan aldatu dezakezu hobespena."</string>
<string name="screen_create_room_topic_label">"Mintzagaia (aukerakoa)"</string> <string name="screen_create_room_topic_label">"Mintzagaia (aukerakoa)"</string>
<string name="screen_room_directory_search_title">"Gelen direktorioa"</string> <string name="screen_room_directory_search_title">"Gelen direktorioa"</string>
<string name="screen_start_chat_error_starting_chat">"Errorea gertatu da txata hasten saiatzean"</string> <string name="screen_start_chat_error_starting_chat">"Errorea gertatu da txata hasten saiatzean"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Sartu…"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Ez da gela aurkitu"</string>
</resources> </resources>

View file

@ -18,4 +18,7 @@ Du kan endre dette når som helst i rominnstillingene."</string>
<string name="screen_create_room_topic_label">"Emne (valgfritt)"</string> <string name="screen_create_room_topic_label">"Emne (valgfritt)"</string>
<string name="screen_room_directory_search_title">"Romkatalog"</string> <string name="screen_room_directory_search_title">"Romkatalog"</string>
<string name="screen_start_chat_error_starting_chat">"Det oppstod en feil når du prøvde å starte en chat"</string> <string name="screen_start_chat_error_starting_chat">"Det oppstod en feil når du prøvde å starte en chat"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ikke en gyldig adresse"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Rom ikke funnet"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"f.eks. #rom-navn:matrix.org"</string>
</resources> </resources>

View file

@ -21,4 +21,10 @@ Możesz to zmienić w ustawieniach pokoju."</string>
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string> <string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
<string name="screen_room_directory_search_title">"Katalog pokoi"</string> <string name="screen_room_directory_search_title">"Katalog pokoi"</string>
<string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string> <string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>
<string name="screen_start_chat_join_room_by_address_action">"Dołącz do pokoju za pomocą adresu"</string>
<string name="screen_start_chat_join_room_by_address_invalid_address">"Nieprawidłowy adres"</string>
<string name="screen_start_chat_join_room_by_address_placeholder">"Wprowadź…"</string>
<string name="screen_start_chat_join_room_by_address_room_found">"Znaleziono pasujący pokój"</string>
<string name="screen_start_chat_join_room_by_address_room_not_found">"Nie znaleziono pokoju"</string>
<string name="screen_start_chat_join_room_by_address_supporting_text">"np. #room-name:matrix.org"</string>
</resources> </resources>

View file

@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
moveToNextStepIfNeeded() moveToNextStepIfNeeded()
}) })
analyticsService.didAskUserConsent() analyticsService.didAskUserConsentFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { moveToNextStepIfNeeded() } .onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)

View file

@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
.onEach { updateState() } .onEach { updateState() }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsent() analyticsService.didAskUserConsentFlow
.distinctUntilChanged() .distinctUntilChanged()
.onEach { updateState() } .onEach { updateState() }
.launchIn(sessionCoroutineScope) .launchIn(sessionCoroutineScope)
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
} }
private suspend fun needsAnalyticsOptIn(): Boolean { private suspend fun needsAnalyticsOptIn(): Boolean {
return analyticsService.didAskUserConsent().first().not() return analyticsService.didAskUserConsentFlow.first().not()
} }
private suspend fun shouldAskNotificationPermissions(): Boolean { private suspend fun shouldAskNotificationPermissions(): Boolean {

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
interface SeenInvitesStore {
/**
* Returns a flow of seen room IDs of invitation.
*/
fun seenRoomIds(): Flow<Set<RoomId>>
/**
* Mark the invitation as seen.
* Call this when the invitation details are shown to the user.
* @param roomId the room ID of the invitation to mark as seen.
*/
suspend fun markAsSeen(roomId: RoomId)
/**
* Mark the invitation as unseen.
* Call this when the invitation has been accepted or declined.
* @param roomId the room ID of the invitation to mark as unseen.
*/
suspend fun markAsUnSeen(roomId: RoomId)
/**
* Delete the store.
*/
suspend fun clear()
}

View file

@ -21,6 +21,7 @@ setupAnvil()
dependencies { dependencies {
api(projects.features.invite.api) api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@ -35,6 +36,7 @@ dependencies {
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)

View file

@ -0,0 +1,90 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.impl
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class)
class DefaultSeenInvitesStore @Inject constructor(
@ApplicationContext context: Context,
currentSessionIdHolder: CurrentSessionIdHolder,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
sessionObserver: SessionObserver,
) : SeenInvitesStore {
private val sessionId: SessionId = currentSessionIdHolder.current
init {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionCreated(userId: String) = Unit
override suspend fun onSessionDeleted(userId: String) {
if (sessionId.value == userId) {
clear()
}
}
})
}
private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
}
private val store = PreferenceDataStoreFactory.create(
scope = sessionCoroutineScope,
migrations = emptyList(),
) {
dataStoreFile
}
override fun seenRoomIds(): Flow<Set<RoomId>> =
store.data.map { prefs ->
prefs[seenInvitesKey]
.orEmpty()
.map { RoomId(it) }
.toSet()
}
override suspend fun markAsSeen(roomId: RoomId) {
store.edit { prefs ->
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
}
}
override suspend fun markAsUnSeen(roomId: RoomId) {
store.edit { prefs ->
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
}
}
override suspend fun clear() {
dataStoreFile.safeDelete()
}
}

View file

@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val joinRoom: JoinRoom, private val joinRoom: JoinRoom,
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<AcceptDeclineInviteState> { ) : Presenter<AcceptDeclineInviteState> {
@Composable @Composable
override fun present(): AcceptDeclineInviteState { override fun present(): AcceptDeclineInviteState {
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
) )
.onSuccess { .onSuccess {
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId) notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
seenInvitesStore.markAsUnSeen(roomId)
} }
.map { roomId } .map { roomId }
} }
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
client.ignoreUser(inviteData.senderId).getOrThrow() client.ignoreUser(inviteData.senderId).getOrThrow()
} }
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId) notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
seenInvitesStore.markAsUnSeen(inviteData.roomId)
inviteData.roomId inviteData.roomId
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }

View file

@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
@Test @Test
fun `present - declining invite cancel flow`() = runTest { fun `present - declining invite cancel flow`() = runTest {
val presenter = createAcceptDeclineInvitePresenter() val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure)) Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
} }
) )
val presenter = createAcceptDeclineInvitePresenter(client = client) val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
} }
assert(declineInviteFailure).isCalledOnce() assert(declineInviteFailure).isCalledOnce()
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess)) Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
} }
) )
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
client = client, client = client,
notificationCleaner = fakeNotificationCleaner, notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions() clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
}, },
ignoreUserResult = ignoreUserSuccess ignoreUserResult = ignoreUserSuccess
) )
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
client = client, client = client,
notificationCleaner = fakeNotificationCleaner, notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions() clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure)) Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
} }
) )
val presenter = createAcceptDeclineInvitePresenter(client = client) val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
} }
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger -> val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias")) Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
} }
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure) val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomFailure,
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList<String>()), value(emptyList<String>()),
value(JoinedRoom.Trigger.Invite) value(JoinedRoom.Trigger.Invite)
) )
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger -> val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.success(Unit) Result.success(Unit)
} }
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess, joinRoomLambda = joinRoomSuccess,
notificationCleaner = fakeNotificationCleaner, notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions() clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
} }
private fun anInviteData( private fun anInviteData(
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit) Result.success(Unit)
}, },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): AcceptDeclineInvitePresenter { ): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter( return AcceptDeclineInvitePresenter(
client = client, client = client,
joinRoom = FakeJoinRoom(joinRoomLambda), joinRoom = FakeJoinRoom(joinRoomLambda),
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
} }
} }

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.invite.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
api(projects.features.invite.api)
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.test
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemorySeenInvitesStore(
initialRoomIds: Set<RoomId> = emptySet(),
) : SeenInvitesStore {
private val roomIds = MutableStateFlow(initialRoomIds)
override fun seenRoomIds(): Flow<Set<RoomId>> = roomIds
override suspend fun markAsSeen(roomId: RoomId) {
roomIds.value += roomId
}
override suspend fun markAsUnSeen(roomId: RoomId) {
roomIds.value -= roomId
}
override suspend fun clear() {
roomIds.value = emptySet()
}
}

View file

@ -42,6 +42,7 @@ dependencies {
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)

View file

@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
@ -67,6 +69,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val forgetRoom: ForgetRoom, private val forgetRoom: ForgetRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { interface Factory {
fun create( fun create(
@ -82,7 +85,9 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState { override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) } var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty()) val roomInfo by remember {
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
}.collectAsState(initial = Optional.empty())
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
@ -147,6 +152,10 @@ class JoinRoomPresenter @AssistedInject constructor(
} }
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(contentState) {
contentState.markRoomInviteAsSeen()
}
fun handleEvents(event: JoinRoomEvents) { fun handleEvents(event: JoinRoomEvents) {
when (event) { when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
@ -234,6 +243,12 @@ class JoinRoomPresenter @AssistedInject constructor(
forgetRoom.invoke(roomId) forgetRoom.invoke(roomId)
} }
} }
private suspend fun ContentState.markRoomInviteAsSeen() {
if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
seenInvitesStore.markAsSeen(roomId)
}
}
} }
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState { private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {

View file

@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
@ -35,6 +36,7 @@ object JoinRoomModule {
forgetRoom: ForgetRoom, forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta, buildMeta: BuildMeta,
seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory {
override fun create( override fun create(
@ -57,6 +59,7 @@ object JoinRoomModule {
cancelKnockRoom = cancelKnockRoom, cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta, buildMeta = buildMeta,
seenInvitesStore = seenInvitesStore,
) )
} }
} }

View file

@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.ForgetRoom import io.element.android.features.joinroom.impl.di.ForgetRoom
import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.joinroom.impl.di.KnockRoom
@ -52,6 +54,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -111,14 +114,19 @@ class JoinRoomPresenterTest {
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomSummary))
} }
} }
val seenInvitesStore = InMemorySeenInvitesStore()
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
matrixClient = matrixClient matrixClient = matrixClient,
seenInvitesStore = seenInvitesStore,
) )
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null)) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
} }
// Check that the roomId is stored in the seen invites store
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
} }
} }
@ -759,7 +767,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(), cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(), forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() } acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter { ): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
@ -773,7 +782,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom = cancelKnockRoom, cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom, forgetRoom = forgetRoom,
buildMeta = buildMeta, buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
seenInvitesStore = seenInvitesStore,
) )
} }

View file

@ -4,7 +4,10 @@
<string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string> <string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string>
<string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string> <string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string>
<string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string> <string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Δεν μπορέσαμε να δεχτούμε όλα τα αιτήματα. Θες να προσπαθήσεις ξανά;"</string>
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Αποτυχία αποδοχής όλων των αιτημάτων"</string>
<string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string> <string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string>
<string name="screen_knock_requests_list_accept_failed_alert_title">"Αποτυχία αποδοχής αιτήματος"</string>
<string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string> <string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string>
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string> <string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string>
<string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string> <string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
@ -17,6 +20,7 @@
<string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string> <string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string>
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string> <string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string> <string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Φόρτωση αιτημάτων συμμετοχής…"</string>
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string> <string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
<plurals name="screen_room_multiple_knock_requests_title"> <plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item> <item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>

View file

@ -7,7 +7,6 @@
package io.element.android.features.licenses.impl.list package io.element.android.features.licenses.impl.list
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -15,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -33,10 +31,11 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DependencyLicensesListView( fun DependencyLicensesListView(
state: DependencyLicensesListState, state: DependencyLicensesListState,
@ -60,7 +59,7 @@ fun DependencyLicensesListView(
) { ) {
if (state.licenses.isSuccess()) { if (state.licenses.isSuccess()) {
// Search field // Search field
OutlinedTextField( TextField(
value = state.filter, value = state.filter,
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) }, onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
leadingIcon = { leadingIcon = {

View file

@ -6,6 +6,7 @@
*/ */
import config.BuildTimeConfig import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.readLocalProperty import extension.readLocalProperty
plugins { plugins {
@ -16,10 +17,17 @@ plugins {
android { android {
namespace = "io.element.android.features.location.api" namespace = "io.element.android.features.location.api"
buildFeatures {
buildConfig = true
}
defaultConfig { defaultConfig {
resValue( buildConfigFieldStr(
type = "string", name = "MAPTILER_BASE_URL",
name = "maptiler_api_key", value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps"
)
buildConfigFieldStr(
name = "MAPTILER_API_KEY",
value = if (isEnterpriseBuild) { value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_APIKEY BuildTimeConfig.SERVICES_MAPTILER_APIKEY
} else { } else {
@ -28,9 +36,8 @@ android {
} }
?: "" ?: ""
) )
resValue( buildConfigFieldStr(
type = "string", name = "MAPTILER_LIGHT_MAP_ID",
name = "maptiler_light_map_id",
value = if (isEnterpriseBuild) { value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
} else { } else {
@ -40,9 +47,8 @@ android {
// fall back to maptiler's default light map. // fall back to maptiler's default light map.
?: "basic-v2" ?: "basic-v2"
) )
resValue( buildConfigFieldStr(
type = "string", name = "MAPTILER_DARK_MAP_ID",
name = "maptiler_dark_map_id",
value = if (isEnterpriseBuild) { value = if (isEnterpriseBuild) {
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
} else { } else {

View file

@ -57,7 +57,7 @@ fun StaticMapView(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) } var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) } val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter( val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) { model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).

View file

@ -1,21 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api.internal
import android.content.Context
import io.element.android.features.location.api.R
internal const val MAPTILER_BASE_URL = "https://api.maptiler.com/maps"
internal fun Context.mapId(darkMode: Boolean) = when (darkMode) {
true -> getString(R.string.maptiler_dark_map_id)
false -> getString(R.string.maptiler_light_map_id)
}
internal val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

View file

@ -7,7 +7,7 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import android.content.Context import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@ -16,14 +16,16 @@ import kotlin.math.roundToInt
* https://docs.maptiler.com/cloud/api/static-maps/ * https://docs.maptiler.com/cloud/api/static-maps/
*/ */
internal class MapTilerStaticMapUrlBuilder( internal class MapTilerStaticMapUrlBuilder(
private val baseUrl: String,
private val apiKey: String, private val apiKey: String,
private val lightMapId: String, private val lightMapId: String,
private val darkMapId: String, private val darkMapId: String,
) : StaticMapUrlBuilder { ) : StaticMapUrlBuilder {
constructor(context: Context) : this( constructor() : this(
apiKey = context.apiKey, baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
lightMapId = context.mapId(darkMode = false), apiKey = BuildConfig.MAPTILER_API_KEY,
darkMapId = context.mapId(darkMode = true), lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
) )
override fun build( override fun build(
@ -55,7 +57,7 @@ internal class MapTilerStaticMapUrlBuilder(
// image smaller than the available space in pixels. // image smaller than the available space in pixels.
// The resulting image will have to be scaled to fit the available space in order // The resulting image will have to be scaled to fit the available space in order
// to keep the perceived content size constant at the expense of sharpness. // to keep the perceived content size constant at the expense of sharpness.
return "$MAPTILER_BASE_URL/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft" return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
} }
override fun isServiceAvailable() = apiKey.isNotEmpty() override fun isServiceAvailable() = apiKey.isNotEmpty()

View file

@ -9,21 +9,23 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import android.content.Context import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder( internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
private val apiKey: String, private val apiKey: String,
private val lightMapId: String, private val lightMapId: String,
private val darkMapId: String, private val darkMapId: String,
) : TileServerStyleUriBuilder { ) : TileServerStyleUriBuilder {
constructor(context: Context) : this( constructor() : this(
apiKey = context.apiKey, baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
lightMapId = context.mapId(darkMode = false), apiKey = BuildConfig.MAPTILER_API_KEY,
darkMapId = context.mapId(darkMode = true), lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
) )
override fun build(darkMode: Boolean): String { override fun build(darkMode: Boolean): String {
val mapId = if (darkMode) darkMapId else lightMapId val mapId = if (darkMode) darkMapId else lightMapId
return "$MAPTILER_BASE_URL/$mapId/style.json?key=$apiKey" return "$baseUrl/$mapId/style.json?key=$apiKey"
} }
} }

View file

@ -7,8 +7,6 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import android.content.Context
/** /**
* Builds an URL for a 3rd party service provider static maps API. * Builds an URL for a 3rd party service provider static maps API.
*/ */
@ -26,4 +24,4 @@ interface StaticMapUrlBuilder {
fun isServiceAvailable(): Boolean fun isServiceAvailable(): Boolean
} }
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context) fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder()

View file

@ -7,10 +7,8 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
/** /**
@ -24,7 +22,7 @@ interface TileServerStyleUriBuilder {
): String ): String
} }
fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder(context = context) fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder()
/** /**
* Provides and remembers a style URI for a MapLibre compatible tile server. * Provides and remembers a style URI for a MapLibre compatible tile server.
@ -33,9 +31,8 @@ fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = Map
*/ */
@Composable @Composable
fun rememberTileStyleUrl(): String { fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) { return remember(darkMode) {
TileServerStyleUriBuilder(context).build(darkMode) TileServerStyleUriBuilder().build(darkMode)
} }
} }

View file

@ -12,6 +12,7 @@ import org.junit.Test
class MapTilerStaticMapUrlBuilderTest { class MapTilerStaticMapUrlBuilderTest {
private val builder = MapTilerStaticMapUrlBuilder( private val builder = MapTilerStaticMapUrlBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey", apiKey = "anApiKey",
lightMapId = "aLightMapId", lightMapId = "aLightMapId",
darkMapId = "aDarkMapId", darkMapId = "aDarkMapId",
@ -25,6 +26,7 @@ class MapTilerStaticMapUrlBuilderTest {
@Test @Test
fun `isServiceAvailable returns false if api key is empty`() { fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder( val builderWithoutKey = MapTilerStaticMapUrlBuilder(
baseUrl = "https://base.url",
apiKey = "", apiKey = "",
lightMapId = "aLightMapId", lightMapId = "aLightMapId",
darkMapId = "aDarkMapId", darkMapId = "aDarkMapId",
@ -44,7 +46,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 600, height = 600,
density = 1f, density = 1f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
} }
@Test @Test
@ -59,7 +61,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 900, height = 900,
density = 1.5f, density = 1.5f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
} }
@Test @Test
@ -74,7 +76,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1200, height = 1200,
density = 2f, density = 2f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
} }
@Test @Test
@ -89,7 +91,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1800, height = 1800,
density = 3f, density = 3f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
} }
@Test @Test
@ -104,7 +106,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048, height = 2048,
density = 1f, density = 1f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
assertThat( assertThat(
builder.build( builder.build(
@ -116,7 +118,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096, height = 4096,
density = 1f, density = 1f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
assertThat( assertThat(
builder.build( builder.build(
@ -128,7 +130,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048, height = 2048,
density = 2f, density = 2f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat( assertThat(
builder.build( builder.build(
@ -140,7 +142,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096, height = 4096,
density = 2f, density = 2f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat( assertThat(
builder.build( builder.build(
@ -152,7 +154,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MAX_VALUE, height = Int.MAX_VALUE,
density = 2f, density = 2f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
} }
@Test @Test
@ -167,7 +169,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0, height = 0,
density = 1f, density = 1f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
assertThat( assertThat(
builder.build( builder.build(
@ -179,7 +181,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0, height = 0,
density = 2f, density = 2f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
assertThat( assertThat(
builder.build( builder.build(
@ -191,6 +193,6 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MIN_VALUE, height = Int.MIN_VALUE,
density = 1f, density = 1f,
) )
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
} }
} }

View file

@ -12,6 +12,7 @@ import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest { class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder( private val builder = MapTilerTileServerStyleUriBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey", apiKey = "anApiKey",
lightMapId = "aLightMapId", lightMapId = "aLightMapId",
darkMapId = "aDarkMapId", darkMapId = "aDarkMapId",
@ -21,13 +22,13 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `light map uri`() { fun `light map uri`() {
assertThat( assertThat(
builder.build(darkMode = false) builder.build(darkMode = false)
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/style.json?key=anApiKey") ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
} }
@Test @Test
fun `dark map uri`() { fun `dark map uri`() {
assertThat( assertThat(
builder.build(darkMode = true) builder.build(darkMode = true)
).isEqualTo("https://api.maptiler.com/maps/aDarkMapId/style.json?key=anApiKey") ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
} }
} }

View file

@ -49,7 +49,6 @@ dependencies {
testImplementation(projects.libraries.testtags) testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -8,17 +8,14 @@
package io.element.android.features.location.impl package io.element.android.features.location.impl
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.BuildConfig
import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.R
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultLocationService @Inject constructor( class DefaultLocationService @Inject constructor() : LocationService {
private val stringProvider: StringProvider,
) : LocationService {
override fun isServiceAvailable(): Boolean { override fun isServiceAvailable(): Boolean {
return stringProvider.getString(R.string.maptiler_api_key).isNotEmpty() return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
} }
} }

View file

@ -11,12 +11,14 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber import timber.log.Timber
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
) : LocationActions { ) : LocationActions {
override fun share(location: Location, label: String?) { override fun share(location: Location, label: String?) {
runCatching { runCatching {
val uri = Uri.parse(buildUrl(location, label)) val uri = buildUrl(location, label).toUri()
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri) val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
val chooserIntent = Intent.createChooser(showMapsIntent, null) val chooserIntent = Intent.createChooser(showMapsIntent, null)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
} }
} }
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
@VisibleForTesting @VisibleForTesting
internal fun buildUrl( internal fun buildUrl(
location: Location, location: Location,
label: String?, label: String?,
urlEncoder: (String) -> String = Uri::encode urlEncoder: (String) -> String = Uri::encode
): String { ): String {
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap // This is needed so the coordinates are formatted with a dot as decimal separator
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon) val locale = Locale.ENGLISH
return if (label == null) { return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
base
} else {
"%s (%s)".format(base, urlEncoder(label))
}
} }

View file

@ -8,30 +8,15 @@
package io.element.android.features.location.impl package io.element.android.features.location.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.R import io.element.android.features.location.api.BuildConfig
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import org.junit.Test import org.junit.Test
class DefaultLocationServiceTest { class DefaultLocationServiceTest {
@Test @Test
fun `if apiKey is empty, isServiceAvailable should return false`() { fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
val fakeStringProvider = FakeStringProvider( val locationService = DefaultLocationService()
defaultResult = "" assertThat(locationService.isServiceAvailable()).isEqualTo(
BuildConfig.MAPTILER_API_KEY.isNotEmpty()
) )
val locationService = DefaultLocationService(
stringProvider = fakeStringProvider,
)
assertThat(locationService.isServiceAvailable()).isFalse()
assertThat(fakeStringProvider.lastResIdParam).isEqualTo(R.string.maptiler_api_key)
}
@Test
fun `if apiKey is not empty, isServiceAvailable should return true`() {
val locationService = DefaultLocationService(
stringProvider = FakeStringProvider(
defaultResult = "aKey"
)
)
assertThat(locationService.isServiceAvailable()).isTrue()
} }
} }

View file

@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import org.junit.Test import org.junit.Test
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Locale
internal class AndroidLocationActionsTest { internal class AndroidLocationActionsTest {
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests // We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
) )
val actual = buildUrl(location, null, ::urlEncoder) val actual = buildUrl(location, null, ::urlEncoder)
val expected = "geo:0,0?q=1.234568,123.456789" val expected = "geo:0,0?q=1.234568,123.456789 ()"
assertThat(actual).isEqualTo(expected) assertThat(actual).isEqualTo(expected)
} }
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
assertThat(actual).isEqualTo(expected) assertThat(actual).isEqualTo(expected)
} }
@Test
fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
val location = Location(
lat = 1.000001,
lon = 2.000001,
accuracy = 0f
)
// Set a locale with comma as decimal separator
Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
assertThat(actual).isEqualTo(expected)
}
} }

View file

@ -14,6 +14,10 @@ plugins {
android { android {
namespace = "io.element.android.features.lockscreen.impl" namespace = "io.element.android.features.lockscreen.impl"
testOptions {
unitTests.isIncludeAndroidResources = true
}
} }
setupAnvil() setupAnvil()
@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api) implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.sessionStorage.api)
@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
} }

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