diff --git a/.gitignore b/.gitignore
index dcbc7f1b..6eb7ea5f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,3 +110,6 @@ screenshots
store
api_aqicn.xml
+# C++ generated files
+app/.cxx/*
+
diff --git a/app/build.gradle b/app/build.gradle
index 9b20edfe..2e8e1b70 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,6 +1,11 @@
apply plugin: 'com.android.application'
apply plugin: 'com.livinglifetechway.quickpermissions_plugin'
apply plugin: 'io.fabric'
+apply plugin: "kotlin-android"
+apply plugin: "kotlin-android-extensions"
+apply plugin: "kotlin-kapt"
+apply plugin: 'dagger.hilt.android.plugin'
+apply plugin: "de.mannodermaus.android-junit5"
android {
@@ -36,8 +41,12 @@ android {
}
}
compileOptions {
- targetCompatibility 1.8
- sourceCompatibility 1.8
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
}
testOptions {
@@ -55,12 +64,14 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.cardview:cardview:1.0.0'
- implementation 'com.google.android.material:material:1.2.0-alpha06'
+ implementation 'com.google.android.material:material:1.3.0-alpha01'
implementation "androidx.preference:preference:1.1.1"
implementation 'com.takisoft.preferencex:preferencex:1.1.0-alpha05'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'com.jakewharton:butterknife:10.2.0'
+ implementation 'androidx.appcompat:appcompat:1.1.0'
+ implementation 'androidx.legacy:legacy-support-v4:1.0.0'
annotationProcessor 'com.jakewharton:butterknife-compiler:10.2.0'
implementation 'com.jakewharton.rxbinding2:rxbinding:2.2.0'
@@ -90,15 +101,63 @@ dependencies {
implementation 'com.github.vic797:prowebview:2.2.1'
- implementation 'com.squareup.retrofit2:retrofit:2.5.0'
+ // Dexter Permissions Requesting
+ implementation 'com.karumi:dexter:6.2.1'
+
+ // Android KTX
+ implementation "androidx.fragment:fragment-ktx:1.2.5"
+ implementation "androidx.activity:activity-ktx:1.1.0"
+ implementation "androidx.fragment:fragment:1.3.0-alpha08"
+
+ // Retrofit
+ implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
+ // Debug Retrofit
+ implementation("com.squareup.okhttp3:logging-interceptor:4.7.2")
+
+ // Hilt
+ implementation "com.google.dagger:hilt-android:2.28-alpha"
+ kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
+ implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
+ kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
+
+ // ViewModel
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
+
+ // LiveData
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
+
+ // Jetpack Navigation
+ implementation 'androidx.navigation:navigation-fragment-ktx:2.3.0'
+ implementation 'androidx.navigation:navigation-ui-ktx:2.3.0'
+
+ // Coroutines
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.7'
+
+ // Junit 5
+ testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2"
+ testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.6.2"
+
+ // Mockk
+ testImplementation "io.mockk:mockk:1.10.0"
+
+ // OkHttp Mock Web Server
+ testImplementation "com.squareup.okhttp3:mockwebserver:4.7.2"
+
testImplementation 'junit:junit:4.12'
- testImplementation 'androidx.test:core:1.2.0'
+ testImplementation 'androidx.test:core:1.3.0'
testImplementation 'org.mockito:mockito-core:1.10.19'
- androidTestImplementation 'androidx.test:runner:1.2.0'
+ androidTestImplementation "androidx.arch.core:core-testing:2.1.0"
+ testImplementation "android.arch.core:core-testing:1.1.1"
+ androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
+
+ implementation 'com.android.support:design:28.0.0'
}
apply plugin: 'com.google.gms.google-services'
-
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
diff --git a/app/src/androidTest/java/hpsaturn/pollutionreporter/ExampleInstrumentedTest.java b/app/src/androidTest/java/hpsaturn/pollutionreporter/ExampleInstrumentedTest.java
deleted file mode 100644
index 8cccceb8..00000000
--- a/app/src/androidTest/java/hpsaturn/pollutionreporter/ExampleInstrumentedTest.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package hpsaturn.pollutionreporter;
-
-import android.content.Context;
-import androidx.test.InstrumentationRegistry;
-import androidx.test.runner.AndroidJUnit4;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import static org.junit.Assert.*;
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * @see Testing documentation
- */
-@RunWith(AndroidJUnit4.class)
-public class ExampleInstrumentedTest {
- @Test
- public void useAppContext() {
- // Context of the app under test.
- Context appContext = InstrumentationRegistry.getTargetContext();
-
- assertEquals("hpsaturn.pollutionreporter", appContext.getPackageName());
- }
-}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2c59a222..183e6afb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -21,28 +21,28 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
-
+
+
+
+
-
-
-
-
-
-
+ android:exported="true" />
+
+
+
+
-
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/AppData.java b/app/src/main/java/hpsaturn/pollutionreporter/AppData.java
index 07855571..386dbd03 100644
--- a/app/src/main/java/hpsaturn/pollutionreporter/AppData.java
+++ b/app/src/main/java/hpsaturn/pollutionreporter/AppData.java
@@ -8,11 +8,14 @@
import com.polidea.rxandroidble2.RxBleClient;
import com.polidea.rxandroidble2.internal.RxBleLog;
+import dagger.hilt.android.HiltAndroidApp;
import hpsaturn.pollutionreporter.api.AqicnApiManager;
/**
* Created by Antonio Vanegas @hpsaturn on 6/13/18.
*/
+
+@HiltAndroidApp
public class AppData extends MultiDexApplication{
private RxBleClient rxBleClient;
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/core/data/mappers/Mapper.kt b/app/src/main/java/hpsaturn/pollutionreporter/core/data/mappers/Mapper.kt
new file mode 100644
index 00000000..6a68680e
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/core/data/mappers/Mapper.kt
@@ -0,0 +1,5 @@
+package hpsaturn.pollutionreporter.core.data.mappers
+
+interface Mapper {
+ operator fun invoke(input: I): O
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/core/domain/entities/Result.kt b/app/src/main/java/hpsaturn/pollutionreporter/core/domain/entities/Result.kt
new file mode 100644
index 00000000..81236161
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/core/domain/entities/Result.kt
@@ -0,0 +1,6 @@
+package hpsaturn.pollutionreporter.core.domain.entities
+
+sealed class Result
+data class Success(val data: T) : Result()
+data class ErrorResult(val exception: Throwable) : Result()
+object InProgress : Result()
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/core/domain/errors/Exceptions.kt b/app/src/main/java/hpsaturn/pollutionreporter/core/domain/errors/Exceptions.kt
new file mode 100644
index 00000000..fe38c21f
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/core/domain/errors/Exceptions.kt
@@ -0,0 +1,7 @@
+package hpsaturn.pollutionreporter.core.domain.errors
+
+class ServerException(message: String = "") : Exception(message)
+class PermissionException(message: String = "") : Exception(message)
+class PermissionNotGrantedException(message: String = "") : Exception(message)
+class ConnectionException(message: String = "") : Exception(message)
+class UnexpectedException(message: String = "") : Exception(message)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardActivity.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardActivity.kt
new file mode 100644
index 00000000..4aff6d62
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardActivity.kt
@@ -0,0 +1,68 @@
+package hpsaturn.pollutionreporter.dashboard
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.navigation.findNavController
+import androidx.navigation.ui.setupWithNavController
+import com.hpsaturn.tools.Logger
+import dagger.hilt.android.AndroidEntryPoint
+import hpsaturn.pollutionreporter.BaseActivity
+import hpsaturn.pollutionreporter.Config
+import hpsaturn.pollutionreporter.R
+import hpsaturn.pollutionreporter.service.RecordTrackScheduler
+import hpsaturn.pollutionreporter.service.RecordTrackService
+import kotlinx.android.synthetic.main.activity_dashboard.*
+
+private val TAG = DashboardActivity::class.java.simpleName
+
+@AndroidEntryPoint
+class DashboardActivity : AppCompatActivity() {
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_dashboard)
+
+ setupNavigationComponent()
+ startRecordTrackService()
+ checkBluetoothSupport()
+ }
+
+ /**
+ * Sets up the app to use Android Navigation Component. More information of this component can
+ * be found here: https://developer.android.com/guide/navigation/navigation-getting-started
+ */
+ private fun setupNavigationComponent() {
+ val navController = findNavController(R.id.nav_host_fragment)
+ bottomNavigation.setupWithNavController(navController)
+ }
+
+ private fun startRecordTrackService() {
+ Log.i(TAG, "starting RecordTrackService..")
+ val trackServiceIntent = Intent(this, RecordTrackService::class.java)
+ startService(trackServiceIntent)
+ RecordTrackScheduler.startScheduleService(this, Config.DEFAULT_INTERVAL)
+ }
+
+
+ // TODO (@juanpa097) - The code bellow this comment should be refactor.
+
+ private fun checkBluetoothSupport() { // Use this check to determine whether BLE is supported on the device.
+ val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+ val mBluetoothAdapter = bluetoothManager.adapter
+ if (!packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
+ Toast.makeText(this, R.string.ble_not_supported, Toast.LENGTH_SHORT).show()
+ } else if (mBluetoothAdapter == null || !mBluetoothAdapter.isEnabled) {
+ val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
+ startActivityForResult(enableBtIntent, 0)
+ } else Logger.i(BaseActivity.TAG, "[BLE] checkBluetoohtBle: ready!")
+ }
+
+}
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardFragment.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardFragment.kt
new file mode 100644
index 00000000..1f4c0340
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardFragment.kt
@@ -0,0 +1,88 @@
+package hpsaturn.pollutionreporter.dashboard
+
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.RotateDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Observer
+import dagger.hilt.android.AndroidEntryPoint
+import hpsaturn.pollutionreporter.R
+import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult
+import hpsaturn.pollutionreporter.core.domain.entities.InProgress
+import hpsaturn.pollutionreporter.core.domain.entities.Success
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.domain.usecases.EvaluateAirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.presentation.DashboardViewModel
+import kotlinx.android.synthetic.main.fragment_dashboard.*
+import javax.inject.Inject
+
+
+@AndroidEntryPoint
+class DashboardFragment : Fragment() {
+
+ @Inject
+ lateinit var evaluateAirQualityStatus: EvaluateAirQualityStatus
+
+ private val dashboardViewModel: DashboardViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ displayAirQualityIndexOnView()
+ displayStationDistance()
+ return inflater.inflate(R.layout.fragment_dashboard, container, false)
+ }
+
+ private fun displayAirQualityIndexOnView() {
+ dashboardViewModel.airQualityStatus.observe(viewLifecycleOwner, Observer {
+ when (it) {
+ is Success -> renderAqiData(it.data)
+ is ErrorResult -> renderError(it.exception)
+ is InProgress -> renderProgress()
+ }
+ })
+ }
+
+ private fun displayStationDistance() {
+ dashboardViewModel.distanceToStation.observe(viewLifecycleOwner, Observer {
+ currentLocationText.text = "$it Km"
+ })
+ }
+
+ private fun renderAqiData(airQualityStatus: AirQualityStatus) {
+ setTextVisible()
+ val scale = evaluateAirQualityStatus(airQualityStatus)
+ val background =
+ (airQualityIndexBar.progressDrawable as RotateDrawable).drawable as GradientDrawable
+ val color = ContextCompat.getColor(requireContext(), scale.colorResourceId)
+ background.colors =
+ intArrayOf(color, color) // Both the same because we don't have a gradient.
+ airQualityIndexText.text = "${airQualityStatus.airQualityIndex}"
+ airQualityLabelText.text = getString(scale.nameResourceId)
+ }
+
+ private fun renderError(exception: Throwable) {
+ setTextVisible()
+ airQualityIndexText.text = context?.getString(R.string.error)
+ airQualityLabelText.text = "${exception.message}"
+ }
+
+ private fun renderProgress() {
+ progressBar.visibility = View.VISIBLE
+ airQualityIndexText.visibility = View.INVISIBLE
+ airQualityLabelText.visibility = View.INVISIBLE
+ }
+
+ private fun setTextVisible() {
+ progressBar.visibility = View.INVISIBLE
+ airQualityIndexText.visibility = View.VISIBLE
+ airQualityLabelText.visibility = View.VISIBLE
+ }
+
+}
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardModule.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardModule.kt
new file mode 100644
index 00000000..87003128
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/DashboardModule.kt
@@ -0,0 +1,46 @@
+package hpsaturn.pollutionreporter.dashboard
+
+import android.location.Location
+import androidx.lifecycle.LiveData
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ApplicationComponent
+import hpsaturn.pollutionreporter.core.data.mappers.Mapper
+import hpsaturn.pollutionreporter.core.domain.entities.Result
+import hpsaturn.pollutionreporter.dashboard.data.mappers.AirQualityStatusMapper
+import hpsaturn.pollutionreporter.dashboard.data.models.AqicnFeedResponse
+import hpsaturn.pollutionreporter.dashboard.data.repositories.AirQualityStatusRepositoryImpl
+import hpsaturn.pollutionreporter.dashboard.data.services.AqicnApiFeedService
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.domain.repositories.AirQualityStatusRepository
+import hpsaturn.pollutionreporter.dashboard.presentation.CurrentLocationLiveData
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(ApplicationComponent::class)
+abstract class DashboardModule {
+
+ @Binds
+ abstract fun bindAirQualityStatusMapper(airQualityStatusMapper: AirQualityStatusMapper):
+ Mapper
+
+ @Binds
+ abstract fun bindAirQualityStatusRepositoryImpl(
+ airQualityStatusRepositoryImpl: AirQualityStatusRepositoryImpl
+ ): AirQualityStatusRepository
+
+ @Binds
+ abstract fun bindCurrentLocationLiveData(currentLocationLiveData: CurrentLocationLiveData):
+ LiveData>
+
+ companion object {
+ @Singleton
+ @Provides
+ fun provideAqicnApiFeedService(retrofit: Retrofit): AqicnApiFeedService =
+ retrofit.create(AqicnApiFeedService::class.java)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/mappers/AirQualityStatusMapper.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/mappers/AirQualityStatusMapper.kt
new file mode 100644
index 00000000..2214cc68
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/mappers/AirQualityStatusMapper.kt
@@ -0,0 +1,15 @@
+package hpsaturn.pollutionreporter.dashboard.data.mappers
+
+import hpsaturn.pollutionreporter.core.data.mappers.Mapper
+import hpsaturn.pollutionreporter.dashboard.data.models.AqicnFeedResponse
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import javax.inject.Inject
+
+class AirQualityStatusMapper @Inject constructor() : Mapper {
+ override fun invoke(input: AqicnFeedResponse): AirQualityStatus = AirQualityStatus(
+ input.data.aqi,
+ input.data.city.name,
+ input.data.city.geo[0],
+ input.data.city.geo[1]
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/AqicnFeedResponse.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/AqicnFeedResponse.kt
new file mode 100644
index 00000000..8cf9f051
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/AqicnFeedResponse.kt
@@ -0,0 +1,13 @@
+package hpsaturn.pollutionreporter.dashboard.data.models
+
+
+/**
+ * Air quality information fetched from Aqicn API.
+ * @property data Air quality station data.
+ * @property status Status code, can be ok or error.
+ * See more here: https://aqicn.org/json-api/doc/
+ */
+data class AqicnFeedResponse(
+ val data: Data,
+ val status: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Attribution.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Attribution.kt
new file mode 100644
index 00000000..34cd9713
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Attribution.kt
@@ -0,0 +1,15 @@
+package hpsaturn.pollutionreporter.dashboard.data.models
+
+
+/**
+ * Attributions of the administrator of the air station.
+ * @property logo Logo of the administrator of the station.
+ * @property name Name of the administrator of the station.
+ * @property name Url of the administrator of the station.
+ * See more here: https://aqicn.org/json-api/doc/
+ */
+data class Attribution(
+ val logo: String,
+ val name: String,
+ val url: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/City.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/City.kt
new file mode 100644
index 00000000..1e1c049a
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/City.kt
@@ -0,0 +1,16 @@
+package hpsaturn.pollutionreporter.dashboard.data.models
+
+
+/**
+ * Information about the monitoring station.
+ * @property name Name of the monitoring station.
+ * @property geo Latitude/Longitude of the monitoring station.
+ * @property url for the attribution link.
+ * See more here: https://aqicn.org/json-api/doc/
+ */
+
+data class City(
+ val geo: List,
+ val name: String,
+ val url: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Data.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Data.kt
new file mode 100644
index 00000000..99f6db6a
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Data.kt
@@ -0,0 +1,20 @@
+package hpsaturn.pollutionreporter.dashboard.data.models
+
+
+/**
+ * Data from the air quality station.
+ * @property aqi Real-time air quality information.
+ * @property attributions List of EPA Attribution for the station.
+ * @property city Information about the monitoring station.
+ * @property idx Unique ID for the city monitoring station.
+ * @property time Measurement time information.
+ * See more here: https://aqicn.org/json-api/doc/
+ */
+
+data class Data(
+ val aqi: Int,
+ val attributions: List,
+ val city: City,
+ val idx: Int,
+ val time: Time
+)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Time.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Time.kt
new file mode 100644
index 00000000..d801cf17
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/models/Time.kt
@@ -0,0 +1,13 @@
+package hpsaturn.pollutionreporter.dashboard.data.models
+
+
+/**
+ * Measurement time information.
+ * @property s Local measurement time time.
+ * @property tz Station timezone.
+ * See more here: https://aqicn.org/json-api/doc/
+ */
+data class Time(
+ val s: String,
+ val tz: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImpl.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImpl.kt
new file mode 100644
index 00000000..b873bb81
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImpl.kt
@@ -0,0 +1,41 @@
+package hpsaturn.pollutionreporter.dashboard.data.repositories
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import hpsaturn.pollutionreporter.R
+import hpsaturn.pollutionreporter.core.data.mappers.Mapper
+import hpsaturn.pollutionreporter.core.domain.errors.ConnectionException
+import hpsaturn.pollutionreporter.core.domain.errors.ServerException
+import hpsaturn.pollutionreporter.core.domain.errors.UnexpectedException
+import hpsaturn.pollutionreporter.dashboard.data.models.AqicnFeedResponse
+import hpsaturn.pollutionreporter.dashboard.data.services.AqicnApiFeedService
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.domain.repositories.AirQualityStatusRepository
+import java.io.IOException
+import javax.inject.Inject
+
+class AirQualityStatusRepositoryImpl @Inject constructor(
+ private val aqicnApiFeedService: AqicnApiFeedService,
+ private val mapper: Mapper,
+ @ApplicationContext private val context: Context
+) : AirQualityStatusRepository {
+
+ override suspend fun getNearestAirQualityStatus(
+ latitude: Double,
+ longitude: Double
+ ): AirQualityStatus {
+
+ val response = runCatching {
+ aqicnApiFeedService.getGeolocationFeed(latitude, longitude)
+ }.getOrElse {
+ context.getString(R.string.internet_connection_unavailable)
+ if (it is IOException) throw ConnectionException(context.getString(R.string.internet_connection_unavailable))
+ else throw UnexpectedException(context.getString(R.string.unexpected_error))
+ }
+ val aqicnFeedResponse = response.body()
+ if (!response.isSuccessful || aqicnFeedResponse == null) {
+ throw ServerException(context.getString(R.string.server_unavailable))
+ }
+ return mapper(aqicnFeedResponse)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/services/AqicnApiFeedService.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/services/AqicnApiFeedService.kt
new file mode 100644
index 00000000..19a7a523
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/services/AqicnApiFeedService.kt
@@ -0,0 +1,14 @@
+package hpsaturn.pollutionreporter.dashboard.data.services
+
+import hpsaturn.pollutionreporter.dashboard.data.models.AqicnFeedResponse
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Path
+
+interface AqicnApiFeedService {
+ @GET("feed/geo:{lat};{long}/")
+ suspend fun getGeolocationFeed(
+ @Path("lat") latitude: Double,
+ @Path("long") longitude: Double
+ ): Response
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/entities/AirQualityScale.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/entities/AirQualityScale.kt
new file mode 100644
index 00000000..34dc7b80
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/entities/AirQualityScale.kt
@@ -0,0 +1,15 @@
+package hpsaturn.pollutionreporter.dashboard.domain.entities
+
+import hpsaturn.pollutionreporter.R
+
+enum class AirQualityScale(val colorResourceId: Int, val nameResourceId: Int) {
+ GOOD(R.color.scale_good, R.string.scale_good),
+ MODERATE(R.color.scale_moderate, R.string.scale_moderate),
+ UNHEALTHY_FOR_SENSITIVE_GROUPS(
+ R.color.scale_unhealthy_for_sensitive_groups,
+ R.string.scale_unhealthy_for_sensitive_groups
+ ),
+ UNHEALTHY(R.color.scale_unhealthy, R.string.scale_unhealthy),
+ VERY_UNHEALTHY(R.color.scale_very_unhealthy, R.string.scale_very_unhealthy),
+ HAZARDOUS(R.color.scale_hazardous, R.string.scale_hazardous)
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/entities/AirQualityStatus.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/entities/AirQualityStatus.kt
new file mode 100644
index 00000000..94672757
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/entities/AirQualityStatus.kt
@@ -0,0 +1,16 @@
+package hpsaturn.pollutionreporter.dashboard.domain.entities
+
+/**
+ * Air quality information.
+ * @property airQualityIndex Real-time air quality information.
+ * @property stationName Name of the monitoring station.
+ * @property stationLongitude Longitude of the monitoring station.
+ * @property stationLatitude Latitude of the monitoring station.
+ */
+
+class AirQualityStatus(
+ val airQualityIndex: Int,
+ val stationName: String,
+ val stationLatitude: Double,
+ val stationLongitude: Double
+)
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/repositories/AirQualityStatusRepository.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/repositories/AirQualityStatusRepository.kt
new file mode 100644
index 00000000..1fc68962
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/repositories/AirQualityStatusRepository.kt
@@ -0,0 +1,10 @@
+package hpsaturn.pollutionreporter.dashboard.domain.repositories
+
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+
+interface AirQualityStatusRepository {
+ suspend fun getNearestAirQualityStatus(
+ latitude: Double,
+ longitude: Double
+ ): AirQualityStatus
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/EvaluateAirQualityStatus.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/EvaluateAirQualityStatus.kt
new file mode 100644
index 00000000..857087a6
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/EvaluateAirQualityStatus.kt
@@ -0,0 +1,19 @@
+package hpsaturn.pollutionreporter.dashboard.domain.usecases
+
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityScale
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import javax.inject.Inject
+
+
+class EvaluateAirQualityStatus @Inject constructor() {
+ operator fun invoke(airQualityStatus: AirQualityStatus): AirQualityScale =
+ when (airQualityStatus.airQualityIndex) {
+ in Int.MIN_VALUE..-1 -> throw IllegalArgumentException("No negative values for AQI.")
+ in 0..50 -> AirQualityScale.GOOD
+ in 51..100 -> AirQualityScale.MODERATE
+ in 101..150 -> AirQualityScale.UNHEALTHY_FOR_SENSITIVE_GROUPS
+ in 151..200 -> AirQualityScale.UNHEALTHY
+ in 201..300 -> AirQualityScale.VERY_UNHEALTHY
+ else -> AirQualityScale.HAZARDOUS
+ }
+}
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/FindNearestAirQualityStatus.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/FindNearestAirQualityStatus.kt
new file mode 100644
index 00000000..411ce047
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/FindNearestAirQualityStatus.kt
@@ -0,0 +1,12 @@
+package hpsaturn.pollutionreporter.dashboard.domain.usecases
+
+import hpsaturn.pollutionreporter.dashboard.domain.repositories.AirQualityStatusRepository
+import javax.inject.Inject
+
+class FindNearestAirQualityStatus @Inject constructor(
+ private val airQualityStatusRepository: AirQualityStatusRepository
+) {
+
+ suspend operator fun invoke(latitude: Double, longitude: Double) =
+ airQualityStatusRepository.getNearestAirQualityStatus(latitude, longitude)
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/CurrentLocationLiveData.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/CurrentLocationLiveData.kt
new file mode 100644
index 00000000..9519974c
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/CurrentLocationLiveData.kt
@@ -0,0 +1,108 @@
+package hpsaturn.pollutionreporter.dashboard.presentation
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.Location
+import androidx.core.app.ActivityCompat
+import androidx.lifecycle.LiveData
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.karumi.dexter.DexterBuilder
+import com.karumi.dexter.MultiplePermissionsReport
+import com.karumi.dexter.PermissionToken
+import com.karumi.dexter.listener.PermissionRequest
+import com.karumi.dexter.listener.multi.MultiplePermissionsListener
+import dagger.hilt.android.qualifiers.ApplicationContext
+import hpsaturn.pollutionreporter.R
+import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult
+import hpsaturn.pollutionreporter.core.domain.entities.InProgress
+import hpsaturn.pollutionreporter.core.domain.entities.Result
+import hpsaturn.pollutionreporter.core.domain.entities.Success
+import hpsaturn.pollutionreporter.core.domain.errors.PermissionException
+import hpsaturn.pollutionreporter.core.domain.errors.PermissionNotGrantedException
+import javax.inject.Inject
+
+class CurrentLocationLiveData @Inject constructor(
+ private val fusedLocationProviderClient: FusedLocationProviderClient,
+ private val locationRequest: LocationRequest,
+ private val dexter: DexterBuilder.Permission,
+ @ApplicationContext private val context: Context
+) : LiveData>() {
+
+ private val setLocationListener = { location: Location -> value = Success(location) }
+
+ @SuppressLint("MissingPermission")
+ override fun onActive() {
+ super.onActive()
+ postValue(InProgress)
+ checkPermissions { startLocationUpdates() }
+ fusedLocationProviderClient.lastLocation.addOnSuccessListener {
+ if (it == null) {
+ postValue(ErrorResult(PermissionNotGrantedException(context.getString(R.string.check_location_settings))))
+ } else {
+ it.also { setLocationListener(it) }
+ }
+ }
+ }
+
+ private fun checkPermissions(onPermissionsGranted: () -> Unit) {
+ dexter.withPermissions(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ ).withListener(object : MultiplePermissionsListener {
+ override fun onPermissionsChecked(multiplePermissionsReport: MultiplePermissionsReport?) {
+ if (multiplePermissionsReport?.areAllPermissionsGranted() == true) {
+ onPermissionsGranted()
+ } else {
+ postValue(ErrorResult(PermissionNotGrantedException(context.getString(R.string.location_permissions_not_granted_error))))
+ }
+ }
+
+ override fun onPermissionRationaleShouldBeShown(
+ requests: MutableList?, token: PermissionToken?
+ ) {
+ postValue(ErrorResult(PermissionException(context.getString(R.string.enable_permission_request))))
+ token?.continuePermissionRequest()
+ }
+
+ }).withErrorListener { postValue(ErrorResult(IllegalAccessException(it.name))) }.check()
+ }
+
+ override fun onInactive() {
+ super.onInactive()
+ fusedLocationProviderClient.removeLocationUpdates(locationCallback)
+ }
+
+ private val locationCallback = object : LocationCallback() {
+ override fun onLocationResult(locationResult: LocationResult?) {
+ locationResult ?: return
+ for (location in locationResult.locations) {
+ setLocationListener(location)
+ }
+ }
+ }
+
+ private fun areLocationPermissionsGranted(): Boolean {
+ return ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ ) != PackageManager.PERMISSION_GRANTED
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun startLocationUpdates() {
+ if (areLocationPermissionsGranted()) {
+ postValue(ErrorResult(PermissionNotGrantedException(context.getString(R.string.location_permissions_not_granted_error))))
+ return
+ }
+ fusedLocationProviderClient.requestLocationUpdates(locationRequest, locationCallback, null)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardViewModel.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardViewModel.kt
new file mode 100644
index 00000000..a69f15b1
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardViewModel.kt
@@ -0,0 +1,64 @@
+package hpsaturn.pollutionreporter.dashboard.presentation
+
+import android.location.Location
+import androidx.hilt.lifecycle.ViewModelInject
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.liveData
+import androidx.lifecycle.switchMap
+import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult
+import hpsaturn.pollutionreporter.core.domain.entities.InProgress
+import hpsaturn.pollutionreporter.core.domain.entities.Result
+import hpsaturn.pollutionreporter.core.domain.entities.Success
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.domain.usecases.FindNearestAirQualityStatus
+import hpsaturn.pollutionreporter.di.DispatchersModule
+import hpsaturn.pollutionreporter.util.combineWith
+import hpsaturn.pollutionreporter.util.round
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+
+class DashboardViewModel @ViewModelInject constructor(
+ private val findNearestAirQualityStatus: FindNearestAirQualityStatus,
+ currentLocationLiveData: LiveData>,
+ @DispatchersModule.IoDispatcher private val ioDispatcher: CoroutineDispatcher
+) : ViewModel() {
+
+ val airQualityStatus: LiveData> = currentLocationLiveData.switchMap {
+ liveData {
+ when (it) {
+ is Success -> emit(resolveResult(it.data))
+ is ErrorResult -> emit(ErrorResult(it.exception))
+ is InProgress -> emit(InProgress)
+ }
+ }
+ }
+
+ private val numberOfDecimals = 2
+ private val metersInOneKilometer = 1000.0
+
+ private val calculateDistanceInKm =
+ { location: Result?, aqi: Result? ->
+ val aqiLocation = Location("")
+ if (location is Success && aqi is Success) {
+ aqiLocation.longitude = aqi.data.stationLongitude
+ aqiLocation.latitude = aqi.data.stationLatitude
+ val distance = location.data.distanceTo(aqiLocation).toDouble() // Result in meters.
+ (distance / metersInOneKilometer).round(numberOfDecimals)
+ } else {
+ 0.0
+ }
+ }
+
+ val distanceToStation: LiveData =
+ currentLocationLiveData.combineWith(airQualityStatus, calculateDistanceInKm)
+
+ private suspend fun resolveResult(location: Location): Result =
+ withContext(ioDispatcher) {
+ runCatching {
+ Success(findNearestAirQualityStatus(location.latitude, location.longitude))
+ }.getOrElse { e -> ErrorResult(e) }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/di/ApplicationModule.kt b/app/src/main/java/hpsaturn/pollutionreporter/di/ApplicationModule.kt
new file mode 100644
index 00000000..94f0eb4a
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/di/ApplicationModule.kt
@@ -0,0 +1,25 @@
+package hpsaturn.pollutionreporter.di
+
+import android.content.Context
+import com.karumi.dexter.Dexter
+import com.karumi.dexter.DexterBuilder.Permission
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ApplicationComponent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.*
+import javax.inject.Singleton
+
+@Module
+@InstallIn(ApplicationComponent::class)
+object ApplicationModule {
+ @Singleton
+ @Provides
+ fun provideDexter(@ApplicationContext context: Context): Permission =
+ Dexter.withContext(context)
+
+ @Singleton
+ @Provides
+ fun provideCalendar(): Calendar = Calendar.getInstance()
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/di/DispatchersModule.kt b/app/src/main/java/hpsaturn/pollutionreporter/di/DispatchersModule.kt
new file mode 100644
index 00000000..9d8659c1
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/di/DispatchersModule.kt
@@ -0,0 +1,44 @@
+package hpsaturn.pollutionreporter.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ApplicationComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Module
+@InstallIn(ApplicationComponent::class)
+object DispatchersModule {
+
+ @Retention(AnnotationRetention.BINARY)
+ @Qualifier
+ annotation class DefaultDispatcher
+
+ @Retention(AnnotationRetention.BINARY)
+ @Qualifier
+ annotation class IoDispatcher
+
+ @Retention(AnnotationRetention.BINARY)
+ @Qualifier
+ annotation class MainDispatcher
+
+ @Singleton
+ @IoDispatcher
+ @Provides
+ fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Singleton
+ @DefaultDispatcher
+ @Provides
+ fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
+
+
+ @Singleton
+ @MainDispatcher
+ @Provides
+ fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/di/LocationModule.kt b/app/src/main/java/hpsaturn/pollutionreporter/di/LocationModule.kt
new file mode 100644
index 00000000..e2b14adf
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/di/LocationModule.kt
@@ -0,0 +1,30 @@
+package hpsaturn.pollutionreporter.di
+
+import android.content.Context
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationServices
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ApplicationComponent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(ApplicationComponent::class)
+object LocationModule {
+ @Singleton
+ @Provides
+ fun provideFusedLocationProviderClient(@ApplicationContext context: Context):
+ FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
+
+ @Singleton
+ @Provides
+ fun provideFusedLocationRequest(): LocationRequest = LocationRequest.create().apply {
+ interval = TimeUnit.SECONDS.toMillis(10)
+ fastestInterval = TimeUnit.SECONDS.toMillis(5)
+ priority = LocationRequest.PRIORITY_HIGH_ACCURACY
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/di/NetworkModule.kt b/app/src/main/java/hpsaturn/pollutionreporter/di/NetworkModule.kt
new file mode 100644
index 00000000..448d72d1
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/di/NetworkModule.kt
@@ -0,0 +1,75 @@
+package hpsaturn.pollutionreporter.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ApplicationComponent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import hpsaturn.pollutionreporter.R
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Module
+@InstallIn(ApplicationComponent::class)
+object NetworkModule {
+
+ @Qualifier
+ @Retention(AnnotationRetention.BINARY)
+ annotation class AuthInterceptorOkHttpClient
+
+ @Singleton
+ @AuthInterceptorOkHttpClient
+ @Provides
+ fun provideAuthInterceptorOkHttpClient(@ApplicationContext context: Context) =
+ Interceptor { chain ->
+ val tokenQueryName = context.getString(R.string.api_aqicn_token_query_name)
+ val token = context.getString(R.string.api_aqicn_key)
+ val url = chain.request()
+ .url
+ .newBuilder()
+ .addQueryParameter(tokenQueryName, token)
+ .build()
+ val request = chain.request()
+ .newBuilder()
+ .url(url)
+ .build()
+ return@Interceptor chain.proceed(request)
+ }
+
+ @Singleton
+ @Provides
+ fun provideHttpLoggingInterceptor() = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+
+ @Singleton
+ @Provides
+ fun provideOkHttpClient(
+ @AuthInterceptorOkHttpClient tokenInterceptor: Interceptor,
+ loggingInterceptor: HttpLoggingInterceptor
+ ) =
+ OkHttpClient.Builder()
+ .addInterceptor(tokenInterceptor)
+ .addInterceptor(loggingInterceptor)
+ .build()
+
+ @Singleton
+ @Provides
+ fun provideRetrofitInstance(
+ @ApplicationContext context: Context,
+ okHttpClient: OkHttpClient
+ ): Retrofit =
+ Retrofit.Builder()
+ .baseUrl(context.getString(R.string.api_aqicn_url))
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/util/Extensions.kt b/app/src/main/java/hpsaturn/pollutionreporter/util/Extensions.kt
new file mode 100644
index 00000000..546b0261
--- /dev/null
+++ b/app/src/main/java/hpsaturn/pollutionreporter/util/Extensions.kt
@@ -0,0 +1,35 @@
+package hpsaturn.pollutionreporter.util
+
+import android.location.Location
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import kotlin.math.round
+
+/**
+ * Combines a liveData `this` with the other liveData [liveData] using the [block] combination function.
+ */
+fun LiveData.combineWith(liveData: LiveData, block: (T?, K?) -> R): LiveData {
+ val result = MediatorLiveData()
+ result.addSource(this) { result.value = block(this.value, liveData.value) }
+ result.addSource(liveData) { result.value = block(this.value, liveData.value) }
+ return result
+}
+
+/**
+ * Creates a new [Location] with the given [latitude] and [longitude].
+ */
+fun Location.createWith(latitude: Double, longitude: Double): Location {
+ val location = Location("")
+ location.longitude = longitude
+ location.latitude = latitude
+ return location
+}
+
+/**
+ * Rounds `this` to [decimals] numbers.
+ */
+fun Double.round(decimals: Int): Double {
+ var multiplier = 1.0
+ repeat(decimals) { multiplier *= 10 }
+ return round(this * multiplier) / multiplier
+}
\ No newline at end of file
diff --git a/app/src/main/java/hpsaturn/pollutionreporter/view/PostsFragment.java b/app/src/main/java/hpsaturn/pollutionreporter/view/PostsFragment.java
index 9fd24f43..1b3a2ed7 100644
--- a/app/src/main/java/hpsaturn/pollutionreporter/view/PostsFragment.java
+++ b/app/src/main/java/hpsaturn/pollutionreporter/view/PostsFragment.java
@@ -16,6 +16,7 @@
import com.firebase.ui.database.FirebaseRecyclerAdapter;
import com.firebase.ui.database.FirebaseRecyclerOptions;
import com.google.firebase.database.DatabaseReference;
+import com.google.firebase.database.FirebaseDatabase;
import com.google.firebase.database.Query;
import com.hpsaturn.tools.Logger;
@@ -33,6 +34,8 @@ public class PostsFragment extends Fragment {
public static String TAG = PostsFragment.class.getSimpleName();
+ private DatabaseReference mDatabaseReference;
+
private RecyclerView mRecordsList;
private TextView mEmptyMessage;
private ChartFragment chart;
@@ -53,6 +56,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa
mRecordsList = view.findViewById(R.id.rv_records);
mEmptyMessage.setText(R.string.msg_not_public_recors);
+ mDatabaseReference = FirebaseDatabase.getInstance().getReference();
+
mManager = new LinearLayoutManager(getActivity());
mManager.setReverseLayout(true);
mManager.setStackFromEnd(true);
@@ -66,7 +71,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
super.onViewCreated(view, savedInstanceState);
// Set up FirebaseRecyclerAdapter with the Query
- Query postsQuery = getMain().getDatabase().child(Config.FB_TRACKS_INFO).orderByKey().limitToLast(20);
+ Query postsQuery = mDatabaseReference.child(Config.FB_TRACKS_INFO).orderByKey().limitToLast(20);
Logger.d(TAG,"[FB][POSTS] Query: "+postsQuery.toString());
FirebaseRecyclerOptions options = new FirebaseRecyclerOptions.Builder()
.setQuery(postsQuery, SensorTrackInfo.class)
@@ -86,14 +91,14 @@ protected void onBindViewHolder(@NonNull PostsViewHolder viewHolder, int positio
final DatabaseReference postRef = getRef(position);
final String recordKey = postRef.getKey();
Logger.d(TAG,"[FB][POSTS] onBindViewHolder: "+recordKey+" name:"+trackInfo.getName());
- getMain().addTrackToMap(trackInfo);
+// getMain().addTrackToMap(trackInfo);
viewHolder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String recordId = trackInfo.getName();
Logger.i(TAG,"[FB][POSTS] onClick -> showing record: "+recordId);
chart = ChartFragment.newInstance(recordId);
- getMain().addFragmentPopup(chart,ChartFragment.TAG);
+// getMain().addFragmentPopup(chart,ChartFragment.TAG);
}
});
// Bind Post to ViewHolder, setting OnClickListener for the star button
diff --git a/app/src/main/res/drawable/circular_progress_bar.xml b/app/src/main/res/drawable/circular_progress_bar.xml
new file mode 100644
index 00000000..5b1ecb16
--- /dev/null
+++ b/app/src/main/res/drawable/circular_progress_bar.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/circular_progress_bar_shape.xml b/app/src/main/res/drawable/circular_progress_bar_shape.xml
new file mode 100644
index 00000000..9f65bca7
--- /dev/null
+++ b/app/src/main/res/drawable/circular_progress_bar_shape.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_analytics_24dp.xml b/app/src/main/res/drawable/ic_analytics_24dp.xml
new file mode 100644
index 00000000..eab330b2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_analytics_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_24dp.xml b/app/src/main/res/drawable/ic_home_24dp.xml
new file mode 100644
index 00000000..e129a8f6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_map_24dp.xml b/app/src/main/res/drawable/ic_map_24dp.xml
new file mode 100644
index 00000000..0f1cd4c1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_map_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_public_24dp.xml b/app/src/main/res/drawable/ic_public_24dp.xml
new file mode 100644
index 00000000..5592519e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_public_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_receipt_long_24dp.xml b/app/src/main/res/drawable/ic_receipt_long_24dp.xml
new file mode 100644
index 00000000..4fe00436
--- /dev/null
+++ b/app/src/main/res/drawable/ic_receipt_long_24dp.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings_24dp.xml b/app/src/main/res/drawable/ic_settings_24dp.xml
new file mode 100644
index 00000000..30a0d50b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings_24dp.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/illustration_about_us.xml b/app/src/main/res/drawable/illustration_about_us.xml
new file mode 100644
index 00000000..4d3e9304
--- /dev/null
+++ b/app/src/main/res/drawable/illustration_about_us.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/illustration_diy_sensor.xml b/app/src/main/res/drawable/illustration_diy_sensor.xml
new file mode 100644
index 00000000..ed4b8edb
--- /dev/null
+++ b/app/src/main/res/drawable/illustration_diy_sensor.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/illustration_feedback.xml b/app/src/main/res/drawable/illustration_feedback.xml
new file mode 100644
index 00000000..f135205d
--- /dev/null
+++ b/app/src/main/res/drawable/illustration_feedback.xml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/illustration_unpair_device.xml b/app/src/main/res/drawable/illustration_unpair_device.xml
new file mode 100644
index 00000000..00d588d3
--- /dev/null
+++ b/app/src/main/res/drawable/illustration_unpair_device.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/font/montserrat_light.ttf b/app/src/main/res/font/montserrat_light.ttf
new file mode 100644
index 00000000..990857de
Binary files /dev/null and b/app/src/main/res/font/montserrat_light.ttf differ
diff --git a/app/src/main/res/font/montserrat_medium.ttf b/app/src/main/res/font/montserrat_medium.ttf
new file mode 100644
index 00000000..6e079f69
Binary files /dev/null and b/app/src/main/res/font/montserrat_medium.ttf differ
diff --git a/app/src/main/res/font/montserrat_regular.ttf b/app/src/main/res/font/montserrat_regular.ttf
new file mode 100644
index 00000000..8d443d5d
Binary files /dev/null and b/app/src/main/res/font/montserrat_regular.ttf differ
diff --git a/app/src/main/res/layout/activity_dashboard.xml b/app/src/main/res/layout/activity_dashboard.xml
new file mode 100644
index 00000000..31c1d894
--- /dev/null
+++ b/app/src/main/res/layout/activity_dashboard.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_dashboard.xml b/app/src/main/res/layout/fragment_dashboard.xml
new file mode 100644
index 00000000..0ef12cd2
--- /dev/null
+++ b/app/src/main/res/layout/fragment_dashboard.xml
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/bottom_navigation.xml b/app/src/main/res/menu/bottom_navigation.xml
new file mode 100644
index 00000000..fd8be01f
--- /dev/null
+++ b/app/src/main/res/menu/bottom_navigation.xml
@@ -0,0 +1,23 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
new file mode 100644
index 00000000..19f42089
--- /dev/null
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/api_aqicn_info.xml b/app/src/main/res/values/api_aqicn_info.xml
new file mode 100644
index 00000000..4000ba36
--- /dev/null
+++ b/app/src/main/res/values/api_aqicn_info.xml
@@ -0,0 +1,5 @@
+
+
+ https://api.waqi.info/
+ token
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index a55aae4a..153a76d9 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -15,7 +15,24 @@
#B74E91
+ #841B63
+ #EB7EC1
+
#5E42A6
+ #2C1976
+
+ #FF595E
+
+ #30292F
+
+ #515052
+ #BFC0C0
+ #EAEAEA
+
+ #8AC926
+ #388E3C
+
+ #FDFDFD
#f00
@@ -33,5 +50,12 @@
#00FFFFFF
#AAFFFFFF
+
+ #388E3C
+ #FFE548
+ #ffb20f
+ #ff4b3e
+ #841B63
+ #972d07
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 74573202..c3dc003e 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -4,4 +4,17 @@
360dp
90dp
125dp
+ 3dp
+
+
+ 156dp
+
+
+ 0dp
+ 4dp
+ 8dp
+ 12dp
+ 16dp
+ 20dp
+ 24dp
diff --git a/app/src/main/res/values/shape_styles.xml b/app/src/main/res/values/shape_styles.xml
new file mode 100644
index 00000000..a9d8b962
--- /dev/null
+++ b/app/src/main/res/values/shape_styles.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 41177609..43bc17b0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -80,4 +80,34 @@
crashkey_api_usr
Feedback
key_send_feedback
+
+ Air Quality Index
+ Give Us Feedback
+ Unpair Device
+ Sensor DIY Guide
+ About
+ Location permission not granted.
+ Please enable location permissions.
+ Please check location settings.
+ Internet connection unavailable.
+ Server is currently unavailable.
+ Unexpected error occurred.
+ Error
+
+
+ Good
+ Moderate
+ Unhealthy for Sensitive Groups
+ Unhealthy
+ Very Unhealthy
+ Hazardous
+
+
+ Hello blank fragment
+ App settings.
+ DIY sensor.
+ About CanAirIO.
+ Give us feedback.
+ Upair device.
+ None
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 394ab9d8..5fa0ca1b 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -35,4 +35,52 @@
- @drawable/launch_screen
+
+
diff --git a/app/src/main/res/values/text_styles.xml b/app/src/main/res/values/text_styles.xml
new file mode 100644
index 00000000..3c65a8f6
--- /dev/null
+++ b/app/src/main/res/values/text_styles.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/core/data/services/RetrofitTestInstance.kt b/app/src/test/java/hpsaturn/pollutionreporter/core/data/services/RetrofitTestInstance.kt
new file mode 100644
index 00000000..ff2a11ce
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/core/data/services/RetrofitTestInstance.kt
@@ -0,0 +1,10 @@
+package hpsaturn.pollutionreporter.core.data.services
+
+import okhttp3.mockwebserver.MockWebServer
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+fun getRetrofitTestInstance(mockWebServer: MockWebServer): Retrofit = Retrofit.Builder()
+ .baseUrl(mockWebServer.url("/"))
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/mappers/AirQualityStatusMapperTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/mappers/AirQualityStatusMapperTest.kt
new file mode 100644
index 00000000..ec358ecc
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/mappers/AirQualityStatusMapperTest.kt
@@ -0,0 +1,32 @@
+package hpsaturn.pollutionreporter.dashboard.data.mappers
+
+import hpsaturn.pollutionreporter.dashboard.data.models.AqicnFeedResponse
+import hpsaturn.pollutionreporter.fixtures.JsonFixture
+import hpsaturn.pollutionreporter.fixtures.readFixture
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+internal class AirQualityStatusMapperTest {
+
+ private lateinit var tAirQualityStatusMapper: AirQualityStatusMapper
+
+ private val tAqicnFeedResponse =
+ readFixture(JsonFixture.STATION_FEED, AqicnFeedResponse::class.java)
+
+ @BeforeEach
+ internal fun setUp() {
+ tAirQualityStatusMapper = AirQualityStatusMapper()
+ }
+
+ @Test
+ fun `should map aqicn response to air quality entity class`() {
+ // act
+ val response = tAirQualityStatusMapper(tAqicnFeedResponse)
+ // assert
+ assertEquals(tAqicnFeedResponse.data.aqi, response.airQualityIndex)
+ assertEquals(tAqicnFeedResponse.data.city.name, response.stationName)
+ assertEquals(tAqicnFeedResponse.data.city.geo[0], response.stationLatitude)
+ assertEquals(tAqicnFeedResponse.data.city.geo[1], response.stationLongitude)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImplTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImplTest.kt
new file mode 100644
index 00000000..5e297516
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImplTest.kt
@@ -0,0 +1,163 @@
+package hpsaturn.pollutionreporter.dashboard.data.repositories
+
+import android.content.Context
+import hpsaturn.pollutionreporter.core.data.mappers.Mapper
+import hpsaturn.pollutionreporter.core.domain.errors.ConnectionException
+import hpsaturn.pollutionreporter.core.domain.errors.ServerException
+import hpsaturn.pollutionreporter.core.domain.errors.UnexpectedException
+import hpsaturn.pollutionreporter.dashboard.data.models.AqicnFeedResponse
+import hpsaturn.pollutionreporter.dashboard.data.services.AqicnApiFeedService
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.fixtures.JsonFixture
+import hpsaturn.pollutionreporter.fixtures.readFixture
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.junit5.MockKExtension
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runBlockingTest
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.IOException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import retrofit2.Response
+
+@ExperimentalCoroutinesApi
+@ExtendWith(MockKExtension::class)
+internal class AirQualityStatusRepositoryImplTest {
+
+ private lateinit var repository: AirQualityStatusRepositoryImpl
+
+ @MockK
+ private lateinit var mockAqicnApiFeedService: AqicnApiFeedService
+
+ @MockK
+ private lateinit var mockMapper: Mapper
+
+ @MockK(relaxed = true)
+ private lateinit var mockContext: Context
+
+ private val tLatitude = 4.645594
+ private val tLongitude = -74.058881
+ private val tErrorMessage = "Server Error"
+ private val tAqicnFeedResponse =
+ readFixture(JsonFixture.STATION_FEED, AqicnFeedResponse::class.java)
+ private val tAirQualityStatus = AirQualityStatus(
+ 1,
+ "station name",
+ tLatitude,
+ tLongitude
+ )
+
+ @BeforeEach
+ fun setUp() {
+ repository = AirQualityStatusRepositoryImpl(
+ mockAqicnApiFeedService,
+ mockMapper,
+ mockContext
+ )
+ }
+
+ @Test
+ fun `should return remote data when the call to remote data source is ok`() = runBlockingTest {
+ // arrange
+ every { mockMapper(any()) } returns tAirQualityStatus
+ coEvery {
+ mockAqicnApiFeedService.getGeolocationFeed(
+ any(),
+ any()
+ )
+ } returns Response.success(tAqicnFeedResponse)
+ // act
+ val result = repository.getNearestAirQualityStatus(tLatitude, tLongitude)
+ // assert
+ coVerify { mockAqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ assertEquals(tAirQualityStatus, result)
+
+ }
+
+ @Test
+ fun `should return error if response null`() = runBlockingTest {
+ // arrange
+ val response = Response.success(200, null)
+ every { mockMapper(any()) } returns tAirQualityStatus
+ coEvery {
+ mockAqicnApiFeedService.getGeolocationFeed(
+ any(),
+ any()
+ )
+ } returns response
+ // act
+ assertThrows {
+ runBlocking {
+ repository.getNearestAirQualityStatus(tLatitude, tLongitude)
+ }
+ }
+ // assert
+ coVerify { mockAqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ }
+
+ @Test
+ fun `should throw exception if response not successful`() = runBlockingTest {
+ // arrange
+ val response = Response.error(400, tErrorMessage.toResponseBody())
+ every { mockMapper(any()) } returns tAirQualityStatus
+ coEvery {
+ mockAqicnApiFeedService.getGeolocationFeed(
+ any(),
+ any()
+ )
+ } returns response
+ // act
+ assertThrows {
+ runBlocking {
+ repository.getNearestAirQualityStatus(tLatitude, tLongitude)
+ }
+ }
+ // assert
+ coVerify { mockAqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ }
+
+ @Test
+ fun `should throw ConnectionException if service throws IOException`() = runBlockingTest {
+ // arrange
+ coEvery {
+ mockAqicnApiFeedService.getGeolocationFeed(
+ any(),
+ any()
+ )
+ } throws IOException()
+ // act
+ assertThrows {
+ runBlocking {
+ repository.getNearestAirQualityStatus(tLatitude, tLongitude)
+ }
+ }
+ // assert
+ coVerify { mockAqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ }
+
+ @Test
+ fun `should throw UnexpectedException if service throws unknown exception`() = runBlockingTest {
+ // arrange
+ coEvery {
+ mockAqicnApiFeedService.getGeolocationFeed(
+ any(),
+ any()
+ )
+ } throws Exception()
+ // act
+ assertThrows {
+ runBlocking {
+ repository.getNearestAirQualityStatus(tLatitude, tLongitude)
+ }
+ }
+ // assert
+ coVerify { mockAqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/services/AqicnApiFeedServiceTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/services/AqicnApiFeedServiceTest.kt
new file mode 100644
index 00000000..02adf0fc
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/data/services/AqicnApiFeedServiceTest.kt
@@ -0,0 +1,75 @@
+package hpsaturn.pollutionreporter.dashboard.data.services
+
+import hpsaturn.pollutionreporter.core.data.services.getRetrofitTestInstance
+import hpsaturn.pollutionreporter.fixtures.JsonFixture
+import hpsaturn.pollutionreporter.fixtures.readFixture
+import kotlinx.coroutines.runBlocking
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.net.HttpURLConnection
+
+internal class AqicnApiFeedServiceTest {
+
+ private val tLatitude = 4.645594
+ private val tLongitude = -74.058881
+
+ private var mockWebServer = MockWebServer()
+ private lateinit var aqicnApiFeedService: AqicnApiFeedService
+
+ @BeforeEach
+ fun setup() {
+ mockWebServer.start()
+ aqicnApiFeedService = getRetrofitTestInstance(mockWebServer)
+ .create(AqicnApiFeedService::class.java)
+ }
+
+ @AfterEach
+ fun teardown() {
+ mockWebServer.shutdown()
+ }
+
+ @Test
+ fun `should make GET request to geo endpoint with latitude and longitude`() {
+ // arrange
+ val mockResponse = MockResponse()
+ .setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBody(readFixture(JsonFixture.STATION_FEED))
+ mockWebServer.enqueue(mockResponse)
+ // act
+ val result = runBlocking { aqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ val lastRequest = mockWebServer.takeRequest()
+ // assert
+ assertNotNull(result.body())
+ assertNotNull(lastRequest)
+ assertNotNull(lastRequest.requestUrl)
+ assertEquals("GET", lastRequest.method)
+ assertEquals(1, mockWebServer.requestCount)
+ assertEquals("feed", lastRequest.requestUrl!!.pathSegments[0])
+ assertEquals("geo:$tLatitude;$tLongitude", lastRequest.requestUrl!!.pathSegments[1])
+ }
+
+ @Test
+ fun `should deserialize JSON to AqicnFeedResponse`() {
+ // arrange
+ val mockResponse = MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
+ .setBody(readFixture(JsonFixture.STATION_FEED))
+ mockWebServer.enqueue(mockResponse)
+ // act
+ val result = runBlocking { aqicnApiFeedService.getGeolocationFeed(tLatitude, tLongitude) }
+ val lastRequest = mockWebServer.takeRequest()
+ // assert
+ assertNotNull(result.body())
+ assertNotNull(lastRequest)
+ assertNotNull(lastRequest.requestUrl)
+ assertEquals("ok", result.body()!!.status)
+ assertEquals(11, result.body()!!.data.aqi)
+ assertEquals(6236, result.body()!!.data.idx)
+ assertEquals(4.5725, result.body()!!.data.city.geo[0])
+ assertEquals(-74.0836, result.body()!!.data.city.geo[1])
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/EvaluateAirQualityStatusTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/EvaluateAirQualityStatusTest.kt
new file mode 100644
index 00000000..f3ea3aba
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/EvaluateAirQualityStatusTest.kt
@@ -0,0 +1,100 @@
+package hpsaturn.pollutionreporter.dashboard.domain.usecases
+
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityScale
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import io.mockk.junit5.MockKExtension
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import kotlin.random.Random
+
+@ExtendWith(MockKExtension::class)
+internal class EvaluateAirQualityStatusTest {
+ private lateinit var useCase: EvaluateAirQualityStatus
+
+ @BeforeEach
+ fun setUp() {
+ useCase = EvaluateAirQualityStatus()
+ }
+
+ @Test
+ fun `should return GOOD if the AQI is between 0 and 50`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(0, 50))
+ // act
+ val result = useCase(tAirQualityStatus)
+ // assert
+ assertEquals(AirQualityScale.GOOD, result)
+ }
+
+ @Test
+ fun `should return MODERATE if the AQI is between 51 and 100`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(51, 100))
+ // act
+ val result = useCase(tAirQualityStatus)
+ // assert
+ assertEquals(AirQualityScale.MODERATE, result)
+ }
+
+ @Test
+ fun `should return UNHEALTHY_FOR_SENSITIVE_GROUPS if the AQI is between 101 and 150`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(101, 150))
+ // act
+ val result = useCase(tAirQualityStatus)
+ // assert
+ assertEquals(AirQualityScale.UNHEALTHY_FOR_SENSITIVE_GROUPS, result)
+ }
+
+ @Test
+ fun `should return UNHEALTHY if the AQI is between 151 and 200`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(151, 200))
+ // act
+ val result = useCase(tAirQualityStatus)
+ // assert
+ assertEquals(AirQualityScale.UNHEALTHY, result)
+ }
+
+ @Test
+ fun `should return VERY_UNHEALTHY if the AQI is between 201 and 300`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(201, 300))
+ // act
+ val result = useCase(tAirQualityStatus)
+ // assert
+ assertEquals(AirQualityScale.VERY_UNHEALTHY, result)
+ }
+
+ @Test
+ fun `should return HAZARDOUS if the AQI is between 201 and 300`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(301, Int.MAX_VALUE))
+ // act
+ val result = useCase(tAirQualityStatus)
+ // assert
+ assertEquals(AirQualityScale.HAZARDOUS, result)
+ }
+
+ @Test
+ fun `should throw IllegalArgumentException if AQI is negative`() {
+ // arrange
+ val tAirQualityStatus = generateMockAirQualityStatus(Random.nextInt(Int.MIN_VALUE, -1))
+ // assert
+ val exception = assertThrows {
+ // act
+ useCase(tAirQualityStatus)
+ }
+ assertEquals("No negative values for AQI.", exception.message)
+ }
+
+ private fun generateMockAirQualityStatus(aqi: Int): AirQualityStatus = AirQualityStatus(
+ aqi,
+ "station name",
+ 4.645594,
+ -74.058881
+ )
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/FindNearestAirQualityStatusTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/FindNearestAirQualityStatusTest.kt
new file mode 100644
index 00000000..aff51397
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/domain/usecases/FindNearestAirQualityStatusTest.kt
@@ -0,0 +1,61 @@
+package hpsaturn.pollutionreporter.dashboard.domain.usecases
+
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.domain.repositories.AirQualityStatusRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.MockK
+import io.mockk.junit5.MockKExtension
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExperimentalCoroutinesApi
+@ExtendWith(MockKExtension::class)
+internal class FindNearestAirQualityStatusTest {
+
+ private lateinit var useCase: FindNearestAirQualityStatus
+
+ @MockK
+ private lateinit var mockAirQualityStatusRepository: AirQualityStatusRepository
+
+ private val tLatitude = 4.645594
+ private val tLongitude = -74.058881
+
+ private val tAirQualityStatus = AirQualityStatus(
+ 1,
+ "station name",
+ tLatitude,
+ tLongitude
+ )
+
+ @BeforeEach
+ fun setUp() {
+ useCase = FindNearestAirQualityStatus(mockAirQualityStatusRepository)
+ }
+
+ @Test
+ fun `should call the repository to fetch the nearest air quality given coordinates`() =
+ runBlockingTest {
+ // arrange
+ coEvery {
+ mockAirQualityStatusRepository.getNearestAirQualityStatus(
+ any(),
+ any()
+ )
+ } returns tAirQualityStatus
+ // act
+ val result = useCase(tLatitude, tLongitude)
+ // assert
+ assertEquals(tAirQualityStatus, result)
+ coVerify {
+ mockAirQualityStatusRepository.getNearestAirQualityStatus(
+ tLatitude,
+ tLongitude
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/presentation/CurrentLocationLiveDataTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/presentation/CurrentLocationLiveDataTest.kt
new file mode 100644
index 00000000..d38f8c64
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/presentation/CurrentLocationLiveDataTest.kt
@@ -0,0 +1,120 @@
+package hpsaturn.pollutionreporter.dashboard.presentation
+
+import android.Manifest
+import android.content.Context
+import android.location.Location
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.tasks.Tasks
+import com.karumi.dexter.DexterBuilder
+import hpsaturn.pollutionreporter.core.domain.entities.InProgress
+import hpsaturn.pollutionreporter.core.domain.entities.Success
+import hpsaturn.pollutionreporter.util.AutoSuccessTask
+import hpsaturn.pollutionreporter.util.InstantExecutorExtension
+import hpsaturn.pollutionreporter.util.getOrAwaitValueTest
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.junit5.MockKExtension
+import io.mockk.verify
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.Extensions
+
+@Extensions(ExtendWith(MockKExtension::class), ExtendWith(InstantExecutorExtension::class))
+internal class CurrentLocationLiveDataTest {
+
+ private lateinit var currentLocationLiveData: CurrentLocationLiveData
+
+ @MockK(relaxed = true)
+ private lateinit var mockFusedLocationProviderClient: FusedLocationProviderClient
+
+ @MockK(relaxed = true)
+ private lateinit var mockLocationRequest: LocationRequest
+
+ @MockK(relaxed = true)
+ private lateinit var mockLocation: Location
+
+ @MockK(relaxed = true)
+ private lateinit var mockDexter: DexterBuilder.Permission
+
+ @MockK(relaxed = true)
+ private lateinit var mockContext: Context
+
+ private val tLatitude = 4.645594
+ private val tLongitude = -74.058881
+
+ @BeforeEach
+ fun setUp() {
+ currentLocationLiveData = CurrentLocationLiveData(
+ mockFusedLocationProviderClient,
+ mockLocationRequest,
+ mockDexter,
+ mockContext
+ )
+ }
+
+ @Test
+ fun `should post a Success last location when FusedLocationProvider found one`() {
+ // arrange
+ val task = AutoSuccessTask(mockLocation)
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ every { mockFusedLocationProviderClient.lastLocation } returns task
+ mockFusedLocationProviderClient.lastLocation.result
+ // act
+ currentLocationLiveData.getOrAwaitValueTest {
+ val data = currentLocationLiveData.value
+ // assert
+ verify { mockFusedLocationProviderClient.lastLocation }
+ assertEquals(Success(mockLocation), data)
+ }
+ }
+
+
+ @Test
+ fun `should remove location updates after getting last location`() {
+ // arrange
+ val task = AutoSuccessTask(mockLocation)
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ every { mockFusedLocationProviderClient.lastLocation } returns task
+ mockFusedLocationProviderClient.lastLocation.result
+ // act
+ currentLocationLiveData.getOrAwaitValueTest()
+ // assert
+ verify { mockFusedLocationProviderClient.removeLocationUpdates(any() as LocationCallback) }
+ }
+
+ @Test
+ fun `should post in progress value when first subscribed to`() {
+ // arrange
+ val task = Tasks.forCanceled()
+ every { mockFusedLocationProviderClient.lastLocation } returns task
+ // act
+ val data = currentLocationLiveData.getOrAwaitValueTest()
+ // assert
+ assertEquals(InProgress, data)
+ }
+
+ @Test
+ fun `should request location permissions when first subscribed to`() {
+ // arrange
+ val task = AutoSuccessTask(mockLocation)
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ every { mockFusedLocationProviderClient.lastLocation } returns task
+ // act
+ currentLocationLiveData.getOrAwaitValueTest()
+ // assert
+ verify { mockFusedLocationProviderClient.removeLocationUpdates(any() as LocationCallback) }
+ verify {
+ mockDexter.withPermissions(
+ Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardViewModelTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardViewModelTest.kt
new file mode 100644
index 00000000..6f37b09e
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardViewModelTest.kt
@@ -0,0 +1,166 @@
+package hpsaturn.pollutionreporter.dashboard.presentation
+
+import android.location.Location
+import androidx.lifecycle.MutableLiveData
+import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult
+import hpsaturn.pollutionreporter.core.domain.entities.InProgress
+import hpsaturn.pollutionreporter.core.domain.entities.Result
+import hpsaturn.pollutionreporter.core.domain.entities.Success
+import hpsaturn.pollutionreporter.dashboard.domain.entities.AirQualityStatus
+import hpsaturn.pollutionreporter.dashboard.domain.usecases.FindNearestAirQualityStatus
+import hpsaturn.pollutionreporter.util.InstantExecutorExtension
+import hpsaturn.pollutionreporter.util.MainCoroutineTestExtension
+import hpsaturn.pollutionreporter.util.observeForTesting
+import hpsaturn.pollutionreporter.util.round
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.junit5.MockKExtension
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.Extensions
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@ExperimentalCoroutinesApi
+@Extensions(ExtendWith(MockKExtension::class), ExtendWith(InstantExecutorExtension::class))
+internal class DashboardViewModelTest {
+
+ private lateinit var dashboardViewModel: DashboardViewModel
+
+ @MockK
+ private lateinit var mockFindNearestAirQualityStatus: FindNearestAirQualityStatus
+
+ @MockK(relaxed = true)
+ private lateinit var mockLocation: Location
+
+ private var mockCurrentLocationLiveData = MutableLiveData>()
+
+ private val tLatitude = 4.645594
+ private val tLongitude = -74.058881
+
+ private val tAirQualityStatus = AirQualityStatus(
+ 1,
+ "station name",
+ tLatitude,
+ tLongitude
+ )
+
+ private val tException = Exception()
+ private val tDistanceInMeters = 589541F
+
+ @JvmField
+ @RegisterExtension
+ val coroutineRule = MainCoroutineTestExtension()
+
+ @BeforeEach
+ fun setUp() {
+ dashboardViewModel = DashboardViewModel(
+ mockFindNearestAirQualityStatus,
+ mockCurrentLocationLiveData,
+ coroutineRule.dispatcher
+ )
+ }
+
+ @Test
+ fun `should emit InProgress if current location returns InProgress when subscribed to airQualityStatus`() =
+ coroutineRule.runBlockingTest {
+ // act
+ dashboardViewModel.airQualityStatus.observeForTesting {
+ // arrange
+ mockCurrentLocationLiveData.value = InProgress
+ // assert
+ assertEquals(InProgress, dashboardViewModel.airQualityStatus.value)
+ }
+ }
+
+ @Test
+ fun `should emit ErrorResult if current location returns ErrorResult when subscribed to airQualityStatus`() =
+ coroutineRule.runBlockingTest {
+ // act
+ dashboardViewModel.airQualityStatus.observeForTesting {
+ // arrange
+ mockCurrentLocationLiveData.value = ErrorResult(tException)
+ // assert
+ assertEquals(ErrorResult(tException), dashboardViewModel.airQualityStatus.value)
+ }
+ }
+
+ @Test
+ fun `should emit Success and call useCase to fetch nearest AQ station when subscribed to airQualityStatus`() =
+ coroutineRule.runBlockingTest {
+ // arrange
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ coEvery { mockFindNearestAirQualityStatus(any(), any()) } returns tAirQualityStatus
+ // act
+ dashboardViewModel.airQualityStatus.observeForTesting {
+ mockCurrentLocationLiveData.value = Success(mockLocation)
+ // assert
+ assertEquals(Success(tAirQualityStatus), dashboardViewModel.airQualityStatus.value)
+ coVerify { mockFindNearestAirQualityStatus(tLatitude, tLongitude) }
+ }
+
+ }
+
+ @Test
+ fun `should emit ErrorResult if useCase throws error when fetching nearest AQ`() =
+ coroutineRule.runBlockingTest {
+ // arrange
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ coEvery { mockFindNearestAirQualityStatus(any(), any()) } throws tException
+ // act
+ dashboardViewModel.airQualityStatus.observeForTesting {
+ mockCurrentLocationLiveData.value = Success(mockLocation)
+ coVerify { mockFindNearestAirQualityStatus(tLatitude, tLongitude) }
+ // assert
+ assertEquals(ErrorResult(tException), dashboardViewModel.airQualityStatus.value)
+ }
+
+ }
+
+ @Test
+ fun `should calculate distance in Km rounded to 2 decimals between current location and station`() =
+ coroutineRule.runBlockingTest {
+ // arrange
+ val distanceInKm = (tDistanceInMeters / 1000F).toDouble().round(2)
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ every { mockLocation.distanceTo(any()) } returns tDistanceInMeters
+ coEvery { mockFindNearestAirQualityStatus(any(), any()) } returns tAirQualityStatus
+ // act
+ dashboardViewModel.distanceToStation.observeForTesting {
+ mockCurrentLocationLiveData.value = Success(mockLocation)
+ // assert
+ assertEquals(distanceInKm, dashboardViewModel.distanceToStation.value)
+ verify { mockLocation.distanceTo(any()) }
+ }
+
+ }
+
+
+ @Test
+ fun `should return 0 in Km between current location and station`() =
+ coroutineRule.runBlockingTest {
+ // arrange
+ every { mockLocation.latitude } returns tLatitude
+ every { mockLocation.longitude } returns tLongitude
+ every { mockLocation.distanceTo(any()) } returns tDistanceInMeters
+ coEvery { mockFindNearestAirQualityStatus(any(), any()) } returns tAirQualityStatus
+ // act
+ dashboardViewModel.distanceToStation.observeForTesting {
+ mockCurrentLocationLiveData.value = ErrorResult(tException)
+ // assert
+ assertEquals(0.00, dashboardViewModel.distanceToStation.value)
+ verify(exactly = 0) { mockLocation.distanceTo(any()) }
+ }
+
+ }
+
+}
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/fixtures/FixtureReader.kt b/app/src/test/java/hpsaturn/pollutionreporter/fixtures/FixtureReader.kt
new file mode 100644
index 00000000..6ac7818a
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/fixtures/FixtureReader.kt
@@ -0,0 +1,18 @@
+package hpsaturn.pollutionreporter.fixtures
+
+import com.google.gson.Gson
+import java.io.File
+import java.lang.reflect.Type
+
+// TODO - This is a quick hack but we recommend to implement a cleaner way to get the path.
+private const val PATH_TO_FIXTURE = "src/test/java/hpsaturn/pollutionreporter/fixtures/"
+
+fun readFixture(fixture: JsonFixture): String = File("${PATH_TO_FIXTURE}station_feed.json")
+ .readLines().joinToString(" ")
+
+fun readFixture(fixture: JsonFixture, classOfT: Class): T = Gson().fromJson(readFixture
+ (fixture), classOfT)
+
+enum class JsonFixture(val fileName: String) {
+ STATION_FEED("station_feed.json")
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/fixtures/station_feed.json b/app/src/test/java/hpsaturn/pollutionreporter/fixtures/station_feed.json
new file mode 100644
index 00000000..e66526ad
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/fixtures/station_feed.json
@@ -0,0 +1,76 @@
+{
+ "status": "ok",
+ "data": {
+ "aqi": 11,
+ "idx": 6236,
+ "attributions": [
+ {
+ "url": "http://oab.ambientebogota.gov.co/",
+ "name": "OAB - El Observatorio Ambiental de Bogot\u0026aacute;",
+ "logo": "Colombia-OAB.png"
+ },
+ {
+ "url": "https://waqi.info/",
+ "name": "World Air Quality Index Project"
+ }
+ ],
+ "city": {
+ "geo": [
+ 4.5725,
+ -74.0836
+ ],
+ "name": "San Cristobal, Bogota, Colombia",
+ "url": "https://aqicn.org/city/colombia/bogota/san-cristobal"
+ },
+ "dominentpol": "pm25",
+ "iaqi": {
+ "co": {
+ "v": 3.7
+ },
+ "dew": {
+ "v": 9
+ },
+ "h": {
+ "v": 73
+ },
+ "no2": {
+ "v": 4.2
+ },
+ "o3": {
+ "v": 3.7
+ },
+ "p": {
+ "v": 1028.4
+ },
+ "pm10": {
+ "v": 4
+ },
+ "pm25": {
+ "v": 11
+ },
+ "r": {
+ "v": 0.4
+ },
+ "so2": {
+ "v": 0.4
+ },
+ "t": {
+ "v": 13.3
+ },
+ "w": {
+ "v": 0.7
+ },
+ "wd": {
+ "v": 135
+ }
+ },
+ "time": {
+ "s": "2020-06-14 00:00:00",
+ "tz": "-05:00",
+ "v": 1592092800
+ },
+ "debug": {
+ "sync": "2020-06-14T14:13:59+09:00"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/util/AutoSuccessTask.kt b/app/src/test/java/hpsaturn/pollutionreporter/util/AutoSuccessTask.kt
new file mode 100644
index 00000000..2c5cf7c8
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/util/AutoSuccessTask.kt
@@ -0,0 +1,48 @@
+package hpsaturn.pollutionreporter.util
+
+import android.app.Activity
+import com.google.android.gms.tasks.OnFailureListener
+import com.google.android.gms.tasks.OnSuccessListener
+import com.google.android.gms.tasks.Task
+import java.util.concurrent.Executor
+
+class AutoSuccessTask(private val data: TResult) : Task() {
+
+
+ override fun isComplete(): Boolean = throw NotImplementedError("Method not implemented")
+
+ override fun getException(): Exception? = throw NotImplementedError("Method not implemented")
+
+ override fun addOnFailureListener(p0: OnFailureListener): Task =
+ throw NotImplementedError("Method not implemented")
+
+ override fun addOnFailureListener(p0: Executor, p1: OnFailureListener): Task =
+ throw NotImplementedError("Method not implemented")
+
+ override fun addOnFailureListener(p0: Activity, p1: OnFailureListener): Task =
+ throw NotImplementedError("Method not implemented")
+
+ override fun getResult(): TResult? = data
+
+ override fun getResult(p0: Class): TResult? =
+ throw NotImplementedError("Method not implemented")
+
+ override fun addOnSuccessListener(onSuccessListener: OnSuccessListener): Task {
+ onSuccessListener.onSuccess(data)
+ return this
+ }
+
+ override fun addOnSuccessListener(
+ p0: Executor,
+ p1: OnSuccessListener
+ ): Task = throw NotImplementedError("Method not implemented")
+
+ override fun addOnSuccessListener(
+ p0: Activity,
+ p1: OnSuccessListener
+ ): Task = throw NotImplementedError("Method not implemented")
+
+ override fun isSuccessful(): Boolean = throw NotImplementedError("Method not implemented")
+
+ override fun isCanceled(): Boolean = throw NotImplementedError("Method not implemented")
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/util/InstantExecutorExtension.kt b/app/src/test/java/hpsaturn/pollutionreporter/util/InstantExecutorExtension.kt
new file mode 100644
index 00000000..04d87518
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/util/InstantExecutorExtension.kt
@@ -0,0 +1,26 @@
+package hpsaturn.pollutionreporter.util
+
+import androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.TaskExecutor
+import org.junit.jupiter.api.extension.AfterEachCallback
+import org.junit.jupiter.api.extension.BeforeEachCallback
+import org.junit.jupiter.api.extension.ExtensionContext
+
+class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
+
+ override fun beforeEach(context: ExtensionContext?) {
+ ArchTaskExecutor.getInstance()
+ .setDelegate(object : TaskExecutor() {
+ override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
+
+ override fun postToMainThread(runnable: Runnable) = runnable.run()
+
+ override fun isMainThread(): Boolean = true
+ })
+ }
+
+ override fun afterEach(context: ExtensionContext?) {
+ ArchTaskExecutor.getInstance().setDelegate(null)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/util/LiveDataTestUtil.kt b/app/src/test/java/hpsaturn/pollutionreporter/util/LiveDataTestUtil.kt
new file mode 100644
index 00000000..fe2955c2
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/util/LiveDataTestUtil.kt
@@ -0,0 +1,60 @@
+package hpsaturn.pollutionreporter.util
+
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+/**
+ * Gets the value of a [LiveData] or waits for it to have one, with a timeout.
+ *
+ * Use this extension from host-side (JVM) tests. It's recommended to use it alongside
+ * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously.
+ */
+@VisibleForTesting(otherwise = VisibleForTesting.NONE)
+fun LiveData.getOrAwaitValueTest(
+ time: Long = 2,
+ timeUnit: TimeUnit = TimeUnit.SECONDS,
+ afterObserve: () -> Unit = {}
+): T {
+ var data: T? = null
+ val latch = CountDownLatch(1)
+ val observer = object : Observer {
+ override fun onChanged(o: T?) {
+ data = o
+ latch.countDown()
+ this@getOrAwaitValueTest.removeObserver(this)
+ }
+ }
+ this.observeForever(observer)
+
+ try {
+ afterObserve.invoke()
+
+ // Don't wait indefinitely if the LiveData is not set.
+ if (!latch.await(time, timeUnit)) {
+ throw TimeoutException("LiveData value was never set.")
+ }
+
+ } finally {
+ this.removeObserver(observer)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ return data as T
+}
+
+/**
+ * Observes a [LiveData] until the `block` is done executing.
+ */
+fun LiveData.observeForTesting(block: () -> Unit) {
+ val observer = Observer { }
+ try {
+ observeForever(observer)
+ block()
+ } finally {
+ removeObserver(observer)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/hpsaturn/pollutionreporter/util/MainCoroutineScopeRule.kt b/app/src/test/java/hpsaturn/pollutionreporter/util/MainCoroutineScopeRule.kt
new file mode 100644
index 00000000..adca02ef
--- /dev/null
+++ b/app/src/test/java/hpsaturn/pollutionreporter/util/MainCoroutineScopeRule.kt
@@ -0,0 +1,30 @@
+package hpsaturn.pollutionreporter.util
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestCoroutineDispatcher
+import kotlinx.coroutines.test.TestCoroutineScope
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.extension.AfterEachCallback
+import org.junit.jupiter.api.extension.BeforeEachCallback
+import org.junit.jupiter.api.extension.ExtensionContext
+
+/**
+ * MainCoroutineRule installs a TestCoroutineDispatcher for Dispatchers.Main.
+ * @param dispatcher if provided, this [TestCoroutineDispatcher] will be used.
+ */
+@ExperimentalCoroutinesApi
+class MainCoroutineTestExtension(
+ val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
+) : BeforeEachCallback, AfterEachCallback, TestCoroutineScope by TestCoroutineScope(dispatcher) {
+
+ override fun beforeEach(context: ExtensionContext?) {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ override fun afterEach(context: ExtensionContext?) {
+ cleanupTestCoroutines()
+ Dispatchers.resetMain()
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 6360facc..0be06e6b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
+
+ ext {
+ kotlinVersion = '1.3.61'
+ }
repositories {
google()
@@ -10,11 +14,13 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.6.3'
+ classpath 'com.android.tools.build:gradle:4.0.0'
classpath 'com.google.gms:google-services:4.3.3'
classpath 'com.github.QuickPermissions:QuickPermissions:0.3.2'
classpath 'io.fabric.tools:gradle:1.31.2'
-
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
+ classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
+ classpath "de.mannodermaus.gradle.plugins:android-junit5:1.6.2.0"
}
}
diff --git a/gradle.properties b/gradle.properties
index 1e1e26f6..2e9e9165 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -15,3 +15,4 @@ mVersionCode=395
mVersionName=0.2.7
android.useAndroidX=true
android.enableJetifier=true
+kotlin.code.style=official
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 446cbe5a..7af46248 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Thu Mar 19 12:01:12 CET 2020
+#Sat Jun 13 22:54:40 COT 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip