Element config (#4471)

* Add handy extension "VariantDimension.buildConfigFieldStr"

* Update configuration for MapTiler.

* Update configuration for Sentry.

* Build AnalyticsConfig depending on analytics configuration.

* Configure analytics policy url.

* Add handy extension "VariantDimension.buildConfigFieldBoolean"

* Configure legal urls.

* Add a way to disable rageshake / reporting bugs.

* Update screenshots

* Quality

* Fix test

* Use `ifBlank` extension

* Add missing configuration for PostHog

* Update configuration for Rageshake.

* Add build log.

* Disable crash detection if rageshake feature is not available.
Disabled twice.

* Hide link to analytics policy if the link is missing.

* Fix test when run in enterprise context.

* Use RageshakeFeatureAvailability where appropriate.

* Rename file.

* Move some classes to their correct module.

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-03-27 11:25:04 +01:00 committed by GitHub
parent 0838f4259a
commit 87fd1372a9
95 changed files with 613 additions and 273 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@ setupAnvil()
dependencies { dependencies {
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.rageshake.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)

View file

@ -10,7 +10,9 @@ package io.element.android.features.onboarding.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
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
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -24,16 +26,19 @@ import javax.inject.Inject
class OnBoardingPresenter @Inject constructor( class OnBoardingPresenter @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<OnBoardingState> { ) : Presenter<OnBoardingState> {
@Composable @Composable
override fun present(): OnBoardingState { override fun present(): OnBoardingState {
val canLoginWithQrCode by produceState(initialValue = false) { val canLoginWithQrCode by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin) value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin)
} }
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
return OnBoardingState( return OnBoardingState(
productionApplicationName = buildMeta.productionApplicationName, productionApplicationName = buildMeta.productionApplicationName,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT, canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug,
) )
} }
} }

View file

@ -11,4 +11,5 @@ data class OnBoardingState(
val productionApplicationName: String, val productionApplicationName: String,
val canLoginWithQrCode: Boolean, val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean, val canCreateAccount: Boolean,
val canReportBug: Boolean,
) )

View file

@ -16,15 +16,18 @@ open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> {
anOnBoardingState(canLoginWithQrCode = true), anOnBoardingState(canLoginWithQrCode = true),
anOnBoardingState(canCreateAccount = true), anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true), anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
) )
} }
fun anOnBoardingState( fun anOnBoardingState(
productionApplicationName: String = "Element", productionApplicationName: String = "Element",
canLoginWithQrCode: Boolean = false, canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false canCreateAccount: Boolean = false,
canReportBug: Boolean = false,
) = OnBoardingState( ) = OnBoardingState(
productionApplicationName = productionApplicationName, productionApplicationName = productionApplicationName,
canLoginWithQrCode = canLoginWithQrCode, canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = canCreateAccount canCreateAccount = canCreateAccount,
canReportBug = canReportBug,
) )

View file

@ -144,8 +144,8 @@ private fun OnBoardingButtons(
text = stringResource(id = signInButtonStringRes), text = stringResource(id = signInButtonStringRes),
onClick = onSignIn, onClick = onSignIn,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.onBoardingSignIn) .testTag(TestTags.onBoardingSignIn)
) )
if (state.canCreateAccount) { if (state.canCreateAccount) {
TextButton( TextButton(
@ -155,15 +155,17 @@ private fun OnBoardingButtons(
.fillMaxWidth() .fillMaxWidth()
) )
} }
// Add a report problem text button. Use a Text since we need a special theme here. if (state.canReportBug) {
Text( // Add a report problem text button. Use a Text since we need a special theme here.
modifier = Modifier Text(
modifier = Modifier
.padding(16.dp) .padding(16.dp)
.clickable(onClick = onReportProblem), .clickable(onClick = onReportProblem),
text = stringResource(id = CommonStrings.common_report_a_problem), text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
) )
}
} }
} }

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
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
@ -38,6 +39,7 @@ class OnBoardingPresenterTest {
val presenter = OnBoardingPresenter( val presenter = OnBoardingPresenter(
buildMeta = buildMeta, buildMeta = buildMeta,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
rageshakeFeatureAvailability = { true },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -46,7 +48,21 @@ class OnBoardingPresenterTest {
assertThat(initialState.canLoginWithQrCode).isFalse() assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isTrue()
assertThat(awaitItem().canLoginWithQrCode).isTrue() assertThat(awaitItem().canLoginWithQrCode).isTrue()
} }
} }
@Test
fun `present - rageshake not available`() = runTest {
val presenter = OnBoardingPresenter(
buildMeta = aBuildMeta(),
featureFlagService = FakeFeatureFlagService(),
rageshakeFeatureAvailability = { false },
)
presenter.test {
skipItems(1)
assertThat(awaitItem().canReportBug).isFalse()
}
}
} }

View file

@ -10,6 +10,7 @@ package io.element.android.features.onboarding.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -76,13 +77,28 @@ class OnboardingViewTest {
fun `clicking on report a problem calls the sign in callback`() { fun `clicking on report a problem calls the sign in callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( rule.setOnboardingView(
state = anOnBoardingState(), state = anOnBoardingState(
canReportBug = true,
),
onReportProblem = callback, onReportProblem = callback,
) )
val text = rule.activity.getString(CommonStrings.common_report_a_problem)
rule.onNodeWithText(text).assertExists()
rule.clickOn(CommonStrings.common_report_a_problem) rule.clickOn(CommonStrings.common_report_a_problem)
} }
} }
@Test
fun `cannot report a problem when the feature is disabled`() {
rule.setOnboardingView(
state = anOnBoardingState(
canReportBug = false,
),
)
val text = rule.activity.getString(CommonStrings.common_report_a_problem)
rule.onNodeWithText(text).assertDoesNotExist()
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView(
state: OnBoardingState, state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),

View file

@ -1,3 +1,5 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupAnvil import extension.setupAnvil
/* /*
@ -19,6 +21,25 @@ android {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
} }
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "URL_COPYRIGHT",
value = BuildTimeConfig.URL_COPYRIGHT ?: "https://element.io/copyright",
)
buildConfigFieldStr(
name = "URL_ACCEPTABLE_USE",
value = BuildTimeConfig.URL_ACCEPTABLE_USE ?: "https://element.io/acceptable-use-policy-terms",
)
buildConfigFieldStr(
name = "URL_PRIVACY",
value = BuildTimeConfig.URL_PRIVACY ?: "https://element.io/privacy",
)
}
} }
setupAnvil() setupAnvil()

View file

@ -8,11 +8,12 @@
package io.element.android.features.preferences.impl.about package io.element.android.features.preferences.impl.about
import androidx.annotation.StringRes import androidx.annotation.StringRes
import io.element.android.features.preferences.impl.BuildConfig
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
private const val COPYRIGHT_URL = "https://element.io/copyright" private const val COPYRIGHT_URL = BuildConfig.URL_COPYRIGHT
private const val USE_POLICY_URL = "https://element.io/acceptable-use-policy-terms" private const val USE_POLICY_URL = BuildConfig.URL_ACCEPTABLE_USE
private const val PRIVACY_URL = "https://element.io/privacy" private const val PRIVACY_URL = BuildConfig.URL_PRIVACY
sealed class ElementLegal( sealed class ElementLegal(
@StringRes val titleRes: Int, @StringRes val titleRes: Int,

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
@ -44,6 +45,7 @@ class PreferencesRootPresenter @Inject constructor(
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val directLogoutPresenter: Presenter<DirectLogoutState>, private val directLogoutPresenter: Presenter<DirectLogoutState>,
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<PreferencesRootState> { ) : Presenter<PreferencesRootState> {
@Composable @Composable
override fun present(): PreferencesRootState { override fun present(): PreferencesRootState {
@ -79,6 +81,7 @@ class PreferencesRootPresenter @Inject constructor(
var canDeactivateAccount by remember { var canDeactivateAccount by remember {
mutableStateOf(false) mutableStateOf(false)
} }
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canDeactivateAccount = matrixClient.canDeactivateAccount() canDeactivateAccount = matrixClient.canDeactivateAccount()
} }
@ -114,6 +117,7 @@ class PreferencesRootPresenter @Inject constructor(
accountManagementUrl = accountManagementUrl.value, accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value, devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders, showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
showDeveloperSettings = showDeveloperSettings, showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount, canDeactivateAccount = canDeactivateAccount,
showNotificationSettings = showNotificationSettings.value, showNotificationSettings = showNotificationSettings.value,

View file

@ -20,6 +20,7 @@ data class PreferencesRootState(
val showSecureBackupBadge: Boolean, val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?, val accountManagementUrl: String?,
val devicesManagementUrl: String?, val devicesManagementUrl: String?,
val canReportBug: Boolean,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean, val canDeactivateAccount: Boolean,

View file

@ -25,6 +25,7 @@ fun aPreferencesRootState(
accountManagementUrl = "aUrl", accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl", devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true, showAnalyticsSettings = true,
canReportBug = true,
showDeveloperSettings = true, showDeveloperSettings = true,
showNotificationSettings = true, showNotificationSettings = true,
showLockScreenSettings = true, showLockScreenSettings = true,

View file

@ -202,11 +202,13 @@ private fun ColumnScope.GeneralSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = onOpenAbout, onClick = onOpenAbout,
) )
ListItem( if (state.canReportBug) {
headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
onClick = onOpenRageShake leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
) onClick = onOpenRageShake
)
}
if (state.showAnalyticsSettings) { if (state.showAnalyticsSettings) {
ListItem( ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) }, headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) },

View file

@ -13,6 +13,7 @@ import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -78,6 +79,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.showLockScreenSettings).isTrue() assertThat(loadedState.showLockScreenSettings).isTrue()
assertThat(loadedState.showNotificationSettings).isTrue() assertThat(loadedState.showNotificationSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue() assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState()) assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(loadedState.snackbarMessage).isNull() assertThat(loadedState.snackbarMessage).isNull()
skipItems(1) skipItems(1)
@ -92,6 +94,22 @@ class PreferencesRootPresenterTest {
} }
} }
@Test
fun `present - cannot report bug`() = runTest {
val matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success("") },
)
createPresenter(
matrixClient = matrixClient,
rageshakeFeatureAvailability = { false },
).test {
val initialState = awaitItem()
assertThat(initialState.canReportBug).isFalse()
skipItems(1)
}
}
@Test @Test
fun `present - can deactivate account is false if the Matrix client say so`() = runTest { fun `present - can deactivate account is false if the Matrix client say so`() = runTest {
createPresenter( createPresenter(
@ -146,6 +164,7 @@ class PreferencesRootPresenterTest {
matrixClient: FakeMatrixClient = FakeMatrixClient(), matrixClient: FakeMatrixClient = FakeMatrixClient(),
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
) = PreferencesRootPresenter( ) = PreferencesRootPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
@ -159,5 +178,6 @@ class PreferencesRootPresenterTest {
), ),
directLogoutPresenter = { aDirectLogoutState() }, directLogoutPresenter = { aDirectLogoutState() },
showDeveloperSettingsProvider = showDeveloperSettingsProvider, showDeveloperSettingsProvider = showDeveloperSettingsProvider,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
) )
} }

View file

@ -0,0 +1,12 @@
/*
* 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.rageshake.api
fun interface RageshakeFeatureAvailability {
fun isAvailable(): Boolean
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.rageshake.api.preferences package io.element.android.features.rageshake.api.preferences
data class RageshakePreferencesState( data class RageshakePreferencesState(
val isFeatureEnabled: Boolean,
val isEnabled: Boolean, val isEnabled: Boolean,
val isSupported: Boolean, val isSupported: Boolean,
val sensitivity: Float, val sensitivity: Float,

View file

@ -12,14 +12,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RageshakePreferencesStateProvider : PreviewParameterProvider<RageshakePreferencesState> { open class RageshakePreferencesStateProvider : PreviewParameterProvider<RageshakePreferencesState> {
override val values: Sequence<RageshakePreferencesState> override val values: Sequence<RageshakePreferencesState>
get() = sequenceOf( get() = sequenceOf(
aRageshakePreferencesState().copy(isEnabled = true, isSupported = true, sensitivity = 0.5f), aRageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f),
aRageshakePreferencesState().copy(isEnabled = true, isSupported = false, sensitivity = 0.5f), aRageshakePreferencesState(isEnabled = true, isSupported = false, sensitivity = 0.5f),
) )
} }
fun aRageshakePreferencesState() = RageshakePreferencesState( fun aRageshakePreferencesState(
isEnabled = false, isFeatureEnabled: Boolean = true,
isSupported = true, isEnabled: Boolean = false,
sensitivity = 0.3f, isSupported: Boolean = true,
eventSink = {} sensitivity: Float = 0.3f,
eventSink: (RageshakePreferencesEvents) -> Unit = {}
) = RageshakePreferencesState(
isFeatureEnabled = isFeatureEnabled,
isEnabled = isEnabled,
isSupported = isSupported,
sensitivity = sensitivity,
eventSink = eventSink,
) )

View file

@ -36,28 +36,30 @@ fun RageshakePreferencesView(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) { if (state.isFeatureEnabled) {
if (state.isSupported) { PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) {
PreferenceSwitch( if (state.isSupported) {
title = stringResource(id = CommonStrings.preference_rageshake), PreferenceSwitch(
isChecked = state.isEnabled, title = stringResource(id = CommonStrings.preference_rageshake),
onCheckedChange = ::onEnabledChanged isChecked = state.isEnabled,
) onCheckedChange = ::onEnabledChanged
PreferenceSlide( )
title = stringResource(id = R.string.settings_rageshake_detection_threshold), PreferenceSlide(
// summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary), title = stringResource(id = R.string.settings_rageshake_detection_threshold),
value = state.sensitivity, // summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary),
enabled = state.isEnabled, value = state.sensitivity,
// 5 possible values - steps are in ]0, 1[ enabled = state.isEnabled,
steps = 3, // 5 possible values - steps are in ]0, 1[
onValueChange = ::onSensitivityChanged steps = 3,
) onValueChange = ::onSensitivityChanged
} else { )
ListItem( } else {
headlineContent = { ListItem(
Text("Rageshaking is not supported by your device") headlineContent = {
}, Text("Rageshaking is not supported by your device")
) },
)
}
} }
} }
} }

View file

@ -0,0 +1,22 @@
/*
* 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.rageshake.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig
import io.element.android.appconfig.isEnabled
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRageshakeFeatureAvailability @Inject constructor() : RageshakeFeatureAvailability {
override fun isAvailable(): Boolean {
return RageshakeConfig.isEnabled
}
}

View file

@ -16,10 +16,10 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.api.crash package io.element.android.features.rageshake.impl.crash
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View file

@ -9,15 +9,17 @@ package io.element.android.features.rageshake.impl.crash
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.crash.CrashDataStore import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.crash.CrashDetectionState import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -25,12 +27,18 @@ import javax.inject.Inject
class DefaultCrashDetectionPresenter @Inject constructor( class DefaultCrashDetectionPresenter @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val crashDataStore: CrashDataStore, private val crashDataStore: CrashDataStore,
) : private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
CrashDetectionPresenter { ) : CrashDetectionPresenter {
@Composable @Composable
override fun present(): CrashDetectionState { override fun present(): CrashDetectionState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val crashDetected = crashDataStore.appHasCrashed().collectAsState(initial = false) val crashDetected = remember {
if (rageshakeFeatureAvailability.isAvailable()) {
crashDataStore.appHasCrashed()
} else {
flowOf(false)
}
}.collectAsState(false)
fun handleEvents(event: CrashDetectionEvents) { fun handleEvents(event: CrashDetectionEvents) {
when (event) { when (event) {

View file

@ -15,7 +15,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
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

View file

@ -20,9 +20,9 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionPre
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.rageshake.RageShake
import io.element.android.features.rageshake.api.screenshot.ImageResult import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder import io.element.android.features.rageshake.impl.rageshake.RageShake
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -75,7 +75,8 @@ class DefaultRageshakeDetectionPresenter @Inject constructor(
LaunchedEffect(preferencesState.sensitivity) { LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity) rageShake.setSensitivity(preferencesState.sensitivity)
} }
val shouldStart = preferencesState.isEnabled && val shouldStart = preferencesState.isFeatureEnabled &&
preferencesState.isEnabled &&
preferencesState.isSupported && preferencesState.isSupported &&
isStarted.value && isStarted.value &&
!takeScreenshot.value && !takeScreenshot.value &&

View file

@ -11,14 +11,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.features.rageshake.api.rageshake.RageShake import io.element.android.features.rageshake.impl.rageshake.RageShake
import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore import io.element.android.features.rageshake.impl.rageshake.RageshakeDataStore
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -28,6 +30,7 @@ import javax.inject.Inject
class DefaultRageshakePreferencesPresenter @Inject constructor( class DefaultRageshakePreferencesPresenter @Inject constructor(
private val rageshake: RageShake, private val rageshake: RageShake,
private val rageshakeDataStore: RageshakeDataStore, private val rageshakeDataStore: RageshakeDataStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : RageshakePreferencesPresenter { ) : RageshakePreferencesPresenter {
@Composable @Composable
override fun present(): RageshakePreferencesState { override fun present(): RageshakePreferencesState {
@ -35,6 +38,7 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
val isSupported: MutableState<Boolean> = rememberSaveable { val isSupported: MutableState<Boolean> = rememberSaveable {
mutableStateOf(rageshake.isAvailable()) mutableStateOf(rageshake.isAvailable())
} }
val isFeatureAvailable = remember { rageshakeFeatureAvailability.isAvailable() }
val isEnabled = rageshakeDataStore val isEnabled = rageshakeDataStore
.isEnabled() .isEnabled()
.collectAsState(initial = false) .collectAsState(initial = false)
@ -51,6 +55,7 @@ class DefaultRageshakePreferencesPresenter @Inject constructor(
} }
return RageshakePreferencesState( return RageshakePreferencesState(
isFeatureEnabled = isFeatureAvailable,
isEnabled = isEnabled.value, isEnabled = isEnabled.value,
isSupported = isSupported.value, isSupported = isSupported.value,
sensitivity = sensitivity.value, sensitivity = sensitivity.value,

View file

@ -13,7 +13,6 @@ import android.hardware.SensorManager
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.seismic.ShakeDetector import com.squareup.seismic.ShakeDetector
import io.element.android.features.rageshake.api.rageshake.RageShake
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 io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn

View file

@ -15,7 +15,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
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

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.api.rageshake package io.element.android.features.rageshake.impl.rageshake
interface RageShake { interface RageShake {
/** /**

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.api.rageshake package io.element.android.features.rageshake.impl.rageshake
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View file

@ -13,10 +13,10 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.RageshakeConfig import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.file.compressFile import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers

View file

@ -11,7 +11,6 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.libraries.androidutils.bitmap.writeBitmap import io.element.android.libraries.androidutils.bitmap.writeBitmap
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.api.screenshot package io.element.android.features.rageshake.impl.screenshot
import android.graphics.Bitmap import android.graphics.Bitmap

View file

@ -11,13 +11,13 @@ 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.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI import io.element.android.features.rageshake.impl.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule

View file

@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.test.crash package io.element.android.features.rageshake.impl.crash
import io.element.android.features.rageshake.api.crash.CrashDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -12,9 +12,9 @@ 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.features.rageshake.api.crash.CrashDetectionEvents import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -51,6 +51,20 @@ class CrashDetectionPresenterTest {
} }
} }
@Test
fun `present - initial state crash is ignored if the feature is not available`() = runTest {
val presenter = createPresenter(
FakeCrashDataStore(appHasCrashed = true),
isFeatureAvailable = false,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse()
}
}
@Test @Test
fun `present - reset app has crashed`() = runTest { fun `present - reset app has crashed`() = runTest {
val presenter = createPresenter( val presenter = createPresenter(
@ -86,8 +100,10 @@ class CrashDetectionPresenterTest {
private fun createPresenter( private fun createPresenter(
crashDataStore: FakeCrashDataStore = FakeCrashDataStore(), crashDataStore: FakeCrashDataStore = FakeCrashDataStore(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
isFeatureAvailable: Boolean = true,
) = DefaultCrashDetectionPresenter( ) = DefaultCrashDetectionPresenter(
buildMeta = buildMeta, buildMeta = buildMeta,
crashDataStore = crashDataStore, crashDataStore = crashDataStore,
rageshakeFeatureAvailability = { isFeatureAvailable },
) )
} }

View file

@ -15,9 +15,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
import io.element.android.features.rageshake.api.screenshot.ImageResult import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
import io.element.android.features.rageshake.test.rageshake.FakeRageShake import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk import io.mockk.mockk
@ -52,6 +52,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter( preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake, rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore, rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -76,6 +77,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter( preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake, rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore, rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -101,6 +103,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter( preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake, rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore, rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -135,6 +138,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter( preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake, rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore, rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -169,6 +173,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter( preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake, rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore, rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {

View file

@ -12,9 +12,9 @@ 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.features.rageshake.api.preferences.RageshakePreferencesEvents import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents
import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY import io.element.android.features.rageshake.impl.rageshake.A_SENSITIVITY
import io.element.android.features.rageshake.test.rageshake.FakeRageShake import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -28,7 +28,8 @@ class RageshakePreferencesPresenterTest {
fun `present - initial state available`() = runTest { fun `present - initial state available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter( val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true), FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true) FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -44,7 +45,8 @@ class RageshakePreferencesPresenterTest {
fun `present - initial state not available`() = runTest { fun `present - initial state not available`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter( val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = false), FakeRageShake(isAvailableValue = false),
FakeRageshakeDataStore(isEnabled = true) FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -60,7 +62,8 @@ class RageshakePreferencesPresenterTest {
fun `present - enable and disable`() = runTest { fun `present - enable and disable`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter( val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true), FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true) FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -79,7 +82,8 @@ class RageshakePreferencesPresenterTest {
fun `present - set sensitivity`() = runTest { fun `present - set sensitivity`() = runTest {
val presenter = DefaultRageshakePreferencesPresenter( val presenter = DefaultRageshakePreferencesPresenter(
FakeRageShake(isAvailableValue = true), FakeRageShake(isAvailableValue = true),
FakeRageshakeDataStore(isEnabled = true) FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { true },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()

View file

@ -5,9 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.test.rageshake package io.element.android.features.rageshake.impl.rageshake
import io.element.android.features.rageshake.api.rageshake.RageShake
class FakeRageShake( class FakeRageShake(
private var isAvailableValue: Boolean = true private var isAvailableValue: Boolean = true

View file

@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.test.rageshake package io.element.android.features.rageshake.impl.rageshake
import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -8,9 +8,10 @@
package io.element.android.features.rageshake.impl.reporter package io.element.android.features.rageshake.impl.reporter
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.RageshakeConfig
import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.FakeSdkMetadata
@ -138,7 +139,7 @@ class DefaultBugReporterTest {
val foundValues = collectValuesFromFormData(request) val foundValues = collectValuesFromFormData(request)
assertThat(foundValues["app"]).isEqualTo("element-x-android") assertThat(foundValues["app"]).isEqualTo(RageshakeConfig.BUG_REPORT_APP_NAME)
assertThat(foundValues["can_contact"]).isEqualTo("true") assertThat(foundValues["can_contact"]).isEqualTo("true")
assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH") assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH")
assertThat(foundValues["sdk_sha"]).isEqualTo("123456789") assertThat(foundValues["sdk_sha"]).isEqualTo("123456789")

View file

@ -16,7 +16,9 @@ class DefaultBugReporterUrlProviderTest {
@Test @Test
fun `test DefaultBugReporterUrlProvider`() { fun `test DefaultBugReporterUrlProvider`() {
val sut = DefaultBugReporterUrlProvider() val sut = DefaultBugReporterUrlProvider()
val result = sut.provide() if (RageshakeConfig.BUG_REPORT_URL.isNotEmpty()) {
assertThat(result).isEqualTo(RageshakeConfig.BUG_REPORT_URL.toHttpUrl()) val result = sut.provide()
assertThat(result).isEqualTo(RageshakeConfig.BUG_REPORT_URL.toHttpUrl())
}
} }
} }

View file

@ -5,10 +5,9 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.rageshake.test.screenshot package io.element.android.features.rageshake.impl.screenshot
import android.graphics.Bitmap import android.graphics.Bitmap
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
const val A_SCREENSHOT_URI = "file://content/uri" const val A_SCREENSHOT_URI = "file://content/uri"

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.features.leaveroom.api) implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
api(projects.features.roomlist.api) api(projects.features.roomlist.api)

View file

@ -30,6 +30,7 @@ import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@ -91,6 +92,7 @@ class RoomListPresenter @Inject constructor(
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
private val logoutPresenter: Presenter<DirectLogoutState>, private val logoutPresenter: Presenter<DirectLogoutState>,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService() private val encryptionService: EncryptionService = client.encryptionService()
@ -103,6 +105,7 @@ class RoomListPresenter @Inject constructor(
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present() val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
roomListDataSource.launchIn(this) roomListDataSource.launchIn(this)
@ -163,6 +166,7 @@ class RoomListPresenter @Inject constructor(
contextMenu = contextMenu.value, contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
filtersState = filtersState, filtersState = filtersState,
canReportBug = canReportBug,
searchState = searchState, searchState = searchState,
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,

View file

@ -29,6 +29,7 @@ data class RoomListState(
val contextMenu: ContextMenu, val contextMenu: ContextMenu,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState, val filtersState: RoomListFiltersState,
val canReportBug: Boolean,
val searchState: RoomListSearchState, val searchState: RoomListSearchState,
val contentState: RoomListContentState, val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState, val acceptDeclineInviteState: AcceptDeclineInviteState,

View file

@ -57,6 +57,7 @@ internal fun aRoomListState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(), searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(), filtersState: RoomListFiltersState = aRoomListFiltersState(),
canReportBug: Boolean = true,
contentState: RoomListContentState = aRoomsContentState(), contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
directLogoutState: DirectLogoutState = aDirectLogoutState(), directLogoutState: DirectLogoutState = aDirectLogoutState(),
@ -69,6 +70,7 @@ internal fun aRoomListState(
contextMenu = contextMenu, contextMenu = contextMenu,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
filtersState = filtersState, filtersState = filtersState,
canReportBug = canReportBug,
searchState = searchState, searchState = searchState,
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,

View file

@ -127,6 +127,7 @@ private fun RoomListScaffold(
displayMenuItems = state.displayActions, displayMenuItems = state.displayActions,
displayFilters = state.displayFilters, displayFilters = state.displayFilters,
filtersState = state.filtersState, filtersState = state.filtersState,
canReportBug = state.canReportBug,
) )
}, },
content = { padding -> content = { padding ->

View file

@ -85,6 +85,7 @@ fun RoomListTopBar(
displayMenuItems: Boolean, displayMenuItems: Boolean,
displayFilters: Boolean, displayFilters: Boolean,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
canReportBug: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
DefaultRoomListTopBar( DefaultRoomListTopBar(
@ -98,6 +99,7 @@ fun RoomListTopBar(
displayMenuItems = displayMenuItems, displayMenuItems = displayMenuItems,
displayFilters = displayFilters, displayFilters = displayFilters,
filtersState = filtersState, filtersState = filtersState,
canReportBug = canReportBug,
modifier = modifier, modifier = modifier,
) )
} }
@ -115,6 +117,7 @@ private fun DefaultRoomListTopBar(
displayMenuItems: Boolean, displayMenuItems: Boolean,
displayFilters: Boolean, displayFilters: Boolean,
filtersState: RoomListFiltersState, filtersState: RoomListFiltersState,
canReportBug: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// We need this to manually clip the top app bar in preview mode // We need this to manually clip the top app bar in preview mode
@ -239,7 +242,7 @@ private fun DefaultRoomListTopBar(
} }
) )
} }
if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM) { if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
DropdownMenuItem( DropdownMenuItem(
onClick = { onClick = {
showMenu = false showMenu = false
@ -319,6 +322,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
displayMenuItems = true, displayMenuItems = true,
displayFilters = true, displayFilters = true,
filtersState = aRoomListFiltersState(), filtersState = aRoomListFiltersState(),
canReportBug = true,
onMenuActionClick = {}, onMenuActionClick = {},
) )
} }
@ -337,6 +341,7 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
displayMenuItems = true, displayMenuItems = true,
displayFilters = true, displayFilters = true,
filtersState = aRoomListFiltersState(), filtersState = aRoomListFiltersState(),
canReportBug = true,
onMenuActionClick = {}, onMenuActionClick = {},
) )
} }

View file

@ -19,6 +19,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory import io.element.android.features.roomlist.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
@ -105,12 +106,14 @@ class RoomListPresenterTest {
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL))) matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
rageshakeFeatureAvailability = { false },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID)) assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
assertThat(initialState.canReportBug).isFalse()
val withUserState = awaitItem() val withUserState = awaitItem()
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID) assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME) assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
@ -135,6 +138,7 @@ class RoomListPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue() assertThat(initialState.showAvatarIndicator).isTrue()
assertThat(initialState.canReportBug).isTrue()
sessionVerificationService.emitNeedsSessionVerification(false) sessionVerificationService.emitNeedsSessionVerification(false)
encryptionService.emitBackupState(BackupState.ENABLED) encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem() val finalState = awaitItem()
@ -675,6 +679,7 @@ class RoomListPresenterTest {
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
syncService = syncService, syncService = syncService,
@ -705,6 +710,7 @@ class RoomListPresenterTest {
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
logoutPresenter = { aDirectLogoutState() }, logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
) )
} }

