From be071cfa3a8c8706ae5153e2be41664d2bce1ee5 Mon Sep 17 00:00:00 2001 From: johan12345 Date: Sat, 3 Jul 2021 20:26:05 +0200 Subject: [PATCH] OpenChargeMap: implement filters --- .../main/java/net/vonforst/evmap/api/Utils.kt | 9 +- .../api/goingelectric/GoingElectricApi.kt | 15 +--- .../api/goingelectric/GoingElectricModel.kt | 36 ++++---- .../api/openchargemap/OpenChargeMapApi.kt | 89 +++++++++++++++++-- .../api/openchargemap/OpenChargeMapModel.kt | 43 +++++---- .../net/vonforst/evmap/storage/Database.kt | 14 ++- .../evmap/storage/OCMReferenceDataDao.kt | 32 +++++-- .../evmap/viewmodel/FilterViewModel.kt | 3 +- .../vonforst/evmap/viewmodel/MapViewModel.kt | 17 +++- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 11 files changed, 190 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/net/vonforst/evmap/api/Utils.kt b/app/src/main/java/net/vonforst/evmap/api/Utils.kt index c280cd3d..ac3dc0ec 100644 --- a/app/src/main/java/net/vonforst/evmap/api/Utils.kt +++ b/app/src/main/java/net/vonforst/evmap/api/Utils.kt @@ -11,6 +11,7 @@ import okhttp3.Response import org.json.JSONArray import java.io.IOException import kotlin.coroutines.resumeWithException +import kotlin.math.abs operator fun JSONArray.iterator(): Iterator = (0 until length()).asSequence().map { @@ -101,4 +102,10 @@ fun iconForPlugType(type: String): Int = Chargepoint.TYPE_1 -> R.drawable.ic_connector_typ1 // TODO: add other connectors else -> 0 - } \ No newline at end of file + } + +val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350) +fun mapPower(i: Int) = powerSteps[i] +fun mapPowerInverse(power: Int) = powerSteps + .mapIndexed { index, v -> abs(v - power) to index } + .minByOrNull { it.first }?.second ?: 0 \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt index 5eefb031..bc4da685 100644 --- a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricApi.kt @@ -10,9 +10,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.withContext import net.vonforst.evmap.BuildConfig import net.vonforst.evmap.R -import net.vonforst.evmap.api.ChargepointApi -import net.vonforst.evmap.api.StringProvider -import net.vonforst.evmap.api.nameForPlugType +import net.vonforst.evmap.api.* import net.vonforst.evmap.model.* import net.vonforst.evmap.ui.cluster import net.vonforst.evmap.viewmodel.Resource @@ -25,7 +23,6 @@ import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.http.GET import retrofit2.http.Query import java.io.IOException -import kotlin.math.abs interface GoingElectricApi { @GET("chargepoints/") @@ -151,7 +148,7 @@ class GoingElectricApiWrapper( return Resource.success(emptyList()) } connectorsVal.values = connectorsVal.values.mapNotNull { - GEChargepoint.convertType(it) + GEChargepoint.convertTypeToGE(it) }.toMutableSet() val connectors = formatMultipleChoice(connectorsVal) @@ -225,7 +222,7 @@ class GoingElectricApiWrapper( it.chargepoints .filter { it.power >= minPower } .filter { if (!connectorsVal.all) it.type in connectorsVal.values else true } - .sumBy { it.count } >= minConnectors + .sumOf { it.count } >= minConnectors } else { true } @@ -380,11 +377,5 @@ class GoingElectricApiWrapper( BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults") ) } - - private val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350) - private fun mapPower(i: Int) = powerSteps[i] - private fun mapPowerInverse(power: Int) = powerSteps - .mapIndexed { index, v -> abs(v - power) to index } - .minByOrNull { it.first }?.second ?: 0 } diff --git a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt index 2094ea64..94566aab 100644 --- a/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt +++ b/app/src/main/java/net/vonforst/evmap/api/goingelectric/GoingElectricModel.kt @@ -173,26 +173,10 @@ data class GEAddress( @JsonClass(generateAdapter = true) data class GEChargepoint(val type: String, val power: Double, val count: Int) { - fun convert() = Chargepoint(convertType(type), power, count) - - private fun convertType(type: String): String { - return when (type) { - "Typ1" -> Chargepoint.TYPE_1 - "Typ2" -> Chargepoint.TYPE_2_UNKNOWN - "Typ3" -> Chargepoint.TYPE_3 - "CCS" -> Chargepoint.CCS_UNKNOWN - "Schuko" -> Chargepoint.SCHUKO - "CHAdeMO" -> Chargepoint.CHADEMO - "Tesla Supercharger" -> Chargepoint.SUPERCHARGER - "CEE Blau" -> Chargepoint.CEE_BLAU - "CEE Rot" -> Chargepoint.CEE_ROT - "Tesla HPC" -> Chargepoint.TESLA_ROADSTER_HPC - else -> type - } - } + fun convert() = Chargepoint(convertTypeFromGE(type), power, count) companion object { - fun convertType(type: String): String? { + fun convertTypeToGE(type: String): String? { return when (type) { Chargepoint.TYPE_1 -> "Typ1" Chargepoint.TYPE_2_UNKNOWN -> "Typ2" @@ -208,6 +192,22 @@ data class GEChargepoint(val type: String, val power: Double, val count: Int) { else -> null } } + + fun convertTypeFromGE(type: String): String { + return when (type) { + "Typ1" -> Chargepoint.TYPE_1 + "Typ2" -> Chargepoint.TYPE_2_UNKNOWN + "Typ3" -> Chargepoint.TYPE_3 + "CCS" -> Chargepoint.CCS_UNKNOWN + "Schuko" -> Chargepoint.SCHUKO + "CHAdeMO" -> Chargepoint.CHADEMO + "Tesla Supercharger" -> Chargepoint.SUPERCHARGER + "CEE Blau" -> Chargepoint.CEE_BLAU + "CEE Rot" -> Chargepoint.CEE_ROT + "Tesla HPC" -> Chargepoint.TESLA_ROADSTER_HPC + else -> type + } + } } } diff --git a/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt b/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt index f54ec1ea..338bfe82 100644 --- a/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapApi.kt @@ -7,8 +7,8 @@ import com.facebook.stetho.okhttp3.StethoInterceptor import com.squareup.moshi.Moshi import kotlinx.coroutines.Dispatchers import net.vonforst.evmap.BuildConfig -import net.vonforst.evmap.api.ChargepointApi -import net.vonforst.evmap.api.StringProvider +import net.vonforst.evmap.R +import net.vonforst.evmap.api.* import net.vonforst.evmap.model.* import net.vonforst.evmap.ui.cluster import net.vonforst.evmap.viewmodel.Resource @@ -27,8 +27,10 @@ interface OpenChargeMapApi { @Query("boundingbox") boundingbox: OCMBoundingBox, @Query("connectiontypeid") plugs: String? = null, @Query("minpowerkw") minPower: Double? = null, + @Query("operatorid") operators: String? = null, @Query("compact") compact: Boolean = true, - @Query("maxresults") maxresults: Int = 100 + @Query("statustypeid") statusType: String? = null, + @Query("maxresults") maxresults: Int = 100, ): Response> @GET("poi/") @@ -89,6 +91,12 @@ class OpenChargeMapApiWrapper( override fun getName() = "OpenChargeMap.org" + private fun formatMultipleChoice(value: MultipleChoiceFilterValue) = + if (value.all) null else value.values.joinToString(",") + + // Unknown, Currently Available, Currently In Use, Operational, Partly Operational + private val noFaultStatuses = "0,10,20,50,75" + override suspend fun getChargepoints( referenceData: ReferenceData, bounds: LatLngBounds, @@ -96,18 +104,46 @@ class OpenChargeMapApiWrapper( filters: FilterValues, ): Resource> { val referenceData = referenceData as OCMReferenceData + + val minPower = filters.getSliderValue("min_power")!!.toDouble() + val minConnectors = filters.getSliderValue("min_connectors")!! + val excludeFaults = filters.getBooleanValue("exclude_faults")!! + + val connectorsVal = filters.getMultipleChoiceValue("connectors")!! + if (connectorsVal.values.isEmpty() && !connectorsVal.all) { + // no connectors chosen + return Resource.success(emptyList()) + } + val connectors = formatMultipleChoice(connectorsVal) + + val operatorsVal = filters.getMultipleChoiceValue("operators")!! + if (operatorsVal.values.isEmpty() && !operatorsVal.all) { + // no operators chosen + return Resource.success(emptyList()) + } + val operators = formatMultipleChoice(operatorsVal) + val response = api.getChargepoints( OCMBoundingBox( bounds.southwest.latitude, bounds.southwest.longitude, bounds.northeast.latitude, bounds.northeast.longitude - ) + ), + minPower = minPower, + plugs = connectors, + operators = operators, + statusType = if (excludeFaults) noFaultStatuses else null ) if (!response.isSuccessful) { return Resource.error(response.message(), null) } - var result = response.body()!!.map { it.convert(referenceData) } - .distinct() as List + var result = response.body()!!.filter { it -> + // apply filters which OCM does not support natively + it.connections + .filter { it.power == null || it.power >= minPower } + .filter { if (!connectorsVal.all) it.connectionTypeId in connectorsVal.values.map { it.toLong() } else true } + .sumOf { it.quantity ?: 0 } >= minConnectors + }.map { it.convert(referenceData) }.distinct() as List val useClustering = zoom < 13 val clusterDistance = if (useClustering) getClusterDistance(zoom) else null @@ -157,7 +193,46 @@ class OpenChargeMapApiWrapper( sp: StringProvider ): List> { val referenceData = referenceData as OCMReferenceData - return emptyList() + + val operatorsMap = referenceData.operators.map { it.id.toString() to it.title }.toMap() + val plugMap = referenceData.connectionTypes.map { it.id.toString() to it.title }.toMap() + + return listOf( + // supported by OCM API + SliderFilter( + sp.getString(R.string.filter_min_power), "min_power", + powerSteps.size - 1, + mapping = ::mapPower, + inverseMapping = ::mapPowerInverse, + unit = "kW" + ), + MultipleChoiceFilter( + sp.getString(R.string.filter_connectors), "connectors", + plugMap, + commonChoices = setOf( + "1", // Type 1 (J1772) + "25", // Type 2 (Socket only) + "1036", // Type 2 (Tethered connector) + "32", // CCS (Type 1) + "33", // CCS (Type 2) + "2" // CHAdeMO + ), + manyChoices = true + ), + MultipleChoiceFilter( + sp.getString(R.string.filter_operators), "operators", + operatorsMap, manyChoices = true + ), + BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults"), + + // local filters + SliderFilter( + sp.getString(R.string.filter_min_connectors), + "min_connectors", + 10, + min = 1 + ), + ) } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapModel.kt b/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapModel.kt index 423d2004..f2019967 100644 --- a/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapModel.kt +++ b/app/src/main/java/net/vonforst/evmap/api/openchargemap/OpenChargeMapModel.kt @@ -100,27 +100,30 @@ data class OCMConnection( @Json(name = "Comments") val comments: String? ) { fun convert(refData: OCMReferenceData) = Chargepoint( - convertConnectionType(connectionTypeId, refData), + convertConnectionTypeFromOCM(connectionTypeId, refData), power ?: 0.0, quantity ?: 0 ) - private fun convertConnectionType(id: Long, refData: OCMReferenceData): String { - val title = refData.connectionTypes.find { it.id == id }?.title - return when (title) { - "CCS (Type 2)" -> Chargepoint.CCS_TYPE_2 - "CHAdeMO" -> Chargepoint.CHADEMO - "CEE 3 Pin" -> Chargepoint.CEE_BLAU - "CEE 5 Pin" -> Chargepoint.CEE_ROT - "CEE 7/4 - Schuko - Type F" -> Chargepoint.SCHUKO - "Tesla (Roadster)" -> Chargepoint.TESLA_ROADSTER_HPC - "Tesla Supercharger" -> Chargepoint.SUPERCHARGER - "Type 2 (Socket Only)" -> Chargepoint.TYPE_2_SOCKET - "Type 2 (Tethered Connector) " -> Chargepoint.TYPE_2_PLUG - "Type 1 (J1772)" -> Chargepoint.TYPE_1 - "SCAME Type 3A (Low Power)" -> Chargepoint.TYPE_3 - "SCAME Type 3C (Schneider-Legrand)" -> Chargepoint.TYPE_3 - else -> title ?: "" + companion object { + fun convertConnectionTypeFromOCM(id: Long, refData: OCMReferenceData): String { + val title = refData.connectionTypes.find { it.id == id }?.title + return when (id) { + 32L -> Chargepoint.CCS_TYPE_1 + 33L -> Chargepoint.CCS_TYPE_2 + 2L -> Chargepoint.CHADEMO + 16L -> Chargepoint.CEE_BLAU + 17L -> Chargepoint.CEE_ROT + 28L -> Chargepoint.SCHUKO + 8L -> Chargepoint.TESLA_ROADSTER_HPC + 27L -> Chargepoint.SUPERCHARGER + 25L -> Chargepoint.TYPE_2_SOCKET + 1036L -> Chargepoint.TYPE_2_PLUG + 1L -> Chargepoint.TYPE_1 + 36L -> Chargepoint.TYPE_3 + 26L -> Chargepoint.TYPE_3 + else -> title ?: "" + } } } } @@ -128,7 +131,8 @@ data class OCMConnection( @JsonClass(generateAdapter = true) data class OCMReferenceData( @Json(name = "ConnectionTypes") val connectionTypes: List, - @Json(name = "Countries") val countries: List + @Json(name = "Countries") val countries: List, + @Json(name = "Operators") val operators: List ) : ReferenceData() @JsonClass(generateAdapter = true) @@ -159,8 +163,9 @@ data class OCMDataProvider( ) @JsonClass(generateAdapter = true) +@Entity data class OCMOperator( - @Json(name = "ID") val id: Long, + @Json(name = "ID") @PrimaryKey val id: Long, @Json(name = "WebsiteURL") val websiteUrl: String?, @Json(name = "Title") val title: String, @Json(name = "ContactEmail") val contactEmail: String?, diff --git a/app/src/main/java/net/vonforst/evmap/storage/Database.kt b/app/src/main/java/net/vonforst/evmap/storage/Database.kt index ce1076e9..a003bbe0 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/Database.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/Database.kt @@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase import net.vonforst.evmap.api.goingelectric.GEChargeCard import net.vonforst.evmap.api.openchargemap.OCMConnectionType import net.vonforst.evmap.api.openchargemap.OCMCountry +import net.vonforst.evmap.api.openchargemap.OCMOperator import net.vonforst.evmap.model.* @Database( @@ -23,8 +24,9 @@ import net.vonforst.evmap.model.* GENetwork::class, GEChargeCard::class, OCMConnectionType::class, - OCMCountry::class - ], version = 14 + OCMCountry::class, + OCMOperator::class + ], version = 15 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -45,7 +47,7 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations( MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6, MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11, - MIGRATION_12, MIGRATION_13, MIGRATION_14 + MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15 ) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { @@ -221,5 +223,11 @@ abstract class AppDatabase : RoomDatabase() { } } + + private val MIGRATION_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `OCMOperator` (`id` INTEGER NOT NULL, `websiteUrl` TEXT, `title` TEXT NOT NULL, `contactEmail` TEXT, `contactTelephone1` TEXT, `contactTelephone2` TEXT, PRIMARY KEY(`id`))"); + } + } } } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/OCMReferenceDataDao.kt b/app/src/main/java/net/vonforst/evmap/storage/OCMReferenceDataDao.kt index 464eb3d6..419a0d3c 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/OCMReferenceDataDao.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/OCMReferenceDataDao.kt @@ -5,10 +5,7 @@ import androidx.lifecycle.MediatorLiveData import androidx.room.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import net.vonforst.evmap.api.openchargemap.OCMConnectionType -import net.vonforst.evmap.api.openchargemap.OCMCountry -import net.vonforst.evmap.api.openchargemap.OCMReferenceData -import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper +import net.vonforst.evmap.api.openchargemap.* import net.vonforst.evmap.viewmodel.Status import java.time.Duration import java.time.Instant @@ -50,6 +47,24 @@ abstract class OCMReferenceDataDao { @Query("SELECT * FROM ocmcountry") abstract fun getAllCountries(): LiveData> + + // OPERATORS + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(vararg operators: OCMOperator) + + @Query("DELETE FROM ocmoperator") + abstract fun deleteAllOperators() + + @Transaction + open suspend fun updateOperators(operators: List) { + deleteAllOperators() + for (operator in operators) { + insert(operator) + } + } + + @Query("SELECT * FROM ocmoperator") + abstract fun getAllOperators(): LiveData> } class OCMReferenceDataRepository( @@ -62,13 +77,15 @@ class OCMReferenceDataRepository( } val connectionTypes = dao.getAllConnectionTypes() val countries = dao.getAllCountries() + val operators = dao.getAllOperators() return MediatorLiveData().apply { - listOf(countries, connectionTypes).map { source -> + listOf(countries, connectionTypes, operators).map { source -> addSource(source) { _ -> val ct = connectionTypes.value val c = countries.value - if (ct.isNullOrEmpty() || c.isNullOrEmpty()) return@addSource - value = OCMReferenceData(ct, c) + val o = operators.value + if (ct.isNullOrEmpty() || c.isNullOrEmpty() || o.isNullOrEmpty()) return@addSource + value = OCMReferenceData(ct, c, o) } } } @@ -88,6 +105,7 @@ class OCMReferenceDataRepository( val data = response.data!! dao.updateConnectionTypes(data.connectionTypes) dao.updateCountries(data.countries) + dao.updateOperators(data.operators) prefs.lastOcmReferenceDataUpdate = Instant.now() } diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/FilterViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/FilterViewModel.kt index feb470a3..af4db511 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/FilterViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/FilterViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.* import kotlinx.coroutines.launch import net.vonforst.evmap.api.ChargepointApi import net.vonforst.evmap.api.createApi -import net.vonforst.evmap.api.goingelectric.GEReferenceData import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.api.stringProvider @@ -63,7 +62,7 @@ class FilterViewModel(application: Application, geApiKey: String) : } private val filters = MediatorLiveData>>().apply { addSource(referenceData) { data -> - value = api.getFilters(data as GEReferenceData, application.stringProvider()) + value = api.getFilters(data, application.stringProvider()) } } diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt index ebeb7a38..8d46ac9a 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt @@ -10,8 +10,11 @@ import net.vonforst.evmap.api.ChargepointApi import net.vonforst.evmap.api.availability.ChargeLocationStatus import net.vonforst.evmap.api.availability.getAvailability import net.vonforst.evmap.api.createApi +import net.vonforst.evmap.api.goingelectric.GEChargepoint import net.vonforst.evmap.api.goingelectric.GEReferenceData import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper +import net.vonforst.evmap.api.openchargemap.OCMConnection +import net.vonforst.evmap.api.openchargemap.OCMReferenceData import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.api.stringProvider import net.vonforst.evmap.model.* @@ -341,7 +344,19 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode .toSet() val connectorsVal = filters.getMultipleChoiceValue("connectors")!! - filteredConnectors.value = if (connectorsVal.all) null else connectorsVal.values + filteredConnectors.value = + if (connectorsVal.all) null else connectorsVal.values.map { + GEChargepoint.convertTypeFromGE(it) + }.toSet() + } else if (api is OpenChargeMapApiWrapper) { + val connectorsVal = filters.getMultipleChoiceValue("connectors")!! + filteredConnectors.value = + if (connectorsVal.all) null else connectorsVal.values.map { + OCMConnection.convertConnectionTypeFromOCM( + it.toLong(), + refData as OCMReferenceData + ) + }.toSet() } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index db364701..f272b301 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -88,6 +88,7 @@ Störungsmeldung Störungsmeldung (Letztes Update: %s) Verbünde + Betreiber Ladetarife Alle ausgewählt %d ausgewählt diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 83f6d90d..ba81e3b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ Fault report Fault report (last update: %s) Networks + Operators Payment methods All selected %d selected