diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b317bc7f..e4223f55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,11 @@ jobs: steps: - name: Checkout the code uses: actions/checkout@v2 + - name: Setup environment + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + echo "${GOOGLE_SERVICES_JSON}" | base64 --decode > app/google-services.json - name: Set up JDK 1.8 uses: actions/setup-java@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da056a24..a1b7dfb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,11 @@ jobs: - run: | echo "${{ secrets.KEYSTORE_FILE_BASE64 }}" > keystore.asc gpg -d --passphrase "${{ secrets.KEYSTORE_FILE_DECRYPT_PASSWORD }}" --batch keystore.asc > .keystore + - name: Setup environment + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + echo "${GOOGLE_SERVICES_JSON}" | base64 --decode > app/google-services.json - name: Set up JDK 1.8 uses: actions/setup-java@v1 with: @@ -26,10 +31,6 @@ jobs: KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} run: | ./gradlew assembleGithubRelease - sudo apt-get -y install tree - tree app/build/outputs - ls -lah app/build/ - ls -lah - name: Get the version id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} diff --git a/README.md b/README.md index c44d0f9f..fec59ba0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![github-ci](https://github.com/vadret/android/workflows/V%C3%A4dret%20Android%20App/badge.svg)](https://github.com/vadret/android/actions) +[![github-releases](https://img.shields.io/github/v/release/sphrak/vadret)](https://github.com/vadret/android/releases/) [![ktlint](https://img.shields.io/badge/code%20style-%E2%9D%A4-FF4081.svg)](https://ktlint.github.io/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/vadret/android/blob/master/LICENSE) [![CodeFactor](https://www.codefactor.io/repository/github/vadret/android/badge)](https://www.codefactor.io/repository/github/vadret/android) @@ -6,7 +7,7 @@ ![Vädret](https://raw.githubusercontent.com/vadret/android/master/assets/logo.png) # Vädret -A simple weather app that uses the Swedish weather service [SMHI](https://opendata-download-metfcst.smhi.se/) to fetch weather data, and [Krisinformation](https://www.krisinformation.se/en) for emergency information from Swedish authorities. Built with MVI/MVVM in mind on top of RxJava2, written entirely in Kotlin. Icons used in this project can be found [here](https://github.com/vadret/assets). The data is licensed under [Creative commons Erkännande 4.0 SE](https://www.smhi.se/klimatdata/oppna-data/information-om-oppna-data/villkor-for-anvandning-1.30622). +A simple weather app that uses the Swedish weather service [SMHI](https://opendata-download-metfcst.smhi.se/) to fetch weather data, and [Krisinformation](https://www.krisinformation.se/en) for emergency information from Swedish authorities. MVI written entirely in Kotlin. Icons used in this project can be found [here](https://github.com/vadret/assets). The data is licensed under [Creative commons Erkännande 4.0 SE](https://www.smhi.se/klimatdata/oppna-data/information-om-oppna-data/villkor-for-anvandning-1.30622). ![Weather](https://raw.githubusercontent.com/vadret/android/master/assets/weather.png) ![Warning](https://raw.githubusercontent.com/vadret/android/master/assets/warning.png) diff --git a/app/build.gradle b/app/build.gradle index 46f731d4..3d051ae8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,9 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlinx-serialization' +apply plugin: 'com.google.gms.google-services' +apply plugin: 'com.google.firebase.crashlytics' def static loadFromEnvironment(String key) { return System.getenv(key) @@ -39,8 +42,8 @@ android { applicationId "fi.kroon.vadret" minSdkVersion 21 targetSdkVersion 30 - versionCode 26 - versionName "1.2.10" + versionCode 27 + versionName "2.0.0" vectorDrawables.useSupportLibrary = true flavorDimensions "default" testInstrumentationRunner "androidx.top.runner.AndroidJUnitRunner" @@ -96,6 +99,9 @@ android { productFlavors { google { signingConfig signingConfigs.release + firebaseCrashlytics { + mappingFileUploadEnabled true + } } fdroid { signingConfig fdroid.signingConfig @@ -131,18 +137,18 @@ dependencies { def CONSTRAINT_LAYOUT_VERSION = "2.0.2" def CORBIND_VERSION = "1.4.0" def CORE_KTX_VERSION = "1.3.2" - def COROUTINES_VERSION = "1.4.0-M1" - def CRASHLYTICS_VERSION = "2.10.1" - def DAGGER_VERSION = "2.29.1" + def COROUTINES_VERSION = "1.4.2" + def DAGGER_VERSION = "2.30.1" def EITHER_VERSION = "1.2.0" def FRAGMENT_VERSION = "1.3.0-beta01" def JUNIT_VERSION = "4.13" def KOTLIN_STDLIB_VERSION = "1.4.10" - def KTLINT_VERSION = "0.39.0" + def KOTLINX_SERIALIZATION = "1.0.1" + def KTLINT_VERSION = "0.40.0" def MATERIAL_VERSION = "1.2.1" def MOCKITO_CORE_VERSION = "3.1.0" def MOSHI_VERSION = "1.9.2" - def NAVIGATION_VERSION = "2.3.1" + def NAVIGATION_VERSION = "2.3.2" def OKHTTP_VERSION = "4.2.2" def OKIO_VERSION = "2.6.0" def OSMDROID_VERSION = "6.1.2" @@ -158,6 +164,7 @@ dependencies { def THREETEN_ABP_VERSION = "1.2.1" def THREETEN_BP_VERSION = "1.4.0" def TIMBER_VERSION = "4.7.1" + def LIFECYCLE_VERSION = "2.2.0" implementation fileTree(dir: 'libs', include: ['*.jar']) @@ -171,6 +178,11 @@ dependencies { implementation "androidx.preference:preference-ktx:${PREFERENCE_VERSION}" implementation "com.google.android.material:material:${MATERIAL_VERSION}" + // Lifecycle + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$LIFECYCLE_VERSION" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$LIFECYCLE_VERSION" + kapt "androidx.lifecycle:lifecycle-compiler:$LIFECYCLE_VERSION" + // Coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${COROUTINES_VERSION}" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${COROUTINES_VERSION}" @@ -196,6 +208,7 @@ dependencies { implementation "com.squareup.retrofit2:adapter-rxjava2:${RETROFIT_VERSION}" implementation "com.squareup.retrofit2:converter-moshi:${RETROFIT_VERSION}" implementation "com.squareup.retrofit2:retrofit:${RETROFIT_VERSION}" + implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0" // Database implementation "androidx.room:room-runtime:${ROOM_VERSION}" @@ -208,6 +221,7 @@ dependencies { implementation "com.squareup.moshi:moshi:${MOSHI_VERSION}" implementation "com.squareup.retrofit2:converter-moshi:${RETROFIT_VERSION}" kapt "com.squareup.moshi:moshi-kotlin-codegen:${MOSHI_VERSION}" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:${KOTLINX_SERIALIZATION}" // Dependency Injection implementation "com.google.dagger:dagger:${DAGGER_VERSION}" @@ -229,9 +243,8 @@ dependencies { // Debugging, Testing, Linting, Analytics debugImplementation "com.squareup.leakcanary:leakcanary-android:2.5" - googleImplementation("com.crashlytics.sdk.android:crashlytics:${CRASHLYTICS_VERSION}@aar") { - transitive = true - } + googleImplementation platform('com.google.firebase:firebase-bom:26.0.0') + googleImplementation 'com.google.firebase:firebase-crashlytics-ktx' implementation "com.jakewharton.timber:timber:${TIMBER_VERSION}" ktlint "com.pinterest:ktlint:${KTLINT_VERSION}" testImplementation "junit:junit:${JUNIT_VERSION}" diff --git a/app/src/googleRelease/java/fi/kroon/vadret/VadretApplication.kt b/app/src/googleRelease/java/fi/kroon/vadret/VadretApplication.kt index 9d9ec9db..883dc8cc 100644 --- a/app/src/googleRelease/java/fi/kroon/vadret/VadretApplication.kt +++ b/app/src/googleRelease/java/fi/kroon/vadret/VadretApplication.kt @@ -1,9 +1,7 @@ package fi.kroon.vadret import android.content.Context -import com.crashlytics.android.Crashlytics -import com.crashlytics.android.core.CrashlyticsCore -import io.fabric.sdk.android.Fabric +import com.google.firebase.crashlytics.FirebaseCrashlytics class VadretApplication : BaseApplication() { @@ -19,9 +17,6 @@ class VadretApplication : BaseApplication() { } private fun initCrashlytics() { - val crashlyticsKit = Crashlytics.Builder() - .core(CrashlyticsCore.Builder().disabled(BuildConfig.DEBUG).build()) - .build() - Fabric.with(this, crashlyticsKit) + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15129e51..1e4b92b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ + \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepository.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepository.kt index ad746811..a5763972 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepository.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepository.kt @@ -11,19 +11,13 @@ import fi.kroon.vadret.data.weatherforecast.model.Weather import fi.kroon.vadret.data.weatherforecast.model.WeatherOut import fi.kroon.vadret.data.weatherforecast.net.WeatherForecastNetDataSource import fi.kroon.vadret.util.HTTP_200_OK -import fi.kroon.vadret.util.HTTP_204_NO_CONTENT -import fi.kroon.vadret.util.HTTP_400_BAD_REQUEST -import fi.kroon.vadret.util.HTTP_403_FORBIDDEN -import fi.kroon.vadret.util.HTTP_404_NOT_FOUND -import fi.kroon.vadret.util.HTTP_500_INTERNAL_SERVER_ERROR -import fi.kroon.vadret.util.HTTP_503_SERVICE_UNAVAILABLE -import fi.kroon.vadret.util.HTTP_504_GATEWAY_TIMEOUT import fi.kroon.vadret.util.NetworkHandler import fi.kroon.vadret.util.extension.asLeft +import fi.kroon.vadret.util.extension.asRight import fi.kroon.vadret.util.extension.toCoordinate import io.github.sphrak.either.Either -import io.reactivex.Single import retrofit2.Response +import timber.log.Timber import javax.inject.Inject class WeatherForecastRepository @Inject constructor( @@ -33,9 +27,9 @@ class WeatherForecastRepository @Inject constructor( private val exceptionHandler: ExceptionHandler ) : IErrorHandler by errorHandler, IExceptionHandler by exceptionHandler { - operator fun invoke(request: WeatherOut): Single> = - when (networkHandler.isConnected) { - true -> + suspend operator fun invoke(request: WeatherOut): Either = + try { + if (networkHandler.isConnected) { weatherForecastNetDataSource .get() .getWeatherForecast( @@ -43,22 +37,20 @@ class WeatherForecastRepository @Inject constructor( request.version, request.longitude.toCoordinate(), request.latitude.toCoordinate() - ).map { response: Response -> - when (response.code()) { - HTTP_200_OK -> Either.Right(response.body()!!) - HTTP_204_NO_CONTENT -> WeatherForecastFailure.NoWeatherAvailable.asLeft() - HTTP_403_FORBIDDEN -> Failure.HttpForbidden403.asLeft() - HTTP_404_NOT_FOUND -> WeatherForecastFailure.NoWeatherAvailableForThisLocation.asLeft() - HTTP_400_BAD_REQUEST -> Failure.HttpBadRequest400.asLeft() - HTTP_500_INTERNAL_SERVER_ERROR -> Failure.HttpInternalServerError500.asLeft() - HTTP_503_SERVICE_UNAVAILABLE -> Failure.HttpServiceUnavailable503.asLeft() - HTTP_504_GATEWAY_TIMEOUT -> Failure.HttpGatewayTimeout504.asLeft() - else -> WeatherForecastFailure.NoWeatherAvailable.asLeft() + ).let { response: Response -> + if (response.code() == HTTP_200_OK) { + response.body()?.asRight() ?: WeatherForecastFailure.NoWeatherAvailable.asLeft() + } else { + WeatherForecastFailure.NoWeatherAvailable.asLeft() } } - false -> getNetworkOfflineError() - }.onErrorReturn { - exceptionHandler(it) - .asLeft() + } else { + Failure + .NetworkOfflineError("error: network offline or not available") + .asLeft() + } + } catch (exception: Exception) { + Timber.e(exception) + Failure.NetworkError().asLeft() } } \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/cache/WeatherForecastCacheDataSource.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/cache/WeatherForecastCacheDataSource.kt deleted file mode 100644 index 76fb3642..00000000 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/cache/WeatherForecastCacheDataSource.kt +++ /dev/null @@ -1,75 +0,0 @@ -package fi.kroon.vadret.data.weatherforecast.cache - -import androidx.collection.LruCache -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.model.Weather -import fi.kroon.vadret.util.extension.asLeft -import fi.kroon.vadret.util.extension.asRight -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class WeatherForecastCacheDataSource @Inject constructor( - private val diskCache: WeatherForecastDiskCache, - private val memoryCache: LruCache -) { - - /** - * In-memory Cache - */ - fun getMemoryCache(cacheKey: String): Single> = - Single.fromCallable { - memoryCache - .snapshot() - .getValue(cacheKey) - .asRight() as Either - }.onErrorReturn { - Failure - .MemoryCacheLruReadFailure - .asLeft() - } - - fun updateMemoryCache(cacheKey: String, weather: Weather): Single> { - memoryCache.put(cacheKey, weather) - return Single.just( - weather.asRight() as Either - ).onErrorReturn { - Failure - .MemoryCacheLruWriteFailure - .asLeft() - } - } - - fun clearMemoryCache(): Single> = Single.fromCallable { - memoryCache - .evictAll() - Unit.asRight() as Either - }.doOnError { failure -> - Timber.e("Memory cache eviction failed: $failure") - }.onErrorReturn { - Failure - .MemoryCacheEvictionFailure - .asLeft() - } - - /** - * Disk IO Cache - */ - fun getDiskCache(cacheKey: String): Single> = diskCache - .read(cacheKey) - - fun updateDiskCache(weather: Weather, cacheKey: String): Single> = - diskCache.put(cacheKey, weather) - - fun clearDiskCache(cacheKey: String): Single> = Single.fromCallable { - diskCache.remove(cacheKey) - Unit.asRight() as Either - }.doOnError { failure -> - Timber.e("Disk cache eviction failed: $failure") - }.onErrorReturn { - Failure - .DiskCacheEvictionFailure - .asLeft() - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/cache/WeatherForecastDiskCache.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/cache/WeatherForecastDiskCache.kt deleted file mode 100644 index a5a62f29..00000000 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/cache/WeatherForecastDiskCache.kt +++ /dev/null @@ -1,74 +0,0 @@ -package fi.kroon.vadret.data.weatherforecast.cache - -import fi.kroon.vadret.data.common.BaseCache -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.model.Weather -import fi.kroon.vadret.util.extension.asLeft -import fi.kroon.vadret.util.extension.asRight -import io.github.sphrak.either.Either -import io.reactivex.Single -import okhttp3.internal.cache.DiskLruCache -import okio.BufferedSink -import okio.Sink -import okio.buffer -import timber.log.Timber -import javax.inject.Inject - -class WeatherForecastDiskCache @Inject constructor( - private val cache: DiskLruCache -) : BaseCache() { - - init { - cache.initialize() - } - - private companion object { - const val INDEX = 0 - } - - fun put(cacheKey: String, weather: Weather): Single> = - Single.fromCallable { - val editor: DiskLruCache.Editor? = cache.edit(cacheKey) - val sink: Sink? = editor?.newSink(INDEX) - val bufferedSink: BufferedSink? = sink?.buffer() - val byteArray: ByteArray = serializerObject(weather) - - bufferedSink.use { _ -> - bufferedSink?.write(byteArray) - } - editor?.commit() - - weather.asRight() as Either - }.doOnError { - Timber.e("Disk cache insert failed: $it") - }.onErrorReturn { - Failure - .DiskCacheLruWriteFailure - .asLeft() - } - - fun read(cacheKey: String): Single> = Single.fromCallable { - val snapshot: DiskLruCache.Snapshot = cache[cacheKey]!! - val byteArray: ByteArray - byteArray = snapshot.getSource(INDEX) - .buffer() - .readByteArray() - - deserializeBytes(byteArray) - .asRight() as Either - }.onErrorReturn { - Failure - .DiskCacheLruReadFailure - .asLeft() - } - - fun remove(cacheKey: String): Single> = Single.fromCallable { - cache - .remove(cacheKey) - .asRight() as Either - }.onErrorReturn { - Failure - .DiskCacheEvictionFailure - .asLeft() - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/exception/WeatherForecastFailure.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/exception/WeatherForecastFailure.kt index 335d3d99..4b11386c 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/exception/WeatherForecastFailure.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/exception/WeatherForecastFailure.kt @@ -5,5 +5,4 @@ import fi.kroon.vadret.data.failure.Failure class WeatherForecastFailure { object NoWeatherAvailable : Failure.FeatureFailure() object NoWeatherAvailableForThisLocation : Failure.FeatureFailure() - object CachingWeatherForecastDataFailed : Failure.FeatureFailure() } \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Geometry.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Geometry.kt index 02e2d612..caa8445d 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Geometry.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Geometry.kt @@ -1,14 +1,12 @@ package fi.kroon.vadret.data.weatherforecast.model -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import java.io.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -@JsonClass(generateAdapter = true) +@Serializable data class Geometry( - @Json(name = "type") + @SerialName(value = "type") val type: String = "Point", - @Json(name = "coordinates") + @SerialName(value = "coordinates") val coordinates: List> - -) : Serializable \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Parameter.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Parameter.kt index 971fb238..39096015 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Parameter.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Parameter.kt @@ -1,24 +1,23 @@ package fi.kroon.vadret.data.weatherforecast.model -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import java.io.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -@JsonClass(generateAdapter = true) +@Serializable data class Parameter( - @Json(name = "name") + @SerialName(value = "name") val name: String, - @Json(name = "levelType") + @SerialName(value = "levelType") val levelType: String, - @Json(name = "level") - val level: String, + @SerialName(value = "level") + val level: Int, - @Json(name = "unit") + @SerialName(value = "unit") val unit: String, - @Json(name = "values") - val values: List -) : Serializable \ No newline at end of file + @SerialName(value = "values") + val values: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/TimeSerie.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/TimeSerie.kt index 380aad90..06b7b578 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/TimeSerie.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/TimeSerie.kt @@ -1,13 +1,12 @@ package fi.kroon.vadret.data.weatherforecast.model -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import java.io.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -@JsonClass(generateAdapter = true) +@Serializable data class TimeSerie( - @Json(name = "validTime") + @SerialName(value = "validTime") val validTime: String, - @Json(name = "parameters") - val parameters: List -) : Serializable \ No newline at end of file + @SerialName(value = "parameters") + val parameters: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Weather.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Weather.kt index 48fb9930..387d27c3 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Weather.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/model/Weather.kt @@ -1,25 +1,21 @@ package fi.kroon.vadret.data.weatherforecast.model -import android.os.Parcelable -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import kotlinx.android.parcel.Parcelize -import java.io.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -@Parcelize -@JsonClass(generateAdapter = true) +@Serializable data class Weather( - @Json(name = "approvedTime") + @SerialName(value = "approvedTime") val approvedTime: String, - @Json(name = "referenceTime") + @SerialName(value = "referenceTime") val referenceTime: String, - @Json(name = "geometry") + @SerialName(value = "geometry") val geometry: Geometry, - @Json(name = "timeSeries") - val timeSeries: List? = emptyList() + @SerialName(value = "timeSeries") + val timeSeries: List = emptyList() -) : Serializable, Parcelable \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/net/WeatherForecastNetDataSource.kt b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/net/WeatherForecastNetDataSource.kt index 987e3d97..2da7e916 100644 --- a/app/src/main/java/fi/kroon/vadret/data/weatherforecast/net/WeatherForecastNetDataSource.kt +++ b/app/src/main/java/fi/kroon/vadret/data/weatherforecast/net/WeatherForecastNetDataSource.kt @@ -1,7 +1,6 @@ package fi.kroon.vadret.data.weatherforecast.net import fi.kroon.vadret.data.weatherforecast.model.Weather -import io.reactivex.Single import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Path @@ -12,10 +11,10 @@ interface WeatherForecastNetDataSource { * Provide lat/lon for 10 day forecast */ @GET("/api/category/{category}/version/{version}/geotype/point/lon/{longitude}/lat/{latitude}/data.json") - fun getWeatherForecast( + suspend fun getWeatherForecast( @Path("category") category: String, @Path("version") version: Int, @Path("longitude") longitude: Double, @Path("latitude") latitude: Double - ): Single> + ): Response } \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/ClearWeatherForecastDiskCacheTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/ClearWeatherForecastDiskCacheTask.kt deleted file mode 100644 index 9ae1bb5e..00000000 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/ClearWeatherForecastDiskCacheTask.kt +++ /dev/null @@ -1,19 +0,0 @@ -package fi.kroon.vadret.domain.weatherforecast - -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.cache.WeatherForecastCacheDataSource -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class ClearWeatherForecastDiskCacheTask @Inject constructor( - private val cacheRepo: WeatherForecastCacheDataSource -) { - operator fun invoke(cacheKey: String): Single> = - cacheRepo - .clearDiskCache(cacheKey) - .doOnError { - Timber.e("DisplayError clearing disk cache: $it") - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/ClearWeatherForecastMemoryCacheTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/ClearWeatherForecastMemoryCacheTask.kt deleted file mode 100644 index e5652327..00000000 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/ClearWeatherForecastMemoryCacheTask.kt +++ /dev/null @@ -1,19 +0,0 @@ -package fi.kroon.vadret.domain.weatherforecast - -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.cache.WeatherForecastCacheDataSource -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class ClearWeatherForecastMemoryCacheTask @Inject constructor( - private val cacheRepo: WeatherForecastCacheDataSource -) { - operator fun invoke(): Single> = - cacheRepo - .clearMemoryCache() - .doOnError { - Timber.e("DisplayError clearing memory cache: $it") - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastDiskCacheTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastDiskCacheTask.kt deleted file mode 100644 index c099721a..00000000 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastDiskCacheTask.kt +++ /dev/null @@ -1,20 +0,0 @@ -package fi.kroon.vadret.domain.weatherforecast - -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.cache.WeatherForecastCacheDataSource -import fi.kroon.vadret.data.weatherforecast.model.Weather -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class GetWeatherForecastDiskCacheTask @Inject constructor( - private val repo: WeatherForecastCacheDataSource -) { - operator fun invoke(cacheKey: String): Single> = - repo - .getDiskCache(cacheKey) - .doOnError { - Timber.e("$it") - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastMemoryCacheTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastMemoryCacheTask.kt deleted file mode 100644 index 6fecbb4e..00000000 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastMemoryCacheTask.kt +++ /dev/null @@ -1,20 +0,0 @@ -package fi.kroon.vadret.domain.weatherforecast - -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.cache.WeatherForecastCacheDataSource -import fi.kroon.vadret.data.weatherforecast.model.Weather -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class GetWeatherForecastMemoryCacheTask @Inject constructor( - private val cacheRepo: WeatherForecastCacheDataSource -) { - operator fun invoke(cacheKey: String): Single> = - cacheRepo - .getMemoryCache(cacheKey) - .doOnError { - Timber.e("$it") - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastService.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastService.kt index e25aae56..5e273f82 100644 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastService.kt +++ b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastService.kt @@ -8,15 +8,12 @@ import fi.kroon.vadret.data.weatherforecast.model.WeatherOut import fi.kroon.vadret.domain.IService import fi.kroon.vadret.presentation.weatherforecast.WeatherForecastMapper import fi.kroon.vadret.presentation.weatherforecast.model.IWeatherForecastModel -import fi.kroon.vadret.util.FIVE_MINUTES_IN_MILLIS -import fi.kroon.vadret.util.extension.asSingle -import fi.kroon.vadret.util.extension.flatMapSingle import io.github.sphrak.either.Either import io.github.sphrak.either.flatMap import io.github.sphrak.either.map -import io.reactivex.Single -import io.reactivex.rxkotlin.zipWith -import timber.log.Timber +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.rx2.await +import kotlinx.coroutines.withContext import javax.inject.Inject class GetWeatherForecastService @Inject constructor( @@ -24,11 +21,7 @@ class GetWeatherForecastService @Inject constructor( private val getLocationAutomaticTask: GetLocationAutomaticTask, private val getReverseLocalityNameTask: GetReverseLocalityNameTask, private val getAppLocationModeTask: GetAppLocationModeTask, - private val getLocationManualTask: GetLocationManualTask, - private val setWeatherForecastDiskCacheTask: SetWeatherForecastDiskCacheTask, - private val setWeatherForecastMemoryCacheTask: SetWeatherForecastMemoryCacheTask, - private val getWeatherForecastMemoryCacheTask: GetWeatherForecastMemoryCacheTask, - private val getWeatherForecastDiskCacheTask: GetWeatherForecastDiskCacheTask + private val getLocationManualTask: GetLocationManualTask ) : IService { data class Data( @@ -42,63 +35,56 @@ class GetWeatherForecastService @Inject constructor( val location: Location? = null ) - /** - * [cacheKey] Must conform to regex [a-z0-9_-]{1,120} - */ - private companion object { - const val cacheKey: String = "weather_forecast_app_cache_key_" - } - /** * [timeStamp] -- Timestamp issued at time of request, used to control whether cache or network * should be used. * [forceNet] -- Forces a network request regardless of value in timeStamp. */ - operator fun invoke(timeStamp: Long, forceNet: Boolean): Single> = - Single.just(Data(timeStamp = timeStamp, forceNet = forceNet)) - .flatMap(::getLocationMode) - .flatMap(::getGpsLocationOrStoredLocation) - .flatMap(::getWeatherForecastList) - .flatMap(::doReverseNominatimLookupOrReturn) - .map(::transform) + suspend operator fun invoke(timeStamp: Long, forceNet: Boolean): Either = + withContext(Dispatchers.Default) { + getEmptyData(timeStamp = timeStamp, forceNet = forceNet) + .getLocationMode() + .getGpsLocationOrStoredLocation() + .getWeatherForecastList() + .doReverseNominatimLookupOrReturn() + .toWeatherForecastMapper() + } + + private fun getEmptyData(timeStamp: Long, forceNet: Boolean): Data = Data(timeStamp = timeStamp, forceNet = forceNet) /** * Determine if location should be derived from GPS or local storage. */ - private fun getLocationMode(data: Data): Single> = + private suspend fun Data.getLocationMode(): Either = getAppLocationModeTask() - .map { either: Either -> - either.map { locationMode -> - data.copy(locationMode = locationMode) - } + .await() + .map { locationMode: Boolean -> + this.copy(locationMode = locationMode) } - private fun getGpsLocationOrStoredLocation(either: Either): Single> = - either.flatMapSingle { data: Data -> + private suspend fun Either.getGpsLocationOrStoredLocation(): Either = + this.flatMap { data: Data -> when (data.locationMode) { false -> getLocationManual(data) - true -> - getLocationAutomatic(data) - .map(::mapLocationEntityToWeatherOut) + true -> mapLocationEntityToWeatherOut(getLocationAutomatic(data)) } } - private fun getLocationAutomatic(data: Data): Single> = - getLocationAutomaticTask().map { either: Either -> - either.map { location: Location -> + private suspend fun getLocationAutomatic(data: Data): Either = + getLocationAutomaticTask() + .await() + .map { location: Location -> data.copy(location = location) } - } - private fun getLocationManual(data: Data): Single> = + private suspend fun getLocationManual(data: Data): Either = getLocationManualTask() - .map { either: Either -> - either.map { weatherOut: WeatherOut -> - data.copy( - weatherOut = weatherOut, - localityName = weatherOut.localityName!! - ) - } + .await() + .map { weatherOut: WeatherOut -> + data.copy( + weatherOut = weatherOut, + localityName = weatherOut.localityName!! + ) } /** @@ -113,124 +99,53 @@ class GetWeatherForecastService @Inject constructor( data.copy(weatherOut = weatherOut) } - private fun getWeatherForecastList(either: Either): Single> = - either.flatMapSingle { data: Data -> + private suspend fun Either.getWeatherForecastList(): Either = + this.flatMap { data: Data -> getWeather(data) - .map { either: Either -> - either.map { dataIn: Data -> - dataIn.copy( - timeStamp = currentTimeMillis - ) - } + .map { dataIn: Data -> + dataIn.copy( + timeStamp = currentTimeMillis + ) } } - private fun doReverseNominatimLookupOrReturn(either: Either): Single> = - either.flatMapSingle { data: Data -> + private suspend fun Either.doReverseNominatimLookupOrReturn(): Either = + this.flatMap { data: Data -> when (data.locationMode) { - true -> doReverseNominatimLookup(either) + true -> doReverseNominatimLookup(this) false -> { - either.asSingle() + this } } } - private fun doReverseNominatimLookup(either: Either): Single> = - either.flatMapSingle { data: Data -> + private suspend fun doReverseNominatimLookup(either: Either): Either = + either.flatMap { data: Data -> val nominatimReverseOut = NominatimReverseOut( latitude = data.weatherOut!!.latitude, longitude = data.weatherOut.longitude ) - getReverseLocalityNameTask(nominatimReverseOut).map { result -> - result.map { localityName: String? -> + getReverseLocalityNameTask(nominatimReverseOut) + .await() + .map { localityName: String? -> localityName?.let { data.copy(localityName = localityName) } ?: data } - } } - private fun getWeather(data: Data): Single> = - with(data) { - when { - forceNet || (currentTimeMillis > (timeStamp + FIVE_MINUTES_IN_MILLIS)) -> { - Timber.d("DATA: $data") - getWeatherForecastTask(data.weatherOut!!) - .map { either: Either -> - either.map { weather: Weather -> - data.copy(weather = weather) - } - }.flatMap { either: Either -> - updateCache(either) - } - } - else -> { - Single.merge( - getWeatherForecastMemoryCacheTask(cacheKey) - .map { either: Either -> - either.map { weather -> - data.copy(weather = weather) - } - }, - getWeatherForecastDiskCacheTask(cacheKey) - .map { either: Either -> - either.map { weather -> - data.copy(weather = weather) - } - } - ).filter { result -> - result.either( - { - false - }, - { data -> - Timber.d("Fetched from cache: ${data.weather}") - data - .weather!! - .timeSeries!! - .isNotEmpty() - } - ) - }.take(1) - .switchIfEmpty( - getWeatherForecastTask(data.weatherOut!!) - .map { either: Either -> - Timber.d("Cache was empty, fetching weather from network.") - either.map { weather: Weather -> - data.copy(weather = weather) - } - }.flatMap { data -> - updateCache(data) - }.toFlowable() - ).singleOrError() - } + private suspend fun getWeather(data: Data): Either { + return getWeatherForecastTask(data.weatherOut!!) + .map { weather: Weather -> + data.copy(weather = weather) } - } - - private fun updateCache(either: Either): Single> = - either.flatMapSingle { data: Data -> - setWeatherForecastMemoryCacheTask(cacheKey, data.weather!!) - .zipWith(setWeatherForecastDiskCacheTask(cacheKey, data.weather)) - .map { pair: Pair, - Either> -> - Timber.i("Updating cache") - val ( - firstEither: Either, - secondEither: Either - ) = pair - firstEither.flatMap { _: Weather -> - secondEither.map { _: Weather -> - data - } - } - } - } + } - private fun transform(either: Either): Either = - either.map { data: Data -> + private fun Either.toWeatherForecastMapper(): Either = + this.map { data: Data -> val locationEntity = Location(data.weatherOut!!.latitude, data.weatherOut.longitude) val baseWeatherForecastModelList: List = WeatherForecastMapper( - data.weather!!.timeSeries!!, + data.weather!!.timeSeries, locationEntity ) data.copy(weatherForecastModelList = baseWeatherForecastModelList) diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastTask.kt index 7335377b..8dc9483d 100644 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastTask.kt +++ b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/GetWeatherForecastTask.kt @@ -5,12 +5,11 @@ import fi.kroon.vadret.data.weatherforecast.WeatherForecastRepository import fi.kroon.vadret.data.weatherforecast.model.Weather import fi.kroon.vadret.data.weatherforecast.model.WeatherOut import io.github.sphrak.either.Either -import io.reactivex.Single import javax.inject.Inject class GetWeatherForecastTask @Inject constructor( - private val repo: WeatherForecastRepository + private val weatherForecastRepository: WeatherForecastRepository ) { - operator fun invoke(request: WeatherOut): Single> = - repo(request) + suspend operator fun invoke(request: WeatherOut): Either = + weatherForecastRepository(request = request) } \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/SetWeatherForecastDiskCacheTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/SetWeatherForecastDiskCacheTask.kt deleted file mode 100644 index 662aac9f..00000000 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/SetWeatherForecastDiskCacheTask.kt +++ /dev/null @@ -1,20 +0,0 @@ -package fi.kroon.vadret.domain.weatherforecast - -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.cache.WeatherForecastCacheDataSource -import fi.kroon.vadret.data.weatherforecast.model.Weather -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class SetWeatherForecastDiskCacheTask @Inject constructor( - private val repo: WeatherForecastCacheDataSource -) { - operator fun invoke(cacheKey: String, weather: Weather): Single> = - repo - .updateDiskCache(weather, cacheKey) - .doOnError { - Timber.e("$it") - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/SetWeatherForecastMemoryCacheTask.kt b/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/SetWeatherForecastMemoryCacheTask.kt deleted file mode 100644 index a71d4f46..00000000 --- a/app/src/main/java/fi/kroon/vadret/domain/weatherforecast/SetWeatherForecastMemoryCacheTask.kt +++ /dev/null @@ -1,20 +0,0 @@ -package fi.kroon.vadret.domain.weatherforecast - -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.cache.WeatherForecastCacheDataSource -import fi.kroon.vadret.data.weatherforecast.model.Weather -import io.github.sphrak.either.Either -import io.reactivex.Single -import timber.log.Timber -import javax.inject.Inject - -class SetWeatherForecastMemoryCacheTask @Inject constructor( - private val cacheRepo: WeatherForecastCacheDataSource -) { - operator fun invoke(cacheKey: String, weather: Weather): Single> = - cacheRepo - .updateMemoryCache(cacheKey, weather) - .doOnError { - Timber.e("$it") - } -} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastFragment.kt b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastFragment.kt index 65077c78..3f2a2ae6 100644 --- a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastFragment.kt +++ b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastFragment.kt @@ -141,13 +141,9 @@ class WeatherForecastFragment : Fragment(R.layout.weather_forecast_fragment) { super.onDestroyView() Timber.d("ON DESTROY VIEW -- WEATHER FORECAST") - weatherForecastRecyclerView.apply { - adapter = null - } - - autoCompleteRecyclerView.apply { - adapter = null - } + weatherForecastRecyclerView.adapter = null + autoCompleteRecyclerView.adapter = null + weatherForecastSearchView.setOnQueryTextListener(null) hideActionBarLocalityName() } @@ -226,13 +222,13 @@ class WeatherForecastFragment : Fragment(R.layout.weather_forecast_fragment) { .offer( WeatherForecastView.Event.OnSearchButtonToggled ) - }.launchIn(lifecycleScope) + }.launchIn(viewLifecycleOwner.lifecycleScope) weatherForecastRefresh .refreshes() .map { eventChannel.offer(WeatherForecastView.Event.OnSwipedToRefresh) - }.launchIn(lifecycleScope) + }.launchIn(viewLifecycleOwner.lifecycleScope) weatherForecastSearchView .queryTextChangeEvents() @@ -257,7 +253,7 @@ class WeatherForecastFragment : Fragment(R.layout.weather_forecast_fragment) { ) } } - }.launchIn(lifecycleScope) + }.launchIn(viewLifecycleOwner.lifecycleScope) eventChannel.offer( WeatherForecastView diff --git a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastMapper.kt b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastMapper.kt index c0b34022..32cb4b54 100644 --- a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastMapper.kt +++ b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastMapper.kt @@ -8,12 +8,10 @@ import fi.kroon.vadret.presentation.weatherforecast.model.WeatherForecastDateIte import fi.kroon.vadret.presentation.weatherforecast.model.WeatherForecastHeadlineModel import fi.kroon.vadret.presentation.weatherforecast.model.WeatherForecastItemModel import fi.kroon.vadret.presentation.weatherforecast.model.WeatherForecastSplashItemModel -import fi.kroon.vadret.util.MPS_TO_KMPH_FACTOR -import fi.kroon.vadret.util.WINDCHILL_FORMULA_MAXIMUM -import fi.kroon.vadret.util.WINDCHILL_FORMULA_MINIMUM import fi.kroon.vadret.util.common.SunsetUtil +import fi.kroon.vadret.util.common.WindChill import fi.kroon.vadret.util.extension.parseToLocalDate -import fi.kroon.vadret.util.extension.toWindChill +import org.threeten.bp.Instant import org.threeten.bp.OffsetDateTime import org.threeten.bp.ZoneId import java.util.Calendar @@ -139,10 +137,6 @@ object WeatherForecastMapper { } weatherDescription = weatherIcon - if (temperature < WINDCHILL_FORMULA_MAXIMUM) { - feelsLikeTemperature = if (windSpeed * MPS_TO_KMPH_FACTOR > WINDCHILL_FORMULA_MINIMUM) temperature.toWindChill(windSpeed) else null - } - return WeatherForecastSplashItemModel( sunriseDateTime = sunriseDateTime, sunsetDateTime = sunsetDateTime, @@ -150,7 +144,7 @@ object WeatherForecastMapper { temperature = temperature, windSpeed = windSpeed, windDirection = windDirection, - feelsLikeTemperature = feelsLikeTemperature, + feelsLikeTemperature = WindChill.calculate(temperature, windSpeed), weatherIcon = weatherIcon, weatherDescription = weatherDescription, precipitationCode = precipitationCode, @@ -160,7 +154,7 @@ object WeatherForecastMapper { private fun getWeatherForecastItemModel(timeSerie: TimeSerie): WeatherForecastItemModel { var temperature: Double = 0.0 - val time: String = OffsetDateTime.parse(timeSerie.validTime).toLocalTime().toString() + val time: String = Instant.parse(timeSerie.validTime).atZone(ZoneId.systemDefault()).toLocalTime().toString() var feelsLikeTemperature: String? = null var windSpeed: Double = 0.0 val precipitationType: Int = 0 @@ -177,14 +171,10 @@ object WeatherForecastMapper { weatherDescription = weatherIcon - if (temperature < WINDCHILL_FORMULA_MAXIMUM) { - feelsLikeTemperature = if (windSpeed * MPS_TO_KMPH_FACTOR > WINDCHILL_FORMULA_MINIMUM) temperature.toWindChill(windSpeed) else null - } - return WeatherForecastItemModel( temperature = temperature, time = time, - feelsLikeTemperature = feelsLikeTemperature, + feelsLikeTemperature = WindChill.calculate(temperature, windSpeed), precipitationType = precipitationType, windSpeed = windSpeed, weatherIcon = weatherIcon, diff --git a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastViewModel.kt b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastViewModel.kt index 8fbae962..1514eb7a 100644 --- a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastViewModel.kt +++ b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/WeatherForecastViewModel.kt @@ -47,9 +47,9 @@ class WeatherForecastViewModel @Inject constructor( reduce(event) } - private suspend fun reduce(event: WeatherForecastView.Event): WeatherForecastView.State { + private suspend fun reduce(event: WeatherForecastView.Event): WeatherForecastView.State = withContext(Dispatchers.IO) { Timber.d("event: $event") - return when (event) { + when (event) { is WeatherForecastView.Event.OnViewInitialised -> onViewInitialisedEvent(event.stateParcel) WeatherForecastView.Event.OnLocationPermissionGranted -> onLocationPermissionGrantedEvent() WeatherForecastView.Event.OnLocationPermissionDenied -> onLocationPermissionDeniedEvent() @@ -357,7 +357,6 @@ class WeatherForecastViewModel @Inject constructor( }, { timeStamp: Long -> getWeatherForecastService(timeStamp, state.forceNet) - .await() .either( { failure: Failure -> Timber.e("loadWeatherForecastFailure: $failure") diff --git a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/di/WeatherForecastModule.kt b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/di/WeatherForecastModule.kt index 96dda4e9..f673cbb9 100644 --- a/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/di/WeatherForecastModule.kt +++ b/app/src/main/java/fi/kroon/vadret/presentation/weatherforecast/di/WeatherForecastModule.kt @@ -2,19 +2,17 @@ package fi.kroon.vadret.presentation.weatherforecast.di import android.content.Context import android.location.LocationManager -import androidx.collection.LruCache +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.squareup.moshi.Moshi import dagger.Lazy import dagger.Module import dagger.Provides import fi.kroon.vadret.data.location.local.LocationLocalDataSource import fi.kroon.vadret.data.nominatim.net.NominatimNetDataSource -import fi.kroon.vadret.data.weatherforecast.model.Weather import fi.kroon.vadret.data.weatherforecast.net.WeatherForecastNetDataSource import fi.kroon.vadret.di.qualifiers.Nominatim import fi.kroon.vadret.di.qualifiers.WeatherQualifier import fi.kroon.vadret.presentation.weatherforecast.WeatherForecastView -import fi.kroon.vadret.util.MEMORY_CACHE_SIZE import fi.kroon.vadret.util.NOMINATIM_BASE_API_URL import fi.kroon.vadret.util.SMHI_API_FORECAST_URL import fi.kroon.vadret.util.Scheduler @@ -22,6 +20,10 @@ import fi.kroon.vadret.util.extension.assertNoInitMainThread import fi.kroon.vadret.util.extension.delegatingCallFactory import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory @@ -31,6 +33,8 @@ import retrofit2.converter.moshi.MoshiConverterFactory @ExperimentalCoroutinesApi object WeatherForecastModule { + private val contentType: MediaType = "application/json".toMediaType() + @Provides @WeatherForecastScope fun provideEventChannel(): ConflatedBroadcastChannel = @@ -44,15 +48,17 @@ object WeatherForecastModule { @WeatherForecastScope fun provideSchedulers(): Scheduler = Scheduler() + @ExperimentalSerializationApi @WeatherQualifier @Provides @WeatherForecastScope - fun provideRetrofitWeather(okHttpClient: Lazy, moshi: Moshi): Retrofit { + fun provideRetrofitWeather( + okHttpClient: Lazy + ): Retrofit { assertNoInitMainThread() return Retrofit.Builder() .baseUrl(SMHI_API_FORECAST_URL) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory(Json.asConverterFactory(contentType)) .delegatingCallFactory(okHttpClient) .build() } @@ -89,10 +95,4 @@ object WeatherForecastModule { @WeatherForecastScope fun provideLocationProvider(locationManager: LocationManager): LocationLocalDataSource = LocationLocalDataSource(locationManager) - - @Provides - @WeatherForecastScope - fun provideWeatherLruCache(): LruCache = LruCache( - MEMORY_CACHE_SIZE - ) } \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/util/common/WindChill.kt b/app/src/main/java/fi/kroon/vadret/util/common/WindChill.kt new file mode 100644 index 00000000..4aad4458 --- /dev/null +++ b/app/src/main/java/fi/kroon/vadret/util/common/WindChill.kt @@ -0,0 +1,25 @@ +package fi.kroon.vadret.util.common + +import kotlin.math.pow + +object WindChill { + + /** + * Returns Wind chill as String if applicable or null + * T_eff = 13.12 + 0.6215 * T - 13.956 * v^0.16 + 0.48669 * T * v^0.16 + * v is meter per second + * T is temperature in celcius + * + * Formula: https://www.smhi.se/kunskapsbanken/meteorologi/vindens-kyleffekt-1.259 + */ + fun calculate(temperatureCelcius: Double, windSpeedMeterPerSecond: Double): String? { + + if (windSpeedMeterPerSecond < 2 || windSpeedMeterPerSecond > 35) return null + if (temperatureCelcius > 10 || temperatureCelcius < -40) return null + + val v: Double = windSpeedMeterPerSecond.pow(0.16) + val windChill: Double = 13.12 + 0.6215 * temperatureCelcius - 13.956 * v + (0.48669 * temperatureCelcius * v) + + return "%.1f".format(windChill.toFloat()).replace(",", ".") + } +} \ No newline at end of file diff --git a/app/src/main/java/fi/kroon/vadret/util/extension/Double.kt b/app/src/main/java/fi/kroon/vadret/util/extension/Double.kt index f5e5c627..6f39b1f8 100644 --- a/app/src/main/java/fi/kroon/vadret/util/extension/Double.kt +++ b/app/src/main/java/fi/kroon/vadret/util/extension/Double.kt @@ -1,7 +1,5 @@ package fi.kroon.vadret.util.extension -import fi.kroon.vadret.util.MPS_TO_KMPH_FACTOR - fun Double.toCoordinate(): Double = "%.6f".format(this) .replace(",", ".") @@ -11,17 +9,4 @@ fun String.toCoordinate(): Double = "%.6f".format( this.replace("−", "-") .toDouble() ).replace(",", ".") - .toDouble() -fun Double.toWindChill(wind: Double): String { - - /** - * If temperature is <= 10 we do this calculation - * Reference implementation: https://web.archive.org/web/20060427103553/ - * http://www.msc.ec.gc.ca/education/windchill/science_equations_e.cfm - */ - val temperature: Double = this - val kmPh: Double = wind * MPS_TO_KMPH_FACTOR - val windChill: Double = 13.12 + 0.6215 * temperature - 11.37 * Math.pow(kmPh, 0.16) + (0.3965 * temperature) * Math.pow(kmPh, 0.16) - - return "%.1f".format(windChill.toFloat()).replace(",", ".") -} \ No newline at end of file + .toDouble() \ No newline at end of file diff --git a/app/src/main/res/raw/changelog.md b/app/src/main/res/raw/changelog.md index 9494d5bd..a63037fd 100644 --- a/app/src/main/res/raw/changelog.md +++ b/app/src/main/res/raw/changelog.md @@ -5,7 +5,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.2.10] -- 2020-10-* +### Fixes + +- Fixed a nasty memory leak +- Fix spelling mistake s/förnärvarande/för närvarande + +## [2.0.0] -- 2020-10-31 🎃 + +This is a major maintenance release that aims to improve the maintainability of this project. A major change +is the removal of RxJava2 based code and rewriting it with the [Kotlin Coroutines & Flow API](https://github.com/Kotlin/kotlinx.coroutines) instead. This should mean a slight +performance increase as well. Another big change is that widgets have been removed as per [issue/221](https://github.com/vadret/android/issues/221) since +they did not work very well and weren't used a lot. The release includes a total of 245 files changed, 2,343 new LOC and 13,134 LOC deleted. + +### Fixes + +- [issue/229](https://github.com/vadret/android/issues/229) -- Fixes a bug where UTC timestamp was incorrectly parsed to local time + +### Added + +- Firebase Crashlytics ### Changed @@ -17,10 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump kotlin 1.4.10 - Replaced travis-ci with Github Actions - Target Android SDK 30 +- Removed RxJava2 from wetfcst data layer +- Added kotlinx serialization to wetfcst data layer ### Removed -- Removed all widgets (see this [issue](https://github.com/vadret/android/issues/221)) +- [issue/221](https://github.com/vadret/android/issues/221) -- Removed all widgets ## [1.2.9] -- 2020-08-17 @@ -214,8 +234,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Radar imagery - 10 day weather forecast -[Unreleased]: https://github.com/vadret/android/compare/1.2.10...HEAD -[1.2.10]: https://github.com/vadret/android/compare/1.2.9...1.2.10 +[Unreleased]: https://github.com/vadret/android/compare/2.0.0...HEAD +[2.0.0]: https://github.com/vadret/android/compare/1.2.9...2.0.0 [1.2.9]: https://github.com/vadret/android/compare/1.2.8...1.2.9 [1.2.8]: https://github.com/vadret/android/compare/1.2.7...1.2.8 [1.2.7]: https://github.com/vadret/android/compare/1.2.6...1.2.7 diff --git a/app/src/main/res/raw/sweden b/app/src/main/res/raw/sweden index 751fc89b..0feab58a 100644 --- a/app/src/main/res/raw/sweden +++ b/app/src/main/res/raw/sweden @@ -1801,7 +1801,7 @@ Zinkgruvan,Askersund,Örebro,58.815435,15.106626 Åminne,Värnamo,Gotland,57.612497,18.761462 Åmmeberg,Askersund,Örebro,58.865610,14.999192 Åmotfors,Eda,Värmland,59.762586,12.362227 -Åmot,Ockelbo,Värmland,59.705202,12.887207 +Åmot,Ockelbo,Gävleborg,60.965613,16.451243 Åmunnen,Kalmar,Kalmar,56.627696,16.236805 Åmynnet,Örnsköldsvik,Västernorrland,63.186110,18.530696 Åmål,Åmål,Västra Götaland,58.969858,12.618004 diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index f4815e07..c8d1d4f9 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -170,5 +170,5 @@ Widget Tema Risk för åska Använd min GPS automatiskt - Inga varning utfärdade förnärvarande. + Inga varning utfärdade för närvarande \ No newline at end of file diff --git a/app/src/test/java/fi/kroon/vadret/common/WindChillTest.kt b/app/src/test/java/fi/kroon/vadret/common/WindChillTest.kt index c30059b0..225df9d1 100644 --- a/app/src/test/java/fi/kroon/vadret/common/WindChillTest.kt +++ b/app/src/test/java/fi/kroon/vadret/common/WindChillTest.kt @@ -1,26 +1,26 @@ package fi.kroon.vadret.common -import fi.kroon.vadret.util.extension.toWindChill +import fi.kroon.vadret.util.common.WindChill import org.assertj.core.api.Assertions.assertThat import org.junit.Test class WindChillTest { @Test - fun `assert toWindChill produces expected results`() { - val temperature = -20.0 - val windSpeed = 1.388889 // 5km/h in m/s + fun `real temperature -10c and windspeed 2 is -34c`() { + val temperature = -10.0 + val windSpeed = 2.0 - val windChill = temperature.toWindChill(windSpeed).toDouble() - assertThat(windChill).isEqualTo(-24.3) + val windChill = WindChill.calculate(temperature, windSpeed) + assertThat(windChill).isEqualTo("-14.1") } @Test - fun ` toWindChill produces expected -33 degree result`() { + fun `real temperature -20c and windspeed is -34c`() { val temperature = -20.0 - val windSpeed = 8.333333 // 30km/h in m/s + val windSpeed = 10.0 - val windChill = temperature.toWindChill(windSpeed).toDouble() - assertThat(windChill).isEqualTo(-32.6) + val windChill = WindChill.calculate(temperature, windSpeed) + assertThat(windChill).isEqualTo("-33.6") } } \ No newline at end of file diff --git a/app/src/test/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepositoryTest.kt b/app/src/test/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepositoryTest.kt deleted file mode 100644 index d35a2db5..00000000 --- a/app/src/test/java/fi/kroon/vadret/data/weatherforecast/WeatherForecastRepositoryTest.kt +++ /dev/null @@ -1,244 +0,0 @@ -package fi.kroon.vadret.data.weatherforecast - -import dagger.Lazy -import fi.kroon.vadret.data.exception.ErrorHandler -import fi.kroon.vadret.data.exception.ExceptionHandler -import fi.kroon.vadret.data.failure.Failure -import fi.kroon.vadret.data.weatherforecast.exception.WeatherForecastFailure -import fi.kroon.vadret.data.weatherforecast.model.Weather -import fi.kroon.vadret.data.weatherforecast.model.WeatherOut -import fi.kroon.vadret.data.weatherforecast.net.WeatherForecastNetDataSource -import fi.kroon.vadret.util.DEFAULT_LATITUDE -import fi.kroon.vadret.util.DEFAULT_LONGITUDE -import fi.kroon.vadret.util.HTTP_200_OK -import fi.kroon.vadret.util.HTTP_204_NO_CONTENT -import fi.kroon.vadret.util.HTTP_400_BAD_REQUEST -import fi.kroon.vadret.util.HTTP_403_FORBIDDEN -import fi.kroon.vadret.util.HTTP_404_NOT_FOUND -import fi.kroon.vadret.util.HTTP_500_INTERNAL_SERVER_ERROR -import fi.kroon.vadret.util.HTTP_503_SERVICE_UNAVAILABLE -import fi.kroon.vadret.util.HTTP_504_GATEWAY_TIMEOUT -import fi.kroon.vadret.util.NetworkHandler -import fi.kroon.vadret.util.extension.asSingle -import io.github.sphrak.either.Either -import io.reactivex.Single -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito.doReturn -import org.mockito.junit.MockitoJUnitRunner -import retrofit2.Response - -@RunWith(MockitoJUnitRunner::class) -class WeatherForecastRepositoryTest { - - private val errorHandler: ErrorHandler = ErrorHandler() - private val exceptionHandler: ExceptionHandler = ExceptionHandler() - - private val weatherForecastRequest: WeatherOut = WeatherOut( - latitude = DEFAULT_LATITUDE.toDouble(), - longitude = DEFAULT_LONGITUDE.toDouble() - ) - - private lateinit var testWeatherForecastRepository: WeatherForecastRepository - - @Mock - private lateinit var mockWeatherForecastNetDataSource: WeatherForecastNetDataSource - - private var mockWeatherForecastNetDataSourceLazy = Lazy { - mockWeatherForecastNetDataSource - } - - @Mock - private lateinit var mockNetworkHandler: NetworkHandler - - @Mock - private lateinit var mockWeatherResponse: Response - - @Mock - private lateinit var mockWeatherForecast: Weather - - @Before - fun setup() { - testWeatherForecastRepository = WeatherForecastRepository( - mockWeatherForecastNetDataSourceLazy, - mockNetworkHandler, - errorHandler = errorHandler, - exceptionHandler = exceptionHandler - ) - } - - @Test - fun `repository returns WeatherIn object correctly`() { - - doReturn(HTTP_200_OK).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(mockWeatherForecast).`when`(mockWeatherResponse).body() - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()) - .getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Right && it.b == mockWeatherForecast } - } - - @Test - fun `repository returns not available when NOT_CONNECTED`() { - - doReturn(false).`when`(mockNetworkHandler).isConnected - - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is Failure.NetworkOfflineError } - } - - @Test - fun `repository returns HTTP_204_NO_CONTENT failure`() { - - doReturn(HTTP_204_NO_CONTENT).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - - doReturn(mockWeatherResponse.asSingle()) - .`when`(mockWeatherForecastNetDataSourceLazy.get()) - .getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is WeatherForecastFailure.NoWeatherAvailable } - } - - @Test - fun `repository returns HTTP_403_FORBIDDEN failure`() { - - doReturn(HTTP_403_FORBIDDEN).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()).getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is Failure.HttpForbidden403 } - } - - @Test - fun `repository returns HTTP_404_NOT_FOUND failure`() { - - doReturn(HTTP_404_NOT_FOUND).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()) - .getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is WeatherForecastFailure.NoWeatherAvailableForThisLocation } - } - - @Test - fun `repository returns HTTP_400_BAD_REQUEST failure`() { - - doReturn(HTTP_400_BAD_REQUEST).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()).getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is Failure.HttpBadRequest400 } - } - - @Test - fun `repository returns HTTP_500_INTERNAL_SERVER_ERROR failure`() { - - doReturn(HTTP_500_INTERNAL_SERVER_ERROR).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()) - .getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is Failure.HttpInternalServerError500 } - } - - @Test - fun `repository returns HTTP_503_SERVICE_UNAVAILABLE failure`() { - - doReturn(HTTP_503_SERVICE_UNAVAILABLE).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()) - .getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is Failure.HttpServiceUnavailable503 } - } - - @Test - fun `repository returns HTTP_504_GATEWAY_TIMEOUT failure`() { - - doReturn(HTTP_504_GATEWAY_TIMEOUT).`when`(mockWeatherResponse).code() - doReturn(true).`when`(mockNetworkHandler).isConnected - doReturn(Single.just(mockWeatherResponse)) - .`when`(mockWeatherForecastNetDataSourceLazy.get()) - .getWeatherForecast( - weatherForecastRequest.category, - weatherForecastRequest.version, - weatherForecastRequest.longitude, - weatherForecastRequest.latitude - ) - testWeatherForecastRepository(weatherForecastRequest) - .test() - .assertComplete() - .assertNoErrors() - .assertValueAt(0) { it is Either.Left && it.a is Failure.HttpGatewayTimeout504 } - } -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0db4d15d..d02dc88e 100644 --- a/build.gradle +++ b/build.gradle @@ -5,10 +5,15 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.0.2' + classpath 'com.android.tools.build:gradle:4.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" + classpath "org.jetbrains.kotlin:kotlin-serialization:$KOTLIN_VERSION" + classpath 'com.google.gms:google-services:4.3.4' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1' } + } + allprojects { repositories { google() diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0902649e..3e5e5e80 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip \ No newline at end of file