View file

@ -6,6 +6,7 @@
*/ */
import config.AnalyticsConfig import config.AnalyticsConfig
import config.BuildTimeConfig
import config.PushProvidersConfig import config.PushProvidersConfig
object ModulesConfig { object ModulesConfig {
@ -14,8 +15,27 @@ object ModulesConfig {
includeUnifiedPush = true, includeUnifiedPush = true,
) )
val analyticsConfig: AnalyticsConfig = AnalyticsConfig.Enabled( val analyticsConfig: AnalyticsConfig = if (isEnterpriseBuild) {
withPosthog = true, // Is Posthog configuration available?
withSentry = true, val withPosthog = BuildTimeConfig.SERVICES_POSTHOG_APIKEY.isNullOrEmpty().not() &&
) BuildTimeConfig.SERVICES_POSTHOG_HOST.isNullOrEmpty().not()
// Is Sentry configuration available?
val withSentry = BuildTimeConfig.SERVICES_SENTRY_DSN.isNullOrEmpty().not()
if (withPosthog || withSentry) {
println("Analytics enabled with Posthog: $withPosthog, Sentry: $withSentry")
AnalyticsConfig.Enabled(
withPosthog = withPosthog,
withSentry = withSentry,
)
} else {
println("Analytics disabled")
AnalyticsConfig.Disabled
}
} else {
println("Analytics enabled with Posthog and Sentry")
AnalyticsConfig.Enabled(
withPosthog = true,
withSentry = true,
)
}
} }

