feat: Configuration module improvements.

This commit is contained in:
Artiom Košelev 2024-04-09 06:37:20 +00:00
parent b3f95c2594
commit 0d7b5f9d8f
45 changed files with 1641 additions and 738 deletions

View File

@ -22,10 +22,13 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.configurator.domain.ConfigurationUseCase
import me.proton.core.configuration.configurator.domain.EnvironmentConfigurationUseCase
import me.proton.core.configuration.configurator.entity.AppConfig
import me.proton.core.test.quark.v2.QuarkCommand
import okhttp3.OkHttpClient
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@Module
@ -37,8 +40,8 @@ object ApplicationModule {
@Singleton
@Provides
fun provideOkHttpClient(): OkHttpClient {
val clientTimeout = 3.seconds.toJavaDuration()
fun provideOkHttpClient(appConfig: AppConfig): OkHttpClient {
val clientTimeout = appConfig.quarkTimeout.toJavaDuration()
return OkHttpClient.Builder().connectTimeout(clientTimeout)
.readTimeout(clientTimeout)
.writeTimeout(clientTimeout)
@ -46,4 +49,12 @@ object ApplicationModule {
.retryOnConnectionFailure(false)
.build()
}
@Singleton
@Provides
fun provideContentResolverConfigurationUseCase(
contentResolverConfigManager: ContentResolverConfigManager,
appConfig: AppConfig,
quark: QuarkCommand
): ConfigurationUseCase = EnvironmentConfigurationUseCase(quark, contentResolverConfigManager, appConfig)
}

View File

@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.configurator.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.core.configuration.configurator.entity.AppConfig
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ConfigurationModule {
@Singleton
@Provides
fun provideAppConfig(): AppConfig = AppConfig()
}

View File

@ -0,0 +1,84 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.configurator.domain
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.configurator.entity.Configuration
import kotlin.reflect.KClass
typealias ConfigFieldSet = Set<ConfigurationUseCase.ConfigField>
open class ConfigurationUseCase(
private val contentResolverConfigManager: ContentResolverConfigManager,
private val configClass: KClass<*>,
private val defaultConfigValueMapper: (ConfigFieldSet, Map<String, Any?>) -> ConfigFieldSet,
private val supportedContractFieldSet: ConfigFieldSet,
) : Configuration {
data class ConfigField(
val name: String,
val isAdvanced: Boolean = true,
val isPreserved: Boolean = false,
val value: Any? = "",
val fetcher: (suspend (String) -> Any)? = null,
)
private val _configState = MutableStateFlow(supportedContractFieldSet)
var configState: StateFlow<ConfigFieldSet> = _configState.asStateFlow()
fun setDefaultConfigurationFields() {
val newValueMap = _configState.value.filter { it.isPreserved }.associate { it.name to it.value }
_configState.value = defaultConfigValueMapper(_configState.value, newValueMap).toSet()
}
override suspend fun fetchConfig() {
val resolvedConfigMap = contentResolverConfigManager.queryAtClassPath(configClass)
_configState.value =
if (resolvedConfigMap == null) {
supportedContractFieldSet
} else {
defaultConfigValueMapper(_configState.value, resolvedConfigMap)
}
}
override suspend fun saveConfig(advanced: Boolean) {
val stateToInsert = _configState.value.filter { if (advanced) true else !it.isAdvanced }
val mapToInsert = stateToInsert.associate { it.name to it.value }
contentResolverConfigManager.insertConfigFieldMapAtClassPath(mapToInsert, configClass)
}
override suspend fun updateConfigField(key: String, newValue: Any) {
_configState.value = _configState.value.withUpdatedValues(key, newValue)
}
override suspend fun fetchConfigField(key: String) {
updateConfigField(
key,
supportedContractFieldSet.firstOrNull { it.name == key }?.fetcher?.let { it(key) }.toString()
)
}
private fun ConfigFieldSet.withUpdatedValues(key: String, newValue: Any): ConfigFieldSet =
map { if (it.name == key) it.copy(value = newValue) else it }.toSet()
}

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.configurator.domain
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.EnvironmentConfiguration
import me.proton.core.configuration.configurator.entity.AppConfig
import me.proton.core.configuration.configurator.extension.getProxyToken
import me.proton.core.configuration.entity.ConfigContract
import me.proton.core.configuration.extension.primitiveFieldMap
import me.proton.core.test.quark.v2.QuarkCommand
import javax.inject.Inject
class EnvironmentConfigurationUseCase @Inject constructor(
quark: QuarkCommand,
contentResolverConfigManager: ContentResolverConfigManager,
appConfig: AppConfig
) : ConfigurationUseCase(
contentResolverConfigManager = contentResolverConfigManager,
configClass = EnvironmentConfiguration::class,
supportedContractFieldSet = setOf(
ConfigField(ConfigContract::host.name, isAdvanced = false, isPreserved = true, value = defaultConfig.host),
ConfigField(ConfigContract::proxyToken.name, isAdvanced = false, isPreserved = true) {
quark.baseUrl(appConfig.proxyUrl).getProxyToken() ?: error("Could not obtain proxy token")
},
ConfigField(ConfigContract::apiPrefix.name, isPreserved = true, value = defaultConfig.apiPrefix),
ConfigField(ConfigContract::apiHost.name, value = defaultConfig.apiHost),
ConfigField(ConfigContract::baseUrl.name, value = defaultConfig.baseUrl),
ConfigField(ConfigContract::hv3Host.name, value = defaultConfig.hv3Host),
ConfigField(ConfigContract::hv3Url.name, value = defaultConfig.hv3Url),
ConfigField(ConfigContract::useDefaultPins.name, value = false),
),
defaultConfigValueMapper = ::configFieldMapper
) {
companion object {
fun configFieldMapper(configFieldSet: ConfigFieldSet, configFieldMap: Map<String, Any?>): ConfigFieldSet {
val config = EnvironmentConfiguration.fromMap(configFieldMap).primitiveFieldMap
return configFieldSet.map { it.copy(value = config[it.name]) }.toSet()
}
val defaultConfig: EnvironmentConfiguration =
EnvironmentConfiguration.fromMap(mapOf("host" to "proton.black"))
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.configurator.entity
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class AppConfig(
val quarkTimeout: Duration = 3.seconds,
val proxyUrl: String = "https://proxy.proton.black"
)

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.configurator.entity
interface Configuration {
suspend fun fetchConfig()
suspend fun saveConfig(advanced: Boolean)
suspend fun updateConfigField(key: String, newValue: Any)
suspend fun fetchConfigField(key: String)
}

View File

@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -35,83 +34,33 @@ import dagger.hilt.android.AndroidEntryPoint
import me.proton.core.compose.component.ProtonSnackbarHost
import me.proton.core.compose.component.ProtonSnackbarHostState
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.EnvironmentConfiguration
import me.proton.core.configuration.configurator.BuildConfig
import me.proton.core.configuration.configurator.R
import me.proton.core.configuration.configurator.extension.getProxyToken
import me.proton.core.configuration.configurator.presentation.components.ConfigurationScreen
import me.proton.core.configuration.configurator.presentation.components.FieldActionMap
import me.proton.core.configuration.configurator.presentation.viewModel.ConfigurationScreenViewModel
import me.proton.core.configuration.entity.ConfigContract
import me.proton.core.presentation.ui.ProtonActivity
import me.proton.core.test.quark.v2.QuarkCommand
import javax.inject.Inject
@AndroidEntryPoint
class ConfigurationActivity : ProtonActivity() {
@Inject
lateinit var quark: QuarkCommand
@Inject
lateinit var contentResolverConfigManager: ContentResolverConfigManager
private val basicEnvConfigFields: FieldActionMap = mapOf(
ConfigContract::host.name to null,
ConfigContract::proxyToken.name to {
quark.baseUrl(BuildConfig.PROXY_URL).getProxyToken() ?: error("Could not obtain proxy token")
},
)
private val advancedEnvConfigFields: FieldActionMap = basicEnvConfigFields + mapOf(
ConfigContract::apiHost.name to null,
ConfigContract::apiPrefix.name to null,
ConfigContract::baseUrl.name to null,
ConfigContract::hv3Host.name to null,
ConfigContract::hv3Url.name to null,
ConfigContract::useDefaultPins.name to null,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val snackbarHostState = remember { ProtonSnackbarHostState() }
Box {
ProtonTheme {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Column {
ConfigurationScreen(
configViewModel = ConfigurationScreenViewModel(
contentResolverConfigManager = contentResolverConfigManager,
configFieldMapper = EnvironmentConfiguration::fromMap,
defaultConfig = EnvironmentConfiguration.fromMap(mapOf())
),
basicFields = basicEnvConfigFields,
advancedFields = advancedEnvConfigFields,
preservedFields = setOf(
ConfigContract::host.name,
ConfigContract::apiPrefix.name,
ConfigContract::proxyToken.name
),
snackbarHostState = snackbarHostState,
title = stringResource(id = R.string.configuration_title_network_configuration)
)
}
}
ProtonSnackbarHost(
modifier = Modifier.align(Alignment.BottomCenter),
hostState = snackbarHostState
)
ProtonTheme {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Column {
ConfigurationScreen(
snackbarHostState = snackbarHostState,
title = stringResource(id = R.string.configuration_title_network_configuration)
)
}
}
ProtonSnackbarHost(snackbarHostState)
}
}
}

View File

@ -1,6 +1,5 @@
package me.proton.core.configuration.configurator.presentation.components
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -17,19 +16,19 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import me.proton.core.compose.component.ProtonOutlinedTextField
import me.proton.core.compose.component.ProtonSnackbarHostState
import me.proton.core.compose.component.ProtonSnackbarType
@ -37,44 +36,186 @@ import me.proton.core.compose.component.ProtonSolidButton
import me.proton.core.compose.component.appbar.ProtonTopAppBar
import me.proton.core.compose.theme.ProtonTheme
import me.proton.core.configuration.configurator.R
import me.proton.core.configuration.configurator.domain.ConfigurationUseCase
import me.proton.core.configuration.configurator.presentation.viewModel.ConfigurationScreenViewModel
import me.proton.core.util.kotlin.EMPTY_STRING
import me.proton.core.presentation.R.drawable as CoreDrawable
typealias FieldActionMap = Map<String, (suspend () -> Any)?>
@Composable
fun ConfigurationScreen(
configViewModel: ConfigurationScreenViewModel = hiltViewModel(),
snackbarHostState: ProtonSnackbarHostState,
title: String,
) {
val configurationState by configViewModel.state.collectAsStateWithLifecycle()
ConfigSettingsScreen(
configFieldSet = configurationState.configFieldSet,
isAdvancedExpanded = configurationState.isAdvanced,
title = title,
onConfigurationFieldUpdate = { key, newValue ->
configViewModel.perform(ConfigurationScreenViewModel.Action.UpdateConfigField(key, newValue))
},
onAdvanceSetting = {
configViewModel.perform(ConfigurationScreenViewModel.Action.SetDefaultConfigFields)
},
onConfigurationSave = {
configViewModel.perform(ConfigurationScreenViewModel.Action.SaveConfig(it))
},
onAdvancedExpanded = {
configViewModel.perform(ConfigurationScreenViewModel.Action.SetAdvanced(it))
},
onConfigurationFieldFetch = {
configViewModel.perform(ConfigurationScreenViewModel.Action.FetchConfigField(it))
}
)
LaunchedEffect(Unit) {
configViewModel.errorFlow.collect {
snackbarHostState.showSnackbar(
type = ProtonSnackbarType.ERROR,
message = it,
actionLabel = "OK"
)
}
}
}
@Composable
fun <T : Any> ConfigurationScreen(
configViewModel: ConfigurationScreenViewModel<T>,
advancedFields: FieldActionMap,
basicFields: FieldActionMap,
preservedFields: Set<String>,
snackbarHostState: ProtonSnackbarHostState,
title: String
private fun ConfigSettingsScreen(
configFieldSet: Set<ConfigurationUseCase.ConfigField>,
title: String,
onConfigurationFieldUpdate: (String, Any) -> Unit,
onAdvanceSetting: () -> Unit,
onConfigurationFieldFetch: (String) -> Unit,
onConfigurationSave: (Boolean) -> Unit,
isAdvancedExpanded: Boolean,
onAdvancedExpanded: (Boolean) -> Unit
) {
var isAdvancedExpanded by remember { mutableStateOf(false) }
Column {
ProtonTopAppBar(title = { Text(title) })
ExpandableHeader(isExpanded = isAdvancedExpanded, onExpandChange = { isAdvancedExpanded = it })
ExpandableHeader(isExpanded = isAdvancedExpanded) {
onAdvancedExpanded(it)
}
val configFields = if (isAdvancedExpanded) advancedFields else basicFields
ConfigurationFields(configViewModel, configFields)
ConfigurationFields(
configFields = configFieldSet,
onFieldUpdate = onConfigurationFieldUpdate,
onConfigurationFieldFetch = onConfigurationFieldFetch
)
AdvancedOptionsColumn(isAdvancedExpanded, preservedFields, configViewModel)
AdvancedOptionsColumn(
isAdvancedExpanded = isAdvancedExpanded,
onClick = onAdvanceSetting
)
SaveConfigurationButton(configFields.keys, configViewModel)
SaveConfigurationButton(
onClick = {
onConfigurationSave(isAdvancedExpanded)
}
)
}
ObserveEvents(configViewModel, snackbarHostState)
}
@Composable
private fun ExpandableHeader(isExpanded: Boolean, onExpandChange: (Boolean) -> Unit) {
private fun ConfigurationFields(
configFields: Set<ConfigurationUseCase.ConfigField>,
onFieldUpdate: (String, Any) -> Unit,
onConfigurationFieldFetch: (String) -> Unit,
) {
configFields.forEach { configField ->
val fetchAction = configField.fetcher?.let { { onConfigurationFieldFetch(configField.name) } }
when (configField.value) {
is String -> ConfigurationTextField(
configField = configField,
onValueChange = { newValue ->
onFieldUpdate(configField.name, newValue)
},
fetchAction = fetchAction
)
is Boolean -> ConfigurationCheckbox(
configField = configField,
onCheckChanged = { newValue ->
onFieldUpdate(configField.name, newValue)
}
)
null -> Unit
else -> error("Unsupported configuration field type for key ${configField.name}")
}
}
}
@Composable
private fun ConfigurationTextField(
configField: ConfigurationUseCase.ConfigField,
onValueChange: (String) -> Unit,
fetchAction: (() -> Unit)? = null,
) {
val initialValue = configField.value?.toString() ?: EMPTY_STRING
val initialTextFieldValue = remember(initialValue) { TextFieldValue(initialValue) }
var textFieldValue by remember { mutableStateOf(initialTextFieldValue) }
LaunchedEffect(initialValue) {
if (textFieldValue.text != initialValue)
textFieldValue = TextFieldValue(text = initialValue)
}
ProtonOutlinedTextField(
modifier = Modifier.bottomPad(8.dp),
value = textFieldValue,
onValueChange = { newValue ->
textFieldValue = newValue
onValueChange(newValue.text)
},
label = { Text(text = configField.name) },
singleLine = true,
keyboardOptions = KeyboardOptions(autoCorrect = false),
trailingIcon = {
fetchAction?.let {
ConfigActionButton(onClick = fetchAction)
}
}
)
}
@Composable
private fun ConfigurationCheckbox(
configField: ConfigurationUseCase.ConfigField,
onCheckChanged: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.bottomPad(8.dp)
.clickable {
onCheckChanged(
!configField.value
.toString()
.toBoolean()
)
},
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = configField.value.toString().toBoolean(),
onCheckedChange = onCheckChanged,
)
Text(text = configField.name.toSpacedWords())
}
}
@Composable
private fun ExpandableHeader(
isExpanded: Boolean,
onExpandChange: (Boolean) -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = ProtonTheme.colors.floatyText)
.clickable { onExpandChange(!isExpanded) }
) {
Text(
@ -84,7 +225,7 @@ private fun ExpandableHeader(isExpanded: Boolean, onExpandChange: (Boolean) -> U
.background(color = ProtonTheme.colors.floatyText, shape = MaterialTheme.shapes.small)
)
Icon(
painter = painterResource(id = if (isExpanded) R.drawable.ic_proton_arrow_up else R.drawable.ic_proton_arrow_down),
painter = painterResource(id = isExpanded.drawable),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp),
@ -95,113 +236,38 @@ private fun ExpandableHeader(isExpanded: Boolean, onExpandChange: (Boolean) -> U
}
@Composable
private fun <T : Any> ConfigurationFields(configViewModel: ConfigurationScreenViewModel<T>, configFields: FieldActionMap) {
configFields.forEach { (key, value) ->
when (configViewModel.configFieldMap[key]) {
is String -> ConfigurationTextField(configViewModel, key, value)
is Boolean -> ConfigurationCheckbox(configViewModel, key)
else -> {}
}
}
}
@Composable
private fun <T : Any> AdvancedOptionsColumn(
private fun AdvancedOptionsColumn(
isAdvancedExpanded: Boolean,
preservedFields: Set<String>,
configViewModel: ConfigurationScreenViewModel<T>,
onClick: () -> Unit,
) {
if (isAdvancedExpanded) {
Column(modifier = Modifier.bottomPad(16.dp), horizontalAlignment = Alignment.End) {
ProtonSolidButton(
modifier = Modifier.bottomPad(8.dp),
onClick = { configViewModel.setDefaultConfigurationFields(preservedFields) }
onClick = onClick
) {
Text(stringResource(id = R.string.configuration_restore_confirmation))
Text(stringResource(id = R.string.configuration_set_defaults))
}
}
}
}
@Composable
private fun SaveConfigurationButton(keys: Set<String>, configViewModel: ConfigurationScreenViewModel<*>) {
private fun SaveConfigurationButton(onClick: () -> Unit) {
Column(modifier = Modifier.bottomPad(16.dp), horizontalAlignment = Alignment.End) {
ProtonSolidButton(
modifier = Modifier.bottomPad(8.dp),
onClick = { configViewModel.saveConfiguration(keys) }
onClick = onClick
) {
Text(stringResource(id = R.string.configuration_button_apply))
}
}
}
@Composable
private fun <T : Any> ConfigurationTextField(
configViewModel: ConfigurationScreenViewModel<T>,
configPropertyKey: String,
trailingAction: (suspend () -> Any)? = null
) {
val fieldValue by configViewModel.observeField(configPropertyKey, EMPTY_STRING).collectAsState()
var textState by remember { mutableStateOf(TextFieldValue(fieldValue)) }
if (fieldValue != textState.text) {
textState = TextFieldValue(fieldValue)
}
ProtonOutlinedTextField(
modifier = Modifier.bottomPad(8.dp),
value = textState,
onValueChange = { newValue ->
textState = newValue
configViewModel.updateConfigField(configPropertyKey, newValue.text)
},
label = { Text(text = configPropertyKey) },
singleLine = true,
keyboardOptions = KeyboardOptions(autoCorrect = false),
trailingIcon = {
if (trailingAction != null) {
ConfigActionButton(onClick = {
configViewModel.fetchConfigField(configPropertyKey, trailingAction)
})
}
}
)
}
@Composable
private fun <T : Any> ConfigurationCheckbox(
configViewModel: ConfigurationScreenViewModel<T>,
configPropertyKey: String
) {
var checkboxState by remember { mutableStateOf(configViewModel.configFieldMap[configPropertyKey] as Boolean) }
Row(
modifier = Modifier
.bottomPad(8.dp)
.clickable {
checkboxState = !checkboxState
},
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checkboxState,
onCheckedChange = { isChecked ->
checkboxState = isChecked
configViewModel.updateConfigField(configPropertyKey, isChecked)
}
)
Text(
text = configPropertyKey.toSpacedWords(),
modifier = Modifier.bottomPad(8.dp)
)
}
}
@Composable
private fun ConfigActionButton(
onClick: () -> Unit,
@DrawableRes drawableId: Int = CoreDrawable.ic_proton_arrow_down_circle,
onClick: () -> Unit = { },
) =
IconButton(onClick) {
Icon(
@ -211,31 +277,8 @@ private fun ConfigActionButton(
)
}
@Composable
private fun <T : Any> ObserveEvents(
configurationScreenViewModel: ConfigurationScreenViewModel<T>,
snackbarHostState: ProtonSnackbarHostState
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
configurationScreenViewModel.errorEvent.collect { throwable ->
snackbarHostState.showSnackbar(
type = ProtonSnackbarType.ERROR,
message = throwable.message ?: "Unknown error",
actionLabel = "OK"
)
}
}
LaunchedEffect(Unit) {
configurationScreenViewModel.infoEvent.collect { info ->
Toast.makeText(context, info, Toast.LENGTH_SHORT).show()
}
}
}
private fun Modifier.bottomPad(bottomPadding: Dp) = fillMaxWidth().padding(bottom = bottomPadding)
private fun String.toSpacedWords(): String = replace("(?<=\\p{Lower})(?=[A-Z])".toRegex(), " ").capitalize()
private val Boolean.drawable: Int @DrawableRes get() = if (this) R.drawable.ic_proton_arrow_up else R.drawable.ic_proton_arrow_down
fun String.toSpacedWords(): String = replace("(?<=\\p{Lower})(?=[A-Z])".toRegex(), " ").capitalize()

View File

@ -20,98 +20,109 @@ package me.proton.core.configuration.configurator.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.extension.primitiveFieldMap
import me.proton.core.compose.viewmodel.stopTimeoutMillis
import me.proton.core.configuration.configurator.domain.ConfigurationUseCase
import javax.inject.Inject
typealias ConfigFieldMapper<T> = (Map<String, Any?>) -> T
class ConfigurationScreenViewModel<T : Any>(
private val contentResolverConfigManager: ContentResolverConfigManager,
private val configFieldMapper: ConfigFieldMapper<T>,
private val defaultConfig: T
@HiltViewModel
class ConfigurationScreenViewModel @Inject constructor(
private val configurationUseCase: ConfigurationUseCase
) : ViewModel() {
private val _configState: MutableStateFlow<T> = MutableStateFlow(defaultConfig)
val configurationState: StateFlow<T> get() = _configState
private val _errorEvent = MutableSharedFlow<Throwable>()
val errorEvent: SharedFlow<Throwable> get() = _errorEvent
private val _infoEvent = MutableSharedFlow<String>()
val infoEvent: SharedFlow<String> get() = _infoEvent
val configFieldMap get() = _configState.value.primitiveFieldMap
init {
fetchInitialConfig()
sealed class Action {
data object ObserveConfig : Action()
data object FetchConfig: Action()
data object SetDefaultConfigFields : Action()
data class SaveConfig(val isAdvanced: Boolean) : Action()
data class SetAdvanced(val isAdvanced: Boolean) : Action()
data class FetchConfigField(val key: String) : Action()
data class UpdateConfigField(val key: String, val value: Any) : Action()
}
fun fetchConfigField(fieldName: String, configurationFieldGetter: suspend () -> Any) {
viewModelScope.launch {
runCatching {
_infoEvent.emit("Fetching $fieldName")
configurationFieldGetter()
}
.onFailure {
_errorEvent.emit(it)
}
.onSuccess { newValue ->
updateConfigField(fieldName, newValue)
}
data class State(
val configFieldSet: Set<ConfigurationUseCase.ConfigField>,
val isAdvanced: Boolean
)
private val mutableErrorFlow: MutableSharedFlow<String> = MutableSharedFlow()
private val isAdvanced: MutableStateFlow<Boolean> = MutableStateFlow(false)
val errorFlow: SharedFlow<String> = mutableErrorFlow.asSharedFlow()
val state: StateFlow<State> =
observeConfig().onStart {
perform(Action.FetchConfig)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis),
initialValue = State(configurationUseCase.configState.value, isAdvanced.value)
)
fun perform(action: Action) = runCatching {
when (action) {
is Action.SetDefaultConfigFields -> setDefaultConfigFields()
is Action.ObserveConfig -> observeConfig()
is Action.FetchConfig -> fetchConfig()
is Action.SaveConfig -> saveConfig(action.isAdvanced)
is Action.SetAdvanced -> setAdvanced(action.isAdvanced)
is Action.FetchConfigField -> fetchConfigField(action.key)
is Action.UpdateConfigField -> updateConfigField(action.key, action.value)
}
}.onFailure {
mutableErrorFlow.tryEmit(it.message ?: "Unknown message")
}
fun saveConfiguration(keysToSave: Set<String> = _configState.value.primitiveFieldMap.keys) {
private fun observeConfig(): Flow<State> = combine(
configurationUseCase.configState,
isAdvanced
) { fieldSet, advanced ->
val fieldList = if (isAdvanced.value) fieldSet else fieldSet.filter { it.isAdvanced == advanced }
State(fieldList.toSet(), isAdvanced.value)
}
private fun setDefaultConfigFields() = launchCatching {
configurationUseCase.setDefaultConfigurationFields()
}
private fun fetchConfig() = launchCatching {
configurationUseCase.fetchConfig()
}
private fun saveConfig(isAdvanced: Boolean) = launchCatching {
configurationUseCase.saveConfig(isAdvanced)
}
private fun fetchConfigField(key: String) = launchCatching {
configurationUseCase.fetchConfigField(key)
}
private fun updateConfigField(key: String, value: Any) = launchCatching {
configurationUseCase.updateConfigField(key, value)
}
private fun setAdvanced(advancedValue: Boolean) = launchCatching {
isAdvanced.emit(advancedValue)
}
private fun launchCatching(block: suspend () -> Unit) =
viewModelScope.launch {
val mapToInsert = keysToSave.associateWith { _configState.value.primitiveFieldMap[it] }
runCatching {
contentResolverConfigManager.insertContentValuesAtPath(
mapToInsert,
_configState.value::class.java.name
)
}.onFailure { _errorEvent.emit(it) }.onSuccess {
_infoEvent.emit("Configuration Saved")
block()
}.onFailure {
mutableErrorFlow.emit(it.message ?: "Unknown error")
}
}
}
fun setDefaultConfigurationFields(preservedFields: Set<String> = configFieldMap.keys) {
val map = preservedFields.associateWith { configFieldMap[it].toString() }
_configState.value = configFieldMapper(map)
}
fun <R> observeField(key: String, defaultValue: R): StateFlow<R> =
_configState.map { state ->
state.primitiveFieldMap[key] as? R ?: defaultValue
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), defaultValue)
private fun fetchInitialConfig() {
viewModelScope.launch {
runCatching {
val configMap =
contentResolverConfigManager.fetchConfigurationDataAtPath(defaultConfig::class.java.name)
configFieldMapper(configMap ?: emptyMap())
}.onFailure { _errorEvent.emit(it) }
.onSuccess { config ->
_configState.value = config
}
}
}
fun updateConfigField(updatedField: String, newValue: Any) {
_configState.value = _configState.value.withUpdatedField(updatedField, newValue).also {
println("Updating field: $updatedField, $newValue")
}
}
private fun T.withUpdatedField(updatedField: String, newValue: Any): T =
configFieldMapper(this.primitiveFieldMap.toMutableMap().apply { this[updatedField] = newValue })
}

View File

@ -19,7 +19,7 @@
<resources>
<string name="app_name" translatable="false">Configurator</string>
<string name="configuration_button_apply" translatable="false">Apply</string>
<string name="configuration_restore_confirmation" translatable="false">Set</string>
<string name="configuration_set_defaults" translatable="false">Set</string>
<string name="configuration_title_network_configuration" translatable="false">Network Configuration</string>
<string name="configuration_text_advanced" translatable="false">Advanced</string>
<string name="configuration_error_unknown" translatable="false">Could not save configuration. Unknown error</string>

View File

@ -37,9 +37,7 @@ public class ContentResolverEnvironmentConfigModule {
contentResolverConfigManager: ContentResolverConfigManager
): EnvironmentConfiguration {
val staticEnvironmentConfig = EnvironmentConfiguration.fromClass()
val contentResolverConfigData = contentResolverConfigManager.fetchConfigurationDataAtPath(
EnvironmentConfiguration::class.java.name
)
val contentResolverConfigData = contentResolverConfigManager.queryAtClassPath(EnvironmentConfiguration::class)
return EnvironmentConfiguration.fromMap(contentResolverConfigData ?: return staticEnvironmentConfig)
}

View File

@ -1,9 +1,9 @@
public class me/proton/core/configuration/ContentResolverConfigManager {
public static final field Companion Lme/proton/core/configuration/ContentResolverConfigManager$Companion;
public fun <init> (Landroid/content/Context;)V
public final fun fetchConfigurationDataAtPath (Ljava/lang/String;)Ljava/util/Map;
public final fun getContext ()Landroid/content/Context;
public final fun insertContentValuesAtPath (Ljava/util/Map;Ljava/lang/String;)Landroid/net/Uri;
public final fun insertConfigFieldMapAtClassPath (Ljava/util/Map;Lkotlin/reflect/KClass;)Landroid/net/Uri;
public final fun queryAtClassPath (Lkotlin/reflect/KClass;)Ljava/util/Map;
}
public final class me/proton/core/configuration/ContentResolverConfigManager$Companion {
@ -11,25 +11,26 @@ public final class me/proton/core/configuration/ContentResolverConfigManager$Com
public final class me/proton/core/configuration/EnvironmentConfiguration : me/proton/core/configuration/entity/ConfigContract {
public static final field Companion Lme/proton/core/configuration/EnvironmentConfiguration$Companion;
public fun <init> (Lkotlin/reflect/KFunction;)V
public final fun component1 ()Lkotlin/reflect/KFunction;
public final fun copy (Lkotlin/reflect/KFunction;)Lme/proton/core/configuration/EnvironmentConfiguration;
public static synthetic fun copy$default (Lme/proton/core/configuration/EnvironmentConfiguration;Lkotlin/reflect/KFunction;ILjava/lang/Object;)Lme/proton/core/configuration/EnvironmentConfiguration;
public fun <init> (Lme/proton/core/configuration/entity/EnvironmentConfigFieldProvider;)V
public final fun component1 ()Lme/proton/core/configuration/entity/EnvironmentConfigFieldProvider;
public final fun copy (Lme/proton/core/configuration/entity/EnvironmentConfigFieldProvider;)Lme/proton/core/configuration/EnvironmentConfiguration;
public static synthetic fun copy$default (Lme/proton/core/configuration/EnvironmentConfiguration;Lme/proton/core/configuration/entity/EnvironmentConfigFieldProvider;ILjava/lang/Object;)Lme/proton/core/configuration/EnvironmentConfiguration;
public fun equals (Ljava/lang/Object;)Z
public fun getApiHost ()Ljava/lang/String;
public fun getApiPrefix ()Ljava/lang/String;
public fun getBaseUrl ()Ljava/lang/String;
public final fun getConfigFieldProvider ()Lme/proton/core/configuration/entity/EnvironmentConfigFieldProvider;
public fun getHost ()Ljava/lang/String;
public fun getHv3Host ()Ljava/lang/String;
public fun getHv3Url ()Ljava/lang/String;
public fun getProxyToken ()Ljava/lang/String;
public final fun getStringProvider ()Lkotlin/reflect/KFunction;
public fun getUseDefaultPins ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class me/proton/core/configuration/EnvironmentConfiguration$Companion {
public final fun fromBundle (Landroid/os/Bundle;)Lme/proton/core/configuration/EnvironmentConfiguration;
public final fun fromClass (Ljava/lang/String;)Lme/proton/core/configuration/EnvironmentConfiguration;
public static synthetic fun fromClass$default (Lme/proton/core/configuration/EnvironmentConfiguration$Companion;Ljava/lang/String;ILjava/lang/Object;)Lme/proton/core/configuration/EnvironmentConfiguration;
public final fun fromMap (Ljava/util/Map;)Lme/proton/core/configuration/EnvironmentConfiguration;
@ -53,8 +54,35 @@ public abstract interface class me/proton/core/configuration/entity/ConfigContra
public abstract fun getUseDefaultPins ()Z
}
public abstract interface class me/proton/core/configuration/entity/EnvironmentConfigFieldProvider {
public abstract fun getBoolean (Ljava/lang/String;)Ljava/lang/Boolean;
public abstract fun getInt (Ljava/lang/String;)Ljava/lang/Integer;
public abstract fun getString (Ljava/lang/String;)Ljava/lang/String;
}
public final class me/proton/core/configuration/extension/EnvironmentConfigurationKt {
public static final fun getConfigContractFields (Ljava/lang/Object;)Ljava/util/Map;
public static final fun getPrimitiveFieldMap (Ljava/lang/Object;)Ljava/util/Map;
}
public final class me/proton/core/configuration/provider/BundleConfigFieldProvider : me/proton/core/configuration/entity/EnvironmentConfigFieldProvider {
public fun <init> (Landroid/os/Bundle;)V
public fun getBoolean (Ljava/lang/String;)Ljava/lang/Boolean;
public fun getInt (Ljava/lang/String;)Ljava/lang/Integer;
public fun getString (Ljava/lang/String;)Ljava/lang/String;
}
public class me/proton/core/configuration/provider/MapConfigFieldProvider : me/proton/core/configuration/entity/EnvironmentConfigFieldProvider {
public fun <init> (Ljava/util/Map;)V
public fun getBoolean (Ljava/lang/String;)Ljava/lang/Boolean;
public fun getInt (Ljava/lang/String;)Ljava/lang/Integer;
public final fun getMap ()Ljava/util/Map;
public fun getString (Ljava/lang/String;)Ljava/lang/String;
}
public final class me/proton/core/configuration/provider/StaticClassConfigFieldProvider : me/proton/core/configuration/entity/EnvironmentConfigFieldProvider {
public fun <init> (Ljava/lang/String;)V
public fun getBoolean (Ljava/lang/String;)Ljava/lang/Boolean;
public fun getInt (Ljava/lang/String;)Ljava/lang/Integer;
public fun getString (Ljava/lang/String;)Ljava/lang/String;
}

View File

@ -17,6 +17,7 @@
*/
import studio.forface.easygradle.dsl.*
import studio.forface.easygradle.dsl.android.*
plugins {
protonAndroidLibrary
@ -30,12 +31,22 @@ android {
}
protonCoverage {
branchCoveragePercentage.set(79)
lineCoveragePercentage.set(85)
branchCoveragePercentage.set(62)
lineCoveragePercentage.set(77)
}
dependencies {
implementation(project(Module.networkData))
testImplementation(junit, mockk)
testImplementation(
junit,
mockk
)
androidTestImplementation(
junit,
`android-test-core-ktx`,
`android-test-runner`,
`android-test-rules`
)
}

View File

@ -0,0 +1,21 @@
<!--
~ Copyright (c) 2022 Proton Technologies AG
~ This file is part of Proton AG and ProtonCore.
~
~ ProtonCore is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ ProtonCore is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="me.proton.core.configuration.ACCESS_DATA"/>
</manifest>

View File

@ -0,0 +1,77 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration
import android.os.Bundle
import me.proton.core.configuration.provider.BundleConfigFieldProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BundleConfigFieldProviderTest {
@Test
fun getString_returnsCorrectValue() {
val bundle = Bundle().apply {
putString("testStringKey", "testStringValue")
}
val provider = BundleConfigFieldProvider(bundle)
assertEquals("testStringValue", provider.getString("testStringKey"))
}
@Test
fun getString_returnsNullForNonexistentKey() {
val bundle = Bundle()
val provider = BundleConfigFieldProvider(bundle)
assertNull(provider.getString("nonexistentKey"))
}
@Test
fun getBoolean_returnsCorrectValue() {
val bundle = Bundle().apply {
putBoolean("testBooleanKey", true)
}
val provider = BundleConfigFieldProvider(bundle)
assertTrue(provider.getBoolean("testBooleanKey")!!)
}
@Test
fun getBoolean_returnsNullForNonexistentKey() {
val bundle = Bundle()
val provider = BundleConfigFieldProvider(bundle)
assertNull(provider.getBoolean("nonexistentKey"))
}
@Test
fun getInt_returnsCorrectValue() {
val bundle = Bundle().apply {
putInt("testIntKey", 123)
}
val provider = BundleConfigFieldProvider(bundle)
assertEquals(123, provider.getInt("testIntKey"))
}
@Test
fun getInt_returnsNullForNonexistentKey() {
val bundle = Bundle()
val provider = BundleConfigFieldProvider(bundle)
assertNull(provider.getInt("nonexistentKey"))
}
}

View File

@ -0,0 +1,87 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import junit.framework.TestCase.assertTrue
import me.proton.core.configuration.entity.ConfigContract
import org.junit.Test
class ContentResolverConfigManagerTest {
@Test
fun queryAtClassPath_ReturnsNonNullMap() {
val appContext = ApplicationProvider.getApplicationContext<Context>()
val manager = ContentResolverConfigManager(appContext)
val result = manager.queryAtClassPath(MyConfigClass::class)
assertNotNull(result)
assertTrue(result!!.isNotEmpty())
}
@Test
fun insertConfigFieldMapAtClassPath_InsertsDataCorrectly() {
val appContext = ApplicationProvider.getApplicationContext<Context>()
val manager = ContentResolverConfigManager(appContext)
val testMap = mapOf("testKey" to "testValue")
val insertUri = manager.insertConfigFieldMapAtClassPath(testMap, MyConfigClass::class)
assertNotNull(insertUri)
val queryResult = manager.queryAtClassPath(MyConfigClass::class)
assertTrue("testValue" == queryResult?.get("testKey"))
}
@Test
fun queryAtClassPathWithInternalClassReturnsNull() {
class Internal {
val host = "test"
}
val appContext = ApplicationProvider.getApplicationContext<Context>()
val manager = ContentResolverConfigManager(appContext)
val result = manager.queryAtClassPath(Internal::class)
assertNull(result)
}
}
class MyConfigClass: ConfigContract {
override val host: String
get() = "host"
override val proxyToken: String
get() = "proxyToken"
override val apiPrefix: String
get() = "apiPrefix"
override val apiHost: String
get() = "apiHost"
override val baseUrl: String
get() = "baseUrl"
override val hv3Host: String
get() = "hv3Host"
override val hv3Url: String
get() = "hv3Url"
override val useDefaultPins: Boolean
get() = false
}

View File

@ -22,30 +22,45 @@ import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import kotlin.reflect.KClass
public open class ContentResolverConfigManager(
public val context: Context
) {
private val String.contentResolverUrl: Uri get() = Uri.parse("content://$CONFIG_AUTHORITY/config/$this")
@Synchronized
public fun fetchConfigurationDataAtPath(path: String): Map<String, Any?>? = context.contentResolver.query(
path.contentResolverUrl,
null,
null,
null,
null
)?.use { cursor ->
cursor.columnNames.associateWith { columnName ->
cursor.retrieveValue(columnName)
public fun queryAtClassPath(clazz: KClass<*>): Map<String, Any?>? {
val cursor = context.contentResolver.query(
clazz.qualifiedName?.contentResolverUrl ?: return null,
null,
null,
null,
null
)
return cursor?.use {
cursor.columnNames.associateWith { columnName ->
cursor.retrieveValue(columnName)
}
}?.takeIf {
it.isNotEmpty()
}
}?.takeIf {
it.isNotEmpty()
}
@Synchronized
public fun insertContentValuesAtPath(configFieldMap: Map<String, Any?>, path: String): Uri? =
context.contentResolver.insert(path.contentResolverUrl, contentValues(configFieldMap))
public fun insertConfigFieldMapAtClassPath(configFieldMap: Map<String, Any?>, clazz: KClass<*>): Uri? =
context.contentResolver.insert(clazz.qualifiedName!!.contentResolverUrl, configFieldMap.contentValues)
private val String.contentResolverUrl: Uri get() = Uri.parse("content://$CONFIG_AUTHORITY/config/$this")
private val Map<String, Any?>.contentValues: ContentValues get() = ContentValues().apply {
forEach { (key, value) ->
when (value) {
is String -> put(key, value)
is Boolean -> put(key, value)
is Int -> put(key, value)
}
}
}
private fun Cursor.retrieveValue(columnName: String): Any? {
val columnIndex = getColumnIndex(columnName)
@ -53,15 +68,6 @@ public open class ContentResolverConfigManager(
return if (moveToFirst()) getString(columnIndex) else null
}
private fun contentValues(map: Map<String, Any?>): ContentValues = ContentValues().apply {
map.forEach { (key, value) ->
when (value) {
is String -> put(key, value)
is Boolean -> put(key, value)
}
}
}
public companion object {
private const val CONFIG_AUTHORITY = "me.proton.core.configuration.configurator"
}

View File

@ -18,49 +18,35 @@
package me.proton.core.configuration
import android.os.Bundle
import me.proton.core.configuration.entity.ConfigContract
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty
import me.proton.core.configuration.entity.EnvironmentConfigFieldProvider
import me.proton.core.configuration.provider.BundleConfigFieldProvider
import me.proton.core.configuration.provider.MapConfigFieldProvider
import me.proton.core.configuration.provider.StaticClassConfigFieldProvider
private const val DEFAULT_CONFIG_CLASS: String = "me.proton.core.configuration.EnvironmentConfigurationDefaults"
public data class EnvironmentConfiguration(
val stringProvider: KFunction1<String, Any?>
val configFieldProvider: EnvironmentConfigFieldProvider
) : ConfigContract {
override val host: String = getString(::host) ?: ""
override val proxyToken: String = getString(::proxyToken) ?: ""
override val apiPrefix: String = getString(::apiPrefix) ?: "api"
override val apiHost: String = getString(::apiHost) ?: "$apiPrefix.$host"
override val baseUrl: String = getString(::baseUrl) ?: "https://$apiHost"
override val hv3Host: String = getString(::hv3Host) ?: "verify.$host"
override val hv3Url: String = getString(::hv3Url) ?: "https://$hv3Host"
override val useDefaultPins: Boolean = getString(::useDefaultPins) ?: (host == "proton.me")
private fun <T> getString(propertyName: KProperty<Any>): T = stringProvider(propertyName.name) as T
override val host: String = configFieldProvider.getString(::host.name) ?: ""
override val proxyToken: String = configFieldProvider.getString(::proxyToken.name) ?: ""
override val apiPrefix: String = configFieldProvider.getString(::apiPrefix.name) ?: "api"
override val apiHost: String = configFieldProvider.getString(::apiHost.name) ?: "$apiPrefix.$host"
override val baseUrl: String = configFieldProvider.getString(::baseUrl.name) ?: "https://$apiHost"
override val hv3Host: String = configFieldProvider.getString(::hv3Host.name) ?: "verify.$host"
override val hv3Url: String = configFieldProvider.getString(::hv3Url.name) ?: "https://$hv3Host"
override val useDefaultPins: Boolean = configFieldProvider.getBoolean(::useDefaultPins.name) ?: (host == "proton.me")
public companion object {
public fun fromMap(configMap: Map<String, Any?>): EnvironmentConfiguration =
EnvironmentConfiguration(configMap::get)
EnvironmentConfiguration(MapConfigFieldProvider(configMap))
public fun fromClass(className: String = DEFAULT_CONFIG_CLASS): EnvironmentConfiguration =
fromMap(getConfigDataMapFromClass(className))
EnvironmentConfiguration(StaticClassConfigFieldProvider(className))
private fun getConfigDataMapFromClass(className: String) = try {
val defaultsClass = Class.forName(className)
val instance = defaultsClass.newInstance()
defaultsClass
.declaredFields
.associate { property ->
property.isAccessible = true
property.name to property.get(instance)
}
} catch (e: ClassNotFoundException) {
throw IllegalStateException(
"Class not found: $className. Make sure environment configuration gradle plugin is enabled!",
e
)
}
public fun fromBundle(bundle: Bundle): EnvironmentConfiguration =
EnvironmentConfiguration(BundleConfigFieldProvider(bundle))
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.entity
public interface EnvironmentConfigFieldProvider {
public fun getString(key: String): String?
public fun getBoolean(key: String): Boolean?
public fun getInt(key: String): Int?
}

View File

@ -18,17 +18,13 @@
package me.proton.core.configuration.extension
import java.lang.reflect.Field
public val Any.configContractFields: Map<String, Field>
get() = this::class.java.declaredFields.associateBy {
it.isAccessible = true
it.name
}
public val Any.primitiveFieldMap: Map<String, Any?>
get() = configContractFields.filter { map ->
map.value.get(this).let { it is String || it is Boolean }
}.mapValues {
it.value.get(this)
}
get() = this::class.java.declaredFields
.associateBy {
it.isAccessible = true
it.name
}
.filter { it.value.get(this).isPrimitive() }
.mapValues { it.value.get(this) }
private fun Any?.isPrimitive(): Boolean = this is String || this is Boolean || this is Int

View File

@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.provider
import android.os.Bundle
import me.proton.core.configuration.entity.EnvironmentConfigFieldProvider
public class BundleConfigFieldProvider(
private val bundle: Bundle
) : EnvironmentConfigFieldProvider {
override fun getString(key: String): String? = bundle.getString(key)
override fun getBoolean(key: String): Boolean? = bundle.takeIf { bundle.containsKey(key) }?.getBoolean(key)
override fun getInt(key: String): Int? = bundle.takeIf { bundle.containsKey(key) }?.getInt(key)
}

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.provider
import me.proton.core.configuration.entity.EnvironmentConfigFieldProvider
public open class MapConfigFieldProvider(
public val map: Map<String, Any?>
) : EnvironmentConfigFieldProvider {
override fun getString(key: String): String? = map[key]?.toString()
override fun getInt(key: String): Int? = map[key]?.toString()?.toInt()
override fun getBoolean(key: String): Boolean? =
map[key].takeIf { map.containsKey(key) && it is Boolean } as? Boolean
}

View File

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration.provider
import me.proton.core.configuration.entity.EnvironmentConfigFieldProvider
import me.proton.core.configuration.extension.primitiveFieldMap
public class StaticClassConfigFieldProvider(
private val className: String
) : EnvironmentConfigFieldProvider {
private val staticConfigDataMap =
runCatching {
val defaultsClass = Class.forName(className)
val instance = defaultsClass.newInstance()
instance.primitiveFieldMap
}.onFailure {
error("Class not found: $className!")
}.getOrThrow()
private val mapConfigFieldProvider = MapConfigFieldProvider(staticConfigDataMap)
override fun getString(key: String): String? = mapConfigFieldProvider.getString(key)
override fun getBoolean(key: String): Boolean? = mapConfigFieldProvider.getBoolean(key)
override fun getInt(key: String): Int? = mapConfigFieldProvider.getInt(key)
}

View File

@ -1,58 +0,0 @@
/*
* Copyright (c) 2022 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ConfigFieldTest {
@Test
fun `returns correct String value when present in map`() {
val key = "testKey"
val expectedValue = "testValue"
val configMap: Map<String, Any?> = mapOf(key to expectedValue)
val actualValue: String = configMap[key] as String
assertEquals(expectedValue, actualValue)
}
@Test
fun `returns correct Boolean value when present in map`() {
val key = "testKey"
val expectedValue = true
val configMap: Map<String, Any?> = mapOf(key to expectedValue)
val actualValue: Boolean = configMap[key] as Boolean
assertEquals(expectedValue, actualValue)
}
@Test
fun `returns null when value in map is null`() {
val key = "testKey"
val configMap: Map<String, Any?> = mapOf(key to null)
val actualValue: String? = configMap[key] as String?
assertNull(actualValue)
}
}

View File

@ -61,7 +61,7 @@ class ContentResolverConfigManagerTest {
every { cursor.getString(1) } returns "value2"
every { contentResolver.query(any(), any(), any(), any(), any()) } returns cursor
val result = configManager.fetchConfigurationDataAtPath(EnvironmentConfiguration::class.java.name)
val result = configManager.queryAtClassPath(EnvironmentConfiguration::class)
assertEquals(mapOf("key1" to "value1", "key2" to "value2"), result)
}
@ -73,7 +73,7 @@ class ContentResolverConfigManagerTest {
every { cursor.moveToFirst() } returns false // No data to move to
every { contentResolver.query(any(), null, null, null, null) } returns cursor
val result = configManager.fetchConfigurationDataAtPath("emptyPath")
val result = configManager.queryAtClassPath(EnvironmentConfiguration::class)
assertTrue(result.isNullOrEmpty())
}
@ -83,7 +83,7 @@ class ContentResolverConfigManagerTest {
fun `fetchConfigurationDataAtPath returns null for invalid path`() {
every { contentResolver.query(any(), null, null, null, null) } returns null
val result = configManager.fetchConfigurationDataAtPath("invalidPath")
val result = configManager.queryAtClassPath(this::class)
assertNull(result)
}

View File

@ -18,86 +18,45 @@
package me.proton.core.configuration
import me.proton.core.configuration.extension.configContractFields
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
import kotlin.reflect.KFunction1
class EnvironmentConfigurationTest {
private val mockConfigData: Map<String, Any?> = mapOf(
"host" to "testHost",
"proxyToken" to "testProxyToken",
"apiPrefix" to "testApiPrefix",
"baseUrl" to "testBaseUrl",
"apiHost" to "api.host",
"hv3Host" to "testHv3Host",
"hv3Url" to "testHv3Url"
)
@Suppress("UNCHECKED_CAST")
private val expected = EnvironmentConfiguration(mockConfigData::get as KFunction1<String, String?>)
private class ValidStaticConfig {
val host: String = "testHost"
val proxyToken: String = "testProxyToken"
val apiPrefix: String = "testApiPrefix"
val baseUrl: String = "testBaseUrl"
val apiHost: String = "api.host"
val hv3Host: String = "testHv3Host"
val hv3Url: String = "testHv3Url"
}
private class InvalidStaticConfig {
val host = 0
}
@Test
fun `load config from map`() {
val actual = EnvironmentConfiguration.fromMap(mockConfigData)
assertEquals(
actual.configContractFields,
expected.configContractFields
fun `EnvironmentConfiguration initializes correctly with MapFieldProvider`() {
val configMap = mapOf(
"host" to "test.proton.me",
"proxyToken" to "token123",
"apiPrefix" to "apiTest",
"useDefaultPins" to false
)
val config = EnvironmentConfiguration.fromMap(configMap)
assertEquals("test.proton.me", config.host)
assertEquals("token123", config.proxyToken)
assertEquals("apiTest", config.apiPrefix)
assertEquals("apiTest.test.proton.me", config.apiHost)
assertEquals("https://apiTest.test.proton.me", config.baseUrl)
assertEquals("verify.test.proton.me", config.hv3Host)
assertEquals("https://verify.test.proton.me", config.hv3Url)
assertEquals(false, config.useDefaultPins)
}
@Test
fun `throw error for unsupported type when loading from map`() {
assertThrows(ClassCastException::class.java) {
EnvironmentConfiguration.fromMap(mapOf("host" to arrayOf("")))
}
}
fun `EnvironmentConfiguration uses defaults correctly`() {
val minimalConfigMap = mapOf(
"host" to "proton.me"
)
val config = EnvironmentConfiguration.fromMap(minimalConfigMap)
@Test
fun `throw error for loading non-existent class`() {
assertThrows(IllegalStateException::class.java) {
EnvironmentConfiguration.fromClass("null")
}
}
@Test
fun `throw error for loading invalid config`() {
assertThrows(ClassCastException::class.java) {
EnvironmentConfiguration.fromClass(InvalidStaticConfig::class.java.name)
}
}
@Test
fun `load config from class`() {
val actual = EnvironmentConfiguration.fromClass(ValidStaticConfig::class.java.name)
assertEquals(actual.configContractFields, expected.configContractFields)
}
@Test
fun `default proxy usage is set`() {
val actual = EnvironmentConfiguration.fromMap(mapOf("host" to "proton.me"))
assertEquals(actual.useDefaultPins, true)
}
@Test
fun `default proxy usage is overridden`() {
val actual = EnvironmentConfiguration.fromMap(mapOf("host" to "proton.me", "useDefaultPins" to false))
assertEquals(actual.useDefaultPins, false)
assertEquals("proton.me", config.host)
assertEquals("", config.proxyToken) // Default empty string
assertEquals("api", config.apiPrefix) // Specified default
assertEquals("api.proton.me", config.apiHost) // Constructed from defaults
assertEquals("https://api.proton.me", config.baseUrl) // Constructed URL
assertEquals("verify.proton.me", config.hv3Host) // Constructed host
assertEquals("https://verify.proton.me", config.hv3Url) // Constructed URL
assertEquals(true, config.useDefaultPins) // Default based on `host == "proton.me"`
}
}

View File

@ -18,48 +18,35 @@
package me.proton.core.configuration
import me.proton.core.configuration.extension.configContractFields
import me.proton.core.configuration.extension.primitiveFieldMap
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ExtensionFunctionTests {
class TestClass {
private val stringField: String = "TestString"
private val booleanField: Boolean = true
private val intField: Int = 42
}
@Test
fun `configContractFields includes all declared fields`() {
val testObject = TestClass()
val fields = testObject.configContractFields
assertEquals(3, fields.size)
assertTrue(fields.containsKey("stringField"))
assertTrue(fields.containsKey("booleanField"))
assertTrue(fields.containsKey("intField"))
class MockClass {
val stringField = "Test String"
val booleanField = true
val intField = 123
val listField = listOf("Not", "Primitive")
val doubleField = 123.45
}
@Test
fun `primitiveFieldMap includes only primitive fields`() {
val testObject = TestClass()
val primitiveFields = testObject.primitiveFieldMap
val mockInstance = MockClass()
val primitiveFieldMap = mockInstance.primitiveFieldMap
assertEquals(2, primitiveFields.size)
assertEquals("TestString", primitiveFields["stringField"])
assertEquals(true, primitiveFields["booleanField"])
assertFalse(primitiveFields.containsKey("intField"))
}
assertEquals(3, primitiveFieldMap.size)
assertTrue(primitiveFieldMap.containsKey("stringField"))
assertTrue(primitiveFieldMap.containsKey("booleanField"))
assertTrue(primitiveFieldMap.containsKey("intField"))
@Test
fun `configContractFields makes fields accessible`() {
val testObject = TestClass()
val fields = testObject.configContractFields
assertEquals("Test String", primitiveFieldMap["stringField"])
assertEquals(true, primitiveFieldMap["booleanField"])
assertEquals(123, primitiveFieldMap["intField"])
assertTrue(fields.all { it.value.isAccessible })
assertTrue(!primitiveFieldMap.containsKey("listField"))
assertTrue(!primitiveFieldMap.containsKey("doubleField"))
}
}

View File

@ -0,0 +1,81 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration
import me.proton.core.configuration.provider.MapConfigFieldProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class MapConfigFieldProviderTest {
@Test
fun `test getString returns correct String value`() {
val map = mapOf("key1" to "value1")
val provider = MapConfigFieldProvider(map)
assertEquals("value1", provider.getString("key1"))
}
@Test
fun `test getString returns null for non-existent key`() {
val map = mapOf<String, Any?>()
val provider = MapConfigFieldProvider(map)
assertNull(provider.getString("key2"))
}
@Test
fun `test getBoolean returns true for true String value`() {
val map = mapOf("key" to true)
val provider = MapConfigFieldProvider(map)
assertTrue(provider.getBoolean("key")!!)
}
@Test
fun `test getBoolean returns false for false String value`() {
val map = mapOf("key" to false)
val provider = MapConfigFieldProvider(map)
assertFalse(provider.getBoolean("key")!!)
}
@Test
fun `test getBoolean returns null for non-boolean String value`() {
val map = mapOf("key" to "notABoolean")
val provider = MapConfigFieldProvider(map)
assertNull(provider.getBoolean("key"))
}
@Test
fun `test getInt returns correct Int value`() {
val map = mapOf("key" to "123")
val provider = MapConfigFieldProvider(map)
assertEquals(123, provider.getInt("key"))
}
@Test
fun `test getInt returns null for non-integer String value`() {
val map = mapOf("key" to "notAnInt")
val provider = MapConfigFieldProvider(map)
assertThrows(NumberFormatException::class.java) {
provider.getInt("key")
}
}
}

View File

@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.configuration
import me.proton.core.configuration.provider.StaticClassConfigFieldProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
class StaticClassConfigFieldProviderTest {
@Test
fun `getString returns correct String value`() {
val provider = StaticClassConfigFieldProvider(TestClass::class.java.name)
assertEquals("stringValue", provider.getString("stringKey"))
}
@Test
fun `getBoolean returns correct Boolean value`() {
val provider = StaticClassConfigFieldProvider(TestClass::class.java.name)
assertTrue(provider.getBoolean("booleanKey")!!)
}
@Test
fun `getInt returns correct Int value`() {
val provider = StaticClassConfigFieldProvider(TestClass::class.java.name)
assertEquals(123, provider.getInt("intKey"))
}
@Test
fun `getString returns null for non-existent key`() {
val provider = StaticClassConfigFieldProvider(TestClass::class.java.name)
assertNull(provider.getString("nonExistentKey"))
}
@Test
fun `getBoolean returns null for non-existent key`() {
val provider = StaticClassConfigFieldProvider(TestClass::class.java.name)
assertNull(provider.getBoolean("nonExistentKey"))
}
@Test
fun `getInt returns null for non-existent key`() {
val provider = StaticClassConfigFieldProvider(TestClass::class.java.name)
assertNull(provider.getInt("nonExistentKey"))
}
@Test
fun `throws exception for non-existent class`() {
val nonExistentClassName = "com.example.NonExistentClass"
assertThrows(IllegalStateException::class.java) {
StaticClassConfigFieldProvider(nonExistentClassName)
}
}
}
class TestClass {
val stringKey: String = "stringValue"
val booleanKey: Boolean = true
val intKey: Int = 123
}

View File

@ -1,9 +1,17 @@
public class dagger/hilt/internal/aggregatedroot/codegen/_me_proton_core_test_rule_EnvironmentConfigRule {
public fun <init> ()V
}
public class hilt_aggregated_deps/_me_proton_core_test_rule_EnvironmentConfigRule_GeneratedInjector {
public fun <init> ()V
}
public class hilt_aggregated_deps/_me_proton_core_test_rule_di_TestEnvironmentConfigModule {
public fun <init> ()V
}
public final class me/proton/core/test/rule/AuthenticationRule : org/junit/rules/TestWatcher {
public fun <init> (Lme/proton/core/test/rule/ProtonRule$UserConfig;)V
public fun <init> (Lkotlin/jvm/functions/Function0;)V
}
public final class me/proton/core/test/rule/BuildConfig {
@ -13,70 +21,41 @@ public final class me/proton/core/test/rule/BuildConfig {
public fun <init> ()V
}
public final class me/proton/core/test/rule/EnvironmentConfigRule : org/junit/rules/TestRule {
public fun <init> ()V
public final class me/proton/core/test/rule/EnvironmentConfigRule : org/junit/rules/TestWatcher {
public fun <init> (Lme/proton/core/test/rule/annotation/EnvironmentConfig;)V
public synthetic fun <init> (Lme/proton/core/test/rule/annotation/EnvironmentConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement;
public final fun getConfig ()Lme/proton/core/test/rule/annotation/EnvironmentConfig;
public fun starting (Lorg/junit/runner/Description;)V
}
public abstract interface class me/proton/core/test/rule/EnvironmentConfigRule_GeneratedInjector {
public abstract fun injectTest (Lme/proton/core/test/rule/EnvironmentConfigRule;)V
}
public class me/proton/core/test/rule/ProtonRule : org/junit/rules/TestRule {
public fun <init> (Lme/proton/core/test/rule/ProtonRule$UserConfig;Lme/proton/core/test/rule/ProtonRule$TestConfig;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V
public fun <init> (Lme/proton/core/test/rule/entity/UserConfig;Lme/proton/core/test/rule/entity/TestConfig;Lme/proton/core/test/rule/entity/HiltConfig;)V
public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement;
public final fun getActivityScenarioRule ()Lorg/junit/rules/TestRule;
public final fun getTestDataRule ()Lme/proton/core/test/rule/QuarkTestDataRule;
}
public final class me/proton/core/test/rule/ProtonRule$TestConfig {
public fun <init> ()V
public fun <init> (Lme/proton/core/test/rule/annotation/EnvironmentConfig;[Lme/proton/core/test/rule/annotation/AnnotationTestData;Lorg/junit/rules/TestRule;)V
public synthetic fun <init> (Lme/proton/core/test/rule/annotation/EnvironmentConfig;[Lme/proton/core/test/rule/annotation/AnnotationTestData;Lorg/junit/rules/TestRule;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lme/proton/core/test/rule/annotation/EnvironmentConfig;
public final fun component2 ()[Lme/proton/core/test/rule/annotation/AnnotationTestData;
public final fun component3 ()Lorg/junit/rules/TestRule;
public final fun copy (Lme/proton/core/test/rule/annotation/EnvironmentConfig;[Lme/proton/core/test/rule/annotation/AnnotationTestData;Lorg/junit/rules/TestRule;)Lme/proton/core/test/rule/ProtonRule$TestConfig;
public static synthetic fun copy$default (Lme/proton/core/test/rule/ProtonRule$TestConfig;Lme/proton/core/test/rule/annotation/EnvironmentConfig;[Lme/proton/core/test/rule/annotation/AnnotationTestData;Lorg/junit/rules/TestRule;ILjava/lang/Object;)Lme/proton/core/test/rule/ProtonRule$TestConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getActivityRule ()Lorg/junit/rules/TestRule;
public final fun getAnnotationTestData ()[Lme/proton/core/test/rule/annotation/AnnotationTestData;
public final fun getEnvConfig ()Lme/proton/core/test/rule/annotation/EnvironmentConfig;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class me/proton/core/test/rule/ProtonRule$UserConfig {
public fun <init> ()V
public fun <init> (Lme/proton/core/test/rule/annotation/TestUserData;ZZZ)V
public synthetic fun <init> (Lme/proton/core/test/rule/annotation/TestUserData;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lme/proton/core/test/rule/annotation/TestUserData;
public final fun component2 ()Z
public final fun component3 ()Z
public final fun component4 ()Z
public final fun copy (Lme/proton/core/test/rule/annotation/TestUserData;ZZZ)Lme/proton/core/test/rule/ProtonRule$UserConfig;
public static synthetic fun copy$default (Lme/proton/core/test/rule/ProtonRule$UserConfig;Lme/proton/core/test/rule/annotation/TestUserData;ZZZILjava/lang/Object;)Lme/proton/core/test/rule/ProtonRule$UserConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getLoginBefore ()Z
public final fun getLogoutAfter ()Z
public final fun getLogoutBefore ()Z
public final fun getOverrideLogin ()Z
public final fun getUserData ()Lme/proton/core/test/rule/annotation/TestUserData;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class me/proton/core/test/rule/ProtonRuleKt {
public static final fun before (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Lorg/junit/rules/ExternalResource;
public static final fun printInfo (Ljava/lang/Object;Ljava/lang/String;)V
}
public final class me/proton/core/test/rule/QuarkTestDataRule : org/junit/rules/TestWatcher {
public static final field Companion Lme/proton/core/test/rule/QuarkTestDataRule$Companion;
public fun <init> ([Lme/proton/core/test/rule/annotation/AnnotationTestData;Lme/proton/core/test/rule/annotation/TestUserData;Lkotlin/jvm/functions/Function0;)V
public fun <init> (Ljava/util/Set;Lme/proton/core/test/rule/annotation/TestUserData;Lkotlin/jvm/functions/Function0;)V
public final fun getTestData (Ljava/lang/Class;)Ljava/lang/annotation/Annotation;
public final fun getTestUserData ()Lme/proton/core/test/rule/annotation/TestUserData;
public final fun setTestUserData (Lme/proton/core/test/rule/annotation/TestUserData;)V
}
public final class me/proton/core/test/rule/QuarkTestDataRule$Companion {
public final fun getQuarkClientTimeout ()Ljava/util/concurrent/atomic/AtomicReference;
public final fun getQuarkClient ()Lokhttp3/OkHttpClient;
}
public final class me/proton/core/test/rule/TestExecutionWatcher : org/junit/rules/TestWatcher {
public fun <init> ()V
}
public final class me/proton/core/test/rule/annotation/AnnotationTestData {
@ -86,8 +65,8 @@ public final class me/proton/core/test/rule/annotation/AnnotationTestData {
public synthetic fun <init> (Ljava/lang/annotation/Annotation;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/annotation/Annotation;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;)V
public synthetic fun <init> (Ljava/lang/annotation/Annotation;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getDefault ()Ljava/lang/annotation/Annotation;
public final fun getImplementation ()Lkotlin/jvm/functions/Function4;
public final fun getInstance ()Ljava/lang/annotation/Annotation;
public final fun getTearDown ()Lkotlin/jvm/functions/Function4;
}
@ -130,6 +109,7 @@ public abstract interface annotation class me/proton/core/test/rule/annotation/T
public static final field Companion Lme/proton/core/test/rule/annotation/TestUserData$Companion;
public abstract fun authVersion ()I
public abstract fun createAddress ()Z
public abstract fun creationTime ()Ljava/lang/String;
public abstract fun external ()Z
public abstract fun externalEmail ()Ljava/lang/String;
public abstract fun genKeys ()Lme/proton/core/test/rule/annotation/TestUserData$GenKeys;
@ -141,6 +121,7 @@ public abstract interface annotation class me/proton/core/test/rule/annotation/T
public abstract fun shouldSeed ()Z
public abstract fun status ()Lme/proton/core/test/rule/annotation/TestUserData$Status;
public abstract fun toTpSecret ()Ljava/lang/String;
public abstract fun vpnSettings ()Ljava/lang/String;
}
public final class me/proton/core/test/rule/annotation/TestUserData$Companion {
@ -171,22 +152,75 @@ public final class me/proton/core/test/rule/annotation/TestUserData$Status : jav
}
public final class me/proton/core/test/rule/annotation/TestUserDataKt {
public static final fun handleExternal (Lme/proton/core/test/rule/annotation/TestUserData;Ljava/lang/String;Ljava/lang/String;)Lme/proton/core/test/rule/annotation/TestUserData;
public static synthetic fun handleExternal$default (Lme/proton/core/test/rule/annotation/TestUserData;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lme/proton/core/test/rule/annotation/TestUserData;
public static final fun getAnnotationTestData (Lme/proton/core/test/rule/annotation/TestUserData;)Lme/proton/core/test/rule/annotation/AnnotationTestData;
public static final fun getShouldHandleExternal (Lme/proton/core/test/rule/annotation/TestUserData;)Z
public static final fun handleExternal (Lme/proton/core/test/rule/annotation/TestUserData;Ljava/lang/String;)Lme/proton/core/test/rule/annotation/TestUserData;
public static synthetic fun handleExternal$default (Lme/proton/core/test/rule/annotation/TestUserData;Ljava/lang/String;ILjava/lang/Object;)Lme/proton/core/test/rule/annotation/TestUserData;
}
public final class me/proton/core/test/rule/di/TestEnvironmentConfigModule {
public static final field INSTANCE Lme/proton/core/test/rule/di/TestEnvironmentConfigModule;
public final fun getOverrideConfig ()Ljava/util/concurrent/atomic/AtomicReference;
public final fun provideEnvironmentConfiguration ()Lme/proton/core/configuration/EnvironmentConfiguration;
public final fun getOverrideEnvironmentConfiguration ()Ljava/util/concurrent/atomic/AtomicReference;
public final fun provideEnvironmentConfiguration (Lme/proton/core/configuration/ContentResolverConfigManager;)Lme/proton/core/configuration/EnvironmentConfiguration;
}
public final class me/proton/core/test/rule/di/TestEnvironmentConfigModule_ProvideEnvironmentConfigurationFactory : dagger/internal/Factory {
public fun <init> ()V
public static fun create ()Lme/proton/core/test/rule/di/TestEnvironmentConfigModule_ProvideEnvironmentConfigurationFactory;
public fun <init> (Ljavax/inject/Provider;)V
public static fun create (Ljavax/inject/Provider;)Lme/proton/core/test/rule/di/TestEnvironmentConfigModule_ProvideEnvironmentConfigurationFactory;
public synthetic fun get ()Ljava/lang/Object;
public fun get ()Lme/proton/core/configuration/EnvironmentConfiguration;
public static fun provideEnvironmentConfiguration ()Lme/proton/core/configuration/EnvironmentConfiguration;
public static fun provideEnvironmentConfiguration (Lme/proton/core/configuration/ContentResolverConfigManager;)Lme/proton/core/configuration/EnvironmentConfiguration;
}
public final class me/proton/core/test/rule/entity/HiltConfig {
public fun <init> (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
public final fun component1 ()Ljava/lang/Object;
public final fun component2 ()Lkotlin/jvm/functions/Function1;
public final fun component3 ()Lkotlin/jvm/functions/Function1;
public final fun copy (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lme/proton/core/test/rule/entity/HiltConfig;
public static synthetic fun copy$default (Lme/proton/core/test/rule/entity/HiltConfig;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lme/proton/core/test/rule/entity/HiltConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getAfterHilt ()Lkotlin/jvm/functions/Function1;
public final fun getBeforeHilt ()Lkotlin/jvm/functions/Function1;
public final fun getHiltInstance ()Ljava/lang/Object;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class me/proton/core/test/rule/entity/TestConfig {
public fun <init> ()V
public fun <init> (Lme/proton/core/test/rule/annotation/EnvironmentConfig;Ljava/util/Set;Lorg/junit/rules/TestRule;)V
public synthetic fun <init> (Lme/proton/core/test/rule/annotation/EnvironmentConfig;Ljava/util/Set;Lorg/junit/rules/TestRule;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lme/proton/core/test/rule/annotation/EnvironmentConfig;
public final fun component2 ()Ljava/util/Set;
public final fun component3 ()Lorg/junit/rules/TestRule;
public final fun copy (Lme/proton/core/test/rule/annotation/EnvironmentConfig;Ljava/util/Set;Lorg/junit/rules/TestRule;)Lme/proton/core/test/rule/entity/TestConfig;
public static synthetic fun copy$default (Lme/proton/core/test/rule/entity/TestConfig;Lme/proton/core/test/rule/annotation/EnvironmentConfig;Ljava/util/Set;Lorg/junit/rules/TestRule;ILjava/lang/Object;)Lme/proton/core/test/rule/entity/TestConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getActivityRule ()Lorg/junit/rules/TestRule;
public final fun getAnnotationTestData ()Ljava/util/Set;
public final fun getEnvConfig ()Lme/proton/core/test/rule/annotation/EnvironmentConfig;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class me/proton/core/test/rule/entity/UserConfig {
public fun <init> ()V
public fun <init> (Lme/proton/core/test/rule/annotation/TestUserData;ZZZ)V
public synthetic fun <init> (Lme/proton/core/test/rule/annotation/TestUserData;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lme/proton/core/test/rule/annotation/TestUserData;
public final fun component2 ()Z
public final fun component3 ()Z
public final fun component4 ()Z
public final fun copy (Lme/proton/core/test/rule/annotation/TestUserData;ZZZ)Lme/proton/core/test/rule/entity/UserConfig;
public static synthetic fun copy$default (Lme/proton/core/test/rule/entity/UserConfig;Lme/proton/core/test/rule/annotation/TestUserData;ZZZILjava/lang/Object;)Lme/proton/core/test/rule/entity/UserConfig;
public fun equals (Ljava/lang/Object;)Z
public final fun getLoginBefore ()Z
public final fun getLogoutAfter ()Z
public final fun getLogoutBefore ()Z
public final fun getUserData ()Lme/proton/core/test/rule/annotation/TestUserData;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
public final class me/proton/core/test/rule/extension/QuarkCommandKt {
@ -196,7 +230,7 @@ public final class me/proton/core/test/rule/extension/QuarkCommandKt {
}
public final class me/proton/core/test/rule/extension/TestRuleKt {
public static final fun protonRule (Ljava/lang/Object;[Lme/proton/core/test/rule/annotation/AnnotationTestData;Lme/proton/core/test/rule/annotation/EnvironmentConfig;Lme/proton/core/test/rule/annotation/TestUserData;ZZZLorg/junit/rules/TestRule;Lkotlin/jvm/functions/Function0;)Lme/proton/core/test/rule/ProtonRule;
public static synthetic fun protonRule$default (Ljava/lang/Object;[Lme/proton/core/test/rule/annotation/AnnotationTestData;Lme/proton/core/test/rule/annotation/EnvironmentConfig;Lme/proton/core/test/rule/annotation/TestUserData;ZZZLorg/junit/rules/TestRule;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lme/proton/core/test/rule/ProtonRule;
public static final fun protonRule (Ljava/lang/Object;Ljava/util/Set;Lme/proton/core/test/rule/annotation/EnvironmentConfig;Lme/proton/core/test/rule/annotation/TestUserData;ZZZLorg/junit/rules/TestRule;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lme/proton/core/test/rule/ProtonRule;
public static synthetic fun protonRule$default (Ljava/lang/Object;Ljava/util/Set;Lme/proton/core/test/rule/annotation/EnvironmentConfig;Lme/proton/core/test/rule/annotation/TestUserData;ZZZLorg/junit/rules/TestRule;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lme/proton/core/test/rule/ProtonRule;
}

View File

@ -24,8 +24,10 @@ import androidx.test.core.app.ApplicationProvider
import dagger.hilt.android.EntryPointAccessors
import me.proton.core.auth.domain.testing.LoginTestHelper
import me.proton.core.auth.presentation.testing.ProtonTestEntryPoint
import me.proton.core.test.rule.entity.UserConfig
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import kotlin.time.measureTime
/**
* A JUnit test rule for managing user authentication states before and after tests.
@ -34,9 +36,11 @@ import org.junit.runner.Description
*/
@SuppressLint("RestrictedApi")
public class AuthenticationRule(
private val userConfig: ProtonRule.UserConfig,
config: () -> UserConfig,
) : TestWatcher() {
private val userConfig by lazy(config)
private val loginTestHelper: LoginTestHelper by lazy {
protonTestEntryPoint.loginTestHelper
}
@ -54,8 +58,15 @@ public class AuthenticationRule(
}
userConfig.userData?.let {
if (userConfig.loginBefore)
loginTestHelper.login(it.name, it.password)
if (userConfig.loginBefore) {
printInfo("Logging in: ${it.name} / ${it.password} ...")
val loginTime = measureTime {
loginTestHelper.login(it.name, it.password)
}
printInfo("Logged in in ${loginTime.inWholeSeconds} seconds.")
}
}
}
@ -66,6 +77,7 @@ public class AuthenticationRule(
}
private fun logout() {
printInfo("Logging out all users")
runCatching { loginTestHelper.logoutAll() }
}
}

View File

@ -18,41 +18,32 @@
package me.proton.core.test.rule
import me.proton.core.configuration.EnvironmentConfiguration
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.configuration.extension.primitiveFieldMap
import me.proton.core.test.rule.annotation.EnvironmentConfig
import me.proton.core.test.rule.annotation.configContractFieldsMap
import me.proton.core.test.rule.di.TestEnvironmentConfigModule.overrideConfig
import me.proton.core.test.rule.di.TestEnvironmentConfigModule.provideEnvironmentConfiguration
import org.junit.rules.TestRule
import me.proton.core.test.rule.annotation.toEnvironmentConfiguration
import me.proton.core.test.rule.di.TestEnvironmentConfigModule.overrideEnvironmentConfiguration
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* A test rule for setting up environment configuration before running tests.
*
* @property defaultConfig The default [EnvironmentConfig] to use for tests if no overrides are specified.
* By default, it uses a configuration provided by [provideEnvironmentConfiguration].
* @property ruleConfig The default [EnvironmentConfig] to use for tests if no overrides are specified.
*/
@HiltAndroidTest
public class EnvironmentConfigRule(
private val defaultConfig: EnvironmentConfig =
EnvironmentConfig.fromConfiguration(provideEnvironmentConfiguration())
) : TestRule {
private val ruleConfig: EnvironmentConfig?
) : TestWatcher() {
public override fun starting(description: Description) {
val annotationConfig = description.getAnnotation(EnvironmentConfig::class.java)
val overrideConfig = annotationConfig ?: ruleConfig ?: return
val overrideEnvironmentConfig = overrideConfig.toEnvironmentConfiguration()
/**
* The active [EnvironmentConfig] for the current test. It is determined by looking for an
* [EnvironmentConfig] annotation on the test method or class. If not found, [defaultConfig] is used.
*
* This property is initialized when the rule is applied and is accessible during the test execution.
*/
public lateinit var config: EnvironmentConfig
private set
overrideEnvironmentConfiguration.set(overrideEnvironmentConfig)
/**
* Applies the environment configuration for the test described by [description].
*/
override fun apply(base: Statement, description: Description): Statement {
config = description.getAnnotation(EnvironmentConfig::class.java) ?: defaultConfig
EnvironmentConfiguration.fromMap(config.configContractFieldsMap).apply(overrideConfig::set)
return base
val overrideString = if (annotationConfig != null) "@EnvironmentConfig annotation" else "ProtonRule argument"
printInfo("Overriding EnvironmentConfiguration with $overrideString: ${overrideEnvironmentConfig.primitiveFieldMap}")
}
}

View File

@ -18,12 +18,15 @@
package me.proton.core.test.rule
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import kotlinx.coroutines.runBlocking
import me.proton.core.test.rule.annotation.AnnotationTestData
import me.proton.core.test.rule.annotation.EnvironmentConfig
import me.proton.core.test.rule.annotation.TestUserData
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.test.rule.di.TestEnvironmentConfigModule.provideEnvironmentConfiguration
import me.proton.core.test.rule.entity.HiltConfig
import me.proton.core.test.rule.entity.TestConfig
import me.proton.core.test.rule.entity.UserConfig
import org.junit.rules.ExternalResource
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
@ -44,74 +47,91 @@ import org.junit.runners.model.Statement
*
* @param userConfig Configuration for user data and related behavior (e.g., login/logout settings).
* @param testConfig Configuration for environment, test data, and an optional activity rule.
* @param hiltTestInstance Hilt test instance for dependency injection.
* @param setup A lambda function containing setup logic to be executed before each test.
* @param hiltConfig Configuration for hooking into hilt setup
*/
public open class ProtonRule(
private val userConfig: UserConfig,
private val userConfig: UserConfig?,
private val testConfig: TestConfig,
private val hiltTestInstance: Any,
private val setup: () -> Any
private val hiltConfig: HiltConfig?,
) : TestRule {
public val activityScenarioRule: TestRule? = testConfig.activityRule
private val targetContext get() = InstrumentationRegistry.getInstrumentation().targetContext
private val environmentConfigRule by lazy {
val envConfig = testConfig.envConfig ?: EnvironmentConfig.fromConfiguration(provideEnvironmentConfiguration())
EnvironmentConfigRule(envConfig)
EnvironmentConfigRule(testConfig.envConfig)
}
private val hiltRule by lazy {
HiltAndroidRule(hiltTestInstance)
private val hiltRule: HiltAndroidRule? by lazy {
HiltAndroidRule(hiltConfig?.hiltInstance ?: return@lazy null)
}
public val testDataRule: QuarkTestDataRule by lazy {
private val hiltInjectRule by lazy {
if (hiltConfig == null) return@lazy null
before {
hiltRule!!.inject()
}
}
public val testDataRule: QuarkTestDataRule? by lazy {
if (userConfig?.userData == null && testConfig.annotationTestData.isEmpty()) return@lazy null
QuarkTestDataRule(
*testConfig.annotationTestData,
initialTestUserData = userConfig.userData,
environmentConfig = { environmentConfigRule.config }
testConfig.annotationTestData,
initialTestUserData = userConfig?.userData,
environmentConfiguration = {
provideEnvironmentConfiguration(ContentResolverConfigManager(targetContext))
}
)
}
private val authenticationRule by lazy {
userConfig
.takeUnless { it.userData == null }
?.let {
AuthenticationRule(it)
}
if (userConfig == null) return@lazy null.also {
printInfo("No UserConfig provided. Skipping authentication.")
}
AuthenticationRule {
UserConfig(
testDataRule?.testUserData,
loginBefore = userConfig.loginBefore,
logoutBefore = userConfig.logoutBefore,
logoutAfter = userConfig.logoutAfter,
)
}
}
private val setupRule by lazy {
before { setup() }
private val beforeHiltRule by lazy {
if (hiltConfig?.beforeHilt == null) return@lazy null
before {
printInfo("Executing beforeHilt()")
hiltConfig!!.beforeHilt.invoke(this)
}
}
private val ruleChain: RuleChain by lazy {
RuleChain.outerRule(environmentConfigRule)
.around(hiltRule)
.around(setupRule)
.around(testDataRule)
private val afterHiltRule by lazy {
if (hiltConfig?.afterHilt == null) return@lazy null
before {
printInfo("Executing afterHilt()")
hiltConfig!!.afterHilt.invoke(this)
}
}
override fun apply(base: Statement, description: Description): Statement {
return RuleChain
.outerRule(beforeHiltRule)
.aroundNullable(hiltRule)
.around(environmentConfigRule)
.around(hiltInjectRule)
.aroundNullable(afterHiltRule)
.aroundNullable(testDataRule)
.aroundNullable(authenticationRule)
.aroundNullable(testConfig.activityRule)
.around(TestExecutionWatcher())
.apply(base, description)
}
}
override fun apply(base: Statement, description: Description): Statement = ruleChain.apply(base, description)
public data class UserConfig(
val userData: TestUserData? = null,
val loginBefore: Boolean = true,
val logoutBefore: Boolean = true,
val logoutAfter: Boolean = true
) {
val overrideLogin: Boolean get() = loginBefore || logoutBefore || logoutAfter
}
public data class TestConfig(
val envConfig: EnvironmentConfig? = null,
val annotationTestData: Array<out AnnotationTestData<Annotation>> = emptyArray(),
val activityRule: TestRule? = null,
)
private fun RuleChain.aroundNullable(rule: TestRule?): RuleChain {
return around(rule ?: return this)
}
private fun RuleChain.aroundNullable(rule: TestRule?): RuleChain {
return around(rule ?: return this)
}
public fun <T> T.before(block: suspend T.() -> Any): ExternalResource =
@ -122,3 +142,11 @@ public fun <T> T.before(block: suspend T.() -> Any): ExternalResource =
}
}
}
public fun Any.printInfo(message: String) {
val (tag, msg) = this::class.java.name to "[ProtonRule] -> $message"
if (message.contains("CRITICAL") || message.contains("failed!"))
Log.e(tag, msg)
else
Log.i(tag, msg)
}

View File

@ -24,15 +24,17 @@ import me.proton.core.test.quark.v2.QuarkCommand
import me.proton.core.test.rule.annotation.AnnotationTestData
import me.proton.core.test.rule.annotation.EnvironmentConfig
import me.proton.core.test.rule.annotation.TestUserData
import me.proton.core.test.rule.annotation.annotationTestData
import me.proton.core.test.rule.annotation.handleExternal
import me.proton.core.test.rule.annotation.toEnvironmentConfiguration
import me.proton.core.test.rule.extension.seedTestUserData
import me.proton.core.test.rule.annotation.shouldHandleExternal
import okhttp3.OkHttpClient
import okhttp3.Response
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.measureTime
import kotlin.time.toJavaDuration
/**
@ -42,20 +44,16 @@ import kotlin.time.toJavaDuration
*
* @property initialTestUserData Initial user data to set up before tests. Can be overridden by test-specific
* annotations.
* @property environmentConfig A lambda expression that supplies the [EnvironmentConfig] used for test execution.
* @property environmentConfiguration A lambda expression that supplies the [EnvironmentConfig] used for test execution.
*/
public class QuarkTestDataRule(
private vararg val annotationTestData: AnnotationTestData<Annotation>,
initialTestUserData: TestUserData?,
private val environmentConfig: () -> EnvironmentConfig
private val annotationTestData: Set<AnnotationTestData<Annotation>>,
private val initialTestUserData: TestUserData?,
private val environmentConfiguration: () -> EnvironmentConfiguration
) : TestWatcher() {
private lateinit var quarkCommand: QuarkCommand
private val environmentConfiguration: EnvironmentConfiguration by lazy {
environmentConfig().toEnvironmentConfiguration()
}
private var createdUserResponse: CreateUserQuarkResponse? = null
private val testDataMap: MutableMap<Class<out Annotation>, Annotation> = mutableMapOf()
@ -64,41 +62,95 @@ public class QuarkTestDataRule(
* The user data applied to the current test, initially set from [initialTestUserData] and potentially
* overridden by test-specific annotations.
*/
public var testUserData: TestUserData? = initialTestUserData?.handleExternal()
private set
public var testUserData: TestUserData? = null
/**
* Prepares and applies test data and configurations before each test starts. This includes setting up
* user/env data and processing all test data entries against the test's annotations.
*/
override fun starting(description: Description) {
quarkCommand = QuarkCommand(quarkClient)
.baseUrl("https://${environmentConfiguration.host}/api/internal")
.proxyToken(environmentConfiguration.proxyToken)
quarkCommand = getQuarkCommand(environmentConfiguration())
testUserData = description.getAnnotation(TestUserData::class.java)?.handleExternal() ?: testUserData
if (initialTestUserData != null) {
val userData = description.getAnnotation(TestUserData::class.java) ?: initialTestUserData
val handledUserData = userData.handleExternal()
testUserData?.takeIf { it.shouldSeed }?.apply {
createdUserResponse = quarkCommand.seedTestUserData(this)
if (userData.shouldHandleExternal) {
printInfo("isExternal set to true, but external email is empty. Overriding values with name: ${handledUserData.name}, externalEmail: ${handledUserData.externalEmail}")
}
handledUserData.annotationTestData.let {
val userSeedingTime = measureTime {
createdUserResponse = it.implementation(
quarkCommand,
it.instance,
null,
null
) as CreateUserQuarkResponse
}
printInfo("${TestUserData::class.java.simpleName} seeding done: $createdUserResponse")
printInfo("${TestUserData::class.java.simpleName} seeded in ${userSeedingTime.inWholeSeconds} seconds")
testUserData = it.instance
testDataMap[it.annotationClass] = it.instance
printInfo("Running Test with $testUserData")
}
}
annotationTestData.forEach { entry ->
val annotationData = description.getAnnotation(entry.annotationClass) ?: entry.default
entry.implementation(quarkCommand, annotationData, testUserData, createdUserResponse)
testDataMap[entry.annotationClass] = annotationData
val annotationData = getRuntimeAnnotationData(description, entry)
val seedingTime = measureTime {
val result = annotationData.implementation(
quarkCommand,
entry.instance,
testUserData,
createdUserResponse
)
if (result is Response) {
printInfo("Seeding response received: { ${result.message} }")
}
printInfo("Running Test with ${entry.instance}")
}
printInfo("${entry.annotationClass.simpleName} data seeded in ${seedingTime.inWholeSeconds} seconds")
testDataMap[entry.annotationClass] = annotationData.instance
}
}
private fun getQuarkCommand(envConfig: EnvironmentConfiguration): QuarkCommand =
QuarkCommand(quarkClient)
.baseUrl("https://${envConfig.host}/api/internal")
.proxyToken(envConfig.proxyToken)
private inline fun <reified T : Annotation> getRuntimeAnnotationData(
description: Description,
defaultData: AnnotationTestData<T>
): AnnotationTestData<T> = AnnotationTestData(
description.getAnnotation(T::class.java) ?: defaultData.instance,
defaultData.implementation,
defaultData.tearDown
)
/** Clean up test data and configurations after each test finishes. **/
override fun finished(description: Description) {
annotationTestData.forEach { data ->
testDataMap[data.annotationClass]?.let {
data.tearDown?.invoke(
annotationTestData.forEach { entry ->
testDataMap[entry.annotationClass]?.let {
val result = entry.tearDown?.invoke(
quarkCommand,
it,
testUserData,
createdUserResponse
)
) ?: return@let
if (result is Response) {
printInfo("Tear down response received: { ${result.message} }")
}
}
}
}
@ -108,12 +160,12 @@ public class QuarkTestDataRule(
public fun <T : Annotation> getTestData(annotationClass: Class<T>): T = testDataMap[annotationClass] as T
private val AnnotationTestData<Annotation>.annotationClass: Class<out Annotation>
get() = default.annotationClass.java
get() = instance.annotationClass.java
public companion object {
public val quarkClientTimeout: AtomicReference<Duration> = AtomicReference(60.seconds)
private val quarkClientTimeout: AtomicReference<Duration> = AtomicReference(60.seconds)
private val quarkClient: OkHttpClient by lazy {
public val quarkClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(quarkClientTimeout.get().toJavaDuration())
.readTimeout(quarkClientTimeout.get().toJavaDuration())
@ -121,4 +173,4 @@ public class QuarkTestDataRule(
.build()
}
}
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.rule
import org.junit.rules.TestWatcher
import org.junit.runner.Description
public class TestExecutionWatcher: TestWatcher() {
override fun starting(description: Description?) {
printInfo("${description?.methodName} starting")
}
override fun finished(description: Description?) {
printInfo("${description?.methodName} finished")
}
override fun failed(e: Throwable?, description: Description?) {
printInfo("${description?.methodName} failed! Exception: ${e!!::class.java.simpleName}")
}
override fun succeeded(description: Description?) {
printInfo("${description?.methodName} succeeded!")
}
}

View File

@ -25,17 +25,17 @@ import me.proton.core.test.quark.v2.QuarkCommand
* Represents a test data configuration for the `QuarkTestDataRule`.
*
* @param T The annotation type associated with this test data configuration.
* @param default The default annotation instance to use if no test-specific annotation is present.
* @param instance The default annotation instance to use if no test-specific annotation is present.
* @param implementation A lambda function defining how to apply the test data using the provided Quark command,
* test user data (if available), and created user response (if available).
* @param tearDown An optional lambda function defining how to tear down the test data after the test, using the
* provided Quark command, test data annotation, test user data (if available),
* and created user response (if available).
*/
public class AnnotationTestData<T: Annotation>(
public val default: T,
public val implementation: QuarkCommand.(T, TestUserData?, CreateUserQuarkResponse?) -> Any,
public val tearDown: (QuarkCommand.(T, TestUserData?, CreateUserQuarkResponse?) -> Unit)? = null,
public class AnnotationTestData<out T: Annotation>(
public val instance: T,
public val implementation: QuarkCommand.(@UnsafeVariance T, TestUserData?, CreateUserQuarkResponse?) -> Any,
public val tearDown: (QuarkCommand.(@UnsafeVariance T, TestUserData?, CreateUserQuarkResponse?) -> Any?)? = null,
) {
/**
@ -50,9 +50,9 @@ public class AnnotationTestData<T: Annotation>(
public constructor(
default: T,
implementation: QuarkCommand.(T) -> Any,
tearDown: (QuarkCommand.(T) -> Unit)? = null
tearDown: (QuarkCommand.(T) -> Any)? = null
) : this(
default = default,
instance = default,
implementation = { data, _, _ -> implementation(data) },
tearDown = { data, _, _ -> tearDown?.invoke(this, data) }
)
@ -71,7 +71,7 @@ public class AnnotationTestData<T: Annotation>(
implementation: QuarkCommand.(T, CreateUserQuarkResponse) -> Any,
tearDown: (QuarkCommand.(T, CreateUserQuarkResponse) -> Unit)? = null
) : this(
default = default,
instance = default,
implementation = { data, _, seededUser ->
implementation(
data,
@ -86,4 +86,4 @@ public class AnnotationTestData<T: Annotation>(
)
}
)
}
}

View File

@ -36,4 +36,4 @@ public val EnvironmentConfig.configContractFieldsMap: Map<String, String?>
get() = mapOf(::host.name to host)
public fun EnvironmentConfig.toEnvironmentConfiguration(): EnvironmentConfiguration =
EnvironmentConfiguration.fromMap(configContractFieldsMap)
EnvironmentConfiguration.fromMap(configContractFieldsMap)

View File

@ -26,7 +26,7 @@ import me.proton.core.util.kotlin.EMPTY_STRING
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
public annotation class TestSubscriptionData(
val plan: Plan = Plan.Free,
val plan: Plan,
val couponCode: String = EMPTY_STRING,
val delinquent: Boolean = false
)

View File

@ -18,6 +18,7 @@
package me.proton.core.test.rule.annotation
import me.proton.core.test.rule.extension.seedTestUserData
import me.proton.core.util.kotlin.EMPTY_STRING
import me.proton.core.util.kotlin.random
@ -37,6 +38,8 @@ public annotation class TestUserData(
val toTpSecret: String = EMPTY_STRING,
val recoveryPhone: String = EMPTY_STRING,
val externalEmail: String = EMPTY_STRING,
val vpnSettings: String = EMPTY_STRING,
val creationTime: String = EMPTY_STRING,
val shouldSeed: Boolean = true,
) {
@ -59,10 +62,9 @@ public annotation class TestUserData(
}
public fun TestUserData.handleExternal(
username: String = TestUserData.randomUsername(),
extEmail: String = "${TestUserData.randomUsername()}@example.lt"
username: String = TestUserData.randomUsername()
): TestUserData = TestUserData(
name = if (name.isEmpty() && external) name else username,
name = if (shouldHandleExternal) username else name,
password,
recoveryEmail,
status,
@ -73,5 +75,16 @@ public fun TestUserData.handleExternal(
external,
toTpSecret,
recoveryPhone,
externalEmail = if (externalEmail.isEmpty() && external) extEmail else externalEmail
externalEmail = if (shouldHandleExternal) "$username@example.lt" else externalEmail
)
public val TestUserData.annotationTestData: AnnotationTestData<TestUserData>
get() = AnnotationTestData(
default = this,
implementation = { data ->
seedTestUserData(data)
}
)
public val TestUserData.shouldHandleExternal: Boolean
get() = externalEmail.isEmpty() && external

View File

@ -18,15 +18,17 @@
package me.proton.core.test.rule.di
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.EnvironmentConfiguration
import me.proton.core.configuration.dagger.ContentResolverEnvironmentConfigModule
import me.proton.core.configuration.entity.ConfigContract
import me.proton.core.configuration.extension.primitiveFieldMap
import me.proton.core.test.rule.printInfo
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Singleton
@ -36,34 +38,34 @@ import javax.inject.Singleton
replaces = [ContentResolverEnvironmentConfigModule::class]
)
public object TestEnvironmentConfigModule {
@Provides
@Singleton
public fun provideEnvironmentConfiguration(): EnvironmentConfiguration =
public val overrideEnvironmentConfiguration: AtomicReference<EnvironmentConfiguration?> = AtomicReference(null)
private val instrumentationArgumentsConfig by lazy {
InstrumentationRegistry
.getArguments()
.configFields()
.takeIf { it.isNotEmpty() }
?.let {
EnvironmentConfiguration.fromMap(it)
} ?: EnvironmentConfiguration(::getConfigValue)
public val overrideConfig: AtomicReference<EnvironmentConfiguration?> = AtomicReference(null)
private val defaultConfig = EnvironmentConfiguration.fromClass()
private fun getConfigValue(key: String): String {
val defaultValue = defaultConfig.primitiveFieldMap[key].toString()
val overrideValue = overrideConfig.get()?.primitiveFieldMap?.get(key)?.toString()
return overrideValue ?: defaultValue
.takeIf { it.containsKey(ConfigContract::host.name) || it.containsKey(ConfigContract::proxyToken.name) }
?.let { args ->
EnvironmentConfiguration.fromBundle(args).also {
printInfo("Overriding EnvironmentConfiguration with Instrumentation arguments: ${it.primitiveFieldMap}")
}
}
}
private fun Bundle.configFields(): Map<String, Any?> {
val hostKey = EnvironmentConfiguration::host.name
val proxyTokenKey = EnvironmentConfiguration::proxyToken.name
return mapOf(
hostKey to (getString(hostKey) ?: getConfigValue(hostKey)),
proxyTokenKey to (getString(proxyTokenKey) ?: getConfigValue(proxyTokenKey))
)
private val staticEnvironmentConfig by lazy(EnvironmentConfiguration::fromClass)
@Provides
@Singleton
public fun provideEnvironmentConfiguration(
contentResolverConfigManager: ContentResolverConfigManager
): EnvironmentConfiguration {
val contentResolverConfig = contentResolverConfigManager
.queryAtClassPath(EnvironmentConfiguration::class)
?.let(EnvironmentConfiguration::fromMap)
return instrumentationArgumentsConfig
?: overrideEnvironmentConfiguration.get()
?: contentResolverConfig
?: staticEnvironmentConfig
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.rule.entity
import me.proton.core.test.rule.ProtonRule
public data class HiltConfig(
val hiltInstance: Any,
val beforeHilt: (ProtonRule) -> Any,
val afterHilt: (ProtonRule) -> Any
)

View File

@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.rule.entity
import me.proton.core.test.rule.annotation.AnnotationTestData
import me.proton.core.test.rule.annotation.EnvironmentConfig
import org.junit.rules.TestRule
public data class TestConfig(
val envConfig: EnvironmentConfig? = null,
val annotationTestData: Set<out AnnotationTestData<Annotation>> = emptySet(),
val activityRule: TestRule? = null,
)

View File

@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 Proton Technologies AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.test.rule.entity
import me.proton.core.test.rule.annotation.TestUserData
public data class UserConfig(
val userData: TestUserData? = null,
val loginBefore: Boolean = true,
val logoutBefore: Boolean = true,
val logoutAfter: Boolean = true
)

View File

@ -41,7 +41,7 @@ public fun QuarkCommand.setPaymentMethods(methods: TestPaymentMethods): Response
public fun QuarkCommand.seedTestUserData(data: TestUserData): CreateUserQuarkResponse {
val args = listOf(
"-N" to data.name,
"-N" to (data.name.takeIf { !data.external } ?: EMPTY_STRING),
"-p" to data.password,
"-c" to data.createAddress.trueOrEmpty(),
"-r" to data.recoveryEmail,
@ -51,9 +51,11 @@ public fun QuarkCommand.seedTestUserData(data: TestUserData): CreateUserQuarkRes
"-k" to data.genKeys.valueOrEmpty(),
"-m" to data.mailboxPassword,
"-e" to data.external.trueOrEmpty(),
"-ts" to data.toTpSecret,
"-rp" to data.recoveryPhone,
"-em" to data.externalEmail,
"--vpn-settings" to data.vpnSettings,
"--creation-time" to data.creationTime,
"--totp-secret" to data.toTpSecret,
"--recovery-phone" to data.recoveryPhone,
"--external-email" to data.externalEmail,
"--format" to "json"
).toEncodedArgs(ignoreEmpty = true)

View File

@ -28,6 +28,9 @@ import me.proton.core.test.rule.ProtonRule
import me.proton.core.test.rule.annotation.AnnotationTestData
import me.proton.core.test.rule.annotation.EnvironmentConfig
import me.proton.core.test.rule.annotation.TestUserData
import me.proton.core.test.rule.entity.HiltConfig
import me.proton.core.test.rule.entity.TestConfig
import me.proton.core.test.rule.entity.UserConfig
import org.junit.rules.TestRule
/**
@ -40,45 +43,51 @@ import org.junit.rules.TestRule
* - Custom setup logic
* - Activity or Compose test rule
*
* @param annotationTestData Array of `AnnotationTestData` for `QuarkTestDataRule`.
* @param annotationTestData Set of `AnnotationTestData` for `QuarkTestDataRule`.
* @param envConfig Environment configuration for the test (optional).
* @param userData Test user data (optional).
* @param loginBefore Whether to perform login before the test (default: false).
* @param logoutBefore Whether to perform logout before the test (default: false).
* @param logoutAfter Whether to perform logout after the test (default: false).
* @param activityRule Optional `TestRule` for managing activities (default: null).
* @param setUp A lambda function containing setup logic to be executed before each test (default: empty).
* @param afterHilt A lambda function containing setup logic to be executed before each test (default: empty).
* @return A new `ProtonRule` instance.
*/
@SuppressWarnings("LongParameterList")
public fun Any.protonRule(
vararg annotationTestData: AnnotationTestData<Annotation>,
annotationTestData: Set<AnnotationTestData<Annotation>> = emptySet(),
envConfig: EnvironmentConfig? = null,
userData: TestUserData? = null,
loginBefore: Boolean = false,
logoutBefore: Boolean = false,
logoutAfter: Boolean = false,
activityRule: TestRule? = null,
setUp: () -> Any = { },
afterHilt: (ProtonRule) -> Any = { },
beforeHilt: (ProtonRule) -> Any = { },
): ProtonRule {
val userConfig = ProtonRule.UserConfig(
val userConfig = UserConfig(
userData = userData,
loginBefore = loginBefore,
logoutBefore = logoutBefore,
logoutAfter = logoutAfter
)
val testConfig = ProtonRule.TestConfig(
val testConfig = TestConfig(
envConfig = envConfig,
annotationTestData = annotationTestData,
activityRule = activityRule
)
val hiltConfig = HiltConfig(
hiltInstance = this,
afterHilt = afterHilt,
beforeHilt = beforeHilt
)
return ProtonRule(
userConfig = userConfig,
testConfig = testConfig,
hiltTestInstance = this,
setup = setUp
hiltConfig = hiltConfig
)
}
@ -102,14 +111,15 @@ public fun Any.protonRule(
*/
@SuppressWarnings("LongParameterList")
public inline fun <reified A : Activity> Any.protonActivityScenarioRule(
vararg annotationTestData: AnnotationTestData<Annotation>,
annotationTestData: Set<AnnotationTestData<Annotation>> = emptySet(),
envConfig: EnvironmentConfig? = null,
userData: TestUserData? = TestUserData.withRandomUsername,
loginBefore: Boolean = true,
logoutBefore: Boolean = true,
logoutAfter: Boolean = true,
activityScenarioRule: ActivityScenarioRule<A> = activityScenarioRule(),
noinline setUp: () -> Any = { },
noinline beforeHilt: (ProtonRule) -> Unit = { },
noinline afterHilt: (ProtonRule) -> Unit = { },
): ProtonRule = protonRule(
annotationTestData = annotationTestData,
envConfig = envConfig,
@ -118,7 +128,8 @@ public inline fun <reified A : Activity> Any.protonActivityScenarioRule(
logoutBefore = logoutBefore,
logoutAfter = logoutAfter,
activityRule = activityScenarioRule,
setUp = setUp
afterHilt = afterHilt,
beforeHilt = beforeHilt
)
/**
@ -136,19 +147,20 @@ public inline fun <reified A : Activity> Any.protonActivityScenarioRule(
* @param logoutAfter Whether to perform logout after the test (default: true).
* @param composeTestRule A `ComposeTestRule` for the specified component activity type
* (default: created using `createAndroidComposeRule()`).
* @param setUp A lambda function containing setup logic to be executed before each test (default: empty).
* @param afterHilt A lambda function containing setup logic to be executed before each test (default: empty).
* @return A new `ProtonRule` instance.
*/
@SuppressWarnings("LongParameterList")
public inline fun <reified A : ComponentActivity> Any.protonAndroidComposeRule(
vararg annotationTestData: AnnotationTestData<Annotation>,
annotationTestData: Set<AnnotationTestData<Annotation>> = emptySet(),
envConfig: EnvironmentConfig? = null,
userData: TestUserData? = TestUserData.withRandomUsername,
loginBefore: Boolean = true,
logoutBefore: Boolean = true,
logoutAfter: Boolean = true,
composeTestRule: ComposeTestRule = createAndroidComposeRule<A>(),
noinline setUp: () -> Any = { },
noinline beforeHilt: (ProtonRule) -> Any = { },
noinline afterHilt: (ProtonRule) -> Any = { },
): ProtonRule = protonRule(
annotationTestData = annotationTestData,
envConfig = envConfig,
@ -157,5 +169,6 @@ public inline fun <reified A : ComponentActivity> Any.protonAndroidComposeRule(
logoutBefore = logoutBefore,
logoutAfter = logoutAfter,
activityRule = composeTestRule,
setUp = setUp
beforeHilt = beforeHilt,
afterHilt = afterHilt
)