Compare commits

...

29 Commits

Author SHA1 Message Date
Johan von Forstner
0685f14d06 WIP: refactor LiveData to Flows 2025-08-24 16:26:35 +02:00
johan12345
7e96c9e5a7 dependency upgrades & replacements 2025-08-23 21:48:02 +02:00
johan12345
44bd2c6159 upgrade MapLibre - 16 KB page size support
https://github.com/maplibre/maplibre-native/pull/3728
2025-08-19 20:38:34 +02:00
johan12345
7d2a19b0a3 upload AboutLibraries file for release builds 2025-08-18 18:02:08 +02:00
johan12345
3414a7581c remove FlipperDiagnosticActivity from manifest 2025-08-17 21:12:38 +02:00
johan12345
df47f7b4c1 upgrade dependencies 2025-08-17 20:31:03 +02:00
johan12345
a08e2ab7e9 upgrade to Java 21 2025-08-17 19:40:34 +02:00
johan12345
c1351ce935 update AGP 2025-08-17 19:36:34 +02:00
johan12345
b4a1a8b546 remove Flipper 2025-08-17 19:31:37 +02:00
johan12345
3865e6c33d update android-spatialite 2025-08-17 19:21:59 +02:00
johan12345
091b0f5ac3 further insets handling in MapFragment 2025-08-17 16:37:15 +02:00
johan12345
1148200f37 Upgrade Robolectric, re-enable CarAppTest 2025-08-16 15:23:07 +02:00
Johan von Forstner
1847e8b771 Rework MapFragment insets handling
fixes gallery height
2025-08-10 21:02:45 +02:00
Johan von Forstner
bbfe8e2bb2 fix detailAppBar popupTheme
commented out in 104913b3
2025-08-10 19:48:31 +02:00
Johan von Forstner
983d368a78 Tesla login fixes
refs 104913b3
2025-08-10 19:40:39 +02:00
johan12345
4a6a34db3a disable CarAppTest due to Robolectric incompatibility 2025-08-10 16:08:48 +02:00
Johan von Forstner
35ddece698 handle navigation bar insets for more fragments
fixes #382
2025-08-10 15:56:29 +02:00
Johan von Forstner
36c6a4053d fix location of ksp in build.gradle.kts 2025-08-10 15:08:08 +02:00
Johan von Forstner
104913b3c4 targetSdk 36, library upgrade, replace LocalBroadcastReceiver 2025-08-10 15:03:41 +02:00
Hosted Weblate
5cc510fe22 Translated using Weblate (Italian)
Currently translated at 100.0% (364 of 364 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Thomas Di Cristofaro <hostedweblate.8347@tdc.akamail.it>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/it/
Translation: EVMap/Android
2025-08-10 12:41:07 +02:00
Hosted Weblate
4250eb2ba8 Translated using Weblate (Portuguese)
Currently translated at 100.0% (364 of 364 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2025-08-10 12:41:07 +02:00
Johan von Forstner
1db82db066 fix location of CarInfo.kt 2025-08-10 12:39:02 +02:00
Johan von Forstner
d6a8fbee7d update Gradle & AGP 2025-07-27 20:41:58 +02:00
johan12345
23e2f0baad fix endless loading with filters that do not support local SQL queries 2025-07-27 17:43:09 +02:00
Johan von Forstner
ea4fb37f30 Merge pull request #381 from weblate/weblate-evmap-android
Translations update from Hosted Weblate
2025-07-17 21:29:36 +02:00
Hosted Weblate
094f38ac87 Translated using Weblate (Czech)
Currently translated at 100.0% (364 of 364 strings)

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/cs/
Translation: EVMap/Android
2025-07-14 22:02:00 +00:00
Hosted Weblate
b84d13d42b Translated using Weblate (Estonian)
Currently translated at 100.0% (364 of 364 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/et/
Translation: EVMap/Android
2025-07-14 22:01:59 +00:00
johan12345
845bd2e5ca API 35 compat: handle bottom nav bar insets 2025-07-14 00:07:46 +02:00
Johan von Forstner
0b68ddb939 Merge pull request #290 from ev-map/openstreetmap
Implement OpenStreetMap data source
2025-07-13 23:23:45 +02:00
41 changed files with 562 additions and 453 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v4
with:
java-version: 17
java-version: 21
distribution: 'zulu'
cache: 'gradle'
- name: Decrypt keystore
@@ -24,7 +24,7 @@ jobs:
- name: Extract version code
run: echo "VERSION_CODE=$(grep -o "^\s*versionCode\s*=\s*[0-9]\+" app/build.gradle.kts | awk '{ print $3 }' | tr -d \''"\\')" >> $GITHUB_ENV
- name: Build app release
- name: Build app release & export libraries
env:
GOINGELECTRIC_API_KEY: ${{ secrets.GOINGELECTRIC_API_KEY }}
OPENCHARGEMAP_API_KEY: ${{ secrets.OPENCHARGEMAP_API_KEY }}
@@ -38,7 +38,7 @@ jobs:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
run: ./gradlew exportLibraryDefinitions assembleRelease --no-daemon
- name: release
uses: actions/create-release@v1
@@ -88,3 +88,12 @@ jobs:
asset_path: app/build/outputs/apk/fossAutomotive/release/app-foss-automotive-release.apk
asset_name: app-foss-automotive-release.apk
asset_content_type: application/vnd.android.package-archive
- name: upload Licenses
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/generated/aboutLibraries/aboutlibraries.json
asset_name: aboutlibraries.json
asset_content_type: application/json

View File

@@ -21,7 +21,7 @@ jobs:
- name: Set up Java environment
uses: actions/setup-java@v4
with:
java-version: 17
java-version: 21
distribution: 'zulu'
cache: 'gradle'

View File

@@ -1,7 +1,7 @@
import java.util.Base64
plugins {
id("com.adarshr.test-logger") version "3.1.0"
id("com.adarshr.test-logger") version "4.0.0"
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
@@ -17,18 +17,18 @@ android {
defaultConfig {
applicationId = "net.vonforst.evmap"
compileSdk = 35
compileSdk = 36
minSdk = 21
targetSdk = 35
targetSdk = 36
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 230
versionName = "1.9.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
val isRunningOnCI = System.getenv("CI") == "true"
@@ -258,18 +258,21 @@ configurations {
}
aboutLibraries {
allowedLicenses = arrayOf(
"Apache-2.0", "mit", "BSD-2-Clause", "BSD-3-Clause", "EPL-1.0",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service", // Google Maps SDK
"provided without support or warranty", // org.json
"Unicode/ICU License", "Unicode-3.0", // icu4j
"Bouncy Castle Licence", // bcprov
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
)
excludeFields = arrayOf("generated")
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
license {
allowedLicenses = setOf(
"Apache-2.0", "mit", "BSD-2-Clause", "BSD-3-Clause", "EPL-1.0",
"asdkl", // Android SDK
"Dual OpenSSL and SSLeay License", // Android NDK OpenSSL
"Google Maps Platform Terms of Service", // Google Maps SDK
"Unicode/ICU License", "Unicode-3.0", // icu4j
"Bouncy Castle Licence", // bcprov
"CDDL + GPLv2 with classpath exception", // javax.annotation-api
)
strictMode = com.mikepenz.aboutlibraries.plugin.StrictMode.FAIL
}
export {
excludeFields = setOf("generated")
}
}
dependencies {
@@ -283,42 +286,41 @@ dependencies {
val testGoogleImplementation by configurations
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.activity:activity-ktx:1.9.0")
implementation("androidx.fragment:fragment-ktx:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("androidx.fragment:fragment-ktx:1.8.9")
implementation("androidx.cardview:cardview:1.0.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("com.google.android.material:material:1.12.0")
implementation("com.google.android.material:material:1.13.0-rc01")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.browser:browser:1.9.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.viewpager2:viewpager2:1.1.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.work:work-runtime-ktx:2.10.3")
implementation("com.github.ev-map:CustomBottomSheetBehavior:e48f73ea7b")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.retrofit2:converter-moshi:3.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-urlconnection:4.12.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
implementation("com.markomilos.jsonapi:jsonapi-retrofit:1.1.0")
implementation("io.coil-kt:coil:2.6.0")
implementation("io.coil-kt:coil:2.7.0")
implementation("com.github.ev-map:StfalconImageViewer:5082ebd392")
implementation("com.mikepenz:aboutlibraries-core:$aboutLibsVersion")
implementation("com.mikepenz:aboutlibraries:$aboutLibsVersion")
implementation("com.airbnb.android:lottie:4.1.0")
implementation("com.airbnb.android:lottie:6.6.7")
implementation("io.michaelrocks.bimap:bimap:1.1.0")
implementation("com.google.guava:guava:29.0-android")
implementation("com.github.pengrad:mapscaleview:1.6.0")
implementation("com.github.romandanylyk:PageIndicatorView:b1bad589b5")
implementation("com.github.erfansn:locale-config-x:1.0.1")
// Android Auto
val carAppVersion = "1.7.0-rc01"
val carAppVersion = "1.7.0"
implementation("androidx.car.app:app:$carAppVersion")
normalImplementation("androidx.car.app:app-projected:$carAppVersion")
automotiveImplementation("androidx.car.app:app-automotive:$carAppVersion")
@@ -327,58 +329,56 @@ dependencies {
val anyMapsVersion = "1174ef9375"
implementation("com.github.ev-map.AnyMaps:anymaps-base:$anyMapsVersion")
googleImplementation("com.github.ev-map.AnyMaps:anymaps-google:$anyMapsVersion")
googleImplementation("com.google.android.gms:play-services-maps:19.0.0")
googleImplementation("com.google.android.gms:play-services-maps:19.2.0")
implementation("com.github.ev-map.AnyMaps:anymaps-maplibre:$anyMapsVersion") {
// duplicates classes from mapbox-sdk-services
exclude("org.maplibre.gl", "android-sdk-geojson")
}
implementation("org.maplibre.gl:android-sdk:10.3.4") {
implementation("org.maplibre.gl:android-sdk:10.3.5") {
exclude("org.maplibre.gl", "android-sdk-geojson")
}
// Google Places
googleImplementation("com.google.android.libraries.places:places:3.5.0")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
googleImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2")
// Mapbox Geocoding
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0")
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.8.0")
// navigation library
implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
// viewmodel library
val lifecycle_version = "2.8.1"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version")
val lifecycleVersion = "2.9.2"
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
// room library
val room_version = "2.7.1"
implementation("androidx.room:room-runtime:$room_version")
ksp("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$room_version")
val roomVersion = "2.7.2"
implementation("androidx.room:room-runtime:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
implementation("com.github.anboralabs:spatia-room:0.3.0") {
exclude("com.github.dalgarins", "android-spatialite")
}
// forked version with upgraded sqlite & libxml
// https://github.com/dalgarins/android-spatialite/pull/10
implementation("com.github.ev-map:android-spatialite:31495dcd81")
// forked version with upgraded sqlite & libxml & 16 KB page size support
// https://github.com/dalgarins/android-spatialite/pull/11
// https://github.com/dalgarins/android-spatialite/pull/12
implementation("io.github.ev-map:android-spatialite:2.2.1-alpha")
// billing library
val billing_version = "7.0.0"
googleImplementation("com.android.billingclient:billing:$billing_version")
googleImplementation("com.android.billingclient:billing-ktx:$billing_version")
val billingVersion = "7.0.0"
googleImplementation("com.android.billingclient:billing:$billingVersion")
googleImplementation("com.android.billingclient:billing-ktx:$billingVersion")
// ACRA (crash reporting)
val acraVersion = "5.11.1"
val acraVersion = "5.12.0"
implementation("ch.acra:acra-http:$acraVersion")
implementation("ch.acra:acra-dialog:$acraVersion")
implementation("ch.acra:acra-limiter:$acraVersion")
// debug tools
debugImplementation("com.facebook.flipper:flipper:0.238.0")
debugImplementation("com.facebook.soloader:soloader:0.10.5")
debugImplementation("com.facebook.flipper:flipper-network-plugin:0.238.0")
debugImplementation("com.jakewharton.timber:timber:5.0.1")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
@@ -386,20 +386,18 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
//noinspection GradleDependency
testImplementation("org.json:json:20080701")
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("androidx.test:core:1.5.0")
testImplementation("org.robolectric:robolectric:4.16-beta-1")
testImplementation("androidx.test:core:1.7.0")
testImplementation("androidx.arch.core:core-testing:2.2.0")
testImplementation("androidx.car.app:app-testing:$carAppVersion")
testImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
fun decode(s: String, key: String): String {

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.facebook.flipper.android.diagnostics.FlipperDiagnosticActivity"
android:exported="true" />
</application>
</manifest>

View File

@@ -2,44 +2,15 @@ package net.vonforst.evmap
import android.content.Context
import android.os.Build
import com.facebook.flipper.android.AndroidFlipperClient
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin
import com.facebook.flipper.plugins.inspector.DescriptorMapping
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin
import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
import com.facebook.soloader.SoLoader
import okhttp3.OkHttpClient
import timber.log.Timber
private val networkFlipperPlugin = NetworkFlipperPlugin()
fun addDebugInterceptors(context: Context) {
if (Build.FINGERPRINT == "robolectric") return
SoLoader.init(context, false)
val client = AndroidFlipperClient.getInstance(context)
client.addPlugin(InspectorFlipperPlugin(context, DescriptorMapping.withDefaults()))
client.addPlugin(networkFlipperPlugin)
client.addPlugin(DatabasesFlipperPlugin(context))
client.addPlugin(SharedPreferencesFlipperPlugin(context))
client.start()
Timber.plant(Timber.DebugTree())
}
fun OkHttpClient.Builder.addDebugInterceptors(): OkHttpClient.Builder {
// Flipper does not work during unit tests - so check whether we are running tests first
var isRunningTest = true
try {
Class.forName("org.junit.Test")
} catch (e: ClassNotFoundException) {
isRunningTest = false
}
if (!isRunningTest) {
this.addNetworkInterceptor(FlipperOkhttpInterceptor(networkFlipperPlugin))
}
return this
}

View File

@@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
@@ -55,6 +56,7 @@ class MapsActivity : AppCompatActivity(),
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
WindowCompat.enableEdgeToEdge(window)
setContentView(R.layout.activity_maps)

View File

@@ -1,18 +1,20 @@
package net.vonforst.evmap.api
import com.google.common.util.concurrent.RateLimiter
import okhttp3.Interceptor
import okhttp3.Response
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
class RateLimitInterceptor : Interceptor {
private val rateLimiter = RateLimiter.create(3.0)
private val rateLimiter = SimpleRateLimiter(3.0)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.url.host == "ui-map.shellrecharge.com") {
// limit requests sent to NewMotion to 3 per second
rateLimiter.acquire(1)
rateLimiter.acquire()
var response: Response = chain.proceed(request)
// 403 is how the NewMotion API indicates a rate limit error
@@ -30,4 +32,27 @@ class RateLimitInterceptor : Interceptor {
return chain.proceed(request)
}
}
}
internal class SimpleRateLimiter(private val permitsPerSecond: Double) {
private val interval: Duration = (1.0 / permitsPerSecond).seconds
private var nextAvailable = TimeSource.Monotonic.markNow()
@Synchronized
fun acquire() {
val now = TimeSource.Monotonic.markNow()
if (now < nextAvailable) {
val waitTime = nextAvailable - now
waitTime.sleep()
nextAvailable += interval
} else {
nextAvailable = now + interval
}
}
}
fun Duration.sleep() {
if (this.isPositive()) {
Thread.sleep(this.inWholeMilliseconds, (this.inWholeNanoseconds % 1_000_000).toInt())
}
}

View File

@@ -1,49 +1,10 @@
package net.vonforst.evmap.api
import androidx.annotation.DrawableRes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import net.vonforst.evmap.R
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.json.JSONArray
import java.io.IOException
import kotlin.coroutines.resumeWithException
import kotlin.experimental.ExperimentalTypeInference
import kotlin.math.abs
operator fun <T> JSONArray.iterator(): Iterator<T> =
(0 until length()).asSequence().map {
@Suppress("UNCHECKED_CAST")
get(it) as T
}.iterator()
@ExperimentalCoroutinesApi
suspend fun Call.await(): Response {
return suspendCancellableCoroutine { continuation ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) {}
}
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) return
continuation.resumeWithException(e)
}
})
continuation.invokeOnCancellation {
try {
cancel()
} catch (ex: Throwable) {
//Ignore cancel exception
}
}
}
}
private val plugNames = mapOf(
Chargepoint.TYPE_1 to R.string.plug_type_1,
Chargepoint.TYPE_2_UNKNOWN to R.string.plug_type_2,

View File

@@ -100,7 +100,8 @@ interface TeslaAuthenticationApi {
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("scope", "openid email offline_access phone")
.appendQueryParameter("is_in_app", "true")
.appendQueryParameter("state", "123").build()
val resultUrlPrefix = "https://auth.tesla.com/void/callback"

View File

@@ -1,18 +1,25 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.add
import androidx.fragment.app.commit
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import net.vonforst.evmap.R
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
companion object {
private val resultRegistry: MutableMap<String, MutableSharedFlow<String>> = mutableMapOf()
fun registerForResult(url: String): Flow<String> {
val flow = MutableSharedFlow<String>(replay = 1)
resultRegistry[url] = flow
return flow
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
@@ -22,10 +29,14 @@ class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
}
}
LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
finish()
val url = intent.getStringExtra(OAuthLoginFragment.EXTRA_URL)!!
supportFragmentManager.setFragmentResultListener(url, this) { _, result ->
val resultUrl = result.getString(OAuthLoginFragment.EXTRA_URL) ?: return@setFragmentResultListener
resultRegistry[url]?.let { flow ->
flow.tryEmit(resultUrl)
resultRegistry.remove(url)
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
finish()
}
}
}

View File

@@ -29,11 +29,13 @@ import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
@@ -340,23 +342,18 @@ class DataSettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ct
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
"#FFFFFF"
).toBundle()
val intent = Intent(carContext, OAuthLoginActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtras(args)
LocalBroadcastManager.getInstance(carContext)
.registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val url = IntentCompat.getParcelableExtra(
intent,
OAuthLoginFragment.EXTRA_URL,
Uri::class.java
)
teslaGetAccessToken(url!!, codeVerifier)
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
val resultFlow = OAuthLoginActivity.registerForResult(uri.toString())
lifecycleScope.launch {
resultFlow.collect { resultUrl ->
teslaGetAccessToken(resultUrl.toUri(), codeVerifier)
}
}
session.cas.startActivity(intent)

View File

@@ -6,6 +6,9 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -108,6 +111,11 @@ class ChargepriceFragment : Fragment() {
binding.toolbar.inflateMenu(R.menu.chargeprice)
binding.toolbar.setTitle(R.string.chargeprice_title)
ViewCompat.setOnApplyWindowInsetsListener(binding.chargePricesList) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -4,6 +4,9 @@ import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import net.vonforst.evmap.R
import net.vonforst.evmap.databinding.FragmentDonateReferralBinding
@@ -22,5 +25,10 @@ abstract class DonateFragmentBase : Fragment() {
return true
}
}
ViewCompat.setOnApplyWindowInsetsListener(referrals.root) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
}
}

View File

@@ -7,6 +7,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -68,6 +71,13 @@ class FavoritesFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
ViewCompat.setOnApplyWindowInsetsListener(
binding.favsList
) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -9,6 +9,9 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -49,6 +52,13 @@ class FilterFragment : Fragment(), MenuProvider {
binding.vm = vm
vm.filterProfile.observe(viewLifecycleOwner) {}
ViewCompat.setOnApplyWindowInsetsListener(
binding.filtersList
) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -8,6 +8,9 @@ import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -60,6 +63,13 @@ class FilterProfilesFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
ViewCompat.setOnApplyWindowInsetsListener(
binding.filterProfilesList
) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}

View File

@@ -117,7 +117,6 @@ import net.vonforst.evmap.viewmodel.Status
import java.io.IOException
import java.time.Duration
import java.time.Instant
import kotlin.collections.set
import kotlin.math.min
@@ -137,7 +136,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
private lateinit var prefs: PreferenceDataSource
private var connectionErrorSnackbar: Snackbar? = null
private var mapTopPadding: Int = 0
private var mapBottomPadding: Int = 0
private var popupMenu: PopupMenu? = null
private var insetBottom: Int = 0
private lateinit var favToggle: MenuItem
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
@@ -215,27 +216,27 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
binding.detailAppBar.toolbar.popupTheme =
com.google.android.material.R.style.ThemeOverlay_AppCompat_DayNight
com.google.android.material.R.style.Theme_Material3_DayNight
ViewCompat.setOnApplyWindowInsetsListener(
binding.root
) { _, insets ->
ViewCompat.onApplyWindowInsets(binding.root, insets)
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
binding.detailAppBar.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
val density = resources.displayMetrics.density
ViewCompat.setOnApplyWindowInsetsListener(binding.detailAppBar.toolbar) { v, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = systemWindowInsetTop
}
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.fabLayers) { v, insets ->
// margin of layers button: status bar height + toolbar height + margin
val density = resources.displayMetrics.density
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
val margin =
if (binding.toolbarContainer.layoutParams.width == ViewGroup.LayoutParams.MATCH_PARENT) {
systemWindowInsetTop + (48 * density).toInt() + (28 * density).toInt()
} else {
systemWindowInsetTop + (12 * density).toInt()
}
binding.fabLayers.updateLayoutParams<ViewGroup.MarginLayoutParams> {
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = margin
}
binding.layersSheet.updateLayoutParams<ViewGroup.MarginLayoutParams> {
@@ -244,11 +245,37 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
// set map padding so that compass is not obstructed by toolbar
mapTopPadding = systemWindowInsetTop + (48 * density).toInt() + (16 * density).toInt()
mapBottomPadding = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
// if we actually use map.setPadding here, MapLibre will re-trigger onApplyWindowInsets
// and cause an infinite loop. So we rely on onMapReady being called later than
// onApplyWindowInsets.
insets
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.fabLocate) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin =
systemBars + resources.getDimensionPixelSize(com.mahc.custombottomsheetbehavior.R.dimen.fab_margin)
}
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.navBarScrim) { v, insets ->
insetBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
v.layoutParams.height = insetBottom
updatePeekHeight()
WindowInsetsCompat.CONSUMED
}
ViewCompat.setOnApplyWindowInsetsListener(binding.galleryContainer) { v, insets ->
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
val newHeight =
resources.getDimensionPixelSize(R.dimen.gallery_height_with_margin) + systemWindowInsetTop
v.layoutParams.height = newHeight
bottomSheetBehavior.anchorPoint = newHeight
WindowInsetsCompat.CONSUMED
}
exitTransition = TransitionInflater.from(requireContext())
@@ -262,6 +289,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
return binding.root
}
private fun updatePeekHeight() {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom + insetBottom
}
private fun getMapProvider(provider: String) = when (provider) {
"mapbox" -> MapFactory.MAPLIBRE
"google" -> MapFactory.GOOGLE
@@ -291,7 +322,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
binding.detailView.topPart.doOnNextLayout {
bottomSheetBehavior.peekHeight = binding.detailView.topPart.bottom
updatePeekHeight()
vm.bottomSheetState.value?.let { bottomSheetBehavior.state = it }
}
bottomSheetBehavior.isCollapsible = bottomSheetCollapsible
@@ -616,16 +647,24 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (bottomSheetBehavior.state == STATE_HIDDEN) {
map?.setPadding(0, mapTopPadding, 0, 0)
map?.setPadding(0, mapTopPadding, 0, mapBottomPadding)
} else {
val height = binding.root.height - bottomSheet.top
map?.setPadding(
0,
mapTopPadding,
0,
min(bottomSheetBehavior.peekHeight, height)
mapBottomPadding + min(bottomSheetBehavior.peekHeight, height)
)
}
println(slideOffset)
if (bottomSheetBehavior.state != STATE_HIDDEN) {
binding.navBarScrim.visibility = View.VISIBLE
binding.navBarScrim.translationY =
(if (slideOffset < 0f) -slideOffset else 2 * slideOffset) * binding.navBarScrim.height
} else {
binding.navBarScrim.visibility = View.INVISIBLE
}
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
@@ -1056,7 +1095,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
map.setTrafficEnabled(vm.mapTrafficEnabled.value ?: false)
// set padding so that compass is not obstructed by toolbar
map.setPadding(0, mapTopPadding, 0, 0)
map.setPadding(0, mapTopPadding, 0, mapBottomPadding)
val mode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
map.setMapStyle(

View File

@@ -1,35 +1,34 @@
package net.vonforst.evmap.fragment.oauth
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.toColorInt
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.transition.MaterialSharedAxis
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import java.lang.IllegalStateException
class OAuthLoginFragment : Fragment() {
companion object {
val ACTION_OAUTH_RESULT = "oauth_result"
val EXTRA_URL = "url"
}
@@ -72,11 +71,11 @@ class OAuthLoginFragment : Fragment() {
}
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
val uri = Uri.parse(args.url)
val uri = args.url.toUri()
webView = view.findViewById(R.id.webView)
args.color?.let { webView.setBackgroundColor(Color.parseColor(it)) }
args.color?.let { webView.setBackgroundColor(it.toColorInt()) }
val progress = view.findViewById<LinearProgressIndicator>(R.id.progress_indicator)
CookieManager.getInstance().removeAllCookies(null)
@@ -89,13 +88,8 @@ class OAuthLoginFragment : Fragment() {
if (url.toString().startsWith(args.resultUrlPrefix)) {
val result = Bundle()
result.putString("url", url.toString())
result.putString(EXTRA_URL, url.toString())
setFragmentResult(args.url, result)
context?.let {
LocalBroadcastManager.getInstance(it).sendBroadcast(
Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url)
)
}
navController?.popBackStack()
}
@@ -104,6 +98,9 @@ class OAuthLoginFragment : Fragment() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
if (BuildConfig.DEBUG) {
Log.w("WebViewClient", url)
}
progress.show()
}
@@ -112,6 +109,24 @@ class OAuthLoginFragment : Fragment() {
progress.hide()
webView.background = null
}
override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
super.onReceivedError(view, request, error)
Log.w("WebViewClient", error.toString())
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse
) {
super.onReceivedHttpError(view, request, errorResponse)
Log.w("WebViewClient", "HTTP Error ${errorResponse.statusCode}")
}
}
webView.settings.javaScriptEnabled = true
webView.loadUrl(args.url)

View File

@@ -1,9 +1,14 @@
package net.vonforst.evmap.fragment.preference
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.Preference
@@ -30,6 +35,19 @@ class AboutFragment : PreferenceFragmentCompat() {
exitTransition = MaterialFadeThrough()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = super.onCreateView(inflater, container, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(listView) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -2,8 +2,13 @@ package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceFragmentCompat
@@ -35,6 +40,19 @@ abstract class BaseSettingsFragment : PreferenceFragmentCompat(),
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = super.onCreateView(inflater, container, savedInstanceState)
ViewCompat.setOnApplyWindowInsetsListener(listView) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

View File

@@ -17,12 +17,14 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
import okhttp3.OkHttpClient
import okio.IOException
import java.time.Instant
import androidx.core.net.toUri
class DataSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -146,7 +148,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
"#FFFFFF"
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
@@ -159,7 +161,7 @@ class DataSettingsFragment : BaseSettingsFragment() {
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = Uri.parse(result.getString("url"))
val url = result.getString(OAuthLoginFragment.EXTRA_URL)!!.toUri()
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)

View File

@@ -3,6 +3,11 @@ package net.vonforst.evmap.storage
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
@@ -16,14 +21,15 @@ import java.time.Instant
* successful.
*/
class CacheLiveData<T>(
cache: LiveData<T>,
cache: LiveData<Resource<T>>,
api: LiveData<Resource<T>>,
skipApi: LiveData<Boolean>? = null
) :
MediatorLiveData<Resource<T>>() {
private var cacheResult: T? = null
private var cacheResult: Resource<T>? = null
private var apiResult: Resource<T>? = null
private var skipApiResult: Boolean = false
private val apiLiveData = api
init {
updateValue()
@@ -64,9 +70,21 @@ class CacheLiveData<T>(
Log.d("CacheLiveData", "cache has finished loading before API")
// cache has finished loading before API
if (skipApiResult) {
value = Resource.success(cache)
value = when (cache.status) {
Status.SUCCESS -> cache
Status.ERROR -> {
Log.d("CacheLiveData", "Cache returned an error, querying API")
addSource(apiLiveData) {
apiResult = it
updateValue()
}
Resource.loading(null)
}
Status.LOADING -> cache
}
} else {
value = Resource.loading(cache)
value = Resource.loading(cache.data)
}
} else if (cache == null && api != null) {
Log.d("CacheLiveData", "API has finished loading before cache")
@@ -81,7 +99,7 @@ class CacheLiveData<T>(
// Both cache and API have finished loading
value = when (api.status) {
Status.SUCCESS -> api
Status.ERROR -> Resource.error(api.message, cache)
Status.ERROR -> Resource.error(api.message, cache.data)
Status.LOADING -> api // should not occur
}
}
@@ -128,4 +146,44 @@ class PreferCacheLiveData(
}
}
}
}
/**
* Flow-based implementation that allows loading data both from a cache and an API.
*
* It first tries loading from cache, and if the result is newer than `cacheSoftLimit` it does not
* reload from the API.
*/
fun preferCacheFlow(
cache: Flow<ChargeLocation?>,
api: Flow<Resource<ChargeLocation>>,
cacheSoftLimit: Duration
): Flow<Resource<ChargeLocation>> = flow {
emit(Resource.loading(null)) // initial state
val cacheRes = cache.firstOrNull() // read cache once
if (cacheRes != null) {
if (cacheRes.isDetailed && cacheRes.timeRetrieved > Instant.now() - cacheSoftLimit) {
emit(Resource.success(cacheRes))
return@flow
} else {
emit(Resource.loading(cacheRes))
emitAll(api.map { apiRes ->
when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, cacheRes)
Status.LOADING -> Resource.loading(cacheRes)
}
})
}
} else {
// No cache → straight to API
emitAll(api.map { apiRes ->
when (apiRes.status) {
Status.SUCCESS -> apiRes
Status.ERROR -> Resource.error(apiRes.message, null)
Status.LOADING -> Resource.loading(null)
}
})
}
}

View File

@@ -14,7 +14,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.ChargepointApi
@@ -34,9 +41,9 @@ import net.vonforst.evmap.utils.splitAtAntimeridian
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.await
import net.vonforst.evmap.viewmodel.singleSwitchMap
import java.time.Duration
import java.time.Instant
import kotlin.time.TimeSource
const val CLUSTER_MAX_ZOOM_LEVEL = 11f
@@ -170,10 +177,9 @@ private const val TAG = "ChargeLocationsDao"
* and clustering functionality.
*/
class ChargeLocationsRepository(
api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
private val api: ChargepointApi<ReferenceData>, private val scope: CoroutineScope,
private val db: AppDatabase, private val prefs: PreferenceDataSource
) {
val api = MutableLiveData<ChargepointApi<ReferenceData>>().apply { value = api }
// if zoom level is below this value, server-side clustering will be used (if the API provides it)
private val serverSideClusteringThreshold = 9f
@@ -182,35 +188,33 @@ class ChargeLocationsRepository(
// if cached data is available and more recent than this duration, API will not be queried
private val cacheSoftLimit = Duration.ofDays(1)
val referenceData = this.api.switchMap { api ->
when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenStreetMapApiWrapper -> {
OSMReferenceDataRepository(db.osmReferenceDataDao()).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
val referenceData = when (api) {
is GoingElectricApiWrapper -> {
GEReferenceDataRepository(
api,
scope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
}
is OpenChargeMapApiWrapper -> {
OCMReferenceDataRepository(
api,
scope,
db.ocmReferenceDataDao(),
prefs
).getReferenceData()
}
is OpenStreetMapApiWrapper -> {
OSMReferenceDataRepository(db.osmReferenceDataDao()).getReferenceData()
}
else -> {
throw RuntimeException("no reference data implemented")
}
}.shareIn(scope, SharingStarted.Lazily, 1)
private val chargeLocationsDao = db.chargeLocationsDao()
private val savedRegionDao = db.savedRegionDao()
@@ -221,39 +225,36 @@ class ChargeLocationsRepository(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues?,
overrideCache: Boolean = false
): LiveData<Resource<List<ChargepointListItem>>> {
overrideCache: Boolean = false,
): Flow<List<ChargepointListItem>> {
if (bounds.crossesAntimeridian()) {
val (a, b) = bounds.splitAtAntimeridian()
val liveDataA = getChargepoints(a, zoom, filters, overrideCache)
val liveDataB = getChargepoints(b, zoom, filters, overrideCache)
return combineLiveData(liveDataA, liveDataB)
val flowA = getChargepoints(a, zoom, filters, overrideCache)
val flowB = getChargepoints(b, zoom, filters, overrideCache)
return flowA.combine(flowB) { a, b -> a + b }
}
val api = api.value!!
val t1 = System.currentTimeMillis()
val dbResult = if (filters.isNullOrEmpty()) {
liveData {
emit(
chargeLocationsDao.getChargeLocationsClustered(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api),
zoom
)
val dbResult = flow {
val t1 = TimeSource.Monotonic.markNow()
val result = if (filters.isNullOrEmpty()) {
chargeLocationsDao.getChargeLocationsClustered(
bounds.southwest.latitude,
bounds.northeast.latitude,
bounds.southwest.longitude,
bounds.northeast.longitude,
api.id,
cacheLimitDate(api),
zoom
)
} else {
queryWithFiltersClustered(api, filters, bounds, zoom)
}
} else {
queryWithFiltersClustered(api, filters, bounds, zoom)
}.map {
val t2 = System.currentTimeMillis()
val t2 = TimeSource.Monotonic.markNow()
Log.d(TAG, "DB loading time: ${t2 - t1}")
Log.d(TAG, "number of chargers: ${it.size}")
it
Log.d(TAG, "number of chargers: ${result.size}")
emit(result)
}
val filtersSerialized =
filters?.filter { it.value != it.filter.defaultValue() }?.takeIf { it.isNotEmpty() }
?.serialize()
@@ -270,8 +271,8 @@ class ChargeLocationsRepository(
)
val useClustering = shouldUseServerSideClustering(zoom)
if (api.supportsOnlineQueries) {
val apiResult = liveData {
val refData = referenceData.await()
val apiResult = flow {
val refData = referenceData.first()
val time = Instant.now()
val result = api.getChargepoints(refData, bounds, zoom, useClustering, filters)
emit(applyLocalClustering(result, zoom))
@@ -321,33 +322,7 @@ class ChargeLocationsRepository(
job.join()
progressJob.cancelAndJoin()
}
emit(Resource.success(dbResult.await()))
}
}
}
private fun combineLiveData(
liveDataA: LiveData<Resource<List<ChargepointListItem>>>,
liveDataB: LiveData<Resource<List<ChargepointListItem>>>
) = MediatorLiveData<Resource<List<ChargepointListItem>>>().apply {
listOf(liveDataA, liveDataB).forEach {
addSource(it) {
val valA = liveDataA.value
val valB = liveDataB.value
val combinedList = if (valA?.data != null && valB?.data != null) {
valA.data + valB.data
} else if (valA?.data != null) {
valA.data
} else if (valB?.data != null) {
valB.data
} else null
if (valA?.status == Status.SUCCESS && valB?.status == Status.SUCCESS) {
Resource.success(combinedList)
} else if (valA?.status == Status.ERROR || valB?.status == Status.ERROR) {
Resource.error(valA?.message ?: valB?.message, combinedList)
} else {
Resource.loading(combinedList)
}
emit(dbResult.await())
}
}
}
@@ -357,12 +332,11 @@ class ChargeLocationsRepository(
radius: Int,
filters: FilterValues?
): LiveData<Resource<List<ChargeLocation>>> {
val api = api.value!!
val radiusMeters = radius.toDouble() * 1000
val dbResult = if (filters.isNullOrEmpty()) {
liveData {
emit(
Resource.success(
chargeLocationsDao.getChargeLocationsRadius(
location.latitude,
location.longitude,
@@ -370,6 +344,7 @@ class ChargeLocationsRepository(
api.id,
cacheLimitDate(api)
)
)
)
}
} else {
@@ -390,7 +365,7 @@ class ChargeLocationsRepository(
)
if (api.supportsOnlineQueries) {
val apiResult = liveData {
val refData = referenceData.await()
val refData = referenceData.first()
val time = Instant.now()
val result =
api.getChargepointsRadius(
@@ -446,12 +421,12 @@ class ChargeLocationsRepository(
job.join()
progressJob.cancelAndJoin()
}
emit(Resource.success(dbResult.await()))
emit(dbResult.await())
}
}
}
private fun applyLocalClustering(
private suspend fun applyLocalClustering(
result: Resource<ChargepointList>,
zoom: Float
): Resource<List<ChargepointListItem>> {
@@ -468,7 +443,7 @@ class ChargeLocationsRepository(
return Resource(result.status, clustered, result.message)
}
private fun applyLocalClustering(
private suspend fun applyLocalClustering(
chargers: List<ChargeLocation>,
zoom: Float
): List<ChargepointListItem> {
@@ -478,7 +453,7 @@ class ChargeLocationsRepository(
val useClustering = chargers.size > 500 || zoom <= CLUSTER_MAX_ZOOM_LEVEL
val chargersClustered = if (useClustering) {
Dispatchers.Default.run {
withContext(Dispatchers.Default) {
cluster(chargers, zoom)
}
} else chargers
@@ -488,9 +463,8 @@ class ChargeLocationsRepository(
fun getChargepointDetail(
id: Long,
overrideCache: Boolean = false
): LiveData<Resource<ChargeLocation>> {
val api = api.value!!
val dbResult = liveData {
): Flow<Resource<ChargeLocation>> {
val dbResult = flow {
emit(
chargeLocationsDao.getChargeLocationById(
id,
@@ -500,9 +474,8 @@ class ChargeLocationsRepository(
)
}
if (api.supportsOnlineQueries) {
val apiResult = liveData {
emit(Resource.loading(null))
val refData = referenceData.await()
val apiResult = flow {
val refData = referenceData.first()
val result = api.getChargepointDetail(refData, id)
emit(result)
if (result.status == Status.SUCCESS) {
@@ -512,22 +485,15 @@ class ChargeLocationsRepository(
return if (overrideCache) {
apiResult
} else {
PreferCacheLiveData(dbResult, apiResult, cacheSoftLimit)
preferCacheFlow(dbResult, apiResult, cacheSoftLimit)
}
} else {
return dbResult.map { Resource.success(it) }
}
}
fun getFilters(sp: StringProvider) = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { refData: ReferenceData? ->
refData?.let { value = api.value!!.getFilters(refData, sp) }
}
}
suspend fun getFiltersAsync(sp: StringProvider): List<Filter<FilterValue>> {
val refData = referenceData.await()
return api.value!!.getFilters(refData, sp)
fun getFilters(sp: StringProvider) = referenceData.map {
api.getFilters(it, sp)
}
val chargeCardMap by lazy {
@@ -542,29 +508,29 @@ class ChargeLocationsRepository(
}
}
private fun queryWithFilters(
private suspend fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds
): LiveData<List<ChargeLocation>> {
): List<ChargeLocation> {
return queryWithFilters(api, filters, boundsSpatialIndexQuery(bounds))
}
private fun queryWithFiltersClustered(
private suspend fun queryWithFiltersClustered(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
bounds: LatLngBounds,
zoom: Float
): LiveData<List<ChargepointListItem>> {
): List<ChargepointListItem> {
return queryWithFiltersClustered(api, filters, boundsSpatialIndexQuery(bounds), zoom)
}
private fun queryWithFilters(
private suspend fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
location: LatLng,
radius: Double
): LiveData<List<ChargeLocation>> {
): List<ChargeLocation> {
val region =
radiusSpatialIndexQuery(location, radius)
val order =
@@ -578,68 +544,50 @@ class ChargeLocationsRepository(
private fun radiusSpatialIndexQuery(location: LatLng, radius: Double) =
"PtDistWithin(coordinates, MakePoint(${location.longitude}, ${location.latitude}, 4326), ${radius}) AND ChargeLocation.ROWID IN (SELECT ROWID FROM SpatialIndex WHERE f_table_name = 'ChargeLocation' AND search_frame = BuildCircleMbr(${location.longitude}, ${location.latitude}, $radius))"
private fun queryWithFilters(
private suspend fun queryWithFilters(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
orderSql: String? = null
): LiveData<List<ChargeLocation>> = referenceData.singleSwitchMap { refData ->
try {
val query = api.convertFiltersToSQL(filters, refData)
val after = cacheLimitDate(api)
val sql = buildFilteredQuery(query, regionSql, after, orderSql)
): List<ChargeLocation> {
val query = api.convertFiltersToSQL(filters, referenceData.first())
val after = cacheLimitDate(api)
val sql = buildFilteredQuery(query, regionSql, after, orderSql)
liveData {
emit(
chargeLocationsDao.getChargeLocationsCustom(
SimpleSQLiteQuery(
sql,
null
)
)
)
}
} catch (e: NotImplementedError) {
MutableLiveData() // in this case we cannot get a DB result
}
return chargeLocationsDao.getChargeLocationsCustom(
SimpleSQLiteQuery(
sql,
null
)
)
}
private fun queryWithFiltersClustered(
private suspend fun queryWithFiltersClustered(
api: ChargepointApi<ReferenceData>,
filters: FilterValues,
regionSql: String,
zoom: Float,
orderSql: String? = null
): LiveData<List<ChargepointListItem>> = referenceData.singleSwitchMap { refData ->
try {
if (zoom > CLUSTER_MAX_ZOOM_LEVEL) {
queryWithFilters(api, filters, regionSql, orderSql).map { it }
} else {
val query = api.convertFiltersToSQL(filters, refData)
val after = cacheLimitDate(api)
val clusterPrecision = getClusterPrecision(zoom)
val sql = buildFilteredQuery(query, regionSql, after, orderSql, clusterPrecision)
): List<ChargepointListItem> = if (zoom > CLUSTER_MAX_ZOOM_LEVEL) {
queryWithFilters(api, filters, regionSql, orderSql).map { it }
} else {
val query = api.convertFiltersToSQL(filters, referenceData.first())
val after = cacheLimitDate(api)
val clusterPrecision = getClusterPrecision(zoom)
val sql = buildFilteredQuery(query, regionSql, after, orderSql, clusterPrecision)
liveData {
val clusters = chargeLocationsDao.getChargeLocationClustersCustom(
SimpleSQLiteQuery(
sql,
null
)
)
val singleChargers =
chargeLocationsDao.getChargeLocationsById(clusters.filter { it.clusterCount == 1 }
.map { it.ids }
.flatten(), prefs.dataSource, after)
emit(
clusters.filter { it.clusterCount > 1 }
.map { it.convert() } + singleChargers
)
}
}
} catch (e: NotImplementedError) {
MutableLiveData() // in this case we cannot get a DB result
}
val clusters = chargeLocationsDao.getChargeLocationClustersCustom(
SimpleSQLiteQuery(
sql,
null
)
)
val singleChargers =
chargeLocationsDao.getChargeLocationsById(clusters.filter { it.clusterCount == 1 }
.map { it.ids }
.flatten(), prefs.dataSource, after)
clusters.filter { it.clusterCount > 1 }
.map { it.convert() } + singleChargers
}
private fun buildFilteredQuery(
@@ -679,7 +627,6 @@ class ChargeLocationsRepository(
}.toString()
private suspend fun fullDownload() {
val api = api.value!!
if (!api.supportsFullDownload) return
val time = Instant.now()

View File

@@ -1,9 +1,15 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEReferenceData
@@ -36,7 +42,7 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM genetwork")
abstract fun getAllNetworks(): LiveData<List<GENetwork>>
abstract fun getAllNetworks(): Flow<List<GENetwork>>
// PLUGS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -54,7 +60,7 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM geplug")
abstract fun getAllPlugs(): LiveData<List<GEPlug>>
abstract fun getAllPlugs(): Flow<List<GEPlug>>
// CHARGE CARDS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -72,31 +78,21 @@ abstract class GEReferenceDataDao {
}
@Query("SELECT * FROM gechargecard")
abstract fun getAllChargeCards(): LiveData<List<GEChargeCard>>
abstract fun getAllChargeCards(): Flow<List<GEChargeCard>>
}
class GEReferenceDataRepository(
private val api: GoingElectricApiWrapper, private val scope: CoroutineScope,
private val dao: GEReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): LiveData<GEReferenceData> {
fun getReferenceData(): Flow<GEReferenceData> {
scope.launch {
updateData()
}
val plugs = dao.getAllPlugs()
val networks = dao.getAllNetworks()
val chargeCards = dao.getAllChargeCards()
return MediatorLiveData<GEReferenceData>().apply {
value = null
listOf(chargeCards, networks, plugs).map { source ->
addSource(source) { _ ->
val p = plugs.value ?: return@addSource
val n = networks.value ?: return@addSource
val cc = chargeCards.value ?: return@addSource
value = GEReferenceData(p.map { it.name }, n.map { it.name }, cc)
}
}
}
return combine(plugs, networks, chargeCards) { p, n, c -> GEReferenceData(p.map { it.name }, n.map { it.name }, c) }
}
private suspend fun updateData() {

View File

@@ -1,11 +1,19 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.openchargemap.*
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.api.openchargemap.OCMReferenceData
import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
@@ -28,7 +36,7 @@ abstract class OCMReferenceDataDao {
}
@Query("SELECT * FROM ocmconnectiontype")
abstract fun getAllConnectionTypes(): LiveData<List<OCMConnectionType>>
abstract fun getAllConnectionTypes(): Flow<List<OCMConnectionType>>
// COUNTRIES
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -46,7 +54,7 @@ abstract class OCMReferenceDataDao {
}
@Query("SELECT * FROM ocmcountry")
abstract fun getAllCountries(): LiveData<List<OCMCountry>>
abstract fun getAllCountries(): Flow<List<OCMCountry>>
// OPERATORS
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -64,32 +72,21 @@ abstract class OCMReferenceDataDao {
}
@Query("SELECT * FROM ocmoperator")
abstract fun getAllOperators(): LiveData<List<OCMOperator>>
abstract fun getAllOperators(): Flow<List<OCMOperator>>
}
class OCMReferenceDataRepository(
private val api: OpenChargeMapApiWrapper, private val scope: CoroutineScope,
private val dao: OCMReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): LiveData<OCMReferenceData> {
fun getReferenceData(): Flow<OCMReferenceData> {
scope.launch {
updateData()
}
val connectionTypes = dao.getAllConnectionTypes()
val countries = dao.getAllCountries()
val operators = dao.getAllOperators()
return MediatorLiveData<OCMReferenceData>().apply {
value = null
listOf(countries, connectionTypes, operators).map { source ->
addSource(source) { _ ->
val ct = connectionTypes.value
val c = countries.value
val o = operators.value
if (ct.isNullOrEmpty() || c.isNullOrEmpty() || o.isNullOrEmpty()) return@addSource
value = OCMReferenceData(ct, c, o)
}
}
}
return combine(connectionTypes, countries, operators) { ct, c, o -> OCMReferenceData(ct, c, o) }
}
private suspend fun updateData() {

View File

@@ -4,6 +4,8 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.openchargemap.*
import net.vonforst.evmap.api.openstreetmap.OSMReferenceData
@@ -32,19 +34,13 @@ abstract class OSMReferenceDataDao {
}
@Query("SELECT * FROM osmnetwork")
abstract fun getAllNetworks(): LiveData<List<OSMNetwork>>
abstract fun getAllNetworks(): Flow<List<OSMNetwork>>
}
class OSMReferenceDataRepository(private val dao: OSMReferenceDataDao) {
fun getReferenceData(): LiveData<OSMReferenceData> {
fun getReferenceData(): Flow<OSMReferenceData> {
val networks = dao.getAllNetworks()
return MediatorLiveData<OSMReferenceData>().apply {
value = null
addSource(networks) { _ ->
val n = networks.value ?: return@addSource
value = OSMReferenceData(n.map { it.name })
}
}
return networks.map { OSMReferenceData(it.map { it.name }) }
}
suspend fun updateReferenceData(refData: OSMReferenceData) {

View File

@@ -1,8 +1,12 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.room.*
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Index
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.SkipQueryVerification
import co.anbora.labs.spatia.geometry.Geometry
import co.anbora.labs.spatia.geometry.LineString
import co.anbora.labs.spatia.geometry.Polygon
@@ -35,31 +39,31 @@ abstract class SavedRegionDao {
@SkipQueryVerification
@Query("SELECT Covers(GUnion(region), BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, BuildMbr(:lng1, :lat1, :lng2, :lat2, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
protected abstract fun savedRegionCoversInt(
protected abstract suspend fun savedRegionCoversInt(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Int>
): Int
@SkipQueryVerification
@Query("SELECT Covers(GUnion(region), MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) FROM savedregion WHERE dataSource == :dataSource AND timeRetrieved > :after AND Intersects(region, MakeEllipse(:lng, :lat, :radiusLng, :radiusLat, 4326)) AND (filters == :filters OR filters IS NULL) AND (isDetailed OR NOT :isDetailed)")
protected abstract fun savedRegionCoversRadiusInt(
protected abstract suspend fun savedRegionCoversRadiusInt(
lat: Double,
lng: Double,
radiusLat: Double,
radiusLng: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Int>
): Int
fun savedRegionCovers(
suspend fun savedRegionCovers(
lat1: Double,
lat2: Double,
lng1: Double,
lng2: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Boolean> {
): Boolean {
return savedRegionCoversInt(
lat1,
lat2,
@@ -69,15 +73,15 @@ abstract class SavedRegionDao {
after,
filters,
isDetailed
).map { it == 1 }
) == 1
}
fun savedRegionCoversRadius(
suspend fun savedRegionCoversRadius(
lat: Double,
lng: Double,
radius: Double,
dataSource: String, after: Long, filters: String? = null, isDetailed: Boolean = false
): LiveData<Boolean> {
): Boolean {
val (radiusLat, radiusLng) = circleAsEllipse(lat, lng, radius)
return savedRegionCoversRadiusInt(
lat,
@@ -88,7 +92,7 @@ abstract class SavedRegionDao {
after,
filters,
isDetailed
).map { it == 1 }
) == 1
}
@Insert

View File

@@ -148,20 +148,4 @@ suspend fun <T> LiveData<Resource<T>>.awaitFinished(): Resource<T> {
removeObserver(observer)
}
}
}
inline fun <X, Y> LiveData<X>.singleSwitchMap(crossinline transform: (X) -> LiveData<Y>?): MediatorLiveData<Y> {
val result = MediatorLiveData<Y>()
result.addSource(this@singleSwitchMap, object : Observer<X> {
override fun onChanged(t: X) {
if (t == null) return
result.removeSource(this@singleSwitchMap)
transform(t)?.let { transformed ->
result.addSource(transformed) {
result.value = it
}
}
}
})
return result
}

View File

@@ -58,6 +58,7 @@
android:id="@+id/charge_prices_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View File

@@ -46,6 +46,7 @@
android:id="@+id/favs_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
app:data="@{vm.listData}" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@@ -35,6 +35,7 @@
android:id="@+id/filters_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
app:data="@{vm.filtersWithValue}"
tools:itemCount="3"
tools:listitem="@layout/item_filter_boolean" />

View File

@@ -38,6 +38,7 @@
android:id="@+id/filter_profiles_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
app:data="@{vm.filterProfiles}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"

View File

@@ -247,6 +247,15 @@
app:layout_behavior="@string/hide_on_scroll_fab_behavior"
android:theme="@style/NoElevationOverlay" />
<View
android:id="@+id/navBarScrim"
android:layout_width="match_parent"
android:layout_height="16dp"
android:background="?android:colorBackground"
android:layout_gravity="bottom"
app:invisibleUnless="@{vm.bottomSheetState == BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED}"
tools:visibility="invisible" />
<androidx.cardview.widget.CardView
android:id="@+id/layers_sheet"
android:layout_height="wrap_content"

View File

@@ -377,4 +377,7 @@
<string name="referral_link">https://ev-map.app/referrals/</string>
<string name="mastodon">Mastodon</string>
<string name="tff_forum">Vlákno na fóru TFF-Forum.de</string>
<string name="data_source_openstreetmap">OpenStreetMap</string>
<string name="data_source_openstreetmap_desc">Experimentální podpora v EVMap, nejsou dostupné všechny funkce.</string>
<string name="downloading_chargers_percent">Stahování… %.0f%%</string>
</resources>

View File

@@ -373,4 +373,7 @@
<string name="referrals_info">Kui peale mõnele järgnevatest linkidest klikkamist ostad kaupu või teenused, siis toetad sellega EVMapi arendajat.</string>
<string name="tff_forum">Jutulõng TFF-Forum.de kasutajate foorumis</string>
<string name="mastodon">Mastodon</string>
<string name="data_source_openstreetmap">OpenStreetMap</string>
<string name="data_source_openstreetmap_desc">Katseline tugi EVMapis - kõik funktsionaalsused pole saadaval.</string>
<string name="downloading_chargers_percent">Laadin alla… %.0f%%</string>
</resources>

View File

@@ -345,7 +345,7 @@
</plurals>
<string name="data_source_goingelectric_desc">Ottimo nei paesi di lingua tedesca. Descrizioni in tedesco. Mantenuto dalla comunità.</string>
<string name="auto_no_data">Non disponibile</string>
<string name="auto_location_permission_needed">Per eseguire EVMap su Android Auto, devi concedere l\'accesso alla propria posizione.</string>
<string name="auto_location_permission_needed">Per eseguire EVMap sulla propria auto, è necessario concedere l\'accesso alla propria posizione.</string>
<string name="prediction_help">La previsione si basa su fattori quali il giorno della settimana, l\'ora del giorno e l\'utilizzo passato, in modo da evitare le colonnine di ricarica sovraffollate. Nessuna garanzia a riguardo.</string>
<string name="charger_website">Sito web</string>
<string name="pref_map_rotate_gestures_on">Usa due dita per ruotare la mappa</string>
@@ -377,4 +377,7 @@
<string name="pref_chargeprice_native_integration_on">I dati sui prezzi saranno visualizzati direttamente in EVMap</string>
<string name="accept_privacy"><![CDATA[Ho letto e accettato l\'<a href=\"%s\">informativa sulla privacy</a> di EVMap.]]></string>
<string name="auto_multipage_goto">Pagina %d</string>
<string name="data_source_openstreetmap">OpenStreetMap</string>
<string name="data_source_openstreetmap_desc">Supporto sperimentale in EVMap, non tutte le funzionalità sono disponibili.</string>
<string name="downloading_chargers_percent">Scaricamento… %.0f%%</string>
</resources>

View File

@@ -377,4 +377,7 @@
<string name="referral_link">https://ev-map.app/referrals/</string>
<string name="tff_forum">Tópico no fórum TFF-Forum.de</string>
<string name="mastodon">Mastodon</string>
<string name="data_source_openstreetmap">OpenStreetMap</string>
<string name="data_source_openstreetmap_desc">Suporte experimental, algumas funcionalidades não estão disponíveis.</string>
<string name="downloading_chargers_percent">A descarregar… %.0f%%</string>
</resources>

View File

@@ -10,12 +10,10 @@ import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ApplicationProvider
import net.vonforst.evmap.FakeAndroidKeyStore
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
@RunWith(RobolectricTestRunner::class)

View File

@@ -2,15 +2,15 @@
buildscript {
val kotlinVersion by extra("2.0.21")
val aboutLibsVersion by extra("10.9.1")
val navVersion by extra("2.7.7")
val aboutLibsVersion by extra("12.2.4")
val navVersion by extra("2.9.3")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
classpath("com.android.tools.build:gradle:8.9.3")
classpath("com.android.tools.build:gradle:8.12.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$aboutLibsVersion")
classpath("androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion")

View File

@@ -1,6 +1,6 @@
#Sat Aug 06 15:33:46 CEST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME