mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-24 23:57:45 -05:00
Compare commits
29 Commits
openstreet
...
flows
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0685f14d06 | ||
|
|
7e96c9e5a7 | ||
|
|
44bd2c6159 | ||
|
|
7d2a19b0a3 | ||
|
|
3414a7581c | ||
|
|
df47f7b4c1 | ||
|
|
a08e2ab7e9 | ||
|
|
c1351ce935 | ||
|
|
b4a1a8b546 | ||
|
|
3865e6c33d | ||
|
|
091b0f5ac3 | ||
|
|
1148200f37 | ||
|
|
1847e8b771 | ||
|
|
bbfe8e2bb2 | ||
|
|
983d368a78 | ||
|
|
4a6a34db3a | ||
|
|
35ddece698 | ||
|
|
36c6a4053d | ||
|
|
104913b3c4 | ||
|
|
5cc510fe22 | ||
|
|
4250eb2ba8 | ||
|
|
1db82db066 | ||
|
|
d6a8fbee7d | ||
|
|
23e2f0baad | ||
|
|
ea4fb37f30 | ||
|
|
094f38ac87 | ||
|
|
b84d13d42b | ||
|
|
845bd2e5ca | ||
|
|
0b68ddb939 |
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user