chore: Added Configurator application.
This commit is contained in:
parent
1b7e538923
commit
fa07a66916
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
package me.proton.core.configuration.configurator
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class App : Application()
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
@ -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 })
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -30,8 +30,8 @@ android {
|
|||
}
|
||||
|
||||
protonCoverage {
|
||||
branchCoveragePercentage.set(56)
|
||||
lineCoveragePercentage.set(74)
|
||||
branchCoveragePercentage.set(79)
|
||||
lineCoveragePercentage.set(85)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -26,4 +26,5 @@ public interface ConfigContract {
|
|||
public val baseUrl: String
|
||||
public val hv3Host: String
|
||||
public val hv3Url: String
|
||||
public val useDefaultPins: Boolean
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -24,6 +24,6 @@ publishOption.shouldBePublishedAsLib = false
|
|||
|
||||
// Global minimum coverage percentage.
|
||||
protonCoverage {
|
||||
branchCoveragePercentage.set(37)
|
||||
branchCoveragePercentage.set(36)
|
||||
lineCoveragePercentage.set(62)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue