chore: Added Configurator application.

This commit is contained in:
Artiom Košelev 2024-03-25 13:47:29 +00:00
parent 1b7e538923
commit fa07a66916
30 changed files with 1094 additions and 116 deletions

View File

@ -156,6 +156,19 @@ assemble:
- crypto/android/build/outputs/apk/
- key-transparency/data/build/outputs/apk/
assemble:configurator:
extends: .gradle-job
stage: build
needs: [ ]
when: manual
allow_failure: true
interruptible: true
script:
- ./gradlew :configuration:configuration-configurator:assembleDebug
artifacts:
paths:
- configuration/configurator/build/outputs/apk/
## test stage ######################################################################################
unit-tests-and-coverage-report:
extends: .gradle-job

View File

@ -0,0 +1,77 @@
import configuration.extensions.protonEnvironment
import studio.forface.easygradle.dsl.*
import studio.forface.easygradle.dsl.android.*
/*
* 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/>.
*/
plugins {
protonAndroidApp
protonDagger
id("me.proton.core.gradle-plugins.environment-config")
kotlin("plugin.serialization")
}
protonCoverage {
disabled.set(true)
}
android {
namespace = "me.proton.core.configuration.configurator"
defaultConfig {
protonEnvironment {
host = "proton.black"
}
buildConfigField("String", "PROXY_URL", "https://proxy.proton.black".toBuildConfigValue())
}
buildFeatures.compose = true
composeOptions {
kotlinCompilerExtensionVersion = `compose compiler version`
}
}
dependencies {
api(
project(Module.presentationCompose),
`compose-runtime`,
`compose-ui`,
)
implementation(
project(Module.configurationData),
project(Module.configurationDaggerContentResolver),
project(Module.presentation),
project(Module.networkData),
project(Module.networkDagger),
project(Module.quark),
datastore,
datastorePreferences,
`hilt-navigation-compose`,
`android-ktx`,
`startup-runtime`,
`lifecycle-viewModel-compose`,
appcompat,
`kotlin-reflect`,
preference
)
}

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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"
xmlns:tools="http://schemas.android.com/tools">
<permission android:name="me.proton.core.configuration.ACCESS_DATA"
android:protectionLevel="signature"/>
<application
android:name="me.proton.core.configuration.configurator.App"
android:theme="@style/ProtonTheme"
android:label="@string/app_name"
tools:replace="android:theme">
<provider
android:name=".ConfigContentProvider"
android:authorities="me.proton.core.configuration.configurator"
android:permission="me.proton.core.configuration.ACCESS_DATA"
android:exported="true" />
<activity
android:name="me.proton.core.configuration.configurator.presentation.ConfigurationActivity"
android:theme="@style/ProtonTheme.Mail"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,7 @@
package me.proton.core.configuration.configurator
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application()

View File

@ -0,0 +1,87 @@
package me.proton.core.configuration.configurator
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.util.TreeMap
class ConfigContentProvider : ContentProvider() {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = ENVIRONMENT_CONFIG_PREFERENCES)
private lateinit var appContext: Context
override fun onCreate(): Boolean {
context?.let {
appContext = it
return true
}
return false
}
override fun getType(uri: Uri): String = UriType.Item.value
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor = runBlocking {
appContext.dataStore.data.first().asMap().mapKeys {
it.key.name
}.toMatrixCursor()
}
override fun insert(uri: Uri, values: ContentValues?): Uri = runBlocking {
appContext.dataStore.edit { preferences ->
preferences.clear()
values?.keySet()?.forEach { key ->
preferences[stringPreferencesKey(key)] = values.getAsString(key)
} ?: error("Values cannot be null for Insert operation!")
}
return@runBlocking uri
}
override fun delete(
uri: Uri,
selection: String?,
selectionArgs: Array<out String>?
): Int {
throw UnsupportedOperationException("delete() is not supported")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
throw UnsupportedOperationException("update() is not supported")
}
private fun Map<String, Any?>.toMatrixCursor(): Cursor {
val keys = keys.sorted().toTypedArray()
val values = TreeMap(this).values.toTypedArray()
return MatrixCursor(keys).apply { addRow(values) }
}
private enum class UriType(val value: String) {
Item("vnd.android.cursor.item/vnd.proton.core.test.config")
}
companion object {
const val ENVIRONMENT_CONFIG_PREFERENCES = "environmentConfigPreferences"
}
}