View file

@ -14,7 +14,21 @@ object BuildTimeConfig {
const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c" const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c"
const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c" const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c"
val METADATA_HOST: String? = null
val URL_WEBSITE: String? = null
val URL_LOGO: String? = null
val URL_COPYRIGHT: String? = null
val URL_ACCEPTABLE_USE: String? = null
val URL_PRIVACY: String? = null
val URL_POLICY: String? = null
val SUPPORT_EMAIL_ADDRESS: String? = null
val SERVICES_MAPTILER_BASE_URL: String? = null
val SERVICES_MAPTILER_APIKEY: String? = null val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
val SERVICES_MAPTILER_DARK_MAPID: String? = null val SERVICES_MAPTILER_DARK_MAPID: String? = null
val SERVICES_POSTHOG_HOST: String? = null
val SERVICES_POSTHOG_APIKEY: String? = null
val SERVICES_SENTRY_DSN: String? = null
val BUG_REPORT_URL: String? = null
val BUG_REPORT_APP_NAME: String? = null
} }

View file

@ -0,0 +1,32 @@
/*
* 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 extension
import com.android.build.api.dsl.VariantDimension
fun VariantDimension.buildConfigFieldStr(
name: String,
value: String,
) {
buildConfigField(
type = "String",
name = name,
value = "\"$value\""
)
}
fun VariantDimension.buildConfigFieldBoolean(
name: String,
value: Boolean,
) {
buildConfigField(
type = "boolean",
name = name,
value = value.toString()
)
}

View file

@ -1,3 +1,5 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupAnvil import extension.setupAnvil
/* /*
@ -12,6 +14,21 @@ plugins {
android { android {
namespace = "io.element.android.services.analyticsproviders.posthog" namespace = "io.element.android.services.analyticsproviders.posthog"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldStr(
name = "POSTHOG_HOST",
value = BuildTimeConfig.SERVICES_POSTHOG_HOST.takeIf { isEnterpriseBuild } ?: ""
)
buildConfigFieldStr(
name = "POSTHOG_APIKEY",
value = BuildTimeConfig.SERVICES_POSTHOG_APIKEY.takeIf { isEnterpriseBuild } ?: ""
)
}
} }
setupAnvil() setupAnvil()
@ -21,6 +38,7 @@ dependencies {
implementation(libs.posthog) { implementation(libs.posthog) {
exclude("com.android.support", "support-annotations") exclude("com.android.support", "support-annotations")
} }
implementation(projects.features.enterprise.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.services.analyticsproviders.api) implementation(projects.services.analyticsproviders.api)

View file

@ -11,7 +11,6 @@ import android.content.Context
import com.posthog.PostHogInterface import com.posthog.PostHogInterface
import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroid
import com.posthog.android.PostHogAndroidConfig import com.posthog.android.PostHogAndroidConfig
import io.element.android.libraries.core.extensions.isElement
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@ -22,8 +21,7 @@ class PostHogFactory @Inject constructor(
private val posthogEndpointConfigProvider: PosthogEndpointConfigProvider, private val posthogEndpointConfigProvider: PosthogEndpointConfigProvider,
) { ) {
fun createPosthog(): PostHogInterface? { fun createPosthog(): PostHogInterface? {
if (!buildMeta.isElement()) return null val endpoint = posthogEndpointConfigProvider.provide() ?: return null
val endpoint = posthogEndpointConfigProvider.provide()
return PostHogAndroid.with( return PostHogAndroid.with(
context, context,
PostHogAndroidConfig( PostHogAndroidConfig(

View file

@ -10,4 +10,6 @@ package io.element.android.services.analyticsproviders.posthog
data class PosthogEndpointConfig( data class PosthogEndpointConfig(
val host: String, val host: String,
val apiKey: String, val apiKey: String,
) ) {
val isValid = host.isNotBlank() && apiKey.isNotBlank()
}

View file

@ -7,24 +7,40 @@
package io.element.android.services.analyticsproviders.posthog package io.element.android.services.analyticsproviders.posthog
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.extensions.isElement
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import javax.inject.Inject import javax.inject.Inject
class PosthogEndpointConfigProvider @Inject constructor( class PosthogEndpointConfigProvider @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
) { ) {
fun provide(): PosthogEndpointConfig { fun provide(): PosthogEndpointConfig? {
return when (buildMeta.buildType) { return if (enterpriseService.isEnterpriseBuild) {
BuildType.RELEASE -> PosthogEndpointConfig( PosthogEndpointConfig(
host = "https://posthog.element.io", host = BuildConfig.POSTHOG_HOST,
apiKey = "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO", apiKey = BuildConfig.POSTHOG_APIKEY,
) ).takeIf {
BuildType.NIGHTLY, // Note that if the config is invalid, this module will not be included in the build.
BuildType.DEBUG -> PosthogEndpointConfig( // So the configuration should be always valid.
host = "https://posthog.element.dev", it.isValid
apiKey = "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN", }
) } else if (buildMeta.isElement()) {
when (buildMeta.buildType) {
BuildType.RELEASE -> PosthogEndpointConfig(
host = "https://posthog.element.io",
apiKey = "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO",
)
BuildType.NIGHTLY,
BuildType.DEBUG -> PosthogEndpointConfig(
host = "https://posthog.element.dev",
apiKey = "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN",
)
}
} else {
null
} }
} }
} }

View file

@ -1,3 +1,5 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.readLocalProperty import extension.readLocalProperty
import extension.setupAnvil import extension.setupAnvil
@ -19,13 +21,15 @@ android {
} }
defaultConfig { defaultConfig {
buildConfigField( buildConfigFieldStr(
type = "String",
name = "SENTRY_DSN", name = "SENTRY_DSN",
value = (System.getenv("ELEMENT_ANDROID_SENTRY_DSN") value = if (isEnterpriseBuild) {
?: readLocalProperty("services.analyticsproviders.sentry.dsn") BuildTimeConfig.SERVICES_SENTRY_DSN
} else {
System.getenv("ELEMENT_ANDROID_SENTRY_DSN")
?: readLocalProperty("services.analyticsproviders.sentry.dsn")
}
?: "" ?: ""
).let { "\"$it\"" }
) )
} }
} }

View file

@ -41,9 +41,7 @@ class SentryAnalyticsProvider @Inject constructor(
Timber.tag(analyticsTag.value).d("Initializing Sentry") Timber.tag(analyticsTag.value).d("Initializing Sentry")
if (Sentry.isEnabled()) return if (Sentry.isEnabled()) return
val dsn = if (SentryConfig.DSN.isNotBlank()) { val dsn = SentryConfig.DSN.ifBlank {
SentryConfig.DSN
} else {
Timber.w("No Sentry DSN provided, Sentry will not be initialized") Timber.w("No Sentry DSN provided, Sentry will not be initialized")
return return
} }

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6241486ded79ef2c079dcc45a0fe97bf01db66c05712ec7f74db035d76a91b5
size 18115

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ca4b3953ec0c460052305d9cfa5c0b1db2a97e8d9a27b483e8b5d15d873d32c
size 17500

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:232fcbe898588450577fca29090873626c2df7cc4afba91a87d3956d29f0afbc
size 81150

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:26f4a151850c4c21ed57d0a1d36ea72aece585ed5ec6d54303edbeaa91d06df9
size 73959

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9ce6f663ca845a54b4e1aca55f1405e00d29084a95b6a61721e5136efe30f7fc oid sha256:0ab623f806fc90bef41beccc5d5f444a882e6634cdadecbbc341e10369bbcfdb
size 311755 size 315380

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c7b714d78970f7bfc32c0bb8a226b2896681134a6eb329c871c0270511e8e81f oid sha256:b73088b5af32e47d18d6961fe5622411f5342388d8a0d78c8f3fb5e27213146a
size 306543 size 315116

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:563c2a20cbf651c88e85dfba98c02fa60197b298ee621b5b4b1bdc4af51a456e oid sha256:24735f133066c55c6d88b7f9930ad983cca431611c7e6833ca5a4d657eeb14a5
size 310172 size 313116

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:171e41e96dbb6495c667a57047cf66e5059a19b8dba7d56e799113cd5a8690e4 oid sha256:1d02a7ff9ff40f73d8c72b9378e10ba1f6580524cbdefcf290066b8047fdc33c
size 304775 size 307633

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:171e41e96dbb6495c667a57047cf66e5059a19b8dba7d56e799113cd5a8690e4
size 304775

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e41a284f2eedb0c7158079f059e05f8f505436ba91a82194faaff459bb30e4b4 oid sha256:f5ac9f2f168b895e8262181f789b0fe2ad2c97b1296a50c1453879438bfe436a
size 392983 size 395942

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:12691cbb9890570cb2081921224b13529e256feff1171d24e77cae11afc676e8 oid sha256:f7c1c7d34935986d01113069c25849fc860133de582fe94ee63f2e28b181e87f
size 380258 size 397378

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4f2c6932790cd4bbdff4dd07a728290aeb2eca47811c0b3d220e17aed5e8e34e oid sha256:a11f8383fc481255fb3fa6f1a5475d23c13c0460c33b04bc190cf3b5931893a3
size 383588 size 395108

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee7b3e5bb14da106ecedbe4827cded220d7222b68837c3c63ae3dfacd66ec2f2 oid sha256:36424ccefbbbd35201800dbcbb743a2d22aa6fe7a606054fc3acf8b98aead272
size 365133 size 381588

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee7b3e5bb14da106ecedbe4827cded220d7222b68837c3c63ae3dfacd66ec2f2
size 365133