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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,8 +144,8 @@ private fun OnBoardingButtons(
text = stringResource(id = signInButtonStringRes),
onClick = onSignIn,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)
.fillMaxWidth()
.testTag(TestTags.onBoardingSignIn)
)
if (state.canCreateAccount) {
TextButton(
@@ -155,15 +155,17 @@ private fun OnBoardingButtons(
.fillMaxWidth()
)
}
// Add a report problem text button. Use a Text since we need a special theme here.
Text(
modifier = Modifier
if (state.canReportBug) {
// Add a report problem text button. Use a Text since we need a special theme here.
Text(
modifier = Modifier
.padding(16.dp)
.clickable(onClick = onReportProblem),
text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
text = stringResource(id = CommonStrings.common_report_a_problem),
style = ElementTheme.typography.fontBodySmRegular,
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.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -38,6 +39,7 @@ class OnBoardingPresenterTest {
val presenter = OnBoardingPresenter(
buildMeta = buildMeta,
featureFlagService = featureFlagService,
rageshakeFeatureAvailability = { true },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -46,7 +48,21 @@ class OnBoardingPresenterTest {
assertThat(initialState.canLoginWithQrCode).isFalse()
assertThat(initialState.productionApplicationName).isEqualTo("B")
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).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.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -76,13 +77,28 @@ class OnboardingViewTest {
fun `clicking on report a problem calls the sign in callback`() {
ensureCalledOnce { callback ->
rule.setOnboardingView(
state = anOnBoardingState(),
state = anOnBoardingState(
canReportBug = true,
),
onReportProblem = callback,
)
val text = rule.activity.getString(CommonStrings.common_report_a_problem)
rule.onNodeWithText(text).assertExists()
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(
state: OnBoardingState,
onSignInWithQrCode: () -> Unit = EnsureNeverCalled(),

View File

@@ -1,3 +1,5 @@
import config.BuildTimeConfig
import extension.buildConfigFieldStr
import extension.setupAnvil
/*
@@ -19,6 +21,25 @@ android {
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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -202,11 +202,13 @@ private fun ColumnScope.GeneralSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = onOpenAbout,
)
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
onClick = onOpenRageShake
)
if (state.canReportBug) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
onClick = onOpenRageShake
)
}
if (state.showAnalyticsSettings) {
ListItem(
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 io.element.android.features.logout.api.direct.aDirectLogoutState
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.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -78,6 +79,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.showLockScreenSettings).isTrue()
assertThat(loadedState.showNotificationSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState())
assertThat(loadedState.snackbarMessage).isNull()
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
fun `present - can deactivate account is false if the Matrix client say so`() = runTest {
createPresenter(
@@ -146,6 +164,7 @@ class PreferencesRootPresenterTest {
matrixClient: FakeMatrixClient = FakeMatrixClient(),
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
) = PreferencesRootPresenter(
matrixClient = matrixClient,
sessionVerificationService = sessionVerificationService,
@@ -159,5 +178,6 @@ class PreferencesRootPresenterTest {
),
directLogoutPresenter = { aDirectLogoutState() },
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
data class RageshakePreferencesState(
val isFeatureEnabled: Boolean,
val isEnabled: Boolean,
val isSupported: Boolean,
val sensitivity: Float,

View File

@@ -12,14 +12,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RageshakePreferencesStateProvider : PreviewParameterProvider<RageshakePreferencesState> {
override val values: Sequence<RageshakePreferencesState>
get() = sequenceOf(
aRageshakePreferencesState().copy(isEnabled = true, isSupported = true, sensitivity = 0.5f),
aRageshakePreferencesState().copy(isEnabled = true, isSupported = false, sensitivity = 0.5f),
aRageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f),
aRageshakePreferencesState(isEnabled = true, isSupported = false, sensitivity = 0.5f),
)
}
fun aRageshakePreferencesState() = RageshakePreferencesState(
isEnabled = false,
isSupported = true,
sensitivity = 0.3f,
eventSink = {}
fun aRageshakePreferencesState(
isFeatureEnabled: Boolean = true,
isEnabled: Boolean = false,
isSupported: Boolean = true,
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) {
PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) {
if (state.isSupported) {
PreferenceSwitch(
title = stringResource(id = CommonStrings.preference_rageshake),
isChecked = state.isEnabled,
onCheckedChange = ::onEnabledChanged
)
PreferenceSlide(
title = stringResource(id = R.string.settings_rageshake_detection_threshold),
// summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary),
value = state.sensitivity,
enabled = state.isEnabled,
// 5 possible values - steps are in ]0, 1[
steps = 3,
onValueChange = ::onSensitivityChanged
)
} else {
ListItem(
headlineContent = {
Text("Rageshaking is not supported by your device")
},
)
if (state.isFeatureEnabled) {
PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) {
if (state.isSupported) {
PreferenceSwitch(
title = stringResource(id = CommonStrings.preference_rageshake),
isChecked = state.isEnabled,
onCheckedChange = ::onEnabledChanged
)
PreferenceSlide(
title = stringResource(id = R.string.settings_rageshake_detection_threshold),
// summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary),
value = state.sensitivity,
enabled = state.isEnabled,
// 5 possible values - steps are in ]0, 1[
steps = 3,
onValueChange = ::onSensitivityChanged
)
} else {
ListItem(
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.remember
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.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.Presenter
import kotlinx.coroutines.CoroutineScope

View File

@@ -5,7 +5,7 @@
* 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

View File

@@ -9,15 +9,17 @@ package io.element.android.features.rageshake.impl.crash
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.CrashDetectionPresenter
import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,12 +27,18 @@ import javax.inject.Inject
class DefaultCrashDetectionPresenter @Inject constructor(
private val buildMeta: BuildMeta,
private val crashDataStore: CrashDataStore,
) :
CrashDetectionPresenter {
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : CrashDetectionPresenter {
@Composable
override fun present(): CrashDetectionState {
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) {
when (event) {

View File

@@ -15,7 +15,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
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.di.AppScope
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.preferences.RageshakePreferencesEvents
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.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -75,7 +75,8 @@ class DefaultRageshakeDetectionPresenter @Inject constructor(
LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity)
}
val shouldStart = preferencesState.isEnabled &&
val shouldStart = preferencesState.isFeatureEnabled &&
preferencesState.isEnabled &&
preferencesState.isSupported &&
isStarted.value &&
!takeScreenshot.value &&

View File

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

View File

@@ -13,7 +13,6 @@ import android.hardware.SensorManager
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
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.ApplicationContext
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.preferencesDataStore
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.di.AppScope
import io.element.android.libraries.di.ApplicationContext

View File

@@ -5,7 +5,7 @@
* 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 {
/**

View File

@@ -5,7 +5,7 @@
* 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

View File

@@ -13,10 +13,10 @@ import androidx.core.net.toFile
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
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.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.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers

View File

@@ -11,7 +11,6 @@ import android.content.Context
import android.graphics.Bitmap
import androidx.core.net.toUri
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.file.safeDelete
import io.element.android.libraries.di.AppScope

View File

@@ -5,7 +5,7 @@
* 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

View File

@@ -11,13 +11,13 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
import io.element.android.features.rageshake.impl.crash.CrashDataStore
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.features.rageshake.impl.screenshot.A_SCREENSHOT_URI
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.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule

View File

@@ -5,9 +5,8 @@
* 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.MutableStateFlow

View File

@@ -12,9 +12,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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.test.crash.A_CRASH_DATA
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta
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
fun `present - reset app has crashed`() = runTest {
val presenter = createPresenter(
@@ -86,8 +100,10 @@ class CrashDetectionPresenterTest {
private fun createPresenter(
crashDataStore: FakeCrashDataStore = FakeCrashDataStore(),
buildMeta: BuildMeta = aBuildMeta(),
isFeatureAvailable: Boolean = true,
) = DefaultCrashDetectionPresenter(
buildMeta = buildMeta,
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.screenshot.ImageResult
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
@@ -52,6 +52,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -76,6 +77,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -101,6 +103,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -135,6 +138,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {
@@ -169,6 +173,7 @@ class RageshakeDetectionPresenterTest {
preferencesPresenter = DefaultRageshakePreferencesPresenter(
rageshake = rageshake,
rageshakeDataStore = rageshakeDataStore,
rageshakeFeatureAvailability = { true },
)
)
moleculeFlow(RecompositionMode.Immediate) {

View File

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

View File

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

View File

@@ -5,9 +5,8 @@
* 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.MutableStateFlow

View File

@@ -8,9 +8,10 @@
package io.element.android.features.rageshake.impl.reporter
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.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.FakeSdkMetadata
@@ -138,7 +139,7 @@ class DefaultBugReporterTest {
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["device_id"]).isEqualTo("ABCDEFGH")
assertThat(foundValues["sdk_sha"]).isEqualTo("123456789")

View File

@@ -16,7 +16,9 @@ class DefaultBugReporterUrlProviderTest {
@Test
fun `test DefaultBugReporterUrlProvider`() {
val sut = DefaultBugReporterUrlProvider()
val result = sut.provide()
assertThat(result).isEqualTo(RageshakeConfig.BUG_REPORT_URL.toHttpUrl())
if (RageshakeConfig.BUG_REPORT_URL.isNotEmpty()) {
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.
*/
package io.element.android.features.rageshake.test.screenshot
package io.element.android.features.rageshake.impl.screenshot
import android.graphics.Bitmap
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
const val A_SCREENSHOT_URI = "file://content/uri"

View File

@@ -48,6 +48,7 @@ dependencies {
implementation(projects.features.networkmonitor.api)
implementation(projects.features.logout.api)
implementation(projects.features.leaveroom.api)
implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences)
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.LeaveRoomState
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.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@@ -91,6 +92,7 @@ class RoomListPresenter @Inject constructor(
private val notificationCleaner: NotificationCleaner,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val appPreferencesStore: AppPreferencesStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService()
@@ -103,6 +105,7 @@ class RoomListPresenter @Inject constructor(
val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
val canReportBug = remember { rageshakeFeatureAvailability.isAvailable() }
LaunchedEffect(Unit) {
roomListDataSource.launchIn(this)
@@ -163,6 +166,7 @@ class RoomListPresenter @Inject constructor(
contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
canReportBug = canReportBug,
searchState = searchState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,7 @@ fun RoomListTopBar(
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
DefaultRoomListTopBar(
@@ -98,6 +99,7 @@ fun RoomListTopBar(
displayMenuItems = displayMenuItems,
displayFilters = displayFilters,
filtersState = filtersState,
canReportBug = canReportBug,
modifier = modifier,
)
}
@@ -115,6 +117,7 @@ private fun DefaultRoomListTopBar(
displayMenuItems: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
// 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(
onClick = {
showMenu = false
@@ -319,6 +322,7 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview {
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
canReportBug = true,
onMenuActionClick = {},
)
}
@@ -337,6 +341,7 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
displayMenuItems = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
canReportBug = true,
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.aLeaveRoomState
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.aRoomListRoomSummaryFactory
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)))
val presenter = createRoomListPresenter(
client = matrixClient,
rageshakeFeatureAvailability = { false },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
assertThat(initialState.canReportBug).isFalse()
val withUserState = awaitItem()
assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
@@ -135,6 +138,7 @@ class RoomListPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue()
assertThat(initialState.canReportBug).isTrue()
sessionVerificationService.emitNeedsSessionVerification(false)
encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem()
@@ -675,6 +679,7 @@ class RoomListPresenterTest {
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
) = RoomListPresenter(
client = client,
syncService = syncService,
@@ -705,6 +710,7 @@ class RoomListPresenterTest {
notificationCleaner = notificationCleaner,
logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore,
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
)
}

View File

@@ -6,6 +6,7 @@
*/
import config.AnalyticsConfig
import config.BuildTimeConfig
import config.PushProvidersConfig
object ModulesConfig {
@@ -14,8 +15,27 @@ object ModulesConfig {
includeUnifiedPush = true,
)
val analyticsConfig: AnalyticsConfig = AnalyticsConfig.Enabled(
withPosthog = true,
withSentry = true,
)
val analyticsConfig: AnalyticsConfig = if (isEnterpriseBuild) {
// Is Posthog configuration available?
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_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_LIGHT_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
/*
@@ -12,6 +14,21 @@ plugins {
android {
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()
@@ -21,6 +38,7 @@ dependencies {
implementation(libs.posthog) {
exclude("com.android.support", "support-annotations")
}
implementation(projects.features.enterprise.api)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.services.analyticsproviders.api)

View File

@@ -11,7 +11,6 @@ import android.content.Context
import com.posthog.PostHogInterface
import com.posthog.android.PostHogAndroid
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.di.ApplicationContext
import javax.inject.Inject
@@ -22,8 +21,7 @@ class PostHogFactory @Inject constructor(
private val posthogEndpointConfigProvider: PosthogEndpointConfigProvider,
) {
fun createPosthog(): PostHogInterface? {
if (!buildMeta.isElement()) return null
val endpoint = posthogEndpointConfigProvider.provide()
val endpoint = posthogEndpointConfigProvider.provide() ?: return null
return PostHogAndroid.with(
context,
PostHogAndroidConfig(

View File

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

View File

@@ -7,24 +7,40 @@
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.BuildType
import javax.inject.Inject
class PosthogEndpointConfigProvider @Inject constructor(
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
) {
fun provide(): PosthogEndpointConfig {
return 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",
)
fun provide(): PosthogEndpointConfig? {
return if (enterpriseService.isEnterpriseBuild) {
PosthogEndpointConfig(
host = BuildConfig.POSTHOG_HOST,
apiKey = BuildConfig.POSTHOG_APIKEY,
).takeIf {
// Note that if the config is invalid, this module will not be included in the build.
// So the configuration should be always valid.
it.isValid
}
} 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.setupAnvil
@@ -19,13 +21,15 @@ android {
}
defaultConfig {
buildConfigField(
type = "String",
buildConfigFieldStr(
name = "SENTRY_DSN",
value = (System.getenv("ELEMENT_ANDROID_SENTRY_DSN")
?: readLocalProperty("services.analyticsproviders.sentry.dsn")
value = if (isEnterpriseBuild) {
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")
if (Sentry.isEnabled()) return
val dsn = if (SentryConfig.DSN.isNotBlank()) {
SentryConfig.DSN
} else {
val dsn = SentryConfig.DSN.ifBlank {
Timber.w("No Sentry DSN provided, Sentry will not be initialized")
return
}