View File

@ -0,0 +1,49 @@
/*
* 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.configurator.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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
@InstallIn(SingletonComponent::class)
object ApplicationModule {
@Singleton
@Provides
fun provideQuarkCommand(client: OkHttpClient): QuarkCommand = QuarkCommand(client)
@Singleton
@Provides
fun provideOkHttpClient(): OkHttpClient {
val clientTimeout = 3.seconds.toJavaDuration()
return OkHttpClient.Builder().connectTimeout(clientTimeout)
.readTimeout(clientTimeout)
.writeTimeout(clientTimeout)
.callTimeout(clientTimeout)
.retryOnConnectionFailure(false)
.build()
}
}

View File

@ -0,0 +1,45 @@
/*
* 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.configurator.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.proton.core.configuration.EnvironmentConfiguration
import me.proton.core.network.data.client.ExtraHeaderProviderImpl
import me.proton.core.network.data.di.BaseProtonApiUrl
import me.proton.core.network.domain.client.ExtraHeaderProvider
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class NetworkModule {
@Singleton
@Provides
@BaseProtonApiUrl
fun provideBaseProtonApiUrl(environmentConfiguration: EnvironmentConfiguration): HttpUrl =
environmentConfiguration.baseUrl.toHttpUrl()
@Singleton
@Provides
fun provideExtraHeaderProvider(): ExtraHeaderProvider = ExtraHeaderProviderImpl()
}

View File

@ -0,0 +1,33 @@
/*
* 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.configurator.extension
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.proton.core.test.quark.v2.QuarkCommand
suspend fun QuarkCommand.getProxyToken(): String? = withContext(Dispatchers.IO) {
route("token/get")
.build()
.let {
client.executeQuarkRequest(it)
}
.body
?.string()
}

View File

@ -0,0 +1,118 @@
/*
* 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.configurator.presentation
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
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
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
)
}
}
}
}
}

View File

@ -0,0 +1,241 @@
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Checkbox
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
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 me.proton.core.compose.component.ProtonOutlinedTextField
import me.proton.core.compose.component.ProtonSnackbarHostState
import me.proton.core.compose.component.ProtonSnackbarType
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.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 <T : Any> ConfigurationScreen(
configViewModel: ConfigurationScreenViewModel<T>,
advancedFields: FieldActionMap,
basicFields: FieldActionMap,
preservedFields: Set<String>,
snackbarHostState: ProtonSnackbarHostState,
title: String
) {
var isAdvancedExpanded by remember { mutableStateOf(false) }
Column {
ProtonTopAppBar(title = { Text(title) })
ExpandableHeader(isExpanded = isAdvancedExpanded, onExpandChange = { isAdvancedExpanded = it })
val configFields = if (isAdvancedExpanded) advancedFields else basicFields
ConfigurationFields(configViewModel, configFields)
AdvancedOptionsColumn(isAdvancedExpanded, preservedFields, configViewModel)
SaveConfigurationButton(configFields.keys, configViewModel)
}
ObserveEvents(configViewModel, snackbarHostState)
}
@Composable
private fun ExpandableHeader(isExpanded: Boolean, onExpandChange: (Boolean) -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpandChange(!isExpanded) }
) {
Text(
text = stringResource(id = R.string.configuration_text_advanced),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.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),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp),
tint = ProtonTheme.colors.iconNorm,
contentDescription = null
)
}
}
@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(
isAdvancedExpanded: Boolean,
preservedFields: Set<String>,
configViewModel: ConfigurationScreenViewModel<T>,
) {
if (isAdvancedExpanded) {
Column(modifier = Modifier.bottomPad(16.dp), horizontalAlignment = Alignment.End) {
ProtonSolidButton(
modifier = Modifier.bottomPad(8.dp),
onClick = { configViewModel.setDefaultConfigurationFields(preservedFields) }
) {
Text(stringResource(id = R.string.configuration_restore_confirmation))
}
}
}
}
@Composable
private fun SaveConfigurationButton(keys: Set<String>, configViewModel: ConfigurationScreenViewModel<*>) {
Column(modifier = Modifier.bottomPad(16.dp), horizontalAlignment = Alignment.End) {
ProtonSolidButton(
modifier = Modifier.bottomPad(8.dp),
onClick = { configViewModel.saveConfiguration(keys) }
) {
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,
) =
IconButton(onClick) {
Icon(
painter = painterResource(id = drawableId),
tint = ProtonTheme.colors.iconNorm,
contentDescription = "Configuration Field Action Icon"
)
}
@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()

View File

@ -0,0 +1,117 @@
/*
* 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.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.stateIn
import kotlinx.coroutines.launch
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.extension.primitiveFieldMap
typealias ConfigFieldMapper<T> = (Map<String, Any?>) -> T
class ConfigurationScreenViewModel<T : Any>(
private val contentResolverConfigManager: ContentResolverConfigManager,
private val configFieldMapper: ConfigFieldMapper<T>,
private val defaultConfig: T
) : 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()
}
fun fetchConfigField(fieldName: String, configurationFieldGetter: suspend () -> Any) {
viewModelScope.launch {
runCatching {
_infoEvent.emit("Fetching $fieldName")
configurationFieldGetter()
}
.onFailure {
_errorEvent.emit(it)
}
.onSuccess { newValue ->
updateConfigField(fieldName, newValue)
}
}
}
fun saveConfiguration(keysToSave: Set<String> = _configState.value.primitiveFieldMap.keys) {
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")
}
}
}
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

@ -0,0 +1,26 @@
<!--
~ Copyright (c) 2020 Proton Technologies AG
~ This file is part of Proton Technologies 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/>.
-->
<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_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>
</resources>

View File

@ -20,4 +20,6 @@
<queries>
<provider android:authorities="me.proton.core.configuration.configurator"/>
</queries>
<uses-permission android:name="me.proton.core.configuration.ACCESS_DATA"/>
</manifest>

View File

@ -26,7 +26,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import me.proton.core.configuration.ContentResolverConfigManager
import me.proton.core.configuration.EnvironmentConfiguration
import me.proton.core.configuration.extension.configContractFieldsMap
import javax.inject.Singleton
@Module
@ -37,9 +36,11 @@ public class ContentResolverEnvironmentConfigModule {
public fun provideEnvironmentConfig(
contentResolverConfigManager: ContentResolverConfigManager
): EnvironmentConfiguration {
val staticConfigData = EnvironmentConfiguration.fromClass().configContractFieldsMap
val contentResolverConfigData = contentResolverConfigManager.fetchConfigDataFromContentResolver()
return EnvironmentConfiguration.fromMap(contentResolverConfigData ?: staticConfigData)
val staticEnvironmentConfig = EnvironmentConfiguration.fromClass()
val contentResolverConfigData = contentResolverConfigManager.fetchConfigurationDataAtPath(
EnvironmentConfiguration::class.java.name
)
return EnvironmentConfiguration.fromMap(contentResolverConfigData ?: return staticEnvironmentConfig)
}
@Provides

View File

@ -1,12 +1,18 @@
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 fetchConfigDataFromContentResolver ()Ljava/util/Map;
public final fun insertConfiguration (Lme/proton/core/configuration/EnvironmentConfiguration;)Landroid/net/Uri;
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 class me/proton/core/configuration/ContentResolverConfigManager$Companion {
}
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 equals (Ljava/lang/Object;)Z
@ -17,7 +23,8 @@ public final class me/proton/core/configuration/EnvironmentConfiguration : me/pr
public fun getHv3Host ()Ljava/lang/String;
public fun getHv3Url ()Ljava/lang/String;
public fun getProxyToken ()Ljava/lang/String;
public final fun getUseDefaultPins ()Z
public final fun getStringProvider ()Lkotlin/reflect/KFunction;
public fun getUseDefaultPins ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
@ -43,11 +50,11 @@ public abstract interface class me/proton/core/configuration/entity/ConfigContra
public abstract fun getHv3Host ()Ljava/lang/String;
public abstract fun getHv3Url ()Ljava/lang/String;
public abstract fun getProxyToken ()Ljava/lang/String;
public abstract fun getUseDefaultPins ()Z
}
public final class me/proton/core/configuration/extension/EnvironmentConfigurationKt {
public static final fun getConfigContractFields (Lme/proton/core/configuration/EnvironmentConfiguration;)Ljava/util/Map;
public static final fun getConfigContractFieldsMap (Lme/proton/core/configuration/EnvironmentConfiguration;)Ljava/util/Map;
public static final fun getContentValues (Lme/proton/core/configuration/EnvironmentConfiguration;)Landroid/content/ContentValues;
public static final fun getConfigContractFields (Ljava/lang/Object;)Ljava/util/Map;
public static final fun getPrimitiveFieldMap (Ljava/lang/Object;)Ljava/util/Map;
}

View File

@ -30,8 +30,8 @@ android {
}
protonCoverage {
branchCoveragePercentage.set(56)
lineCoveragePercentage.set(74)
branchCoveragePercentage.set(79)
lineCoveragePercentage.set(85)
}
dependencies {

View File

@ -18,17 +18,17 @@
package me.proton.core.configuration
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import me.proton.core.configuration.extension.contentValues
public open class ContentResolverConfigManager(
private val context: Context
public val context: Context
) {
@Synchronized
public fun fetchConfigDataFromContentResolver(): Map<String, Any?>? = context.contentResolver.query(
CONFIG_CONTENT_URI,
public fun fetchConfigurationDataAtPath(path: String): Map<String, Any?>? = context.contentResolver.query(
path.contentResolverUrl,
null,
null,
null,
@ -42,10 +42,10 @@ public open class ContentResolverConfigManager(
}
@Synchronized
public fun insertConfiguration(configuration: EnvironmentConfiguration): Uri? = context.contentResolver.insert(
CONFIG_CONTENT_URI,
configuration.contentValues
)
public fun insertContentValuesAtPath(configFieldMap: Map<String, Any?>, path: String): Uri? =
context.contentResolver.insert(path.contentResolverUrl, contentValues(configFieldMap))
private val String.contentResolverUrl: Uri get() = Uri.parse("content://$CONFIG_AUTHORITY/config/$this")
private fun Cursor.retrieveValue(columnName: String): Any? {
val columnIndex = getColumnIndex(columnName)
@ -53,8 +53,16 @@ public open class ContentResolverConfigManager(
return if (moveToFirst()) getString(columnIndex) else null
}
private companion object {
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"
val CONFIG_CONTENT_URI: Uri = Uri.parse("content://$CONFIG_AUTHORITY/config")
}
}

View File

@ -20,26 +20,28 @@ package me.proton.core.configuration
import me.proton.core.configuration.entity.ConfigContract
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty
private const val DEFAULT_CONFIG_CLASS: String = "me.proton.core.configuration.EnvironmentConfigurationDefaults"
public data class EnvironmentConfiguration(
private val stringProvider: KFunction1<String, String?>
val stringProvider: KFunction1<String, Any?>
) : ConfigContract {
override val host: String = stringProvider(::host.name) ?: ""
override val proxyToken: String = stringProvider(::proxyToken.name) ?: ""
override val apiPrefix: String = stringProvider(::apiPrefix.name) ?: "api"
override val apiHost: String = stringProvider(::apiHost.name) ?: "$apiPrefix.$host"
override val baseUrl: String = stringProvider(::baseUrl.name) ?: "https://$apiHost"
override val hv3Host: String = stringProvider(::hv3Host.name) ?: "verify.$host"
override val hv3Url: String = stringProvider(::hv3Url.name) ?: "https://$hv3Host"
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")
val useDefaultPins: Boolean get() = host == "proton.me"
private fun <T> getString(propertyName: KProperty<Any>): T = stringProvider(propertyName.name) as T
public companion object {
private const val DEFAULT_CONFIG_CLASS: String = "me.proton.core.configuration.EnvironmentConfigurationDefaults"
public fun fromMap(configMap: Map<String, Any?>): EnvironmentConfiguration =
EnvironmentConfiguration(configMap::configField)
EnvironmentConfiguration(configMap::get)
public fun fromClass(className: String = DEFAULT_CONFIG_CLASS): EnvironmentConfiguration =
fromMap(getConfigDataMapFromClass(className))
@ -62,11 +64,3 @@ public data class EnvironmentConfiguration(
}
}
}
public inline fun <reified T> Map<String, Any?>.configField(key: String): T = this[key].let {
require((it is String? || it is Boolean?) && it is T) {
"Unexpected value type for property: $key. " +
"Expected String? or Boolean?. Found ${it?.javaClass?.name}."
}
it
}

View File

@ -26,4 +26,5 @@ public interface ConfigContract {
public val baseUrl: String
public val hv3Host: String
public val hv3Url: String
public val useDefaultPins: Boolean
}

View File

@ -18,28 +18,17 @@
package me.proton.core.configuration.extension
import android.content.ContentValues
import me.proton.core.configuration.EnvironmentConfiguration
import java.lang.reflect.Field
public val EnvironmentConfiguration.configContractFields: Map<String, Field>
public val Any.configContractFields: Map<String, Field>
get() = this::class.java.declaredFields.associateBy {
it.isAccessible = true
it.name
}
public val EnvironmentConfiguration.configContractFieldsMap: Map<String, Any?>
get() = configContractFields.mapValues {
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)
}
public val EnvironmentConfiguration.contentValues: ContentValues
get() = ContentValues().also { contentValues ->
configContractFields.forEach {
val stringValue = it.value.get(this)?.toString()
when (it.value.type) {
String::class.java -> contentValues.put(it.key, stringValue)
Boolean::class.java -> contentValues.put(it.key, stringValue.toBoolean())
}
}
}

View File

@ -20,7 +20,6 @@ package me.proton.core.configuration
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
class ConfigFieldTest {
@ -31,7 +30,7 @@ class ConfigFieldTest {
val expectedValue = "testValue"
val configMap: Map<String, Any?> = mapOf(key to expectedValue)
val actualValue: String = configMap.configField(key)
val actualValue: String = configMap[key] as String
assertEquals(expectedValue, actualValue)
}
@ -42,7 +41,7 @@ class ConfigFieldTest {
val expectedValue = true
val configMap: Map<String, Any?> = mapOf(key to expectedValue)
val actualValue: Boolean = configMap.configField(key)
val actualValue: Boolean = configMap[key] as Boolean
assertEquals(expectedValue, actualValue)
}
@ -52,29 +51,8 @@ class ConfigFieldTest {
val key = "testKey"
val configMap: Map<String, Any?> = mapOf(key to null)
val actualValue: String? = configMap.configField(key)
val actualValue: String? = configMap[key] as String?
assertNull(actualValue)
}
@Test
fun `throws exception when value in map is not of expected type`() {
val key = "testKey"
val intValue = 123
val configMap: Map<String, Any?> = mapOf(key to intValue)
assertThrows(IllegalArgumentException::class.java) {
configMap.configField(key)
}
}
@Test
fun `throws exception when key is not present in map`() {
val key = "missingKey"
val configMap: Map<String, Any?> = emptyMap()
assertThrows(IllegalArgumentException::class.java) {
configMap.configField(key)
}
}
}

View File

@ -26,6 +26,9 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ContentResolverConfigManagerTest {
@ -34,8 +37,8 @@ class ContentResolverConfigManagerTest {
private lateinit var contentResolver: ContentResolver
private lateinit var configManager: ContentResolverConfigManager
@Test
fun `fetchConfigDataFromContentResolver returns correct data`() {
@Before
fun setUp() {
mockkStatic(Uri::class)
every { Uri.parse(any()) } returns mockk(relaxed = true)
@ -44,7 +47,11 @@ class ContentResolverConfigManagerTest {
every { context.contentResolver } returns contentResolver
configManager = ContentResolverConfigManager(context)
}
@Test
fun `fetchConfigDataFromContentResolver returns correct data`() {
val cursor: Cursor = mockk(relaxed = true)
every { cursor.columnNames } returns arrayOf("key1", "key2")
every { cursor.getColumnIndex("key1") } returns 0
@ -54,8 +61,30 @@ class ContentResolverConfigManagerTest {
every { cursor.getString(1) } returns "value2"
every { contentResolver.query(any(), any(), any(), any(), any()) } returns cursor
val result = configManager.fetchConfigDataFromContentResolver()
val result = configManager.fetchConfigurationDataAtPath(EnvironmentConfiguration::class.java.name)
assertEquals(mapOf("key1" to "value1", "key2" to "value2"), result)
}
@Test
fun `fetchConfigurationDataAtPath returns empty map when no data found`() {
val cursor: Cursor = mockk(relaxed = true)
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")
assertTrue(result.isNullOrEmpty())
}
@Test
fun `fetchConfigurationDataAtPath returns null for invalid path`() {
every { contentResolver.query(any(), null, null, null, null) } returns null
val result = configManager.fetchConfigurationDataAtPath("invalidPath")
assertNull(result)
}
}

View File

@ -64,7 +64,7 @@ class EnvironmentConfigurationTest {
@Test
fun `throw error for unsupported type when loading from map`() {
assertThrows(IllegalArgumentException::class.java) {
assertThrows(ClassCastException::class.java) {
EnvironmentConfiguration.fromMap(mapOf("host" to arrayOf("")))
}
}
@ -78,7 +78,7 @@ class EnvironmentConfigurationTest {
@Test
fun `throw error for loading invalid config`() {
assertThrows(IllegalArgumentException::class.java) {
assertThrows(ClassCastException::class.java) {
EnvironmentConfiguration.fromClass(InvalidStaticConfig::class.java.name)
}
}
@ -88,4 +88,16 @@ class EnvironmentConfigurationTest {
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)
}
}

View File

@ -0,0 +1,65 @@
/*
* 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.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"))
}
@Test
fun `primitiveFieldMap includes only primitive fields`() {
val testObject = TestClass()
val primitiveFields = testObject.primitiveFieldMap
assertEquals(2, primitiveFields.size)
assertEquals("TestString", primitiveFields["stringField"])
assertEquals(true, primitiveFields["booleanField"])
assertFalse(primitiveFields.containsKey("intField"))
}
@Test
fun `configContractFields makes fields accessible`() {
val testObject = TestClass()
val fields = testObject.configContractFields
assertTrue(fields.all { it.value.isAccessible })
}
}

View File

@ -145,7 +145,7 @@ fun setupFlavors(testedExtension: TestedExtension) {
}
}
val atlasHost: String = localProperties.getProperty("HOST") ?: "proton.me"
val atlasHost: String = localProperties.getProperty("HOST") ?: "proton.black"
val keyTransparencyEnv: String? = localProperties.getProperty(buildConfigFieldKeys.KEY_TRANSPARENCY_ENV)
val sentryDsn: String? = localProperties.getProperty(buildConfigFieldKeys.SENTRY_DSN)
val accountSentryDsn: String? = localProperties.getProperty(buildConfigFieldKeys.ACCOUNT_SENTRY_DSN)

View File

@ -24,6 +24,6 @@ publishOption.shouldBePublishedAsLib = false
// Global minimum coverage percentage.
protonCoverage {
branchCoveragePercentage.set(37)
branchCoveragePercentage.set(36)
lineCoveragePercentage.set(62)
}

View File

@ -64,9 +64,9 @@ open class EnvironmentConfigSettings : EnvironmentConfig() {
}
private var _useDefaultPins: Boolean? = null
final override var useDefaultPins: Boolean
override var useDefaultPins: Boolean
get() = _useDefaultPins ?: true
private set(value) {
set(value) {
_useDefaultPins = value
}
@ -78,9 +78,9 @@ open class EnvironmentConfigSettings : EnvironmentConfig() {
}
private var _proxyToken: String? = null
final override var proxyToken: String?
get() = proxyTokenFromCurl.takeIf { useProxy } ?: ""
private set(value) {
override var proxyToken: String?
get() = proxyTokenFromCurl.takeIf { useProxy } ?: _proxyToken ?: ""
set(value) {
_proxyToken = value
}
}

View File

@ -19,7 +19,6 @@ package configuration
import com.android.build.api.dsl.ApplicationBuildType
import com.android.build.api.dsl.DefaultConfig
import com.android.build.api.dsl.ProductFlavor
import com.android.build.gradle.BaseExtension
import configuration.extensions.environmentConfiguration
import configuration.extensions.mergeWith
@ -28,6 +27,9 @@ import configuration.extensions.sourceClassContent
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.configurationcache.extensions.capitalized
import java.util.Locale
typealias ConfigMatrix = List<Pair<String, EnvironmentConfig>>
class ProtonEnvironmentConfigurationPlugin : Plugin<Project> {
@ -49,36 +51,50 @@ class ProtonEnvironmentConfigurationPlugin : Plugin<Project> {
*/
private fun handleConfigurations(project: Project) {
project.extensions.getByType(BaseExtension::class.java).apply {
buildTypes.all { buildType ->
productFlavors.takeIf { it.isNotEmpty() }?.all { flavor ->
project.handleFlavorAndBuildType(defaultConfig, flavor, buildType)
true
} ?: run {
project.handleBuildTypeOnly(defaultConfig, buildType)
val flavors = flavorDimensionList.mapNotNull { dimension ->
productFlavors.filter { it.dimension == dimension }
.takeIf { it.isNotEmpty() }
?.map { it.name.capitalized() to it.environmentConfiguration }
}
if (flavors.isEmpty()) {
buildTypes.forEach {
project.handleBuildTypeOnly(defaultConfig, it)
}
return@apply
}
flavors.permutations.forEach { flavor ->
buildTypes.forEach { buildType ->
project.handleFlavorAndBuildType(
defaultConfig,
flavor.joinedDecapitalized(),
buildType,
mergeConfigurations(*flavor.joinedConfig(buildType))
)
}
true
}
}
}
private fun Project.handleFlavorAndBuildType(
defaultConfig: DefaultConfig,
flavor: ProductFlavor,
buildType: ApplicationBuildType
flavorName: String,
buildType: ApplicationBuildType,
environmentConfig: EnvironmentConfig
) {
val mergedConfig = mergeConfigurations(
defaultConfig.environmentConfiguration,
buildType.environmentConfiguration,
flavor.environmentConfiguration
)
val variantName = "${flavor.name}${buildType.name.capitalized()}"
val mergedConfig = mergeConfigurations(defaultConfig.environmentConfiguration, environmentConfig)
val variantName = "${flavorName}${buildType.name.capitalized()}"
val variantLocation = "${flavorName}/${buildType.name}"
createJavaFileForVariant(
variantName = variantName,
variantLocation = "${flavor.name}/${buildType.name}",
variantLocation = variantLocation,
config = mergedConfig
)
}
private fun Project.handleBuildTypeOnly(
defaultConfig: DefaultConfig,
buildType: ApplicationBuildType
@ -161,3 +177,17 @@ class ProtonEnvironmentConfigurationPlugin : Plugin<Project> {
const val DEFAULTS_CLASS_NAME: String = "EnvironmentConfigurationDefaults"
}
}
val <T> List<List<T>>.permutations: List<List<T>>
get() =
takeIf { isNotEmpty() }
?.first()
?.flatMap { item ->
drop(1).permutations.map { listOf(item) + it }
} ?: listOf(listOf())
fun ConfigMatrix.joinedDecapitalized() = joinToString("") { it.first }
.replaceFirstChar { it.lowercase(Locale.getDefault()) }
fun ConfigMatrix.joinedConfig(buildType: ApplicationBuildType): Array<out EnvironmentConfig> =
(map { it.second } + listOf(buildType.environmentConfiguration)).toTypedArray()

View File

@ -25,7 +25,7 @@ import org.jetbrains.kotlin.gradle.plugin.extraProperties
var BaseFlavor.environmentConfiguration: EnvironmentConfig
get() = extraProperties.getEnvironmentConfigurationByName(getName())
private set(config) = extraProperties.setEnvironmentConfigurationByName(getName(), config)
set(config) = extraProperties.setEnvironmentConfigurationByName(getName(), config)
fun BaseFlavor.protonEnvironment(action: EnvironmentConfigSettings.() -> Unit) {
environmentConfiguration = EnvironmentConfigSettings().apply(action)

View File

@ -26,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import me.proton.core.configuration.EnvironmentConfiguration
import me.proton.core.configuration.dagger.ContentResolverEnvironmentConfigModule
import me.proton.core.configuration.extension.configContractFieldsMap
import me.proton.core.configuration.extension.primitiveFieldMap
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Singleton
@ -53,8 +53,8 @@ public object TestEnvironmentConfigModule {
private val defaultConfig = EnvironmentConfiguration.fromClass()
private fun getConfigValue(key: String): String {
val defaultValue = defaultConfig.configContractFieldsMap[key].toString()
val overrideValue = overrideConfig.get()?.configContractFieldsMap?.get(key)?.toString()
val defaultValue = defaultConfig.primitiveFieldMap[key].toString()
val overrideValue = overrideConfig.get()?.primitiveFieldMap?.get(key)?.toString()
return overrideValue ?: defaultValue
}