diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22bd3d3f6..0f89b875c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,17 @@ android { if (goingelectricKey != null) { resValue("string", "goingelectric_key", goingelectricKey) } + var nobilKey = + System.getenv("NOBIL_API_KEY") ?: project.findProperty("NOBIL_API_KEY")?.toString() + if (nobilKey == null && project.hasProperty("NOBIL_API_KEY_ENCRYPTED")) { + nobilKey = decode( + project.findProperty("NOBIL_API_KEY_ENCRYPTED").toString(), + "FmK.d,-f*p+rD+WK!eds" + ) + } + if (nobilKey != null) { + resValue("string", "nobil_key", nobilKey) + } var openchargemapKey = System.getenv("OPENCHARGEMAP_API_KEY") ?: project.findProperty("OPENCHARGEMAP_API_KEY") ?.toString() diff --git a/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt b/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt index 37c252aa0..873264340 100644 --- a/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt +++ b/app/src/main/java/net/vonforst/evmap/api/ChargepointApi.kt @@ -5,6 +5,7 @@ import com.car2go.maps.model.LatLng import com.car2go.maps.model.LatLngBounds import net.vonforst.evmap.R import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper +import net.vonforst.evmap.api.nobil.NobilApiWrapper import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.model.* import net.vonforst.evmap.viewmodel.Resource @@ -72,6 +73,13 @@ fun Context.stringProvider() = object : StringProvider { fun createApi(type: String, ctx: Context): ChargepointApi { return when (type) { + "nobil" -> { + NobilApiWrapper( + ctx.getString( + R.string.nobil_key + ) + ) + } "openchargemap" -> { OpenChargeMapApiWrapper( ctx.getString( diff --git a/app/src/main/java/net/vonforst/evmap/api/availability/EnBwAvailabilityDetector.kt b/app/src/main/java/net/vonforst/evmap/api/availability/EnBwAvailabilityDetector.kt index 58c3bf9b1..09c15f2b6 100644 --- a/app/src/main/java/net/vonforst/evmap/api/availability/EnBwAvailabilityDetector.kt +++ b/app/src/main/java/net/vonforst/evmap/api/availability/EnBwAvailabilityDetector.kt @@ -266,6 +266,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) : "Spanien", "Tschechien" ) && charger.network != "Tesla Supercharger" + "nobil" -> charger.network != "Tesla" "openchargemap" -> country in listOf( "DE", "AT", 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 adc2c8539..09f521093 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 @@ -77,6 +77,7 @@ data class GEChargeLocation( address.convert(), chargepoints.map { it.convert() }, network, + "https://www.goingelectric.de/", "https:${url}", "https:${url}edit/", faultReport?.convert(), @@ -88,6 +89,7 @@ data class GEChargeLocation( locationDescription, photos?.map { it.convert(apikey) }, chargecards?.map { it.convert() }, + null, openinghours?.convert(), cost?.convert(), null, diff --git a/app/src/main/java/net/vonforst/evmap/api/nobil/NobilAdapters.kt b/app/src/main/java/net/vonforst/evmap/api/nobil/NobilAdapters.kt new file mode 100644 index 000000000..adf12aad1 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/api/nobil/NobilAdapters.kt @@ -0,0 +1,34 @@ +package net.vonforst.evmap.api.nobil + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.ToJson +import net.vonforst.evmap.model.Coordinate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +internal class CoordinateAdapter { + @FromJson + fun fromJson(position: String): Coordinate { + val pattern = """\((?\d+(\.\d+)?), *(?-?\d+(\.\d+)?)\)""" + val match = Regex(pattern).matchEntire(position) + if (match == null) throw JsonDataException("Unexpected coordinate format: '$position'") + + val groups = match.groups + val latitude : String = groups["lat"]?.value?: "0.0" + val longitude : String = groups["long"]?.value?: "0.0" + return Coordinate(latitude.toDouble(), longitude.toDouble()) + } + @ToJson + fun toJson(value: Coordinate): String = "(" + value.lat + ", " + value.lng + ")" +} + +internal class LocalDateTimeAdapter { + @FromJson + fun fromJson(value: String?): LocalDateTime? = value?.let { + LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + + @ToJson + fun toJson(value: LocalDateTime?): String? = value?.toString() +} diff --git a/app/src/main/java/net/vonforst/evmap/api/nobil/NobilApi.kt b/app/src/main/java/net/vonforst/evmap/api/nobil/NobilApi.kt new file mode 100644 index 000000000..0656fba51 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/api/nobil/NobilApi.kt @@ -0,0 +1,287 @@ +package net.vonforst.evmap.api.nobil + +import android.content.Context +import android.database.DatabaseUtils +import com.car2go.maps.model.LatLng +import com.car2go.maps.model.LatLngBounds +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.Moshi +import net.vonforst.evmap.BuildConfig +import net.vonforst.evmap.R +import net.vonforst.evmap.addDebugInterceptors +import net.vonforst.evmap.api.ChargepointApi +import net.vonforst.evmap.api.ChargepointList +import net.vonforst.evmap.api.FiltersSQLQuery +import net.vonforst.evmap.api.StringProvider +import net.vonforst.evmap.api.mapPower +import net.vonforst.evmap.api.mapPowerInverse +import net.vonforst.evmap.api.powerSteps +import net.vonforst.evmap.model.BooleanFilter +import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.model.Chargepoint +import net.vonforst.evmap.model.ChargepointListItem +import net.vonforst.evmap.model.Filter +import net.vonforst.evmap.model.FilterValue +import net.vonforst.evmap.model.FilterValues +import net.vonforst.evmap.model.MultipleChoiceFilter +import net.vonforst.evmap.model.ReferenceData +import net.vonforst.evmap.model.SliderFilter +import net.vonforst.evmap.model.getBooleanValue +import net.vonforst.evmap.model.getMultipleChoiceValue +import net.vonforst.evmap.model.getSliderValue +import net.vonforst.evmap.viewmodel.Resource +import okhttp3.Cache +import okhttp3.OkHttpClient +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.Body +import retrofit2.http.POST +import java.io.IOException +import java.time.Duration + +private const val maxResults = 2000 + +interface NobilApi { + @POST("search.php") + suspend fun getChargepoints( + @Body request: NobilRectangleSearchRequest + ): Response + + @POST("search.php") + suspend fun getChargepointsRadius( + @Body request: NobilRadiusSearchRequest + ): Response + + @POST("search.php") + suspend fun getChargepointDetail( + @Body request: NobilDetailSearchRequest + ): Response + + companion object { + private val cacheSize = 10L * 1024 * 1024 // 10MB + + private val moshi = Moshi.Builder() + .add(LocalDateTimeAdapter()) + .add(CoordinateAdapter()) + .build() + + fun create( + baseurl: String, + context: Context? + ): NobilApi { + val client = OkHttpClient.Builder().apply { + if (BuildConfig.DEBUG) { + addDebugInterceptors() + } + if (context != null) { + cache(Cache(context.cacheDir, cacheSize)) + } + }.build() + + val retrofit = Retrofit.Builder() + .baseUrl(baseurl) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .client(client) + .build() + return retrofit.create(NobilApi::class.java) + } + } +} + +class NobilApiWrapper( + val apikey: String, + baseurl: String = "https://nobil.no/api/server/", + context: Context? = null +) : ChargepointApi { + override val cacheLimit = Duration.ofDays(300L) + val api = NobilApi.create(baseurl, context) + + override val name = "Nobil" + override val id = "nobil" + + override suspend fun getChargepoints( + referenceData: ReferenceData, + bounds: LatLngBounds, + zoom: Float, + useClustering: Boolean, + filters: FilterValues?, + ): Resource { + try { + val northeast = "(" + bounds.northeast.latitude + ", " + bounds.northeast.longitude + ")" + val southwest = "(" + bounds.southwest.latitude + ", " + bounds.southwest.longitude + ")" + val request = NobilRectangleSearchRequest(apikey, northeast, southwest, maxResults) + val response = api.getChargepoints(request) + if (!response.isSuccessful) { + return Resource.error(response.message(), null) + } + + val data = response.body()!! + if (data.chargerStations == null) { + return Resource.success(ChargepointList.empty()) + } + val result = postprocessResult( + data, + filters + ) + return Resource.success(ChargepointList(result, data.chargerStations.size < maxResults)) + } catch (e: IOException) { + return Resource.error(e.message, null) + } catch (e: HttpException) { + return Resource.error(e.message, null) + } + } + + override suspend fun getChargepointsRadius( + referenceData: ReferenceData, + location: LatLng, + radius: Int, + zoom: Float, + useClustering: Boolean, + filters: FilterValues? + ): Resource { + try { + val request = NobilRadiusSearchRequest(apikey, location.latitude, location.longitude, radius * 1000.0, maxResults) + val response = api.getChargepointsRadius(request) + if (!response.isSuccessful) { + return Resource.error(response.message(), null) + } + + val data = response.body()!! + if (data.chargerStations == null) { + return Resource.error(response.message(), null) + } + val result = postprocessResult( + data, + filters + ) + return Resource.success(ChargepointList(result, data.chargerStations.size < maxResults)) + } catch (e: IOException) { + return Resource.error(e.message, null) + } catch (e: HttpException) { + return Resource.error(e.message, null) + } + } + + private fun postprocessResult( + data: NobilResponseData, + filters: FilterValues? + ): List { + if (data.rights == null ) throw JsonDataException("Rights field is missing in received data") + + return data.chargerStations!!.mapNotNull { it.convert(data.rights, filters) }.distinct() + } + + override suspend fun getChargepointDetail( + referenceData: ReferenceData, + id: Long + ): Resource { + // TODO: Nobil ids are "SWE_1234", not Long + return Resource.error("getChargepointDetail is not implemented", null) + } + + override suspend fun getReferenceData(): Resource { + return Resource.success(NobilReferenceData(0)) + } + + override fun getFilters( + referenceData: ReferenceData, + sp: StringProvider + ): List> { + val connectorMap = mapOf( + Chargepoint.TYPE_1 to "Type 1", + Chargepoint.TYPE_2_SOCKET to "Type 2", + Chargepoint.TYPE_2_PLUG to "Type 2 Tethered", + Chargepoint.CCS_UNKNOWN to "CCS", + Chargepoint.CHADEMO to "CHAdeMO", + Chargepoint.SUPERCHARGER to "Tesla" + ) + val availabilityMap = mapOf( + "Public" to sp.getString(R.string.availability_public), + "Visitors" to sp.getString(R.string.availability_visitors), + "Employees" to sp.getString(R.string.availability_employees), + "By appointment" to sp.getString(R.string.availability_by_appointment), + "Residents" to sp.getString(R.string.availability_residents) + ) + return listOf( + BooleanFilter(sp.getString(R.string.filter_free_parking), "freeparking"), + BooleanFilter(sp.getString(R.string.filter_open_247), "open_247"), + 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", + connectorMap, manyChoices = true + ), + SliderFilter( + sp.getString(R.string.filter_min_connectors), + "min_connectors", + 10, + min = 1 + ), + MultipleChoiceFilter( + sp.getString(R.string.filter_availability), "availabilities", + availabilityMap, manyChoices = true + ) + ) + } + + override fun convertFiltersToSQL( + filters: FilterValues, + referenceData: ReferenceData + ): FiltersSQLQuery { + if (filters.isEmpty()) return FiltersSQLQuery("", false, false) + + var requiresChargepointQuery = false + val result = StringBuilder() + + if (filters.getBooleanValue("freeparking") == true) { + result.append(" AND freeparking IS 1") + } + + if (filters.getBooleanValue("open_247") == true) { + result.append(" AND twentyfourSeven IS 1") + } + + val minPower = filters.getSliderValue("min_power") + if (minPower != null && minPower > 0) { + result.append(" AND json_extract(cp.value, '$.power') >= $minPower") + requiresChargepointQuery = true + } + + val connectors = filters.getMultipleChoiceValue("connectors") + if (connectors != null && !connectors.all) { + val connectorsList = connectors.values.joinToString(",") { + DatabaseUtils.sqlEscapeString(it) + } + result.append(" AND json_extract(cp.value, '$.type') IN (${connectorsList})") + requiresChargepointQuery = true + } + + val minConnectors = filters.getSliderValue("min_connectors") + if (minConnectors != null && minConnectors > 1) { + result.append(" GROUP BY ChargeLocation.id HAVING SUM(json_extract(cp.value, '$.count')) >= $minConnectors") + requiresChargepointQuery = true + } + + val availabilities = filters.getMultipleChoiceValue("availabilities") + if (availabilities != null && !availabilities.all) { + val availabilitiesList = availabilities.values.joinToString(",") { + DatabaseUtils.sqlEscapeString(it) + } + result.append(" AND availability IN (${availabilitiesList})") + } + + return FiltersSQLQuery(result.toString(), requiresChargepointQuery, false) + } + + override fun filteringInSQLRequiresDetails(filters: FilterValues): Boolean { + return false + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/api/nobil/NobilModel.kt b/app/src/main/java/net/vonforst/evmap/api/nobil/NobilModel.kt new file mode 100644 index 000000000..df58bfcc3 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/api/nobil/NobilModel.kt @@ -0,0 +1,314 @@ +package net.vonforst.evmap.api.nobil + +import android.net.Uri +import androidx.core.text.HtmlCompat +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import net.vonforst.evmap.max +import net.vonforst.evmap.model.Address +import net.vonforst.evmap.model.ChargeLocation +import net.vonforst.evmap.model.Chargepoint +import net.vonforst.evmap.model.ChargerPhoto +import net.vonforst.evmap.model.Coordinate +import net.vonforst.evmap.model.Cost +import net.vonforst.evmap.model.FilterValues +import net.vonforst.evmap.model.OpeningHours +import net.vonforst.evmap.model.ReferenceData +import net.vonforst.evmap.model.getBooleanValue +import net.vonforst.evmap.model.getMultipleChoiceValue +import net.vonforst.evmap.model.getSliderValue +import java.time.Instant +import java.time.LocalDateTime + +data class NobilReferenceData( + val dummy: Int +) : ReferenceData() + +@JsonClass(generateAdapter = true) +data class NobilRectangleSearchRequest( + val apikey: String, + val northeast: String, + val southwest: String, + val limit: Int, + val action: String = "search", + val type: String = "rectangle", + val format: String = "json", + val apiversion: String = "3", + // val existingids: String +) + +@JsonClass(generateAdapter = true) +data class NobilRadiusSearchRequest( + val apikey: String, + val lat: Double, + val long: Double, + val distance: Double, // meters + val limit: Int, + val action: String = "search", + val type: String = "near", + val format: String = "json", + val apiversion: String = "3", + // val existingids: String, +) + +@JsonClass(generateAdapter = true) +data class NobilDetailSearchRequest( + val apikey: String, + val id: String, + val action: String = "search", + val type: String = "id", + val format: String = "json", + val apiversion: String = "3", +) + +@JsonClass(generateAdapter = true) +data class NobilResponseData( + @Json(name = "error") val error: String?, + @Json(name = "Provider") val provider: String?, + @Json(name = "Rights") val rights: String?, + @Json(name = "apiver") val apiver: String?, + @Json(name = "chargerstations") val chargerStations: List? +) + +@JsonClass(generateAdapter = true) +data class NobilChargerStation( + @Json(name = "csmd") val chargerStationData: NobilChargerStationData, + @Json(name = "attr") val chargerStationAttributes: NobilChargerStationAttributes +) { + fun convert(dataLicense: String, + filters: FilterValues?) : ChargeLocation? { + val chargepoints = chargerStationAttributes.conn + .mapNotNull { createChargepointFromNobilConnection(it.value) } + if (chargepoints.isEmpty()) return null + + val minPower = filters?.getSliderValue("min_power") + val connectors = filters?.getMultipleChoiceValue("connectors") + val minConnectors = filters?.getSliderValue("min_connectors") + if (chargepoints + .filter { it.power != null && it.power >= (minPower ?: 0) } + .filter { if (connectors != null && !connectors.all) it.type in connectors.values else true } + .size < (minConnectors ?: 0)) return null + + val chargeLocation = ChargeLocation( + chargerStationData.id, + "nobil", + HtmlCompat.fromHtml(chargerStationData.name, HtmlCompat.FROM_HTML_MODE_COMPACT) + .toString(), + chargerStationData.position, + Address( + chargerStationData.city, + when (chargerStationData.landCode) { + "DAN" -> "Denmark" + "FIN" -> "Finland" + "ISL" -> "Iceland" + "NOR" -> "Norway" + "SWE" -> "Sweden" + else -> "" + }, + chargerStationData.zipCode, + listOfNotNull( + chargerStationData.street, + chargerStationData.houseNumber + ).joinToString(" ") + ), + chargepoints, + if (chargerStationData.operator != null) HtmlCompat.fromHtml( + chargerStationData.operator, + HtmlCompat.FROM_HTML_MODE_COMPACT + ).toString() else null, + "https://nobil.no/", + null, + when (chargerStationData.landCode) { + "SWE" -> "https://www.energimyndigheten.se/klimat/transporter/laddinfrastruktur/registrera-din-laddstation/elbilsagare/" + else -> "mailto:post@nobil.no?subject=" + Uri.encode("Regarding charging station " + chargerStationData.internationalId) + }, + null, + chargerStationData.ocpiId != null || + chargerStationData.updated.isAfter(LocalDateTime.now().minusMonths(6)), + null, + if (chargerStationData.ownedBy != null) HtmlCompat.fromHtml( + chargerStationData.ownedBy, + HtmlCompat.FROM_HTML_MODE_COMPACT + ).toString() else null, + if (chargerStationData.userComment != null) HtmlCompat.fromHtml( + chargerStationData.userComment, + HtmlCompat.FROM_HTML_MODE_COMPACT + ).toString() else null, + null, + if (chargerStationData.description != null) HtmlCompat.fromHtml( + chargerStationData.description, + HtmlCompat.FROM_HTML_MODE_COMPACT + ).toString() else null, + if (Regex("""\d+\.\w+""").matchEntire(chargerStationData.image) != null) listOf( + NobilChargerPhotoAdapter(chargerStationData.image) + ) else null, + null, + // 2: Availability + chargerStationAttributes.st["2"]?.attrTrans, + // 24: Open 24h + if (chargerStationAttributes.st["24"]?.attrTrans == "Yes") OpeningHours( + twentyfourSeven = true, + null, + null + ) else null, + Cost( + // 7: Parking fee + freeparking = when (chargerStationAttributes.st["7"]?.attrTrans) { + "Yes" -> false + "No" -> true + else -> null + }, + descriptionLong = chargerStationAttributes.conn.mapNotNull { + // 19: Payment method + when (it.value["19"]?.attrValId) { + "1" -> listOf("Mobile phone") // TODO: Translate + "2" -> listOf("Bank card") + "10" -> listOf("Other") + "20" -> listOf("Mobile phone", "Charging card") + "21" -> listOf("Bank card", "Charging card") + "25" -> listOf("Bank card", "Charging card", "Mobile phone") + else -> null + } + }.flatten().sorted().toSet().ifEmpty { null } + ?.joinToString(prefix = "Accepted payment methods: ") + ), + dataLicense, + null, + null, + null, + Instant.now(), + true + ) + + val availabilities = filters?.getMultipleChoiceValue("availabilities") + if (availabilities != null && !availabilities.all) { + if (!availabilities.values.contains(chargeLocation.availability)) return null + } + + val freeparking = filters?.getBooleanValue("freeparking") + if (freeparking == true && chargeLocation.cost?.freeparking != true) return null + + val open247 = filters?.getBooleanValue("open_247") + if (open247 == true && chargeLocation.openinghours?.twentyfourSeven != true) return null + + return chargeLocation + } + + companion object { + fun createChargepointFromNobilConnection(attribs: Map): Chargepoint? { + // https://nobil.no/admin/attributes.php + + val isFixedCable = attribs["25"]?.attrTrans == "Yes" + val connectionType = when (attribs["4"]?.attrValId) { + "0" -> "" // Unspecified + "30" -> Chargepoint.CHADEMO // CHAdeMO + "31" -> Chargepoint.TYPE_1 // Type 1 + "32" -> if (isFixedCable) Chargepoint.TYPE_2_PLUG else Chargepoint.TYPE_2_SOCKET // Type 2 + "39" -> Chargepoint.CCS_UNKNOWN // CCS/Combo + "40" -> Chargepoint.SUPERCHARGER // Tesla Connector Model + "50" -> if (isFixedCable) Chargepoint.TYPE_2_PLUG else Chargepoint.TYPE_2_SOCKET // Type 2 + Schuko + "60" -> Chargepoint.CCS_UNKNOWN // Type1/Type2 + "70" -> return null // Hydrogen + "82" -> return null // Biogas + else -> "" + } + + val connectionPower = when (attribs["5"]?.attrValId) { + "7" -> 3.6 // 3,6 kW - 230V 1-phase max 16A + "8" -> 7.4 // 7,4 kW - 230V 1-phase max 32A + "10" -> 11.0 // 11 kW - 400V 3-phase max 16A + "11" -> 22.0 // 22 kW - 400V 3-phase max 32A + "12" -> 43.0 // 43 kW - 400V 3-phase max 63A + "13" -> 50.0 // 50 kW - 500VDC max 100A + "16" -> 11.0 // 230V 3-phase max 16A' + "17" -> 22.0 // 230V 3-phase max 32A + "18" -> 43.0 // 230V 3-phase max 63A + "19" -> 20.0 // 20 kW - 500VDC max 50A + "22" -> 135.0 // 135 kW - 480VDC max 270A + "23" -> 100.0 // 100 kW - 500VDC max 200A + "24" -> 150.0 // 150 kW DC + "25" -> 350.0 // 350 kW DC + "26" -> null // 350 bar + "27" -> null // 700 bar + "29" -> 75.0 // 75 kW DC + "30" -> 225.0 // 225 kW DC + "31" -> 250.0 // 250 kW DC + "32" -> 200.0 // 200 kW DC + "33" -> 300.0 // 300 kW DC + "34" -> null // CBG + "35" -> null // LBG + "36" -> 400.0 // 400 kW DC + "37" -> 30.0 // 30 kW DC + "38" -> 62.5 // 62,5 kW DC + "39" -> 500.0 // 500 kW DC + "41" -> 175.0 // 175 kW DC + else -> null + } + + val connectionVoltage = if (attribs["12"]?.attrVal is String) attribs["12"]?.attrVal.toString().toDoubleOrNull() else null + val connectionCurrent = if (attribs["31"]?.attrVal is String) attribs["31"]?.attrVal.toString().toDoubleOrNull() else null + val evseId = if (attribs["28"]?.attrVal is String) listOf(attribs["28"]?.attrVal.toString()) else null + + return Chargepoint(connectionType, connectionPower, 1, connectionCurrent, connectionVoltage, evseId) + } + } +} + +@JsonClass(generateAdapter = true) +data class NobilChargerStationData( + @Json(name = "id") val id: Long, + @Json(name = "name") val name: String, + @Json(name = "ocpidb_mapping_stasjon_id") val ocpiId: String?, + @Json(name = "Street") val street: String?, + @Json(name = "House_number") val houseNumber: String, + @Json(name = "Zipcode") val zipCode: String?, + @Json(name = "City") val city: String?, + @Json(name = "Municipality_ID") val municipalityId: String, + @Json(name = "Municipality") val municipality: String, + @Json(name = "County_ID") val countyId: String, + @Json(name = "County") val county: String, + @Json(name = "Description_of_location") val description: String?, + @Json(name = "Owned_by") val ownedBy: String?, + @Json(name = "Operator") val operator: String?, + @Json(name = "Number_charging_points") val numChargePoints: Int, + @Json(name = "Position") val position: Coordinate, + @Json(name = "Image") val image: String, + @Json(name = "Available_charging_points") val availableChargePoints: Int, + @Json(name = "User_comment") val userComment: String?, + @Json(name = "Contact_info") val contactInfo: String?, + @Json(name = "Created") val created: LocalDateTime, + @Json(name = "Updated") val updated: LocalDateTime, + @Json(name = "Station_status") val stationStatus: Int, + @Json(name = "Land_code") val landCode: String, + @Json(name = "International_id") val internationalId: String +) + +@JsonClass(generateAdapter = true) +data class NobilChargerStationAttributes( + @Json(name = "st") val st: Map, + @Json(name = "conn") val conn: Map> +) + +@JsonClass(generateAdapter = true) +data class NobilChargerStationGenericAttribute( + @Json(name = "attrtypeid") val attrTypeId: String, + @Json(name = "attrname") val attrName: String, + @Json(name = "attrvalid") val attrValId: String, + @Json(name = "trans") val attrTrans: String, + @Json(name = "attrval") val attrVal: Any +) + +@Parcelize +@JsonClass(generateAdapter = true) +class NobilChargerPhotoAdapter(override val id: String) : + ChargerPhoto(id) { + override fun getUrl(height: Int?, width: Int?, size: Int?, allowOriginal: Boolean): String { + val maxSize = size ?: max(height, width) + return "https://www.nobil.no/img/ladestasjonbilder/" + + when (maxSize) { + in 0..50 -> "tn_" + id + else -> id + } + } +} 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 4657c31b2..487438165 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 @@ -64,6 +64,7 @@ data class OCMChargepoint( addressInfo.toAddress(refData), connections.map { it.convert(refData) }, operatorInfo?.title ?: refData.operators.find { it.id == operatorId }?.title, + "https://openchargemap.org/", "https://map.openchargemap.io/?id=$id", "https://map.openchargemap.io/?id=$id", convertFaultReport(), @@ -76,6 +77,7 @@ data class OCMChargepoint( mediaItems?.mapNotNull { it.convert() }, null, null, + null, cost?.takeIf { it.isNotBlank() }.let { Cost(descriptionShort = it) }, dataProvider?.let { "© ${it.title}" + if (it.license != null) ". ${it.license}" else "" }, ChargepriceData( diff --git a/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt b/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt index 2fc4c5cf8..014fe258b 100644 --- a/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt +++ b/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt @@ -90,6 +90,7 @@ data class OSMChargingStation( null, // TODO: Can we determine this with overpass? getChargepoints(), tags["network"], + "https://www.openstreetmap.org/", "https://www.openstreetmap.org/node/$id", "https://www.openstreetmap.org/edit?node=$id", null, @@ -101,6 +102,7 @@ data class OSMChargingStation( null, null, null, + null, getOpeningHours(), getCost(), "© OpenStreetMap contributors", diff --git a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt index e15074f9a..a2c5a30e7 100644 --- a/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt +++ b/app/src/main/java/net/vonforst/evmap/auto/SettingsScreens.kt @@ -398,6 +398,7 @@ class ChooseDataSourceScreen( val descriptions = when (type) { Type.CHARGER_DATA_SOURCE -> listOf( carContext.getString(R.string.data_source_goingelectric_desc), + carContext.getString(R.string.data_source_nobil_desc), carContext.getString(R.string.data_source_openchargemap_desc) ) Type.SEARCH_PROVIDER -> null diff --git a/app/src/main/java/net/vonforst/evmap/fragment/DataSourceSelectDialog.kt b/app/src/main/java/net/vonforst/evmap/fragment/DataSourceSelectDialog.kt index 71eada813..525aada4b 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/DataSourceSelectDialog.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/DataSourceSelectDialog.kt @@ -53,6 +53,7 @@ class DataSourceSelectDialog : MaterialDialogFragment() { if (prefs.dataSourceSet) { when (prefs.dataSource) { "goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true + "nobil" -> binding.rgDataSource.rbNobil.isChecked = true "openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true } } @@ -63,6 +64,8 @@ class DataSourceSelectDialog : MaterialDialogFragment() { binding.btnOK.setOnClickListener { val result = if (binding.rgDataSource.rbGoingElectric.isChecked) { "goingelectric" + } else if (binding.rgDataSource.rbNobil.isChecked) { + "nobil" } else if (binding.rgDataSource.rbOpenChargeMap.isChecked) { "openchargemap" } else { diff --git a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt index eeb954dc2..e4a6f02d3 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt @@ -3,11 +3,14 @@ package net.vonforst.evmap.fragment import android.Manifest.permission.ACCESS_COARSE_LOCATION import android.Manifest.permission.ACCESS_FINE_LOCATION import android.annotation.SuppressLint +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.content.res.Configuration import android.graphics.Color +import android.net.Uri import android.os.Build import android.os.Bundle import android.text.method.KeyListener @@ -409,7 +412,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider { binding.detailView.sourceButton.setOnClickListener { val charger = vm.charger.value?.data if (charger != null) { - (activity as? MapsActivity)?.openUrl(charger.url, binding.root, true) + (activity as? MapsActivity)?.openUrl(charger.url ?: charger.dataSourceUrl, binding.root, true) } } binding.detailView.btnChargeprice.setOnClickListener { @@ -470,7 +473,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider { } R.id.menu_share -> { val charger = vm.charger.value?.data - if (charger != null) { + if (charger != null && charger.url != null) { (activity as? MapsActivity)?.shareUrl(charger.url) } true @@ -478,7 +481,23 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider { R.id.menu_edit -> { val charger = vm.charger.value?.data if (charger?.editUrl != null) { - (activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true) + val uri = Uri.parse(charger.editUrl) + if (uri.getScheme() == "mailto") { + val intent = Intent(Intent.ACTION_SENDTO, uri) + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + requireContext(), + R.string.no_email_app_found, + Toast.LENGTH_LONG + ).show() + } + } + else { + (activity as? MapsActivity)?.openUrl(charger.editUrl, binding.root, true) + } + if (vm.apiId.value == "goingelectric") { // instructions specific to GoingElectric Toast.makeText( @@ -659,6 +678,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider { removeSearchFocus() binding.fabDirections.show() detailAppBarBehavior.setToolbarTitle(it.name) + updateShareItemVisibility() updateFavoriteToggle() markerManager?.highlighedCharger = it markerManager?.animateBounce(it) @@ -769,6 +789,12 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider { } } + private fun updateShareItemVisibility() { + val charger = vm.chargerSparse.value ?: return + val shareItem = binding.detailAppBar.toolbar.menu.findItem(R.id.menu_share) + shareItem.isVisible = charger.url != null + } + private fun setupAdapters() { var viewer: StfalconImageViewer? = null val galleryClickListener = object : GalleryAdapter.ItemClickListener { @@ -832,11 +858,13 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider { (activity as? MapsActivity)?.showLocation(charger, binding.root) } R.drawable.ic_fault_report -> { - (activity as? MapsActivity)?.openUrl( - charger.url, - binding.root, - true - ) + if (charger.url != null) { + (activity as? MapsActivity)?.openUrl( + charger.url, + binding.root, + true + ) + } } R.drawable.ic_payment -> { diff --git a/app/src/main/java/net/vonforst/evmap/fragment/OnboardingFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/OnboardingFragment.kt index c05a50b7a..8c4c60b1c 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/OnboardingFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/OnboardingFragment.kt @@ -211,6 +211,8 @@ class DataSourceSelectFragment : OnboardingPageFragment() { binding.rgDataSource.textView27, binding.rgDataSource.rbOpenChargeMap, binding.rgDataSource.textView28, + binding.rgDataSource.rbNobil, + binding.rgDataSource.textView29, binding.dataSourceHint, binding.cbAcceptPrivacy ) @@ -239,6 +241,7 @@ class DataSourceSelectFragment : OnboardingPageFragment() { for (rb in listOf( binding.rgDataSource.rbGoingElectric, + binding.rgDataSource.rbNobil, binding.rgDataSource.rbOpenChargeMap )) { rb.setOnCheckedChangeListener { _, _ -> @@ -253,6 +256,7 @@ class DataSourceSelectFragment : OnboardingPageFragment() { if (prefs.dataSourceSet) { when (prefs.dataSource) { "goingelectric" -> binding.rgDataSource.rbGoingElectric.isChecked = true + "nobil" -> binding.rgDataSource.rbNobil.isChecked = true "openchargemap" -> binding.rgDataSource.rbOpenChargeMap.isChecked = true } } @@ -270,6 +274,8 @@ class DataSourceSelectFragment : OnboardingPageFragment() { val result = if (binding.rgDataSource.rbGoingElectric.isChecked) { "goingelectric" + } else if (binding.rgDataSource.rbNobil.isChecked) { + "nobil" } else if (binding.rgDataSource.rbOpenChargeMap.isChecked) { "openchargemap" } else { diff --git a/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt b/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt index 2329e5d9c..cfe1e3d9b 100644 --- a/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt +++ b/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt @@ -32,6 +32,7 @@ sealed class ChargepointListItem * @param address The charge location address * @param chargepoints List of chargepoints at this location * @param network The charging network (Mobility Service Provider, MSP) + * @param dataSourceUrl A link to the data source website * @param url A link to this charging site * @param editUrl A link to a website where this charging site can be edited * @param faultReport Set this if the charging site is reported to be out of service @@ -44,6 +45,7 @@ sealed class ChargepointListItem * @param locationDescription Directions on how to find the charger (e.g. "In the parking garage on level 5") * @param photos List of photos of this charging site * @param chargecards List of charge cards accepted here + * @param availability Specifies who may use this charge location * @param openinghours List of times when this charging site can be accessed / used * @param cost The cost for charging and/or parking * @param license How the data about this chargepoint is licensed @@ -62,7 +64,8 @@ data class ChargeLocation( @Embedded val address: Address?, val chargepoints: List, val network: String?, - val url: String, // URL of this charger at the data source + val dataSourceUrl: String, // URL to the data source + val url: String?, // URL of this charger at the data source val editUrl: String?, // URL to edit this charger at the data source @Embedded(prefix = "fault_report_") val faultReport: FaultReport?, val verified: Boolean, @@ -74,6 +77,7 @@ data class ChargeLocation( val locationDescription: String?, val photos: List?, val chargecards: List?, + val availability: String?, @Embedded val openinghours: OpeningHours?, @Embedded val cost: Cost?, val license: String?, @@ -130,9 +134,11 @@ data class ChargeLocation( val filtered = chargepoints .filter { it.type == variant.type && it.power == variant.power } val count = filtered.sumOf { it.count } + val mergedEvseIds = filtered.map { if (it.evseIds == null) List(it.count) {null} else it.evseIds }.flatten() Chargepoint(variant.type, variant.power, count, filtered.map { it.current }.distinct().singleOrNull(), - filtered.map { it.voltage }.distinct().singleOrNull() + filtered.map { it.voltage }.distinct().singleOrNull(), + if (mergedEvseIds.all { it == null }) null else mergedEvseIds ) } } @@ -405,7 +411,9 @@ data class Chargepoint( // Max voltage in V (or null if unknown). // note that for DC chargers: current * voltage may be larger than power // (each of the three can be separately limited) - val voltage: Double? = null + val voltage: Double? = null, + // Electric Vehicle Supply Equipment Ids for this Chargepoint's plugs/sockets + val evseIds: List? = null ) : Equatable, Parcelable { fun hasKnownPower(): Boolean = power != null fun hasKnownVoltageAndCurrent(): Boolean = voltage != null && current != null diff --git a/app/src/main/java/net/vonforst/evmap/navigation/CustomNavigator.kt b/app/src/main/java/net/vonforst/evmap/navigation/CustomNavigator.kt index 60d9293b9..ecc3d799f 100644 --- a/app/src/main/java/net/vonforst/evmap/navigation/CustomNavigator.kt +++ b/app/src/main/java/net/vonforst/evmap/navigation/CustomNavigator.kt @@ -35,6 +35,7 @@ class CustomNavigator( val prefs = PreferenceDataSource(context) val url = when (prefs.dataSource) { "goingelectric" -> "https://www.goingelectric.de/stromtankstellen/new/" + "nobil" -> "http://nobil.no/api/chargerregistration/chargerregistration.php?action=register" "openchargemap" -> "https://openchargemap.org/site/poi/add" else -> throw IllegalArgumentException() } diff --git a/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt b/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt index d0177e2ab..d10243e26 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/ChargeLocationsDao.kt @@ -16,6 +16,7 @@ import net.vonforst.evmap.api.ChargepointList import net.vonforst.evmap.api.StringProvider import net.vonforst.evmap.api.goingelectric.GEReferenceData import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper +import net.vonforst.evmap.api.nobil.NobilApiWrapper import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.model.* import net.vonforst.evmap.ui.cluster @@ -122,6 +123,9 @@ class ChargeLocationsRepository( prefs ).getReferenceData() } + is NobilApiWrapper -> { + NobilReferenceDataRepository(scope, prefs).getReferenceData() + } is OpenChargeMapApiWrapper -> { OCMReferenceDataRepository( api, 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 5b855c2e9..34740855c 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/Database.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/Database.kt @@ -34,7 +34,7 @@ import net.vonforst.evmap.model.* OCMCountry::class, OCMOperator::class, SavedRegion::class - ], version = 22 + ], version = 25 ) @TypeConverters(Converters::class, GeometryConverters::class) abstract class AppDatabase : RoomDatabase() { @@ -75,12 +75,13 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_7, MIGRATION_8, MIGRATION_9, MIGRATION_10, MIGRATION_11, MIGRATION_12, MIGRATION_13, MIGRATION_14, MIGRATION_15, MIGRATION_16, MIGRATION_17, MIGRATION_18, MIGRATION_19, MIGRATION_20, MIGRATION_21, - MIGRATION_22 + MIGRATION_22, MIGRATION_23, MIGRATION_24, MIGRATION_25 ) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { // create default filter profile for each data source db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('goingelectric', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") + db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('nobil', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('openchargemap', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") // initialize spatialite columns db.query("SELECT RecoverGeometryColumn('ChargeLocation', 'coordinates', 4326, 'POINT', 'XY');") @@ -459,6 +460,42 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("DELETE FROM savedregion") } } + + private val MIGRATION_23 = object : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + // API nobil added + db.execSQL("INSERT INTO `FilterProfile` (`dataSource`, `name`, `id`, `order`) VALUES ('nobil', 'FILTERS_CUSTOM', $FILTERS_CUSTOM, 0)") + } + } + + private val MIGRATION_24 = object : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) { + // adding dataSourceUrl and making url optional + db.execSQL( + "CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `dataSourceUrl` TEXT NOT NULL, `url` TEXT, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `license` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, PRIMARY KEY(`id`, `dataSource`))" + ) + + db.execSQL("INSERT INTO `ChargeLocationNew` SELECT `id`, `dataSource`, `name`, `coordinates`, `chargepoints`, `network`, '', `url`, `editUrl`, `verified`, `barrierFree`, `operator`, `generalInformation`, `amenities`, `locationDescription`, `photos`, `chargecards`, `license`, `timeRetrieved`, `isDetailed`, `city`, `country`, `postcode`, `street`, `fault_report_created`, `fault_report_description`, `twentyfourSeven`, `description`, `mostart`, `moend`, `tustart`, `tuend`, `westart`, `weend`, `thstart`, `thend`, `frstart`, `frend`, `sastart`, `saend`, `sustart`, `suend`, `hostart`, `hoend`, `freecharging`, `freeparking`, `descriptionShort`, `descriptionLong`, `chargepricecountry`, `chargepricenetwork`, `chargepriceplugTypes`, `networkUrl`, `chargerUrl` FROM `ChargeLocation`") + db.execSQL("UPDATE ChargeLocationNew SET `dataSourceUrl` = 'https://www.goingelectric.de/' WHERE `dataSource` = 'goingelectric'") + db.execSQL("UPDATE ChargeLocationNew SET `dataSourceUrl` = 'https://openchargemap.org/' WHERE `dataSource` = 'openchargemap'") + db.execSQL("UPDATE ChargeLocationNew SET `dataSourceUrl` = 'https://www.openstreetmap.org/' WHERE `dataSource` = 'openstreetmap'") + db.execSQL("DROP TABLE `ChargeLocation`") + db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`") + } + } + + private val MIGRATION_25 = object : Migration(24, 25) { + override fun migrate(db: SupportSQLiteDatabase) { + // adding availability to ChargeLocation + db.execSQL( + "CREATE TABLE `ChargeLocationNew` (`id` INTEGER NOT NULL, `dataSource` TEXT NOT NULL, `name` TEXT NOT NULL, `coordinates` BLOB NOT NULL, `chargepoints` TEXT NOT NULL, `network` TEXT, `dataSourceUrl` TEXT NOT NULL, `url` TEXT, `editUrl` TEXT, `verified` INTEGER NOT NULL, `barrierFree` INTEGER, `operator` TEXT, `generalInformation` TEXT, `amenities` TEXT, `locationDescription` TEXT, `photos` TEXT, `chargecards` TEXT, `availability` TEXT, `license` TEXT, `timeRetrieved` INTEGER NOT NULL, `isDetailed` INTEGER NOT NULL, `city` TEXT, `country` TEXT, `postcode` TEXT, `street` TEXT, `fault_report_created` INTEGER, `fault_report_description` TEXT, `twentyfourSeven` INTEGER, `description` TEXT, `mostart` TEXT, `moend` TEXT, `tustart` TEXT, `tuend` TEXT, `westart` TEXT, `weend` TEXT, `thstart` TEXT, `thend` TEXT, `frstart` TEXT, `frend` TEXT, `sastart` TEXT, `saend` TEXT, `sustart` TEXT, `suend` TEXT, `hostart` TEXT, `hoend` TEXT, `freecharging` INTEGER, `freeparking` INTEGER, `descriptionShort` TEXT, `descriptionLong` TEXT, `chargepricecountry` TEXT, `chargepricenetwork` TEXT, `chargepriceplugTypes` TEXT, `networkUrl` TEXT, `chargerUrl` TEXT, PRIMARY KEY(`id`, `dataSource`))" + ) + + db.execSQL("INSERT INTO `ChargeLocationNew` SELECT `id`, `dataSource`, `name`, `coordinates`, `chargepoints`, `network`, `dataSourceUrl`, `url`, `editUrl`, `verified`, `barrierFree`, `operator`, `generalInformation`, `amenities`, `locationDescription`, `photos`, `chargecards`, null, `license`, `timeRetrieved`, `isDetailed`, `city`, `country`, `postcode`, `street`, `fault_report_created`, `fault_report_description`, `twentyfourSeven`, `description`, `mostart`, `moend`, `tustart`, `tuend`, `westart`, `weend`, `thstart`, `thend`, `frstart`, `frend`, `sastart`, `saend`, `sustart`, `suend`, `hostart`, `hoend`, `freecharging`, `freeparking`, `descriptionShort`, `descriptionLong`, `chargepricecountry`, `chargepricenetwork`, `chargepriceplugTypes`, `networkUrl`, `chargerUrl` FROM `ChargeLocation`") + db.execSQL("DROP TABLE `ChargeLocation`") + db.execSQL("ALTER TABLE `ChargeLocationNew` RENAME TO `ChargeLocation`") + } + } } /** diff --git a/app/src/main/java/net/vonforst/evmap/storage/NobilReferenceDataDao.kt b/app/src/main/java/net/vonforst/evmap/storage/NobilReferenceDataDao.kt new file mode 100644 index 000000000..2d0fce488 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/storage/NobilReferenceDataDao.kt @@ -0,0 +1,26 @@ +package net.vonforst.evmap.storage + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.room.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import net.vonforst.evmap.api.nobil.* +import net.vonforst.evmap.viewmodel.Status +import java.time.Duration +import java.time.Instant + +@Dao +abstract class NobilReferenceDataDao { +} + +class NobilReferenceDataRepository( + private val scope: CoroutineScope, + private val prefs: PreferenceDataSource +) { + fun getReferenceData(): LiveData { + return MediatorLiveData().apply { + value = NobilReferenceData(0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt b/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt index f2fe6d9a6..9a8a82f8e 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/TypeConverters.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory import net.vonforst.evmap.api.goingelectric.GEChargerPhotoAdapter +import net.vonforst.evmap.api.nobil.NobilChargerPhotoAdapter import net.vonforst.evmap.api.openchargemap.OCMChargerPhotoAdapter import net.vonforst.evmap.autocomplete.AutocompletePlaceType import net.vonforst.evmap.model.ChargeCardId @@ -22,6 +23,7 @@ class Converters { .add( PolymorphicJsonAdapterFactory.of(ChargerPhoto::class.java, "type") .withSubtype(GEChargerPhotoAdapter::class.java, "goingelectric") + .withSubtype(NobilChargerPhotoAdapter::class.java, "nobil") .withSubtype(OCMChargerPhotoAdapter::class.java, "openchargemap") .withDefaultValue(null) ) diff --git a/app/src/main/res/layout/data_source_select.xml b/app/src/main/res/layout/data_source_select.xml index 67e4b9c5d..095c1569d 100644 --- a/app/src/main/res/layout/data_source_select.xml +++ b/app/src/main/res/layout/data_source_select.xml @@ -41,4 +41,22 @@ android:layout_marginStart="32dp" android:text="@string/data_source_openchargemap_desc" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 38f4fa1a2..0c0628600 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -26,10 +26,12 @@ @string/data_source_goingelectric + @string/data_source_nobil @string/data_source_openchargemap goingelectric + nobil openchargemap diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01df5dd49..3499b139e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Connectors Install a navigation app first Install a web browser first + Install an email app first Address Operator Network @@ -107,6 +108,7 @@ Available 24/7 Usable without registration Exclude chargers with reported faults + Charger availability Payment methods and %d others Map provider @@ -223,8 +225,10 @@ Unknown operator Please pick a data source for charging stations. It can later be changed in the app settings. GoingElectric.de + NOBIL Open Charge Map Great in the German-speaking countries. Descriptions in German. Community-maintained. + next Get started @@ -370,4 +374,9 @@ Pricing data will be shown directly in EVMap Price comparison button will refer to the Chargeprice app or website Zoom in to see details + Public + Visitors + Employees + By appointment + Residents \ No newline at end of file diff --git a/doc/api_keys.md b/doc/api_keys.md index 484f037d4..ec3693742 100644 --- a/doc/api_keys.md +++ b/doc/api_keys.md @@ -38,6 +38,9 @@ be put into the app in the form of a resource file called `apikeys.xml` under insert your ACRA crash reporting credentials here + + insert your nobil key here + ``` @@ -167,6 +170,14 @@ in German. +### **NOBIL** + +NOBIL lists charging stations in the Nordic countries (Denmark, Finland, Iceland, Norway, Sweden) +and provides an open [API](https://info.nobil.no/api) to access the data. + +To get a NOBIL API key, fill in and submit the form on [this page](https://info.nobil.no/api). +Then, wait for an an e-mail with your API key. + ### **OpenChargeMap** [API documentation](https://openchargemap.org/site/develop/api)