Merge branch 'release/25.04.0' into main
53
.github/workflows/nightly_enterprise.yml
vendored
|
|
@ -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 }}
|
|
||||||
2
.github/workflows/pull_request.yml
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
name: Pull Request
|
name: Pull Request
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request_target:
|
||||||
types: [ opened, edited, labeled, unlabeled, synchronize ]
|
types: [ opened, edited, labeled, unlabeled, synchronize ]
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
secrets:
|
||||||
|
|
|
||||||
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="2.1.10" />
|
<option name="version" value="2.1.20" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
18
CHANGES.md
|
|
@ -1,3 +1,21 @@
|
||||||
|
Changes in Element X v25.03.4
|
||||||
|
=============================
|
||||||
|
|
||||||
|
<!-- Release notes generated using configuration in .github/release.yml at v25.03.4 -->
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
### 🙌 Improvements
|
||||||
|
* Change : composer suggestions by @ganfra in https://github.com/element-hq/element-x-android/pull/4485
|
||||||
|
### 🧱 Build
|
||||||
|
* Fix flaky incoming verification tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4479
|
||||||
|
### Dependency upgrades
|
||||||
|
* fix(deps): update dagger to v2.56.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4472
|
||||||
|
* fix(deps): update dependencyanalysis to v2.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4473
|
||||||
|
* Upgrade embedded EC version to `v0.9.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4489
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.3...v25.03.4
|
||||||
|
|
||||||
Changes in Element X v25.03.3
|
Changes in Element X v25.03.3
|
||||||
=============================
|
=============================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import extension.allEnterpriseImpl
|
||||||
import extension.allFeaturesImpl
|
import extension.allFeaturesImpl
|
||||||
import extension.allLibrariesImpl
|
import extension.allLibrariesImpl
|
||||||
import extension.allServicesImpl
|
import extension.allServicesImpl
|
||||||
|
import extension.buildConfigFieldStr
|
||||||
import extension.koverDependencies
|
import extension.koverDependencies
|
||||||
import extension.locales
|
import extension.locales
|
||||||
import extension.setupAnvil
|
import extension.setupAnvil
|
||||||
|
|
@ -102,7 +103,7 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
val baseAppName = BuildTimeConfig.APPLICATION_NAME
|
val baseAppName = BuildTimeConfig.APPLICATION_NAME
|
||||||
logger.warnInBox("Building $baseAppName")
|
logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName)")
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
|
@ -170,13 +171,13 @@ android {
|
||||||
create("gplay") {
|
create("gplay") {
|
||||||
dimension = "store"
|
dimension = "store"
|
||||||
isDefault = true
|
isDefault = true
|
||||||
buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"G\"")
|
buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "G")
|
||||||
buildConfigField("String", "FLAVOR_DESCRIPTION", "\"GooglePlay\"")
|
buildConfigFieldStr("FLAVOR_DESCRIPTION", "GooglePlay")
|
||||||
}
|
}
|
||||||
create("fdroid") {
|
create("fdroid") {
|
||||||
dimension = "store"
|
dimension = "store"
|
||||||
buildConfigField("String", "SHORT_FLAVOR_DESCRIPTION", "\"F\"")
|
buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "F")
|
||||||
buildConfigField("String", "FLAVOR_DESCRIPTION", "\"FDroid\"")
|
buildConfigFieldStr("FLAVOR_DESCRIPTION", "FDroid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -291,8 +292,8 @@ tasks.withType<GenerateBuildConfig>().configureEach {
|
||||||
outputs.upToDateWhen { false }
|
outputs.upToDateWhen { false }
|
||||||
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
|
val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get()
|
||||||
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
|
val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get()
|
||||||
android.defaultConfig.buildConfigField("String", "GIT_REVISION", "\"$gitRevision\"")
|
android.defaultConfig.buildConfigFieldStr("GIT_REVISION", gitRevision)
|
||||||
android.defaultConfig.buildConfigField("String", "GIT_BRANCH_NAME", "\"$gitBranchName\"")
|
android.defaultConfig.buildConfigFieldStr("GIT_BRANCH_NAME", gitBranchName)
|
||||||
}
|
}
|
||||||
|
|
||||||
licensee {
|
licensee {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ package io.element.android.x
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.startup.AppInitializer
|
import androidx.startup.AppInitializer
|
||||||
|
import io.element.android.appconfig.RageshakeConfig
|
||||||
|
import io.element.android.appconfig.isEnabled
|
||||||
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
|
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
|
||||||
import io.element.android.libraries.di.DaggerComponentOwner
|
import io.element.android.libraries.di.DaggerComponentOwner
|
||||||
import io.element.android.x.di.AppComponent
|
import io.element.android.x.di.AppComponent
|
||||||
|
|
@ -23,7 +25,9 @@ class ElementXApplication : Application(), DaggerComponentOwner {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AppInitializer.getInstance(this).apply {
|
AppInitializer.getInstance(this).apply {
|
||||||
initializeComponent(CrashInitializer::class.java)
|
if (RageshakeConfig.isEnabled) {
|
||||||
|
initializeComponent(CrashInitializer::class.java)
|
||||||
|
}
|
||||||
initializeComponent(PlatformInitializer::class.java)
|
initializeComponent(PlatformInitializer::class.java)
|
||||||
initializeComponent(CacheCleanerInitializer::class.java)
|
initializeComponent(CacheCleanerInitializer::class.java)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ class PlatformInitializer : Initializer<Unit> {
|
||||||
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
|
writesToFilesConfiguration = defaultWriteToDiskConfiguration(bugReporter),
|
||||||
logLevel = logLevel,
|
logLevel = logLevel,
|
||||||
extraTargets = listOf(ELEMENT_X_TARGET),
|
extraTargets = listOf(ELEMENT_X_TARGET),
|
||||||
|
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
|
||||||
)
|
)
|
||||||
bugReporter.setCurrentTracingLogLevel(logLevel.name)
|
bugReporter.setCurrentTracingLogLevel(logLevel.name)
|
||||||
platformService.init(tracingConfiguration)
|
platformService.init(tracingConfiguration)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
~ Please see LICENSE files in the repository root for full details.
|
~ Please see LICENSE files in the repository root for full details.
|
||||||
-->
|
-->
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Must be equal to DarkDesignTokens.colorThemeBg -->
|
<!-- Must be equal to DarkColorTokens.colorThemeBg -->
|
||||||
<color name="splashscreen_bg_dark">#FF101317</color>
|
<color name="splashscreen_bg_dark">#FF101317</color>
|
||||||
<!-- Must be equal to LightDesignTokens.colorThemeBg -->
|
<!-- Must be equal to LightColorTokens.colorThemeBg -->
|
||||||
<color name="splashscreen_bg_light">#FFFFFFFF</color>
|
<color name="splashscreen_bg_light">#FFFFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import config.BuildTimeConfig
|
||||||
|
import extension.buildConfigFieldStr
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright 2022-2024 New Vector Ltd.
|
* Copyright 2022-2024 New Vector Ltd.
|
||||||
*
|
*
|
||||||
|
|
@ -10,6 +13,37 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.appconfig"
|
namespace = "io.element.android.appconfig"
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
buildConfigFieldStr(
|
||||||
|
name = "URL_POLICY",
|
||||||
|
value = if (isEnterpriseBuild) {
|
||||||
|
BuildTimeConfig.URL_POLICY ?: ""
|
||||||
|
} else {
|
||||||
|
"https://element.io/cookie-policy"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
buildConfigFieldStr(
|
||||||
|
name = "BUG_REPORT_URL",
|
||||||
|
value = if (isEnterpriseBuild) {
|
||||||
|
BuildTimeConfig.BUG_REPORT_URL ?: ""
|
||||||
|
} else {
|
||||||
|
"https://riot.im/bugreports/submit"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
buildConfigFieldStr(
|
||||||
|
name = "BUG_REPORT_APP_NAME",
|
||||||
|
value = if (isEnterpriseBuild) {
|
||||||
|
BuildTimeConfig.BUG_REPORT_APP_NAME ?: ""
|
||||||
|
} else {
|
||||||
|
"element-x-android"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,5 @@
|
||||||
package io.element.android.appconfig
|
package io.element.android.appconfig
|
||||||
|
|
||||||
object AnalyticsConfig {
|
object AnalyticsConfig {
|
||||||
const val POLICY_LINK = "https://element.io/cookie-policy"
|
const val POLICY_LINK = BuildConfig.URL_POLICY
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,23 @@ object RageshakeConfig {
|
||||||
/**
|
/**
|
||||||
* The URL to submit bug reports to.
|
* The URL to submit bug reports to.
|
||||||
*/
|
*/
|
||||||
const val BUG_REPORT_URL = "https://riot.im/bugreports/submit"
|
const val BUG_REPORT_URL = BuildConfig.BUG_REPORT_URL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As per https://github.com/matrix-org/rageshake:
|
* As per https://github.com/matrix-org/rageshake:
|
||||||
* Identifier for the application (eg 'riot-web').
|
* Identifier for the application (eg 'riot-web').
|
||||||
* Should correspond to a mapping configured in the configuration file for github issue reporting to work.
|
* Should correspond to a mapping configured in the configuration file for github issue reporting to work.
|
||||||
*/
|
*/
|
||||||
const val BUG_REPORT_APP_NAME = "element-x-android"
|
const val BUG_REPORT_APP_NAME = BuildConfig.BUG_REPORT_APP_NAME
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
|
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
|
||||||
*/
|
*/
|
||||||
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
|
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rageshake feature is enabled.
|
||||||
|
*/
|
||||||
|
val RageshakeConfig.isEnabled: Boolean
|
||||||
|
get() = BUG_REPORT_URL.isNotEmpty() && BUG_REPORT_APP_NAME.isNotEmpty()
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -10,25 +10,28 @@ package io.element.android.appicon.element
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
internal fun IconPreview() {
|
internal fun IconPreview() {
|
||||||
Box {
|
Box {
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
|
Image(
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
|
modifier = Modifier.matchParentSize(),
|
||||||
|
painter = painterResource(id = R.drawable.ic_launcher_background),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,8 +39,15 @@ internal fun IconPreview() {
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoundIconPreview() {
|
internal fun RoundIconPreview() {
|
||||||
Box(modifier = Modifier.clip(shape = CircleShape)) {
|
Box(modifier = Modifier.clip(shape = CircleShape)) {
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null)
|
Image(
|
||||||
Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null)
|
modifier = Modifier.matchParentSize(),
|
||||||
|
painter = painterResource(id = R.drawable.ic_launcher_background),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.mipmap.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,10 +56,7 @@ internal fun RoundIconPreview() {
|
||||||
internal fun MonochromeIconPreview() {
|
internal fun MonochromeIconPreview() {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(108.dp)
|
.background(Color(0xFF2F3133)),
|
||||||
.background(Color(0xFF2F3133))
|
|
||||||
.clip(shape = RoundedCornerShape(32.dp)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
|
painter = painterResource(id = R.mipmap.ic_launcher_monochrome),
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -1,2 +1,10 @@
|
||||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:src="@mipmap/ic_launcher_background" />
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#010302"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="m0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
|
|
@ -79,7 +78,7 @@ class LoggedInPresenter @Inject constructor(
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
|
||||||
val isOnline by syncService.isOnline().collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
val showSyncSpinner by remember {
|
val showSyncSpinner by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
|
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -211,7 +210,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
|
||||||
val isOnline by syncService.isOnline().collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
LoadingRoomNodeView(
|
LoadingRoomNodeView(
|
||||||
state = LoadingRoomState.Loading,
|
state = LoadingRoomState.Loading,
|
||||||
hasNetworkConnection = isOnline,
|
hasNetworkConnection = isOnline,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.sync.isOnline
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
@ -114,7 +113,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
|
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
|
||||||
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
|
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
|
||||||
val isOnline by syncService.isOnline().collectAsState()
|
val isOnline by syncService.isOnline.collectAsState()
|
||||||
LoadingRoomNodeView(
|
LoadingRoomNodeView(
|
||||||
state = loadingRoomState,
|
state = loadingRoomState,
|
||||||
hasNetworkConnection = isOnline,
|
hasNetworkConnection = isOnline,
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ class IntentResolverTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createIntentResolver(
|
private fun createIntentResolver(
|
||||||
permalinkParserResult: () -> PermalinkData = { lambdaError() }
|
permalinkParserResult: (String) -> PermalinkData = { lambdaError() }
|
||||||
): IntentResolver {
|
): IntentResolver {
|
||||||
return IntentResolver(
|
return IntentResolver(
|
||||||
deeplinkParser = DeeplinkParser(),
|
deeplinkParser = DeeplinkParser(),
|
||||||
|
|
|
||||||
2
fastlane/metadata/android/en-US/changelogs/202504000.txt
Normal 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
|
||||||
|
|
@ -1,38 +1,28 @@
|
||||||
Element X brings you both sovereign & seamless collaboration built on Matrix.
|
Freedom to communicate on your own terms
|
||||||
|
|
||||||
The collaboration capabilities include chat & video calls with the modern set of features such as:
|
For individuals and communities - private communication between family, friends, hobby groups, clubs, etc.
|
||||||
• public & private channels
|
|
||||||
• room moderation & access control
|
|
||||||
• replies, reactions, polls, read receipts, pinned messages, etc.
|
|
||||||
• simultaneous chat & calls (picture in picture)
|
|
||||||
• decentralized & federated communication across organizations
|
|
||||||
|
|
||||||
All this comes in a secure & sovereign fashion without compromising responsiveness or overall usability of the app:
|
Element X gives you fast, secure and private instant messaging and video calls built on Matrix, the open standard for real-time communication. This is a free and open-source app maintained at https://github.com/element-hq/element-x-android.
|
||||||
• enterprise-grade single sign-on
|
|
||||||
• easy & secure login & device verification via QR-code
|
|
||||||
• end to end encryption & zero trust
|
|
||||||
• protection against MITM & other cyber attacks
|
|
||||||
|
|
||||||
If you’re a new user, use the new Element X app from the start. Compared to the current Element app you will get:
|
Stay in touch with friends, family and communities with:
|
||||||
• greatly enhanced performance, sleek user interface and overall better user experience
|
• Real time messaging & video calls
|
||||||
• enterprise-grade support for single sign-on (OIDC)
|
• Public rooms for open group communication
|
||||||
• QR-code based login & device verification
|
• Private rooms for closed group communication
|
||||||
• natively integrated Element Call for video calls
|
• Rich messaging features: emoji reactions, replies, polls, pinned messages and more.
|
||||||
• continuous improvements, bug fixes and new features
|
• Video calling while browsing messages.
|
||||||
|
• Interoperability with other Matrix-based apps such as FluffyChat, Cinny and many more.
|
||||||
|
|
||||||
If you’re 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 don’t mine your data or monitor your communications.
|
||||||
|
|
||||||
<b>Own your data</b>
|
<b>Own your conversations</b>
|
||||||
Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
|
Choose where to host your data - from any public server (the largest free server is matrix.org, but there are plenty of others to choose from) to creating your own personal server and hosting it on your own domain. This ability to choose a server is a large part of what differentiates us from other real time communication apps. However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
|
||||||
|
|
||||||
<b>Interoperate natively</b>
|
<b>Communicate in real time, all the time</b>
|
||||||
Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends, partners or customers are on a different Matrix-based app - you can still connect.
|
Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io
|
||||||
|
|
||||||
<b>Encrypt your data</b>
|
<b>Element X is our next-generation app</b>
|
||||||
Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages.
|
If you’re using the original Element app, it’s time to try Element X! It’s faster, easier to use, and more powerful than the original app. It’s better in every way and we’re adding new features all the time.
|
||||||
|
|
||||||
<b>Chat across multiple devices</b>
|
|
||||||
Stay in touch wherever you are with fully synchronized message history across all your devices, even those running Element legacy app, and on the web at https://app.element.io
|
|
||||||
|
|
||||||
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.
|
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 98 KiB |
|
|
@ -13,12 +13,17 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
|
||||||
override val values: Sequence<AnalyticsPreferencesState>
|
override val values: Sequence<AnalyticsPreferencesState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aAnalyticsPreferencesState().copy(isEnabled = true),
|
aAnalyticsPreferencesState().copy(isEnabled = true),
|
||||||
|
aAnalyticsPreferencesState().copy(isEnabled = true, policyUrl = ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
|
fun aAnalyticsPreferencesState(
|
||||||
applicationName = "Element X",
|
applicationName: String = "Element X",
|
||||||
isEnabled = false,
|
isEnabled: Boolean = false,
|
||||||
policyUrl = "https://element.io",
|
policyUrl: String = "https://element.io",
|
||||||
|
) = AnalyticsPreferencesState(
|
||||||
|
applicationName = applicationName,
|
||||||
|
isEnabled = isEnabled,
|
||||||
|
policyUrl = policyUrl,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,6 @@ fun AnalyticsPreferencesView(
|
||||||
id = R.string.screen_analytics_settings_help_us_improve,
|
id = R.string.screen_analytics_settings_help_us_improve,
|
||||||
state.applicationName
|
state.applicationName
|
||||||
)
|
)
|
||||||
val linkText = buildAnnotatedStringWithStyledPart(
|
|
||||||
R.string.screen_analytics_settings_read_terms,
|
|
||||||
R.string.screen_analytics_settings_read_terms_content_link,
|
|
||||||
tagAndLink = LINK_TAG to state.policyUrl,
|
|
||||||
)
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
@ -57,7 +52,14 @@ fun AnalyticsPreferencesView(
|
||||||
onEnabledChanged(!state.isEnabled)
|
onEnabledChanged(!state.isEnabled)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ListSupportingText(annotatedString = linkText)
|
if (state.policyUrl.isNotEmpty()) {
|
||||||
|
val linkText = buildAnnotatedStringWithStyledPart(
|
||||||
|
R.string.screen_analytics_settings_read_terms,
|
||||||
|
R.string.screen_analytics_settings_read_terms_content_link,
|
||||||
|
tagAndLink = LINK_TAG to state.policyUrl,
|
||||||
|
)
|
||||||
|
ListSupportingText(annotatedString = linkText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.analytics.impl
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import io.element.android.appconfig.AnalyticsConfig
|
||||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
|
@ -36,6 +37,7 @@ class AnalyticsOptInPresenter @Inject constructor(
|
||||||
|
|
||||||
return AnalyticsOptInState(
|
return AnalyticsOptInState(
|
||||||
applicationName = buildMeta.applicationName,
|
applicationName = buildMeta.applicationName,
|
||||||
|
hasPolicyLink = AnalyticsConfig.POLICY_LINK.isNotEmpty(),
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||||
|
|
||||||
data class AnalyticsOptInState(
|
data class AnalyticsOptInState(
|
||||||
val applicationName: String,
|
val applicationName: String,
|
||||||
|
val hasPolicyLink: Boolean,
|
||||||
val eventSink: (AnalyticsOptInEvents) -> Unit
|
val eventSink: (AnalyticsOptInEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,14 @@ open class AnalyticsOptInStateProvider @Inject constructor() : PreviewParameterP
|
||||||
override val values: Sequence<AnalyticsOptInState>
|
override val values: Sequence<AnalyticsOptInState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aAnalyticsOptInState(),
|
aAnalyticsOptInState(),
|
||||||
|
aAnalyticsOptInState(hasPolicyLink = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aAnalyticsOptInState() = AnalyticsOptInState(
|
fun aAnalyticsOptInState(
|
||||||
|
hasPolicyLink: Boolean = true,
|
||||||
|
) = AnalyticsOptInState(
|
||||||
applicationName = "Element X",
|
applicationName = "Element X",
|
||||||
|
hasPolicyLink = hasPolicyLink,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -95,25 +95,27 @@ private fun AnalyticsOptInHeader(
|
||||||
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
|
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
|
||||||
)
|
)
|
||||||
val text = buildAnnotatedStringWithStyledPart(
|
if (state.hasPolicyLink) {
|
||||||
R.string.screen_analytics_prompt_read_terms,
|
val text = buildAnnotatedStringWithStyledPart(
|
||||||
R.string.screen_analytics_prompt_read_terms_content_link,
|
R.string.screen_analytics_prompt_read_terms,
|
||||||
color = Color.Unspecified,
|
R.string.screen_analytics_prompt_read_terms_content_link,
|
||||||
underline = false,
|
color = Color.Unspecified,
|
||||||
bold = true,
|
underline = false,
|
||||||
tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
|
bold = true,
|
||||||
)
|
tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK,
|
||||||
ClickableLinkText(
|
)
|
||||||
annotatedString = text,
|
ClickableLinkText(
|
||||||
onClick = { onClickTerms() },
|
annotatedString = text,
|
||||||
modifier = Modifier
|
onClick = { onClickTerms() },
|
||||||
.padding(8.dp),
|
modifier = Modifier
|
||||||
style = ElementTheme.typography.fontBodyMdRegular
|
.padding(8.dp),
|
||||||
.copy(
|
style = ElementTheme.typography.fontBodyMdRegular
|
||||||
color = ElementTheme.colors.textSecondary,
|
.copy(
|
||||||
textAlign = TextAlign.Center,
|
color = ElementTheme.colors.textSecondary,
|
||||||
)
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ class AnalyticsPreferencesPresenter @Inject constructor(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AnalyticsPreferencesState {
|
override fun present(): AnalyticsPreferencesState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val isEnabled = analyticsService.getUserConsent()
|
val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
|
||||||
.collectAsState(initial = false)
|
|
||||||
|
|
||||||
fun handleEvents(event: AnalyticsOptInEvents) {
|
fun handleEvents(event: AnalyticsOptInEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ class AnalyticsOptInPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
|
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true))
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||||
assertThat(analyticsService.getUserConsent().first()).isTrue()
|
assertThat(analyticsService.userConsentFlow.first()).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,10 +53,10 @@ class AnalyticsOptInPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isFalse()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
|
||||||
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
|
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false))
|
||||||
assertThat(analyticsService.didAskUserConsent().first()).isTrue()
|
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
|
||||||
assertThat(analyticsService.getUserConsent().first()).isFalse()
|
assertThat(analyticsService.userConsentFlow.first()).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.appconfig.AnalyticsConfig
|
||||||
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
import io.element.android.features.analytics.api.AnalyticsOptInEvents
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
|
@ -35,7 +36,7 @@ class AnalyticsPreferencesPresenterTest {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.isEnabled).isTrue()
|
assertThat(initialState.isEnabled).isTrue()
|
||||||
assertThat(initialState.policyUrl).isNotEmpty()
|
assertThat(initialState.policyUrl).isEqualTo(AnalyticsConfig.POLICY_LINK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ interface ElementCallEntryPoint {
|
||||||
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
* @param notificationChannelId The id of the notification channel to use for the call notification.
|
||||||
* @param textContent The text content of the notification. If null the default content from the system will be used.
|
* @param textContent The text content of the notification. If null the default content from the system will be used.
|
||||||
*/
|
*/
|
||||||
fun handleIncomingCall(
|
suspend fun handleIncomingCall(
|
||||||
callType: CallType.RoomCall,
|
callType: CallType.RoomCall,
|
||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
senderId: UserId,
|
senderId: UserId,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import extension.buildConfigFieldStr
|
||||||
import extension.readLocalProperty
|
import extension.readLocalProperty
|
||||||
import extension.setupAnvil
|
import extension.setupAnvil
|
||||||
|
|
||||||
|
|
@ -26,45 +27,35 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
buildConfigField(
|
buildConfigFieldStr(
|
||||||
type = "String",
|
|
||||||
name = "SENTRY_DSN",
|
name = "SENTRY_DSN",
|
||||||
value = (System.getenv("ELEMENT_CALL_SENTRY_DSN")
|
value = System.getenv("ELEMENT_CALL_SENTRY_DSN")
|
||||||
?: readLocalProperty("features.call.sentry.dsn")
|
?: readLocalProperty("features.call.sentry.dsn")
|
||||||
?: ""
|
?: ""
|
||||||
).let { "\"$it\"" }
|
|
||||||
)
|
)
|
||||||
buildConfigField(
|
buildConfigFieldStr(
|
||||||
type = "String",
|
|
||||||
name = "POSTHOG_USER_ID",
|
name = "POSTHOG_USER_ID",
|
||||||
value = (System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
|
value = System.getenv("ELEMENT_CALL_POSTHOG_USER_ID")
|
||||||
?: readLocalProperty("features.call.posthog.userid")
|
?: readLocalProperty("features.call.posthog.userid")
|
||||||
?: ""
|
?: ""
|
||||||
).let { "\"$it\"" }
|
|
||||||
)
|
)
|
||||||
buildConfigField(
|
buildConfigFieldStr(
|
||||||
type = "String",
|
|
||||||
name = "POSTHOG_API_HOST",
|
name = "POSTHOG_API_HOST",
|
||||||
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
|
value = System.getenv("ELEMENT_CALL_POSTHOG_API_HOST")
|
||||||
?: readLocalProperty("features.call.posthog.api.host")
|
?: readLocalProperty("features.call.posthog.api.host")
|
||||||
?: ""
|
?: ""
|
||||||
).let { "\"$it\"" }
|
|
||||||
)
|
)
|
||||||
buildConfigField(
|
buildConfigFieldStr(
|
||||||
type = "String",
|
|
||||||
name = "POSTHOG_API_KEY",
|
name = "POSTHOG_API_KEY",
|
||||||
value = (System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
|
value = System.getenv("ELEMENT_CALL_POSTHOG_API_KEY")
|
||||||
?: readLocalProperty("features.call.posthog.api.key")
|
?: readLocalProperty("features.call.posthog.api.key")
|
||||||
?: ""
|
?: ""
|
||||||
).let { "\"$it\"" }
|
|
||||||
)
|
)
|
||||||
buildConfigField(
|
buildConfigFieldStr(
|
||||||
type = "String",
|
|
||||||
name = "RAGESHAKE_URL",
|
name = "RAGESHAKE_URL",
|
||||||
value = (System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
|
value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL")
|
||||||
?: readLocalProperty("features.call.regeshake.url")
|
?: readLocalProperty("features.call.regeshake.url")
|
||||||
?: ""
|
?: ""
|
||||||
).let { "\"$it\"" }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
|
||||||
context.startActivity(IntentProvider.createIntent(context, callType))
|
context.startActivity(IntentProvider.createIntent(context, callType))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleIncomingCall(
|
override suspend fun handleIncomingCall(
|
||||||
callType: CallType.RoomCall,
|
callType: CallType.RoomCall,
|
||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
senderId: UserId,
|
senderId: UserId,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import io.element.android.features.call.impl.di.CallBindings
|
||||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,10 +29,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
|
||||||
}
|
}
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var activeCallManager: ActiveCallManager
|
lateinit var activeCallManager: ActiveCallManager
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appCoroutineScope: CoroutineScope
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent?) {
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||||
?: return
|
?: return
|
||||||
context.bindings<CallBindings>().inject(this)
|
context.bindings<CallBindings>().inject(this)
|
||||||
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
appCoroutineScope.launch {
|
||||||
|
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
private val activeCallManager: ActiveCallManager,
|
private val activeCallManager: ActiveCallManager,
|
||||||
private val languageTagProvider: LanguageTagProvider,
|
private val languageTagProvider: LanguageTagProvider,
|
||||||
private val appForegroundStateService: AppForegroundStateService,
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
|
private val appCoroutineScope: CoroutineScope,
|
||||||
) : Presenter<CallScreenState> {
|
) : Presenter<CallScreenState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
|
|
@ -87,7 +88,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
// Sets the call as joined
|
// Sets the call as joined
|
||||||
activeCallManager.joinedCall(callType)
|
activeCallManager.joinedCall(callType)
|
||||||
loadUrl(
|
fetchRoomCallUrl(
|
||||||
inputs = callType,
|
inputs = callType,
|
||||||
urlState = urlState,
|
urlState = urlState,
|
||||||
callWidgetDriver = callWidgetDriver,
|
callWidgetDriver = callWidgetDriver,
|
||||||
|
|
@ -96,7 +97,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDispose {
|
onDispose {
|
||||||
activeCallManager.hungUpCall(callType)
|
appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +188,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadUrl(
|
private suspend fun fetchRoomCallUrl(
|
||||||
inputs: CallType,
|
inputs: CallType,
|
||||||
urlState: MutableState<AsyncData<String>>,
|
urlState: MutableState<AsyncData<String>>,
|
||||||
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
|
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,11 @@ import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
import io.element.android.libraries.designsystem.theme.ElementThemeApp
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,6 +57,9 @@ class IncomingCallActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var buildMeta: BuildMeta
|
lateinit var buildMeta: BuildMeta
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var appCoroutineScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
|
@ -102,6 +107,8 @@ class IncomingCallActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private fun onCancel() {
|
private fun onCancel() {
|
||||||
val activeCall = activeCallManager.activeCall.value ?: return
|
val activeCall = activeCallManager.activeCall.value ?: return
|
||||||
activeCallManager.hungUpCall(callType = activeCall.callType)
|
appCoroutineScope.launch {
|
||||||
|
activeCallManager.hungUpCall(callType = activeCall.callType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@
|
||||||
package io.element.android.features.call.impl.utils
|
package io.element.android.features.call.impl.utils
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.appconfig.ElementCallConfig
|
import io.element.android.appconfig.ElementCallConfig
|
||||||
import io.element.android.features.call.api.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
|
|
@ -17,6 +20,7 @@ import io.element.android.features.call.api.CurrentCall
|
||||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
|
|
@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -55,25 +61,26 @@ interface ActiveCallManager {
|
||||||
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
|
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
|
||||||
* @param notificationData The data for the incoming call notification.
|
* @param notificationData The data for the incoming call notification.
|
||||||
*/
|
*/
|
||||||
fun registerIncomingCall(notificationData: CallNotificationData)
|
suspend fun registerIncomingCall(notificationData: CallNotificationData)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the active call has been hung up. It will remove any existing UI and the active call.
|
* Called when the active call has been hung up. It will remove any existing UI and the active call.
|
||||||
* @param callType The type of call that the user hung up, either an external url one or a room one.
|
* @param callType The type of call that the user hung up, either an external url one or a room one.
|
||||||
*/
|
*/
|
||||||
fun hungUpCall(callType: CallType)
|
suspend fun hungUpCall(callType: CallType)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
|
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
|
||||||
*
|
*
|
||||||
* @param callType The type of call that the user joined, either an external url one or a room one.
|
* @param callType The type of call that the user joined, either an external url one or a room one.
|
||||||
*/
|
*/
|
||||||
fun joinedCall(callType: CallType)
|
suspend fun joinedCall(callType: CallType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultActiveCallManager @Inject constructor(
|
class DefaultActiveCallManager @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
|
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
|
||||||
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
||||||
|
|
@ -83,33 +90,47 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
) : ActiveCallManager {
|
) : ActiveCallManager {
|
||||||
private var timedOutCallJob: Job? = null
|
private var timedOutCallJob: Job? = null
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
|
internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService<PowerManager>()
|
||||||
|
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
|
||||||
|
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
|
||||||
|
|
||||||
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeRingingCall()
|
observeRingingCall()
|
||||||
observeCurrentCall()
|
observeCurrentCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||||
if (activeCall.value != null) {
|
mutex.withLock {
|
||||||
displayMissedCallNotification(notificationData)
|
if (activeCall.value != null) {
|
||||||
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
|
displayMissedCallNotification(notificationData)
|
||||||
return
|
Timber.w("Already have an active call, ignoring incoming call: $notificationData")
|
||||||
}
|
return
|
||||||
activeCall.value = ActiveCall(
|
}
|
||||||
callType = CallType.RoomCall(
|
activeCall.value = ActiveCall(
|
||||||
sessionId = notificationData.sessionId,
|
callType = CallType.RoomCall(
|
||||||
roomId = notificationData.roomId,
|
sessionId = notificationData.sessionId,
|
||||||
),
|
roomId = notificationData.roomId,
|
||||||
callState = CallState.Ringing(notificationData),
|
),
|
||||||
)
|
callState = CallState.Ringing(notificationData),
|
||||||
|
)
|
||||||
|
|
||||||
timedOutCallJob = coroutineScope.launch {
|
timedOutCallJob = coroutineScope.launch {
|
||||||
showIncomingCallNotification(notificationData)
|
showIncomingCallNotification(notificationData)
|
||||||
|
|
||||||
// Wait for the ringing call to time out
|
// Wait for the ringing call to time out
|
||||||
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
|
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
|
||||||
incomingCallTimedOut(displayMissedCallNotification = true)
|
incomingCallTimedOut(displayMissedCallNotification = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
|
||||||
|
if (activeWakeLock?.isHeld == false) {
|
||||||
|
activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,10 +138,13 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
|
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
|
||||||
*/
|
*/
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
fun incomingCallTimedOut(displayMissedCallNotification: Boolean) {
|
suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
|
||||||
val previousActiveCall = activeCall.value ?: return
|
val previousActiveCall = activeCall.value ?: return
|
||||||
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
|
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
|
||||||
activeCall.value = null
|
activeCall.value = null
|
||||||
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
activeWakeLock.release()
|
||||||
|
}
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
|
|
||||||
|
|
@ -129,18 +153,24 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hungUpCall(callType: CallType) {
|
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
|
||||||
if (activeCall.value?.callType != callType) {
|
if (activeCall.value?.callType != callType) {
|
||||||
Timber.w("Call type $callType does not match the active call type, ignoring")
|
Timber.w("Call type $callType does not match the active call type, ignoring")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
activeWakeLock.release()
|
||||||
|
}
|
||||||
timedOutCallJob?.cancel()
|
timedOutCallJob?.cancel()
|
||||||
activeCall.value = null
|
activeCall.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun joinedCall(callType: CallType) {
|
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
activeWakeLock.release()
|
||||||
|
}
|
||||||
timedOutCallJob?.cancel()
|
timedOutCallJob?.cancel()
|
||||||
|
|
||||||
activeCall.value = ActiveCall(
|
activeCall.value = ActiveCall(
|
||||||
|
|
@ -201,6 +231,7 @@ class DefaultActiveCallManager @Inject constructor(
|
||||||
?.getRoom(callType.roomId)
|
?.getRoom(callType.roomId)
|
||||||
?.roomInfoFlow
|
?.roomInfoFlow
|
||||||
?.map {
|
?.map {
|
||||||
|
Timber.d("Has room call status changed for ringing call: ${it.hasRoomCall}")
|
||||||
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
|
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
|
||||||
}
|
}
|
||||||
?: flowOf()
|
?: flowOf()
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,21 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
import org.robolectric.Shadows.shadowOf
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class DefaultElementCallEntryPointTest {
|
class DefaultElementCallEntryPointTest {
|
||||||
@Test
|
@Test
|
||||||
fun `startCall - starts ElementCallActivity setup with the needed extras`() {
|
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
|
||||||
val entryPoint = createEntryPoint()
|
val entryPoint = createEntryPoint()
|
||||||
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
|
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
|
||||||
|
|
||||||
|
|
@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest {
|
||||||
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
|
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() {
|
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
|
||||||
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
|
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
|
||||||
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
|
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
|
||||||
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
|
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
|
||||||
|
|
@ -57,10 +63,12 @@ class DefaultElementCallEntryPointTest {
|
||||||
textContent = "textContent",
|
textContent = "textContent",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
|
|
||||||
registerIncomingCallLambda.assertions().isCalledOnce()
|
registerIncomingCallLambda.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createEntryPoint(
|
private fun TestScope.createEntryPoint(
|
||||||
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
|
||||||
) = DefaultElementCallEntryPoint(
|
) = DefaultElementCallEntryPoint(
|
||||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,14 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class CallScreenPresenterTest {
|
@OptIn(ExperimentalCoroutinesApi::class) class CallScreenPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
|
@ -66,7 +68,8 @@ class CallScreenPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
// Wait until the URL is loaded
|
// Wait until the URL is loaded
|
||||||
skipItems(1)
|
advanceTimeBy(1.seconds)
|
||||||
|
skipItems(2)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
|
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
|
||||||
assertThat(initialState.webViewError).isNull()
|
assertThat(initialState.webViewError).isNull()
|
||||||
|
|
@ -101,16 +104,23 @@ class CallScreenPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
// Wait until the URL is loaded
|
// Wait until the URL is loaded
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
joinedCallLambda.assertions().isCalledOnce()
|
joinedCallLambda.assertions().isCalledOnce()
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
|
||||||
assertThat(initialState.isCallActive).isFalse()
|
assertThat(initialState.isCallActive).isFalse()
|
||||||
assertThat(initialState.isInWidgetMode).isTrue()
|
assertThat(initialState.isInWidgetMode).isTrue()
|
||||||
assertThat(widgetProvider.getWidgetCalled).isTrue()
|
assertThat(widgetProvider.getWidgetCalled).isTrue()
|
||||||
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
|
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
|
||||||
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
|
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
|
||||||
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
|
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
// Wait until the WidgetDriver is loaded
|
||||||
|
skipItems(1)
|
||||||
|
|
||||||
|
assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +136,9 @@ class CallScreenPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
// Give it time to load the URL and WidgetDriver
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
||||||
|
|
||||||
|
|
@ -141,7 +154,6 @@ class CallScreenPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
|
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
val navigator = FakeCallScreenNavigator()
|
val navigator = FakeCallScreenNavigator()
|
||||||
|
|
@ -158,11 +170,15 @@ class CallScreenPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
// Give it time to load the URL and WidgetDriver
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
|
|
||||||
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
||||||
|
|
||||||
initialState.eventSink(CallScreenEvents.Hangup)
|
initialState.eventSink(CallScreenEvents.Hangup)
|
||||||
|
|
||||||
// Let background coroutines run
|
// Let background coroutines run and the widget drive be received
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
assertThat(navigator.closeCalled).isTrue()
|
assertThat(navigator.closeCalled).isTrue()
|
||||||
|
|
@ -172,7 +188,6 @@ class CallScreenPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
|
fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
val navigator = FakeCallScreenNavigator()
|
val navigator = FakeCallScreenNavigator()
|
||||||
|
|
@ -189,11 +204,16 @@ class CallScreenPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
// Give it time to load the URL and WidgetDriver
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
|
|
||||||
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
||||||
|
|
||||||
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
|
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
|
||||||
|
|
||||||
// Let background coroutines run
|
// Let background coroutines run
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
assertThat(navigator.closeCalled).isTrue()
|
assertThat(navigator.closeCalled).isTrue()
|
||||||
|
|
@ -218,7 +238,9 @@ class CallScreenPresenterTest {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
// Give it time to load the URL and WidgetDriver
|
||||||
|
advanceTimeBy(1.seconds)
|
||||||
|
skipItems(2)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.isCallActive).isFalse()
|
assertThat(initialState.isCallActive).isFalse()
|
||||||
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
||||||
|
|
@ -235,7 +257,7 @@ class CallScreenPresenterTest {
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
skipItems(1)
|
skipItems(2)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.isCallActive).isTrue()
|
assertThat(finalState.isCallActive).isTrue()
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +322,8 @@ class CallScreenPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
// Wait until the URL is loaded
|
// Wait until the URL is loaded
|
||||||
skipItems(1)
|
advanceTimeBy(1.seconds)
|
||||||
|
skipItems(2)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
|
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
|
|
@ -329,6 +352,8 @@ class CallScreenPresenterTest {
|
||||||
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
|
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.webViewError).isNull()
|
assertThat(finalState.webViewError).isNull()
|
||||||
|
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,6 +386,7 @@ class CallScreenPresenterTest {
|
||||||
screenTracker = screenTracker,
|
screenTracker = screenTracker,
|
||||||
languageTagProvider = FakeLanguageTagProvider("en-US"),
|
languageTagProvider = FakeLanguageTagProvider("en-US"),
|
||||||
appForegroundStateService = appForegroundStateService,
|
appForegroundStateService = appForegroundStateService,
|
||||||
|
appCoroutineScope = backgroundScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
|
|
||||||
package io.element.android.features.call.utils
|
package io.element.android.features.call.utils
|
||||||
|
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.call.api.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
|
|
@ -49,6 +51,7 @@ import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class DefaultActiveCallManagerTest {
|
class DefaultActiveCallManagerTest {
|
||||||
|
|
@ -57,10 +60,12 @@ class DefaultActiveCallManagerTest {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
|
fun `registerIncomingCall - sets the incoming call as active`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
inCancellableScope {
|
inCancellableScope {
|
||||||
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
|
||||||
|
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
assertThat(manager.activeCall.value).isNull()
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
|
||||||
val callNotificationData = aCallNotificationData()
|
val callNotificationData = aCallNotificationData()
|
||||||
|
|
@ -78,6 +83,7 @@ class DefaultActiveCallManagerTest {
|
||||||
|
|
||||||
runCurrent()
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
verify { notificationManagerCompat.notify(notificationId, any()) }
|
verify { notificationManagerCompat.notify(notificationId, any()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +134,7 @@ class DefaultActiveCallManagerTest {
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
|
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||||
inCancellableScope {
|
inCancellableScope {
|
||||||
|
|
@ -138,11 +145,13 @@ class DefaultActiveCallManagerTest {
|
||||||
|
|
||||||
manager.registerIncomingCall(aCallNotificationData())
|
manager.registerIncomingCall(aCallNotificationData())
|
||||||
assertThat(manager.activeCall.value).isNotNull()
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
|
||||||
manager.incomingCallTimedOut(displayMissedCallNotification = true)
|
manager.incomingCallTimedOut(displayMissedCallNotification = true)
|
||||||
advanceTimeBy(1)
|
advanceTimeBy(1)
|
||||||
|
|
||||||
assertThat(manager.activeCall.value).isNull()
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
addMissedCallNotificationLambda.assertions().isCalledOnce()
|
addMissedCallNotificationLambda.assertions().isCalledOnce()
|
||||||
verify { notificationManagerCompat.cancel(notificationId) }
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +159,7 @@ class DefaultActiveCallManagerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
|
fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
// Create a cancellable coroutine scope to cancel the test when needed
|
// Create a cancellable coroutine scope to cancel the test when needed
|
||||||
inCancellableScope {
|
inCancellableScope {
|
||||||
|
|
@ -158,9 +168,11 @@ class DefaultActiveCallManagerTest {
|
||||||
val notificationData = aCallNotificationData()
|
val notificationData = aCallNotificationData()
|
||||||
manager.registerIncomingCall(notificationData)
|
manager.registerIncomingCall(notificationData)
|
||||||
assertThat(manager.activeCall.value).isNotNull()
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
|
||||||
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
assertThat(manager.activeCall.value).isNull()
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
|
|
||||||
verify { notificationManagerCompat.cancel(notificationId) }
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +180,7 @@ class DefaultActiveCallManagerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
// Create a cancellable coroutine scope to cancel the test when needed
|
// Create a cancellable coroutine scope to cancel the test when needed
|
||||||
inCancellableScope {
|
inCancellableScope {
|
||||||
|
|
@ -175,9 +188,11 @@ class DefaultActiveCallManagerTest {
|
||||||
|
|
||||||
manager.registerIncomingCall(aCallNotificationData())
|
manager.registerIncomingCall(aCallNotificationData())
|
||||||
assertThat(manager.activeCall.value).isNotNull()
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
|
||||||
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
|
manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
|
||||||
assertThat(manager.activeCall.value).isNotNull()
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
|
||||||
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
|
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
|
||||||
}
|
}
|
||||||
|
|
@ -284,12 +299,19 @@ class DefaultActiveCallManagerTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupShadowPowerManager() {
|
||||||
|
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
|
||||||
|
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.createActiveCallManager(
|
private fun CoroutineScope.createActiveCallManager(
|
||||||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
|
||||||
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
|
||||||
coroutineScope: CoroutineScope = this,
|
coroutineScope: CoroutineScope = this,
|
||||||
) = DefaultActiveCallManager(
|
) = DefaultActiveCallManager(
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
|
onMissedCallNotificationHandler = onMissedCallNotificationHandler,
|
||||||
ringingCallNotificationCreator = RingingCallNotificationCreator(
|
ringingCallNotificationCreator = RingingCallNotificationCreator(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.impl.notifications.CallNotificationData
|
import io.element.android.features.call.impl.notifications.CallNotificationData
|
||||||
import io.element.android.features.call.impl.utils.ActiveCall
|
import io.element.android.features.call.impl.utils.ActiveCall
|
||||||
import io.element.android.features.call.impl.utils.ActiveCallManager
|
import io.element.android.features.call.impl.utils.ActiveCallManager
|
||||||
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
class FakeActiveCallManager(
|
class FakeActiveCallManager(
|
||||||
|
|
@ -20,15 +21,15 @@ class FakeActiveCallManager(
|
||||||
) : ActiveCallManager {
|
) : ActiveCallManager {
|
||||||
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
override val activeCall = MutableStateFlow<ActiveCall?>(null)
|
||||||
|
|
||||||
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
|
||||||
registerIncomingCallResult(notificationData)
|
registerIncomingCallResult(notificationData)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hungUpCall(callType: CallType) {
|
override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
|
||||||
hungUpCallResult(callType)
|
hungUpCallResult(callType)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun joinedCall(callType: CallType) {
|
override suspend fun joinedCall(callType: CallType) = simulateLongTask {
|
||||||
joinedCallResult(callType)
|
joinedCallResult(callType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class FakeElementCallEntryPoint(
|
||||||
startCallResult(callType)
|
startCallResult(callType)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleIncomingCall(
|
override suspend fun handleIncomingCall(
|
||||||
callType: CallType.RoomCall,
|
callType: CallType.RoomCall,
|
||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
senderId: UserId,
|
senderId: UserId,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class CreateRoomDataStore @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
||||||
selectedUserListDataStore.selectedUsers(),
|
selectedUserListDataStore.selectedUsers,
|
||||||
createRoomConfigFlow,
|
createRoomConfigFlow,
|
||||||
) { selectedUsers, config ->
|
) { selectedUsers, config ->
|
||||||
config.copy(invites = selectedUsers.toImmutableList())
|
config.copy(invites = selectedUsers.toImmutableList())
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||||
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
|
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
|
||||||
val homeserverName = remember { matrixClient.userIdServerName() }
|
val homeserverName = remember { matrixClient.userIdServerName() }
|
||||||
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
|
val isKnockFeatureEnabled by remember {
|
||||||
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
|
||||||
|
}.collectAsState(initial = false)
|
||||||
val roomAddressValidity = remember {
|
val roomAddressValidity = remember {
|
||||||
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,9 @@ class CreateRoomRootPresenter @Inject constructor(
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
val isRoomDirectorySearchEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch).collectAsState(initial = false)
|
val isRoomDirectorySearchEnabled by remember {
|
||||||
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch)
|
||||||
|
}.collectAsState(initial = false)
|
||||||
|
|
||||||
fun handleEvents(event: CreateRoomRootEvents) {
|
fun handleEvents(event: CreateRoomRootEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
||||||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||||
}
|
}
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList())
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
|
||||||
mutableStateOf(SearchBarResultState.Initial())
|
mutableStateOf(SearchBarResultState.Initial())
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,22 @@
|
||||||
package io.element.android.features.createroom.impl.userlist
|
package io.element.android.features.createroom.impl.userlist
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class UserListDataStore @Inject constructor() {
|
class UserListDataStore @Inject constructor() {
|
||||||
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
private val _selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||||
|
|
||||||
fun selectUser(user: MatrixUser) {
|
fun selectUser(user: MatrixUser) {
|
||||||
if (!selectedUsers.value.contains(user)) {
|
if (!_selectedUsers.value.contains(user)) {
|
||||||
selectedUsers.tryEmit(selectedUsers.value.plus(user))
|
_selectedUsers.tryEmit(_selectedUsers.value.plus(user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeUserFromSelection(user: MatrixUser) {
|
fun removeUserFromSelection(user: MatrixUser) {
|
||||||
selectedUsers.tryEmit(selectedUsers.value.minus(user))
|
_selectedUsers.tryEmit(_selectedUsers.value.minus(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers
|
val selectedUsers = _selectedUsers.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,6 @@ Gelaren ezarpenetan aldatu dezakezu hobespena."</string>
|
||||||
<string name="screen_create_room_topic_label">"Mintzagaia (aukerakoa)"</string>
|
<string name="screen_create_room_topic_label">"Mintzagaia (aukerakoa)"</string>
|
||||||
<string name="screen_room_directory_search_title">"Gelen direktorioa"</string>
|
<string name="screen_room_directory_search_title">"Gelen direktorioa"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"Errorea gertatu da txata hasten saiatzean"</string>
|
<string name="screen_start_chat_error_starting_chat">"Errorea gertatu da txata hasten saiatzean"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_placeholder">"Sartu…"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_room_not_found">"Ez da gela aurkitu"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,7 @@ Du kan endre dette når som helst i rominnstillingene."</string>
|
||||||
<string name="screen_create_room_topic_label">"Emne (valgfritt)"</string>
|
<string name="screen_create_room_topic_label">"Emne (valgfritt)"</string>
|
||||||
<string name="screen_room_directory_search_title">"Romkatalog"</string>
|
<string name="screen_room_directory_search_title">"Romkatalog"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"Det oppstod en feil når du prøvde å starte en chat"</string>
|
<string name="screen_start_chat_error_starting_chat">"Det oppstod en feil når du prøvde å starte en chat"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_invalid_address">"Ikke en gyldig adresse"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_room_not_found">"Rom ikke funnet"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_supporting_text">"f.eks. #rom-navn:matrix.org"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,10 @@ Możesz to zmienić w ustawieniach pokoju."</string>
|
||||||
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
|
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
|
||||||
<string name="screen_room_directory_search_title">"Katalog pokoi"</string>
|
<string name="screen_room_directory_search_title">"Katalog pokoi"</string>
|
||||||
<string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>
|
<string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_action">"Dołącz do pokoju za pomocą adresu"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_invalid_address">"Nieprawidłowy adres"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_placeholder">"Wprowadź…"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_room_found">"Znaleziono pasujący pokój"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_room_not_found">"Nie znaleziono pokoju"</string>
|
||||||
|
<string name="screen_start_chat_join_room_by_address_supporting_text">"np. #room-name:matrix.org"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
moveToNextStepIfNeeded()
|
moveToNextStepIfNeeded()
|
||||||
})
|
})
|
||||||
|
|
||||||
analyticsService.didAskUserConsent()
|
analyticsService.didAskUserConsentFlow
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { moveToNextStepIfNeeded() }
|
.onEach { moveToNextStepIfNeeded() }
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class DefaultFtueService @Inject constructor(
|
||||||
.onEach { updateState() }
|
.onEach { updateState() }
|
||||||
.launchIn(sessionCoroutineScope)
|
.launchIn(sessionCoroutineScope)
|
||||||
|
|
||||||
analyticsService.didAskUserConsent()
|
analyticsService.didAskUserConsentFlow
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { updateState() }
|
.onEach { updateState() }
|
||||||
.launchIn(sessionCoroutineScope)
|
.launchIn(sessionCoroutineScope)
|
||||||
|
|
@ -118,7 +118,7 @@ class DefaultFtueService @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun needsAnalyticsOptIn(): Boolean {
|
private suspend fun needsAnalyticsOptIn(): Boolean {
|
||||||
return analyticsService.didAskUserConsent().first().not()
|
return analyticsService.didAskUserConsentFlow.first().not()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ setupAnvil()
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.invite.api)
|
api(projects.features.invite.api)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
|
|
@ -35,6 +36,7 @@ dependencies {
|
||||||
testImplementation(libs.molecule.runtime)
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
|
import io.element.android.features.invite.api.SeenInvitesStore
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||||
|
|
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||||
private val client: MatrixClient,
|
private val client: MatrixClient,
|
||||||
private val joinRoom: JoinRoom,
|
private val joinRoom: JoinRoom,
|
||||||
private val notificationCleaner: NotificationCleaner,
|
private val notificationCleaner: NotificationCleaner,
|
||||||
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : Presenter<AcceptDeclineInviteState> {
|
) : Presenter<AcceptDeclineInviteState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): AcceptDeclineInviteState {
|
override fun present(): AcceptDeclineInviteState {
|
||||||
|
|
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
|
||||||
|
seenInvitesStore.markAsUnSeen(roomId)
|
||||||
}
|
}
|
||||||
.map { roomId }
|
.map { roomId }
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
|
||||||
client.ignoreUser(inviteData.senderId).getOrThrow()
|
client.ignoreUser(inviteData.senderId).getOrThrow()
|
||||||
}
|
}
|
||||||
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
|
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
|
||||||
|
seenInvitesStore.markAsUnSeen(inviteData.roomId)
|
||||||
inviteData.roomId
|
inviteData.roomId
|
||||||
}.runCatchingUpdatingState(declinedAction)
|
}.runCatchingUpdatingState(declinedAction)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
|
import io.element.android.features.invite.api.SeenInvitesStore
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
|
||||||
import io.element.android.features.invite.api.response.InviteData
|
import io.element.android.features.invite.api.response.InviteData
|
||||||
|
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - declining invite cancel flow`() = runTest {
|
fun `present - declining invite cancel flow`() = runTest {
|
||||||
val presenter = createAcceptDeclineInvitePresenter()
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
|
client = client,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
cancelAndConsumeRemainingEvents()
|
cancelAndConsumeRemainingEvents()
|
||||||
}
|
}
|
||||||
assert(declineInviteFailure).isCalledOnce()
|
assert(declineInviteFailure).isCalledOnce()
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
|
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
val presenter = createAcceptDeclineInvitePresenter(
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
notificationCleaner = fakeNotificationCleaner,
|
notificationCleaner = fakeNotificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
|
|
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
clearMembershipNotificationForRoomLambda.assertions()
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
},
|
},
|
||||||
ignoreUserResult = ignoreUserSuccess
|
ignoreUserResult = ignoreUserSuccess
|
||||||
)
|
)
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
val presenter = createAcceptDeclineInvitePresenter(
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
notificationCleaner = fakeNotificationCleaner,
|
notificationCleaner = fakeNotificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
|
|
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
clearMembershipNotificationForRoomLambda.assertions()
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val presenter = createAcceptDeclineInvitePresenter(client = client)
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
|
client = client,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
}
|
}
|
||||||
assertThat(awaitItem().declineAction.isLoading()).isTrue()
|
assertThat(awaitItem().declineAction.isLoading()).isTrue()
|
||||||
}
|
}
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||||
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
|
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
|
||||||
}
|
}
|
||||||
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure)
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
|
joinRoomLambda = joinRoomFailure,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
|
|
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
value(emptyList<String>()),
|
value(emptyList<String>()),
|
||||||
value(JoinedRoom.Trigger.Invite)
|
value(JoinedRoom.Trigger.Invite)
|
||||||
)
|
)
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
|
||||||
val presenter = createAcceptDeclineInvitePresenter(
|
val presenter = createAcceptDeclineInvitePresenter(
|
||||||
joinRoomLambda = joinRoomSuccess,
|
joinRoomLambda = joinRoomSuccess,
|
||||||
notificationCleaner = fakeNotificationCleaner,
|
notificationCleaner = fakeNotificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val inviteData = anInviteData()
|
val inviteData = anInviteData()
|
||||||
|
|
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
clearMembershipNotificationForRoomLambda.assertions()
|
clearMembershipNotificationForRoomLambda.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
.with(value(A_SESSION_ID), value(A_ROOM_ID))
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun anInviteData(
|
private fun anInviteData(
|
||||||
|
|
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
},
|
},
|
||||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||||
|
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||||
): AcceptDeclineInvitePresenter {
|
): AcceptDeclineInvitePresenter {
|
||||||
return AcceptDeclineInvitePresenter(
|
return AcceptDeclineInvitePresenter(
|
||||||
client = client,
|
client = client,
|
||||||
joinRoom = FakeJoinRoom(joinRoomLambda),
|
joinRoom = FakeJoinRoom(joinRoomLambda),
|
||||||
notificationCleaner = notificationCleaner,
|
notificationCleaner = notificationCleaner,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
features/invite/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ dependencies {
|
||||||
testImplementation(libs.test.robolectric)
|
testImplementation(libs.test.robolectric)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(projects.features.invite.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
|
import io.element.android.features.invite.api.SeenInvitesStore
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.InviteData
|
import io.element.android.features.invite.api.response.InviteData
|
||||||
|
|
@ -67,6 +69,7 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
private val forgetRoom: ForgetRoom,
|
private val forgetRoom: ForgetRoom,
|
||||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
|
private val seenInvitesStore: SeenInvitesStore,
|
||||||
) : Presenter<JoinRoomState> {
|
) : Presenter<JoinRoomState> {
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
|
|
@ -82,7 +85,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
override fun present(): JoinRoomState {
|
override fun present(): JoinRoomState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var retryCount by remember { mutableIntStateOf(0) }
|
var retryCount by remember { mutableIntStateOf(0) }
|
||||||
val roomInfo by matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias()).collectAsState(initial = Optional.empty())
|
val roomInfo by remember {
|
||||||
|
matrixClient.getRoomInfoFlow(roomId.toRoomIdOrAlias())
|
||||||
|
}.collectAsState(initial = Optional.empty())
|
||||||
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
|
|
@ -147,6 +152,10 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||||
|
|
||||||
|
LaunchedEffect(contentState) {
|
||||||
|
contentState.markRoomInviteAsSeen()
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: JoinRoomEvents) {
|
fun handleEvents(event: JoinRoomEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
|
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
|
||||||
|
|
@ -234,6 +243,12 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||||
forgetRoom.invoke(roomId)
|
forgetRoom.invoke(roomId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun ContentState.markRoomInviteAsSeen() {
|
||||||
|
if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
|
||||||
|
seenInvitesStore.markAsSeen(roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
|
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
|
import io.element.android.features.invite.api.SeenInvitesStore
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
import io.element.android.features.joinroom.impl.JoinRoomPresenter
|
||||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
|
|
@ -35,6 +36,7 @@ object JoinRoomModule {
|
||||||
forgetRoom: ForgetRoom,
|
forgetRoom: ForgetRoom,
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||||
buildMeta: BuildMeta,
|
buildMeta: BuildMeta,
|
||||||
|
seenInvitesStore: SeenInvitesStore,
|
||||||
): JoinRoomPresenter.Factory {
|
): JoinRoomPresenter.Factory {
|
||||||
return object : JoinRoomPresenter.Factory {
|
return object : JoinRoomPresenter.Factory {
|
||||||
override fun create(
|
override fun create(
|
||||||
|
|
@ -57,6 +59,7 @@ object JoinRoomModule {
|
||||||
cancelKnockRoom = cancelKnockRoom,
|
cancelKnockRoom = cancelKnockRoom,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom
|
import im.vector.app.features.analytics.plan.JoinedRoom
|
||||||
|
import io.element.android.features.invite.api.SeenInvitesStore
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||||
|
import io.element.android.features.invite.test.InMemorySeenInvitesStore
|
||||||
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
|
import io.element.android.features.joinroom.impl.di.CancelKnockRoom
|
||||||
import io.element.android.features.joinroom.impl.di.ForgetRoom
|
import io.element.android.features.joinroom.impl.di.ForgetRoom
|
||||||
import io.element.android.features.joinroom.impl.di.KnockRoom
|
import io.element.android.features.joinroom.impl.di.KnockRoom
|
||||||
|
|
@ -52,6 +54,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
|
@ -111,14 +114,19 @@ class JoinRoomPresenterTest {
|
||||||
flowOf(Optional.of(roomSummary))
|
flowOf(Optional.of(roomSummary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val seenInvitesStore = InMemorySeenInvitesStore()
|
||||||
val presenter = createJoinRoomPresenter(
|
val presenter = createJoinRoomPresenter(
|
||||||
matrixClient = matrixClient
|
matrixClient = matrixClient,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
|
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
|
||||||
}
|
}
|
||||||
|
// Check that the roomId is stored in the seen invites store
|
||||||
|
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -759,7 +767,8 @@ class JoinRoomPresenterTest {
|
||||||
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
|
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
|
||||||
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
forgetRoom: ForgetRoom = FakeForgetRoom(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
|
||||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
|
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||||
|
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
|
||||||
): JoinRoomPresenter {
|
): JoinRoomPresenter {
|
||||||
return JoinRoomPresenter(
|
return JoinRoomPresenter(
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
|
|
@ -773,7 +782,8 @@ class JoinRoomPresenterTest {
|
||||||
cancelKnockRoom = cancelKnockRoom,
|
cancelKnockRoom = cancelKnockRoom,
|
||||||
forgetRoom = forgetRoom,
|
forgetRoom = forgetRoom,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
|
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||||
|
seenInvitesStore = seenInvitesStore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
<string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string>
|
<string name="screen_knock_requests_list_accept_all_alert_description">"Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;"</string>
|
||||||
<string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string>
|
<string name="screen_knock_requests_list_accept_all_alert_title">"Αποδοχή όλων των αιτημάτων"</string>
|
||||||
<string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string>
|
<string name="screen_knock_requests_list_accept_all_button_title">"Αποδοχή όλων"</string>
|
||||||
|
<string name="screen_knock_requests_list_accept_all_failed_alert_description">"Δεν μπορέσαμε να δεχτούμε όλα τα αιτήματα. Θες να προσπαθήσεις ξανά;"</string>
|
||||||
|
<string name="screen_knock_requests_list_accept_all_failed_alert_title">"Αποτυχία αποδοχής όλων των αιτημάτων"</string>
|
||||||
<string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string>
|
<string name="screen_knock_requests_list_accept_all_loading_title">"Αποδοχή όλων των αιτημάτων συμμετοχής"</string>
|
||||||
|
<string name="screen_knock_requests_list_accept_failed_alert_title">"Αποτυχία αποδοχής αιτήματος"</string>
|
||||||
<string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string>
|
<string name="screen_knock_requests_list_accept_loading_title">"Γίνεται αποδοχή αιτήματος συμμετοχής"</string>
|
||||||
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string>
|
<string name="screen_knock_requests_list_ban_alert_confirm_button_title">"Ναι, απόρριψη και αποκλεισμός"</string>
|
||||||
<string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
|
<string name="screen_knock_requests_list_ban_alert_description">"Σίγουρα θες να απορρίψειε και να αποκλείσεις τον χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορεί να ζητήσει πρόσβαση για να συμμετάσχει ξανά σε αυτό το δωμάτιο."</string>
|
||||||
|
|
@ -17,6 +20,7 @@
|
||||||
<string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string>
|
<string name="screen_knock_requests_list_decline_loading_title">"Γίνεται απόρριψη αιτήματος συμμετοχής"</string>
|
||||||
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
|
<string name="screen_knock_requests_list_empty_state_description">"Όταν κάποιος θα ζητήσει να συμμετάσχει στο δωμάτιο, θα μπορείς να δεις το αίτημά του εδώ."</string>
|
||||||
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
|
<string name="screen_knock_requests_list_empty_state_title">"Δεν υπάρχει εκκρεμές αίτημα συμμετοχής"</string>
|
||||||
|
<string name="screen_knock_requests_list_initial_loading_title">"Φόρτωση αιτημάτων συμμετοχής…"</string>
|
||||||
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
|
<string name="screen_knock_requests_list_title">"Αιτήματα συμμετοχής"</string>
|
||||||
<plurals name="screen_room_multiple_knock_requests_title">
|
<plurals name="screen_room_multiple_knock_requests_title">
|
||||||
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
|
<item quantity="one">"Οι χρήστες %1$s +%2$d ακόμη θέλουν να συμμετάσχουν σε αυτό το δωμάτιο"</item>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
|
|
||||||
package io.element.android.features.licenses.impl.list
|
package io.element.android.features.licenses.impl.list
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -15,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -33,10 +31,11 @@ import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DependencyLicensesListView(
|
fun DependencyLicensesListView(
|
||||||
state: DependencyLicensesListState,
|
state: DependencyLicensesListState,
|
||||||
|
|
@ -60,7 +59,7 @@ fun DependencyLicensesListView(
|
||||||
) {
|
) {
|
||||||
if (state.licenses.isSuccess()) {
|
if (state.licenses.isSuccess()) {
|
||||||
// Search field
|
// Search field
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = state.filter,
|
value = state.filter,
|
||||||
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
|
onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) },
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import config.BuildTimeConfig
|
import config.BuildTimeConfig
|
||||||
|
import extension.buildConfigFieldStr
|
||||||
import extension.readLocalProperty
|
import extension.readLocalProperty
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|
@ -16,10 +17,17 @@ plugins {
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.location.api"
|
namespace = "io.element.android.features.location.api"
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
resValue(
|
buildConfigFieldStr(
|
||||||
type = "string",
|
name = "MAPTILER_BASE_URL",
|
||||||
name = "maptiler_api_key",
|
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps"
|
||||||
|
)
|
||||||
|
buildConfigFieldStr(
|
||||||
|
name = "MAPTILER_API_KEY",
|
||||||
value = if (isEnterpriseBuild) {
|
value = if (isEnterpriseBuild) {
|
||||||
BuildTimeConfig.SERVICES_MAPTILER_APIKEY
|
BuildTimeConfig.SERVICES_MAPTILER_APIKEY
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -28,9 +36,8 @@ android {
|
||||||
}
|
}
|
||||||
?: ""
|
?: ""
|
||||||
)
|
)
|
||||||
resValue(
|
buildConfigFieldStr(
|
||||||
type = "string",
|
name = "MAPTILER_LIGHT_MAP_ID",
|
||||||
name = "maptiler_light_map_id",
|
|
||||||
value = if (isEnterpriseBuild) {
|
value = if (isEnterpriseBuild) {
|
||||||
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
|
BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -40,9 +47,8 @@ android {
|
||||||
// fall back to maptiler's default light map.
|
// fall back to maptiler's default light map.
|
||||||
?: "basic-v2"
|
?: "basic-v2"
|
||||||
)
|
)
|
||||||
resValue(
|
buildConfigFieldStr(
|
||||||
type = "string",
|
name = "MAPTILER_DARK_MAP_ID",
|
||||||
name = "maptiler_dark_map_id",
|
|
||||||
value = if (isEnterpriseBuild) {
|
value = if (isEnterpriseBuild) {
|
||||||
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
|
BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ fun StaticMapView(
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var retryHash by remember { mutableIntStateOf(0) }
|
var retryHash by remember { mutableIntStateOf(0) }
|
||||||
val builder = remember { StaticMapUrlBuilder(context) }
|
val builder = remember { StaticMapUrlBuilder() }
|
||||||
val painter = rememberAsyncImagePainter(
|
val painter = rememberAsyncImagePainter(
|
||||||
model = if (constraints.isZero) {
|
model = if (constraints.isZero) {
|
||||||
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
|
// Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.location.api.internal
|
package io.element.android.features.location.api.internal
|
||||||
|
|
||||||
import android.content.Context
|
import io.element.android.features.location.api.BuildConfig
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,14 +16,16 @@ import kotlin.math.roundToInt
|
||||||
* https://docs.maptiler.com/cloud/api/static-maps/
|
* https://docs.maptiler.com/cloud/api/static-maps/
|
||||||
*/
|
*/
|
||||||
internal class MapTilerStaticMapUrlBuilder(
|
internal class MapTilerStaticMapUrlBuilder(
|
||||||
|
private val baseUrl: String,
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
private val lightMapId: String,
|
private val lightMapId: String,
|
||||||
private val darkMapId: String,
|
private val darkMapId: String,
|
||||||
) : StaticMapUrlBuilder {
|
) : StaticMapUrlBuilder {
|
||||||
constructor(context: Context) : this(
|
constructor() : this(
|
||||||
apiKey = context.apiKey,
|
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
|
||||||
lightMapId = context.mapId(darkMode = false),
|
apiKey = BuildConfig.MAPTILER_API_KEY,
|
||||||
darkMapId = context.mapId(darkMode = true),
|
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||||
|
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun build(
|
override fun build(
|
||||||
|
|
@ -55,7 +57,7 @@ internal class MapTilerStaticMapUrlBuilder(
|
||||||
// image smaller than the available space in pixels.
|
// image smaller than the available space in pixels.
|
||||||
// The resulting image will have to be scaled to fit the available space in order
|
// The resulting image will have to be scaled to fit the available space in order
|
||||||
// to keep the perceived content size constant at the expense of sharpness.
|
// to keep the perceived content size constant at the expense of sharpness.
|
||||||
return "$MAPTILER_BASE_URL/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
|
return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isServiceAvailable() = apiKey.isNotEmpty()
|
override fun isServiceAvailable() = apiKey.isNotEmpty()
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,23 @@
|
||||||
|
|
||||||
package io.element.android.features.location.api.internal
|
package io.element.android.features.location.api.internal
|
||||||
|
|
||||||
import android.content.Context
|
import io.element.android.features.location.api.BuildConfig
|
||||||
|
|
||||||
internal class MapTilerTileServerStyleUriBuilder(
|
internal class MapTilerTileServerStyleUriBuilder(
|
||||||
|
private val baseUrl: String,
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
private val lightMapId: String,
|
private val lightMapId: String,
|
||||||
private val darkMapId: String,
|
private val darkMapId: String,
|
||||||
) : TileServerStyleUriBuilder {
|
) : TileServerStyleUriBuilder {
|
||||||
constructor(context: Context) : this(
|
constructor() : this(
|
||||||
apiKey = context.apiKey,
|
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
|
||||||
lightMapId = context.mapId(darkMode = false),
|
apiKey = BuildConfig.MAPTILER_API_KEY,
|
||||||
darkMapId = context.mapId(darkMode = true),
|
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||||
|
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun build(darkMode: Boolean): String {
|
override fun build(darkMode: Boolean): String {
|
||||||
val mapId = if (darkMode) darkMapId else lightMapId
|
val mapId = if (darkMode) darkMapId else lightMapId
|
||||||
return "$MAPTILER_BASE_URL/$mapId/style.json?key=$apiKey"
|
return "$baseUrl/$mapId/style.json?key=$apiKey"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
|
|
||||||
package io.element.android.features.location.api.internal
|
package io.element.android.features.location.api.internal
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an URL for a 3rd party service provider static maps API.
|
* Builds an URL for a 3rd party service provider static maps API.
|
||||||
*/
|
*/
|
||||||
|
|
@ -26,4 +24,4 @@ interface StaticMapUrlBuilder {
|
||||||
fun isServiceAvailable(): Boolean
|
fun isServiceAvailable(): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
fun StaticMapUrlBuilder(context: Context): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder(context = context)
|
fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder()
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@
|
||||||
|
|
||||||
package io.element.android.features.location.api.internal
|
package io.element.android.features.location.api.internal
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,7 +22,7 @@ interface TileServerStyleUriBuilder {
|
||||||
): String
|
): String
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder(context = context)
|
fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides and remembers a style URI for a MapLibre compatible tile server.
|
* Provides and remembers a style URI for a MapLibre compatible tile server.
|
||||||
|
|
@ -33,9 +31,8 @@ fun TileServerStyleUriBuilder(context: Context): TileServerStyleUriBuilder = Map
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberTileStyleUrl(): String {
|
fun rememberTileStyleUrl(): String {
|
||||||
val context = LocalContext.current
|
|
||||||
val darkMode = !ElementTheme.isLightTheme
|
val darkMode = !ElementTheme.isLightTheme
|
||||||
return remember(darkMode) {
|
return remember(darkMode) {
|
||||||
TileServerStyleUriBuilder(context).build(darkMode)
|
TileServerStyleUriBuilder().build(darkMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import org.junit.Test
|
||||||
|
|
||||||
class MapTilerStaticMapUrlBuilderTest {
|
class MapTilerStaticMapUrlBuilderTest {
|
||||||
private val builder = MapTilerStaticMapUrlBuilder(
|
private val builder = MapTilerStaticMapUrlBuilder(
|
||||||
|
baseUrl = "https://base.url",
|
||||||
apiKey = "anApiKey",
|
apiKey = "anApiKey",
|
||||||
lightMapId = "aLightMapId",
|
lightMapId = "aLightMapId",
|
||||||
darkMapId = "aDarkMapId",
|
darkMapId = "aDarkMapId",
|
||||||
|
|
@ -25,6 +26,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
@Test
|
@Test
|
||||||
fun `isServiceAvailable returns false if api key is empty`() {
|
fun `isServiceAvailable returns false if api key is empty`() {
|
||||||
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
|
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
|
||||||
|
baseUrl = "https://base.url",
|
||||||
apiKey = "",
|
apiKey = "",
|
||||||
lightMapId = "aLightMapId",
|
lightMapId = "aLightMapId",
|
||||||
darkMapId = "aDarkMapId",
|
darkMapId = "aDarkMapId",
|
||||||
|
|
@ -44,7 +46,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 600,
|
height = 600,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -59,7 +61,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 900,
|
height = 900,
|
||||||
density = 1.5f,
|
density = 1.5f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -74,7 +76,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 1200,
|
height = 1200,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -89,7 +91,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 1800,
|
height = 1800,
|
||||||
density = 3f,
|
density = 3f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -104,7 +106,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 2048,
|
height = 2048,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|
@ -116,7 +118,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 4096,
|
height = 4096,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|
@ -128,7 +130,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 2048,
|
height = 2048,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|
@ -140,7 +142,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 4096,
|
height = 4096,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|
@ -152,7 +154,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = Int.MAX_VALUE,
|
height = Int.MAX_VALUE,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -167,7 +169,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 0,
|
height = 0,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|
@ -179,7 +181,7 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = 0,
|
height = 0,
|
||||||
density = 2f,
|
density = 2f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft")
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(
|
builder.build(
|
||||||
|
|
@ -191,6 +193,6 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||||
height = Int.MIN_VALUE,
|
height = Int.MIN_VALUE,
|
||||||
density = 1f,
|
density = 1f,
|
||||||
)
|
)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import org.junit.Test
|
||||||
|
|
||||||
class MapTilerTileServerStyleUriBuilderTest {
|
class MapTilerTileServerStyleUriBuilderTest {
|
||||||
private val builder = MapTilerTileServerStyleUriBuilder(
|
private val builder = MapTilerTileServerStyleUriBuilder(
|
||||||
|
baseUrl = "https://base.url",
|
||||||
apiKey = "anApiKey",
|
apiKey = "anApiKey",
|
||||||
lightMapId = "aLightMapId",
|
lightMapId = "aLightMapId",
|
||||||
darkMapId = "aDarkMapId",
|
darkMapId = "aDarkMapId",
|
||||||
|
|
@ -21,13 +22,13 @@ class MapTilerTileServerStyleUriBuilderTest {
|
||||||
fun `light map uri`() {
|
fun `light map uri`() {
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(darkMode = false)
|
builder.build(darkMode = false)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aLightMapId/style.json?key=anApiKey")
|
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dark map uri`() {
|
fun `dark map uri`() {
|
||||||
assertThat(
|
assertThat(
|
||||||
builder.build(darkMode = true)
|
builder.build(darkMode = true)
|
||||||
).isEqualTo("https://api.maptiler.com/maps/aDarkMapId/style.json?key=anApiKey")
|
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ dependencies {
|
||||||
testImplementation(projects.libraries.testtags)
|
testImplementation(projects.libraries.testtags)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.features.messages.test)
|
testImplementation(projects.features.messages.test)
|
||||||
testImplementation(projects.services.toolbox.test)
|
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,14 @@
|
||||||
package io.element.android.features.location.impl
|
package io.element.android.features.location.impl
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.location.api.BuildConfig
|
||||||
import io.element.android.features.location.api.LocationService
|
import io.element.android.features.location.api.LocationService
|
||||||
import io.element.android.features.location.api.R
|
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultLocationService @Inject constructor(
|
class DefaultLocationService @Inject constructor() : LocationService {
|
||||||
private val stringProvider: StringProvider,
|
|
||||||
) : LocationService {
|
|
||||||
override fun isServiceAvailable(): Boolean {
|
override fun isServiceAvailable(): Boolean {
|
||||||
return stringProvider.getString(R.string.maptiler_api_key).isNotEmpty()
|
return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,14 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
|
|
@ -25,7 +27,7 @@ class AndroidLocationActions @Inject constructor(
|
||||||
) : LocationActions {
|
) : LocationActions {
|
||||||
override fun share(location: Location, label: String?) {
|
override fun share(location: Location, label: String?) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val uri = Uri.parse(buildUrl(location, label))
|
val uri = buildUrl(location, label).toUri()
|
||||||
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||||
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||||
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
@ -42,17 +44,14 @@ class AndroidLocationActions @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
internal fun buildUrl(
|
internal fun buildUrl(
|
||||||
location: Location,
|
location: Location,
|
||||||
label: String?,
|
label: String?,
|
||||||
urlEncoder: (String) -> String = Uri::encode
|
urlEncoder: (String) -> String = Uri::encode
|
||||||
): String {
|
): String {
|
||||||
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
// This is needed so the coordinates are formatted with a dot as decimal separator
|
||||||
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
|
val locale = Locale.ENGLISH
|
||||||
return if (label == null) {
|
return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty()))
|
||||||
base
|
|
||||||
} else {
|
|
||||||
"%s (%s)".format(base, urlEncoder(label))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,30 +8,15 @@
|
||||||
package io.element.android.features.location.impl
|
package io.element.android.features.location.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.R
|
import io.element.android.features.location.api.BuildConfig
|
||||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DefaultLocationServiceTest {
|
class DefaultLocationServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun `if apiKey is empty, isServiceAvailable should return false`() {
|
fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
|
||||||
val fakeStringProvider = FakeStringProvider(
|
val locationService = DefaultLocationService()
|
||||||
defaultResult = ""
|
assertThat(locationService.isServiceAvailable()).isEqualTo(
|
||||||
|
BuildConfig.MAPTILER_API_KEY.isNotEmpty()
|
||||||
)
|
)
|
||||||
val locationService = DefaultLocationService(
|
|
||||||
stringProvider = fakeStringProvider,
|
|
||||||
)
|
|
||||||
assertThat(locationService.isServiceAvailable()).isFalse()
|
|
||||||
assertThat(fakeStringProvider.lastResIdParam).isEqualTo(R.string.maptiler_api_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if apiKey is not empty, isServiceAvailable should return true`() {
|
|
||||||
val locationService = DefaultLocationService(
|
|
||||||
stringProvider = FakeStringProvider(
|
|
||||||
defaultResult = "aKey"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assertThat(locationService.isServiceAvailable()).isTrue()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.location.api.Location
|
import io.element.android.features.location.api.Location
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
internal class AndroidLocationActionsTest {
|
internal class AndroidLocationActionsTest {
|
||||||
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
|
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
|
||||||
|
|
@ -25,7 +26,7 @@ internal class AndroidLocationActionsTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
val actual = buildUrl(location, null, ::urlEncoder)
|
val actual = buildUrl(location, null, ::urlEncoder)
|
||||||
val expected = "geo:0,0?q=1.234568,123.456789"
|
val expected = "geo:0,0?q=1.234568,123.456789 ()"
|
||||||
|
|
||||||
assertThat(actual).isEqualTo(expected)
|
assertThat(actual).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
@ -57,4 +58,20 @@ internal class AndroidLocationActionsTest {
|
||||||
|
|
||||||
assertThat(actual).isEqualTo(expected)
|
assertThat(actual).isEqualTo(expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() {
|
||||||
|
val location = Location(
|
||||||
|
lat = 1.000001,
|
||||||
|
lon = 2.000001,
|
||||||
|
accuracy = 0f
|
||||||
|
)
|
||||||
|
// Set a locale with comma as decimal separator
|
||||||
|
Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR"))
|
||||||
|
|
||||||
|
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
|
||||||
|
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
|
||||||
|
|
||||||
|
assertThat(actual).isEqualTo(expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.features.lockscreen.impl"
|
namespace = "io.element.android.features.lockscreen.impl"
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAnvil()
|
setupAnvil()
|
||||||
|
|
@ -30,6 +34,8 @@ dependencies {
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
implementation(projects.libraries.cryptography.api)
|
implementation(projects.libraries.cryptography.api)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.testtags)
|
||||||
|
implementation(projects.libraries.uiUtils)
|
||||||
implementation(projects.features.logout.api)
|
implementation(projects.features.logout.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
|
|
@ -42,6 +48,9 @@ dependencies {
|
||||||
testImplementation(libs.molecule.runtime)
|
testImplementation(libs.molecule.runtime)
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(libs.test.robolectric)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
testImplementation(libs.androidx.test.ext.junit)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.libraries.cryptography.test)
|
testImplementation(projects.libraries.cryptography.test)
|
||||||
|
|
@ -50,4 +59,5 @@ dependencies {
|
||||||
testImplementation(projects.libraries.sessionStorage.test)
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(projects.features.logout.test)
|
testImplementation(projects.features.logout.test)
|
||||||
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||