Merge branch 'release/25.04.0' into main

This commit is contained in:
Benoit Marty 2025-04-08 15:49:39 +02:00
commit 2c67c01f10
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
on:
pull_request:
pull_request_target:
types: [ opened, edited, labeled, unlabeled, synchronize ]
workflow_call:
secrets:

2
.idea/kotlinc.xml generated
View file

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

View file

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

View file

@ -9,6 +9,8 @@ package io.element.android.x
import android.app.Application
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.libraries.di.DaggerComponentOwner
import io.element.android.x.di.AppComponent
@ -23,7 +25,9 @@ class ElementXApplication : Application(), DaggerComponentOwner {
override fun onCreate() {
super.onCreate()
AppInitializer.getInstance(this).apply {
initializeComponent(CrashInitializer::class.java)
if (RageshakeConfig.isEnabled) {
initializeComponent(CrashInitializer::class.java)
}
initializeComponent(PlatformInitializer::class.java)
initializeComponent(CacheCleanerInitializer::class.java)
}

View file

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

View file

@ -5,8 +5,8 @@
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<!-- Must be equal to DarkDesignTokens.colorThemeBg -->
<!-- Must be equal to DarkColorTokens.colorThemeBg -->
<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>
</resources>

View file

@ -1,3 +1,6 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
/*
* Copyright 2022-2024 New Vector Ltd.
*
@ -10,6 +13,37 @@ plugins {
android {
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 {

View file

@ -8,5 +8,5 @@
package io.element.android.appconfig
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.
*/
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:
* Identifier for the application (eg 'riot-web').
* 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.
*/
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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
internal fun IconPreview() {
Box {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
Image(
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
internal fun RoundIconPreview() {
Box(modifier = Modifier.clip(shape = CircleShape)) {
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
Image(
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() {
Box(
modifier = Modifier
.size(108.dp)
.background(Color(0xFF2F3133))
.clip(shape = RoundedCornerShape(32.dp)),
contentAlignment = Alignment.Center
.background(Color(0xFF2F3133)),
) {
Image(
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"
android:src="@mipmap/ic_launcher_background" />
<vector xmlns:android="http://schemas.android.com/apk/res/android"
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.sync.SlidingSyncVersion
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.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
derivedStateOf {
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.alias.ResolvedRoomAlias
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.distinctUntilChanged
import kotlinx.coroutines.flow.first
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
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.matrix.api.core.RoomId
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.launchIn
import kotlinx.coroutines.flow.map
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
val isOnline by syncService.isOnline().collectAsState()
val isOnline by syncService.isOnline.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
hasNetworkConnection = isOnline,

View file

@ -241,7 +241,7 @@ class IntentResolverTest {
}
private fun createIntentResolver(
permalinkParserResult: () -> PermalinkData = { lambdaError() }
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
): IntentResolver {
return IntentResolver(
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:
• 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
For individuals and communities - private communication between family, friends, hobby groups, clubs, etc.
All this comes in a secure & sovereign fashion without compromising responsiveness or overall usability of the app:
• 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
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.
If youre a new user, use the new Element X app from the start. Compared to the current Element app you will get:
• greatly enhanced performance, sleek user interface and overall better user experience
• enterprise-grade support for single sign-on (OIDC)
• QR-code based login & device verification
• natively integrated Element Call for video calls
• continuous improvements, bug fixes and new features
Stay in touch with friends, family and communities with:
• Real time messaging & video calls
• Public rooms for open group communication
• Private rooms for closed group communication
• Rich messaging features: emoji reactions, replies, polls, pinned messages and more.
• 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>
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.
<b>Own your conversations</b>
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>
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.
<b>Communicate in real time, all the time</b>
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>
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.
<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
<b>Element X is our next-generation app</b>
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.
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>
get() = sequenceOf(
aAnalyticsPreferencesState().copy(isEnabled = true),
aAnalyticsPreferencesState().copy(isEnabled = true, policyUrl = ""),
)
}
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
applicationName = "Element X",
isEnabled = false,
policyUrl = "https://element.io",
fun aAnalyticsPreferencesState(
applicationName: String = "Element X",
isEnabled: Boolean = false,
policyUrl: String = "https://element.io",
) = AnalyticsPreferencesState(
applicationName = applicationName,
isEnabled = isEnabled,
policyUrl = policyUrl,
eventSink = {}
)

View file

@ -36,11 +36,6 @@ fun AnalyticsPreferencesView(
id = R.string.screen_analytics_settings_help_us_improve,
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) {
ListItem(
headlineContent = {
@ -57,7 +52,14 @@ fun AnalyticsPreferencesView(
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.rememberCoroutineScope
import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@ -36,6 +37,7 @@ class AnalyticsOptInPresenter @Inject constructor(
return AnalyticsOptInState(
applicationName = buildMeta.applicationName,
hasPolicyLink = AnalyticsConfig.POLICY_LINK.isNotEmpty(),
eventSink = ::handleEvents
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
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.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
@ -35,7 +36,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1)
val initialState = awaitItem()
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 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,
eventId: EventId,
senderId: UserId,

View file

@ -1,3 +1,4 @@
import extension.buildConfigFieldStr
import extension.readLocalProperty
import extension.setupAnvil
@ -26,45 +27,35 @@ android {
}
defaultConfig {
buildConfigField(
type = "String",
buildConfigFieldStr(
name = "SENTRY_DSN",
value = (System.getenv("ELEMENT_CALL_SENTRY_DSN")
value = System.getenv("ELEMENT_CALL_SENTRY_DSN")
?: readLocalProperty("features.call.sentry.dsn")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
buildConfigFieldStr(
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")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
buildConfigFieldStr(
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")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
buildConfigFieldStr(
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")
?: ""
).let { "\"$it\"" }
)
buildConfigField(
type = "String",
buildConfigFieldStr(
name = "RAGESHAKE_URL",
value = (System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
?: readLocalProperty("features.call.regeshake.url")
?: ""
).let { "\"$it\"" }
)
}
}

View file

@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
context.startActivity(IntentProvider.createIntent(context, callType))
}
override fun handleIncomingCall(
override suspend fun handleIncomingCall(
callType: CallType.RoomCall,
eventId: EventId,
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.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -27,10 +29,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
}
@Inject
lateinit var activeCallManager: ActiveCallManager
@Inject
lateinit var appCoroutineScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
?: return
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 languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
@ -87,7 +88,7 @@ class CallScreenPresenter @AssistedInject constructor(
coroutineScope.launch {
// Sets the call as joined
activeCallManager.joinedCall(callType)
loadUrl(
fetchRoomCallUrl(
inputs = callType,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
@ -96,7 +97,7 @@ class CallScreenPresenter @AssistedInject constructor(
)
}
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,
urlState: MutableState<AsyncData<String>>,
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.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -55,6 +57,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var buildMeta: BuildMeta
@Inject
lateinit var appCoroutineScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -102,6 +107,8 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
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
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig
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.RingingCallNotificationCreator
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.matrix.api.MatrixClientProvider
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.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
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.
* @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.
* @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].
*
* @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)
@ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor(
@ApplicationContext context: Context,
private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
@ -83,33 +90,47 @@ class DefaultActiveCallManager @Inject constructor(
) : ActiveCallManager {
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)
private val mutex = Mutex()
init {
observeRingingCall()
observeCurrentCall()
}
override fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) {
displayMissedCallNotification(notificationData)
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
return
}
activeCall.value = ActiveCall(
callType = CallType.RoomCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
),
callState = CallState.Ringing(notificationData),
)
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
mutex.withLock {
if (activeCall.value != null) {
displayMissedCallNotification(notificationData)
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
return
}
activeCall.value = ActiveCall(
callType = CallType.RoomCall(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
),
callState = CallState.Ringing(notificationData),
)
timedOutCallJob = coroutineScope.launch {
showIncomingCallNotification(notificationData)
timedOutCallJob = coroutineScope.launch {
showIncomingCallNotification(notificationData)
// Wait for the ringing call to time out
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
incomingCallTimedOut(displayMissedCallNotification = true)
// Wait for the ringing call to time out
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
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.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun incomingCallTimedOut(displayMissedCallNotification: Boolean) {
suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
val previousActiveCall = activeCall.value ?: return
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
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) {
Timber.w("Call type $callType does not match the active call type, ignoring")
return
}
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
timedOutCallJob?.cancel()
activeCall.value = null
}
override fun joinedCall(callType: CallType) {
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
@ -201,6 +231,7 @@ class DefaultActiveCallManager @Inject constructor(
?.getRoom(callType.roomId)
?.roomInfoFlow
?.map {
Timber.d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
}
?: 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_USER_ID_2
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.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() {
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
@ -57,10 +63,12 @@ class DefaultElementCallEntryPointTest {
textContent = "textContent",
)
advanceTimeBy(1.seconds)
registerIncomingCallLambda.assertions().isCalledOnce()
}
private fun createEntryPoint(
private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext,

View file

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

View file

@ -7,7 +7,9 @@
package io.element.android.features.call.utils
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
@ -49,6 +51,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest {
@ -57,10 +60,12 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData()
@ -78,6 +83,7 @@ class DefaultActiveCallManagerTest {
runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) }
}
}
@ -128,6 +134,7 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
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 addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope {
@ -138,11 +145,13 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) }
}
@ -150,6 +159,7 @@ class DefaultActiveCallManagerTest {
@Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
@ -158,9 +168,11 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) }
}
@ -168,6 +180,7 @@ class DefaultActiveCallManagerTest {
@Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
@ -175,9 +188,11 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
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(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = coroutineScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
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.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
@ -20,15 +21,15 @@ class FakeActiveCallManager(
) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) {
override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
registerIncomingCallResult(notificationData)
}
override fun hungUpCall(callType: CallType) {
override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType)
}
override fun joinedCall(callType: CallType) {
override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType)
}

View file

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

View file

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

View file

@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
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 {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}

View file

@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
val localCoroutineScope = rememberCoroutineScope()
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) {
when (event) {

View file

@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
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 searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.Initial())

View file

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

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_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_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>

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_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_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>

View file

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

View file

@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
analyticsService.didAskUserConsent()
analyticsService.didAskUserConsentFlow
.distinctUntilChanged()
.onEach { updateState() }
.launchIn(sessionCoroutineScope)
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
}
private suspend fun needsAnalyticsOptIn(): Boolean {
return analyticsService.didAskUserConsent().first().not()
return analyticsService.didAskUserConsentFlow.first().not()
}
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 {
api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@ -35,6 +36,7 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.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.rememberCoroutineScope
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.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient,
private val joinRoom: JoinRoom,
private val notificationCleaner: NotificationCleaner,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<AcceptDeclineInviteState> {
@Composable
override fun present(): AcceptDeclineInviteState {
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
)
.onSuccess {
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
seenInvitesStore.markAsUnSeen(roomId)
}
.map { roomId }
}
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
client.ignoreUser(inviteData.senderId).getOrThrow()
}
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
seenInvitesStore.markAsUnSeen(inviteData.roomId)
inviteData.roomId
}.runCatchingUpdatingState(declinedAction)
}

View file

@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat
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.ConfirmingDeclineInvite
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.matrix.api.MatrixClient
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.toRoomIdOrAlias
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_SESSION_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.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
@Test
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 {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
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
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
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 {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
cancelAndConsumeRemainingEvents()
}
assert(declineInviteFailure).isCalledOnce()
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
}
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
},
ignoreUserResult = ignoreUserSuccess
)
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
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 {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
}
assertThat(awaitItem().declineAction.isLoading()).isTrue()
}
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
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 {
val inviteData = anInviteData()
awaitItem().also { state ->
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList<String>()),
value(JoinedRoom.Trigger.Invite)
)
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
}
@Test
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.success(Unit)
}
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess,
notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
)
presenter.test {
val inviteData = anInviteData()
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.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(
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit)
},
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter(
client = client,
joinRoom = FakeJoinRoom(joinRoomLambda),
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.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
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.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
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.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
@ -67,6 +69,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val forgetRoom: ForgetRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> {
interface Factory {
fun create(
@ -82,7 +85,9 @@ class JoinRoomPresenter @AssistedInject constructor(
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
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 knockAction: 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()
LaunchedEffect(contentState) {
contentState.markRoomInviteAsSeen()
}
fun handleEvents(event: JoinRoomEvents) {
when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
@ -234,6 +243,12 @@ class JoinRoomPresenter @AssistedInject constructor(
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 {

View file

@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
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.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
@ -35,6 +36,7 @@ object JoinRoomModule {
forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory {
override fun create(
@ -57,6 +59,7 @@ object JoinRoomModule {
cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
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 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.AcceptDeclineInviteState
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.ForgetRoom
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.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -111,14 +114,19 @@ class JoinRoomPresenterTest {
flowOf(Optional.of(roomSummary))
}
}
val seenInvitesStore = InMemorySeenInvitesStore()
val presenter = createJoinRoomPresenter(
matrixClient = matrixClient
matrixClient = matrixClient,
seenInvitesStore = seenInvitesStore,
)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test {
skipItems(1)
awaitItem().also { state ->
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(),
forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter {
return JoinRoomPresenter(
roomId = roomId,
@ -773,7 +782,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
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_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_failed_alert_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_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
@ -17,6 +20,7 @@
<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_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
<string name="screen_knock_requests_list_initial_loading_title">"Φόρτωση αιτημάτων συμμετοχής…"</string>
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
<plurals name="screen_room_multiple_knock_requests_title">
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>

View file

@ -7,7 +7,6 @@
package io.element.android.features.licenses.impl.list
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.Scaffold
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.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DependencyLicensesListView(
state: DependencyLicensesListState,
@ -60,7 +59,7 @@ fun DependencyLicensesListView(
) {
if (state.licenses.isSuccess()) {
// Search field
OutlinedTextField(
TextField(
value = state.filter,
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
leadingIcon = {

View file

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

View file

@ -57,7 +57,7 @@ fun StaticMapView(
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder(context) }
val builder = remember { StaticMapUrlBuilder() }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// 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
import android.content.Context
import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt
/**
@ -16,14 +16,16 @@ import kotlin.math.roundToInt
* https://docs.maptiler.com/cloud/api/static-maps/
*/
internal class MapTilerStaticMapUrlBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(
@ -55,7 +57,7 @@ internal class MapTilerStaticMapUrlBuilder(
// image smaller than the available space in pixels.
// 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.
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()

View file

@ -9,21 +9,23 @@
package io.element.android.features.location.api.internal
import android.content.Context
import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor(context: Context) : this(
apiKey = context.apiKey,
lightMapId = context.mapId(darkMode = false),
darkMapId = context.mapId(darkMode = true),
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(darkMode: Boolean): String {
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
import android.content.Context
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
@ -26,4 +24,4 @@ interface StaticMapUrlBuilder {
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
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.element.android.compound.theme.ElementTheme
/**
@ -24,7 +22,7 @@ interface TileServerStyleUriBuilder {
): 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.
@ -33,9 +31,8 @@ fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = Map
*/
@Composable
fun rememberTileStyleUrl(): String {
val context = LocalContext.current
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode) {
TileServerStyleUriBuilder(context).build(darkMode)
TileServerStyleUriBuilder().build(darkMode)
}
}

View file

@ -12,6 +12,7 @@ import org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
private val builder = MapTilerStaticMapUrlBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
@ -25,6 +26,7 @@ class MapTilerStaticMapUrlBuilderTest {
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
baseUrl = "https://base.url",
apiKey = "",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
@ -44,7 +46,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 600,
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
@ -59,7 +61,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 900,
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
@ -74,7 +76,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1200,
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
@ -89,7 +91,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 1800,
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
@ -104,7 +106,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048,
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(
builder.build(
@ -116,7 +118,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096,
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(
builder.build(
@ -128,7 +130,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 2048,
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(
builder.build(
@ -140,7 +142,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 4096,
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(
builder.build(
@ -152,7 +154,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MAX_VALUE,
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
@ -167,7 +169,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0,
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(
builder.build(
@ -179,7 +181,7 @@ class MapTilerStaticMapUrlBuilderTest {
height = 0,
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(
builder.build(
@ -191,6 +193,6 @@ class MapTilerStaticMapUrlBuilderTest {
height = Int.MIN_VALUE,
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 {
private val builder = MapTilerTileServerStyleUriBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
@ -21,13 +22,13 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `light map uri`() {
assertThat(
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
fun `dark map uri`() {
assertThat(
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.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

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

View file

@ -8,30 +8,15 @@
package io.element.android.features.location.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.R
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.features.location.api.BuildConfig
import org.junit.Test
class DefaultLocationServiceTest {
@Test
fun `if apiKey is empty, isServiceAvailable should return false`() {
val fakeStringProvider = FakeStringProvider(
defaultResult = ""
fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
val locationService = DefaultLocationService()
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 org.junit.Test
import java.net.URLEncoder
import java.util.Locale
internal class AndroidLocationActionsTest {
// 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 expected = "geo:0,0?q=1.234568,123.456789"
val expected = "geo:0,0?q=1.234568,123.456789 ()"
assertThat(actual).isEqualTo(expected)
}
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
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 {
namespace = "io.element.android.features.lockscreen.impl"
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
setupAnvil()
@ -30,6 +34,8 @@ dependencies {
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.cryptography.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(projects.features.logout.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
@ -42,6 +48,9 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
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.tests.testutils)
testImplementation(projects.libraries.cryptography.test)
@ -50,4 +59,5 @@ dependencies {
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.appnavstate.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