diff --git a/.gitignore b/.gitignore index 5f712c90..d7b08dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,8 @@ screenshots store api_aqicn.xml +# C++ generated files +app/.cxx/* app/.cxx mainframer remoteAssemble diff --git a/app/build.gradle b/app/build.gradle index 5f585505..a1f69c78 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,14 @@ 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" apply plugin: 'com.google.firebase.crashlytics' + android { compileSdkVersion project.mCompileSdkVersion.toInteger() defaultConfig { @@ -16,6 +24,7 @@ android { } } } + buildFeatures { dataBinding = true } buildTypes { release { minifyEnabled false @@ -34,8 +43,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 { @@ -54,13 +67,17 @@ dependencies { implementation 'com.karumi:dexter:6.2.1' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' - implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.cardview:cardview:1.0.0 + implementation 'com.google.android.material:material:1.3.0-alpha01' implementation 'com.google.android.material:material:1.3.0' + 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,6 +107,66 @@ dependencies { implementation 'com.github.vic797:prowebview:2.2.1' + // Firebase + implementation platform('com.google.firebase:firebase-bom:26.0.0') + implementation 'com.google.firebase:firebase-database-ktx' + + // 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-beta01" + + // 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.1' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.1' + + // 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.3.0' + testImplementation 'org.mockito:mockito-core:1.10.19' + 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' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' @@ -98,7 +175,9 @@ dependencies { testImplementation 'org.mockito:mockito-core:3.3.3' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.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 dd8676c3..b1671d01 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,7 @@ 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..096d6986 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/core/domain/entities/Result.kt @@ -0,0 +1,13 @@ +package hpsaturn.pollutionreporter.core.domain.entities + +sealed class Result { + override fun toString(): String = when (this) { + is Success<*> -> "Success[data=$data]" + is ErrorResult -> "Error[exception=$exception]" + InProgress -> "Loading" + } +} + +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/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..683167b3 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/data/repositories/AirQualityStatusRepositoryImpl.kt @@ -0,0 +1,40 @@ +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 { + 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/DashboardFragment.kt b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardFragment.kt new file mode 100644 index 00000000..e45dcb28 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/dashboard/presentation/DashboardFragment.kt @@ -0,0 +1,90 @@ +package hpsaturn.pollutionreporter.dashboard.presentation + +import android.annotation.SuppressLint +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 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? = inflater.inflate(R.layout.fragment_dashboard, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + displayAirQualityIndexOnView() + displayStationDistance() + } + + private fun displayAirQualityIndexOnView() { + dashboardViewModel.airQualityStatus.observe(viewLifecycleOwner, Observer { + when (it) { + is Success -> renderAqiData(it.data) + is ErrorResult -> renderError(it.exception) + is InProgress -> renderProgress() + } + }) + } + + @SuppressLint("SetTextI18n") + 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/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..54bc6efa --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/di/ApplicationModule.kt @@ -0,0 +1,32 @@ +package hpsaturn.pollutionreporter.di + +import android.content.Context +import com.google.firebase.database.DatabaseReference +import com.google.firebase.database.ktx.database +import com.google.firebase.ktx.Firebase +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() + + @Singleton + @Provides + fun provideDatabaseReference(): DatabaseReference = Firebase.database.reference +} \ 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/reports/SensorReportModule.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/SensorReportModule.kt new file mode 100644 index 00000000..3cd294bd --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/SensorReportModule.kt @@ -0,0 +1,42 @@ +package hpsaturn.pollutionreporter.reports + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ApplicationComponent +import hpsaturn.pollutionreporter.core.data.mappers.Mapper +import hpsaturn.pollutionreporter.reports.open.data.mappers.SensorDataPointMapper +import hpsaturn.pollutionreporter.reports.open.data.mappers.SensorReportInformationMapper +import hpsaturn.pollutionreporter.reports.open.data.models.TracksData +import hpsaturn.pollutionreporter.reports.open.data.models.TracksInfo +import hpsaturn.pollutionreporter.reports.open.data.repositories.OpenSensorReportsRepositoryImpl +import hpsaturn.pollutionreporter.reports.open.data.services.PublicSensorReportService +import hpsaturn.pollutionreporter.reports.open.data.services.PublicSensorReportServiceImp +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorDataPoint +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +import hpsaturn.pollutionreporter.reports.open.domain.repositories.OpenSensorReportsRepository + +@Module +@InstallIn(ApplicationComponent::class) +abstract class SensorReportModule { + + @Binds + abstract fun bindSensorDataPointMapper( + sensorDataPointMapper: SensorDataPointMapper + ): Mapper + + @Binds + abstract fun bindSensorReportInformationMapper( + sensorDataPointMapper: SensorReportInformationMapper + ): Mapper + + @Binds + abstract fun bindSensorReportRepositoryImpl( + sensorReportRepositoryImpl: OpenSensorReportsRepositoryImpl + ): OpenSensorReportsRepository + + @Binds + abstract fun bindPublicSensorReportServiceImpl( + publicSensorReportServiceImp: PublicSensorReportServiceImp + ): PublicSensorReportService +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorDataPointMapper.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorDataPointMapper.kt new file mode 100644 index 00000000..49a3fdfe --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorDataPointMapper.kt @@ -0,0 +1,19 @@ +package hpsaturn.pollutionreporter.reports.open.data.mappers + +import hpsaturn.pollutionreporter.core.data.mappers.Mapper +import hpsaturn.pollutionreporter.reports.open.data.models.TracksData +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorDataPoint +import hpsaturn.pollutionreporter.util.toUnixTimeStamp +import javax.inject.Inject + +class SensorDataPointMapper @Inject constructor() : Mapper { + override fun invoke(input: TracksData): SensorDataPoint = SensorDataPoint( + input.id, + input.p10, + input.p25, + input.spd, + input.latitude, + input.longitude, + input.timestamp.toUnixTimeStamp() + ) +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorReportInformationMapper.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorReportInformationMapper.kt new file mode 100644 index 00000000..64759118 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorReportInformationMapper.kt @@ -0,0 +1,18 @@ +package hpsaturn.pollutionreporter.reports.open.data.mappers + +import hpsaturn.pollutionreporter.core.data.mappers.Mapper +import hpsaturn.pollutionreporter.reports.open.data.models.TracksInfo +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +import javax.inject.Inject + +class SensorReportInformationMapper @Inject constructor() : + Mapper { + override fun invoke(input: TracksInfo): SensorReportInformation = SensorReportInformation( + input.deviceId, + input.date, + input.lastLat, + input.lastLon, + input.name, + input.size + ) +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/models/TracksData.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/models/TracksData.kt new file mode 100644 index 00000000..3ce2b86f --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/models/TracksData.kt @@ -0,0 +1,23 @@ +package hpsaturn.pollutionreporter.reports.open.data.models + +/** + * Information of single data point of a report. + * @property altitude altitude of sea level of the data point. + * @property p10 value of the P10 contaminant. + * @property p25 value of the P2.5 contaminant. + * @property spd value of SPD. + * @property latitude Latitude of the data point. + * @property longitude Longitude of the data point. + * @property timestamp Timestamp of the data point. + */ + +class TracksData( + val id: String, + val altitude: Double, + val p10: Double, + val p25: Double, + val spd: Double, + val latitude: Double, + val longitude: Double, + val timestamp: Long +) \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/models/TracksInfo.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/models/TracksInfo.kt new file mode 100644 index 00000000..402134a7 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/models/TracksInfo.kt @@ -0,0 +1,21 @@ +package hpsaturn.pollutionreporter.reports.open.data.models + +/** + * Basic information of a sensor report. + * @property deviceId ID of the device that gathered the data. + * @property date Date of the report. + * @property lastLat Latitude of the last data point. + * @property lastLon Longitude of the last data point. + * @property name Name of the device that gathered the data. + * @property size Number of data points gathered. + */ + +data class TracksInfo( + val date: String = "", + val deviceId: String = "", + val lastLat: Double = 0.0, + val lastLon: Double = 0.0, + val lastTrackData: TracksData? = null, + val name: String = "", + val size: Int = 0 +) diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/repositories/OpenSensorReportsRepositoryImpl.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/repositories/OpenSensorReportsRepositoryImpl.kt new file mode 100644 index 00000000..c2c43f35 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/repositories/OpenSensorReportsRepositoryImpl.kt @@ -0,0 +1,24 @@ +package hpsaturn.pollutionreporter.reports.open.data.repositories + +import hpsaturn.pollutionreporter.core.data.mappers.Mapper +import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult +import hpsaturn.pollutionreporter.core.domain.entities.Result +import hpsaturn.pollutionreporter.core.domain.entities.Success +import hpsaturn.pollutionreporter.reports.open.data.models.TracksInfo +import hpsaturn.pollutionreporter.reports.open.data.services.PublicSensorReportService +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +import hpsaturn.pollutionreporter.reports.open.domain.repositories.OpenSensorReportsRepository +import javax.inject.Inject + +class OpenSensorReportsRepositoryImpl @Inject constructor( + private val sensorReportService: PublicSensorReportService, + private val mapper: Mapper +) : OpenSensorReportsRepository { + override suspend fun getPublicSensorReports(): Result> = + runCatching { + Success(sensorReportService.getTracksInfo().map { mapper(it) }) + }.getOrElse { + ErrorResult(it) + } + +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/services/OpenSensorReportsService.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/services/OpenSensorReportsService.kt new file mode 100644 index 00000000..9bdcb871 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/data/services/OpenSensorReportsService.kt @@ -0,0 +1,33 @@ +package hpsaturn.pollutionreporter.reports.open.data.services + +import com.google.firebase.database.DatabaseReference +import com.google.firebase.database.GenericTypeIndicator +import hpsaturn.pollutionreporter.reports.open.data.models.TracksInfo +import hpsaturn.pollutionreporter.reports.open.domain.entities.TracksInfoNotFoundException +import hpsaturn.pollutionreporter.util.getSuspendValue +import javax.inject.Inject + +interface PublicSensorReportService { + suspend fun getTracksInfo(): List +} + +class PublicSensorReportServiceImp @Inject constructor( + private val database: DatabaseReference +) : PublicSensorReportService { + + override suspend fun getTracksInfo(): List { + val result = database + .child(TRACKS_INFO_COLLECTION) + .limitToLast(20) + .getSuspendValue() + .getValue(object : + GenericTypeIndicator>() {}) + + return result?.values?.toList() ?: throw TracksInfoNotFoundException() + } + + companion object { + private const val TRACKS_INFO_COLLECTION = "tracks_info" + } + +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/Exceptions.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/Exceptions.kt new file mode 100644 index 00000000..7fba3193 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/Exceptions.kt @@ -0,0 +1,3 @@ +package hpsaturn.pollutionreporter.reports.open.domain.entities + +class TracksInfoNotFoundException : Throwable() \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/SensorDataPoint.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/SensorDataPoint.kt new file mode 100644 index 00000000..6d6c3113 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/SensorDataPoint.kt @@ -0,0 +1,24 @@ +package hpsaturn.pollutionreporter.reports.open.domain.entities + +import java.util.* + +/** + * Information of single data point of a report. + * @property pointId ID of the data point. + * @property p10 value of the P10 contaminant. + * @property p25 value of the P2.5 contaminant. + * @property spd value of SPD. + * @property latitude Latitude of the data point. + * @property longitude Longitude of the data point. + * @property timestamp Timestamp of the data point. + */ + +class SensorDataPoint( + val pointId: String, + val p10: Double, + val p25: Double, + val spd: Double, + val latitude: Double, + val longitude: Double, + val timestamp: Date +) \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/SensorReportInformation.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/SensorReportInformation.kt new file mode 100644 index 00000000..668a1228 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/entities/SensorReportInformation.kt @@ -0,0 +1,20 @@ +package hpsaturn.pollutionreporter.reports.open.domain.entities + +/** + * Basic information of a sensor report. + * @property deviceId ID of the device that gathered the data. + * @property date Date of the report. + * @property lastLatitude Latitude of the last data point. + * @property lastLongitude Longitude of the last data point. + * @property name Name of the device that gathered the data. + * @property numberOfPoints Number of data points gathered. + */ + +data class SensorReportInformation( + val deviceId: String, + val date: String, + val lastLatitude: Double, + val lastLongitude: Double, + val name: String, + val numberOfPoints: Int +) \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/repositories/OpenSensorReportsRepository.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/repositories/OpenSensorReportsRepository.kt new file mode 100644 index 00000000..e8b9660e --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/repositories/OpenSensorReportsRepository.kt @@ -0,0 +1,8 @@ +package hpsaturn.pollutionreporter.reports.open.domain.repositories + +import hpsaturn.pollutionreporter.core.domain.entities.Result +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation + +interface OpenSensorReportsRepository { + suspend fun getPublicSensorReports(): Result> +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/usecases/LoadOpenSensorReports.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/usecases/LoadOpenSensorReports.kt new file mode 100644 index 00000000..00d41a43 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/domain/usecases/LoadOpenSensorReports.kt @@ -0,0 +1,18 @@ +package hpsaturn.pollutionreporter.reports.open.domain.usecases + +import hpsaturn.pollutionreporter.di.DispatchersModule +import hpsaturn.pollutionreporter.reports.open.domain.repositories.OpenSensorReportsRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Loads public sensor reports from the backend. + */ +class LoadOpenSensorReports @Inject constructor( + private val openSensorReportsRepository: OpenSensorReportsRepository, + @DispatchersModule.IoDispatcher private val ioDispatcher: CoroutineDispatcher +) { + suspend operator fun invoke() = + withContext(ioDispatcher) { openSensorReportsRepository.getPublicSensorReports() } +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsInformationListFragment.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsInformationListFragment.kt new file mode 100644 index 00000000..9fbde6df --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsInformationListFragment.kt @@ -0,0 +1,80 @@ +package hpsaturn.pollutionreporter.reports.open.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +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.reports.open.domain.entities.SensorReportInformation +import kotlinx.android.synthetic.main.fragment_sensor_report_information_list.* +import javax.inject.Inject + +@AndroidEntryPoint +class OpenSensorReportsInformationListFragment : Fragment() { + + @Inject + lateinit var sensorReportAdapter: SensorReportAdapter + + private val openSensorReportsViewModel: OpenSensorReportsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_sensor_report_information_list, container, false) + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initRecyclerView() + loadSensorReports() + } + + private fun initRecyclerView() { + recordsListRecyclerView.apply { + layoutManager = LinearLayoutManager(activity) + adapter = sensorReportAdapter + addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + } + } + + private fun loadSensorReports() { + openSensorReportsViewModel.publicReports.observe(viewLifecycleOwner, Observer { + setAllViewsInvisible() + when (it) { + is Success -> renderData(it.data) + is ErrorResult -> renderError(it.exception) + is InProgress -> renderProgress() + } + }) + } + + private fun renderData(sensorReportsInformation: List) { + recordsListRecyclerView.visibility = View.VISIBLE + sensorReportAdapter.submitList(sensorReportsInformation) + } + + private fun renderError(exception: Throwable) { + errorMessage.visibility = View.VISIBLE + errorMessage.text = exception.message + } + + private fun renderProgress() { + openSensorReportsLoadingIndicator.visibility = View.VISIBLE + } + + private fun setAllViewsInvisible() { + recordsListRecyclerView.visibility = View.INVISIBLE + openSensorReportsLoadingIndicator.visibility = View.INVISIBLE + errorMessage.visibility = View.INVISIBLE + } + +} diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsViewModel.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsViewModel.kt new file mode 100644 index 00000000..e807d13f --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsViewModel.kt @@ -0,0 +1,19 @@ +package hpsaturn.pollutionreporter.reports.open.presentation + +import androidx.hilt.lifecycle.ViewModelInject +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.liveData +import hpsaturn.pollutionreporter.core.domain.entities.InProgress +import hpsaturn.pollutionreporter.core.domain.entities.Result +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +import hpsaturn.pollutionreporter.reports.open.domain.usecases.LoadOpenSensorReports + +class OpenSensorReportsViewModel @ViewModelInject constructor( + private val loadOpenSensorReports: LoadOpenSensorReports +) : ViewModel() { + val publicReports: LiveData>> = liveData { + emit(InProgress) + emit(loadOpenSensorReports()) + } +} \ No newline at end of file diff --git a/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/SensorReportAdapter.kt b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/SensorReportAdapter.kt new file mode 100644 index 00000000..f145ccd1 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/reports/open/presentation/SensorReportAdapter.kt @@ -0,0 +1,49 @@ +package hpsaturn.pollutionreporter.reports.open.presentation + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import hpsaturn.pollutionreporter.R +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +import kotlinx.android.synthetic.main.item_record.view.* +import javax.inject.Inject + +class SensorReportAdapter @Inject constructor() : + ListAdapter(SensorReportDiff) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SensorReportViewHolder = + SensorReportViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.item_record, parent, false) + ) + + override fun onBindViewHolder(holder: SensorReportViewHolder, position: Int) = + holder.bind(getItem(position)) +} + +class SensorReportViewHolder(itemView: View) : + RecyclerView.ViewHolder(itemView) { + fun bind(sensorReportInformation: SensorReportInformation) { + itemView.stationName.text = sensorReportInformation.name + itemView.reportDate.text = sensorReportInformation.date + itemView.reportNumberOfPoints.text = "${sensorReportInformation.numberOfPoints}" + } +} + +private object SensorReportDiff : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: SensorReportInformation, + newItem: SensorReportInformation + ): Boolean = oldItem.deviceId == newItem.deviceId && + oldItem.date == newItem.date && + oldItem.lastLatitude == newItem.lastLatitude && + oldItem.lastLongitude == newItem.lastLongitude && + oldItem.numberOfPoints == newItem.numberOfPoints && + oldItem.name == newItem.name + + override fun areContentsTheSame( + oldItem: SensorReportInformation, + newItem: SensorReportInformation + ): Boolean = oldItem == newItem +} \ 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..c2318b56 --- /dev/null +++ b/app/src/main/java/hpsaturn/pollutionreporter/util/Extensions.kt @@ -0,0 +1,52 @@ +package hpsaturn.pollutionreporter.util + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.Query +import com.google.firebase.database.ValueEventListener +import java.util.* +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +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 +} + +/** + * Rounds `this` to [decimals] numbers. + */ +fun Double.round(decimals: Int): Double { + var multiplier = 1.0 + repeat(decimals) { multiplier *= 10 } + return round(this * multiplier) / multiplier +} + +suspend fun Query.getSuspendValue(): DataSnapshot = suspendCoroutine { continuation -> + addListenerForSingleValueEvent(ImpValueEventListener( + onDataChange = { continuation.resume(it) }, + onError = { continuation.resumeWithException(it.toException()) } + )) +} + +class ImpValueEventListener( + val onDataChange: (DataSnapshot) -> Unit, + val onError: (DatabaseError) -> Unit +) : ValueEventListener { + override fun onDataChange(data: DataSnapshot) = onDataChange.invoke(data) + override fun onCancelled(error: DatabaseError) = onError.invoke(error) +} + +/** + * Creates a [Date] from UNIX timestamp. + */ +fun Long.toUnixTimeStamp() = Date(this * 1000) 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/java/hpsaturn/pollutionreporter/view/PostsViewHolder.java b/app/src/main/java/hpsaturn/pollutionreporter/view/PostsViewHolder.java index 0822f6c5..a3769bef 100644 --- a/app/src/main/java/hpsaturn/pollutionreporter/view/PostsViewHolder.java +++ b/app/src/main/java/hpsaturn/pollutionreporter/view/PostsViewHolder.java @@ -1,9 +1,10 @@ package hpsaturn.pollutionreporter.view; -import androidx.recyclerview.widget.RecyclerView; import android.view.View; import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; + import hpsaturn.pollutionreporter.R; import hpsaturn.pollutionreporter.models.SensorTrackInfo; @@ -20,9 +21,9 @@ public class PostsViewHolder extends RecyclerView.ViewHolder { public PostsViewHolder(View itemView) { super(itemView); - record_name = itemView.findViewById(R.id.tv_record_name); - record_date = itemView.findViewById(R.id.tv_record_date); - record_location = itemView.findViewById(R.id.tv_record_location); + record_name = itemView.findViewById(R.id.stationName); + record_date = itemView.findViewById(R.id.reportDate); + record_location = itemView.findViewById(R.id.reportNumberOfPoints); } public void bindToPost(SensorTrackInfo sensorTrack){ diff --git a/app/src/main/java/hpsaturn/pollutionreporter/view/RecordViewHolder.java b/app/src/main/java/hpsaturn/pollutionreporter/view/RecordViewHolder.java index 31395978..739f140e 100644 --- a/app/src/main/java/hpsaturn/pollutionreporter/view/RecordViewHolder.java +++ b/app/src/main/java/hpsaturn/pollutionreporter/view/RecordViewHolder.java @@ -1,9 +1,10 @@ package hpsaturn.pollutionreporter.view; -import androidx.recyclerview.widget.RecyclerView; import android.view.View; import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; + import hpsaturn.pollutionreporter.R; @@ -21,10 +22,10 @@ public class RecordViewHolder extends RecyclerView.ViewHolder implements View.On public RecordViewHolder(View itemView, RecordsAdapter adapter) { super(itemView); itemView.setOnClickListener(this); - this.mAdapter=adapter; - record_name = itemView.findViewById(R.id.tv_record_name); - record_date = itemView.findViewById(R.id.tv_record_date); - record_location = itemView.findViewById(R.id.tv_record_location); + this.mAdapter = adapter; + record_name = itemView.findViewById(R.id.stationName); + record_date = itemView.findViewById(R.id.reportDate); + record_location = itemView.findViewById(R.id.reportNumberOfPoints); } @Override 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..2ef78e7d --- /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/layout/fragment_records.xml b/app/src/main/res/layout/fragment_records.xml index 405de980..687ab9ac 100644 --- a/app/src/main/res/layout/fragment_records.xml +++ b/app/src/main/res/layout/fragment_records.xml @@ -28,13 +28,4 @@ - - - - diff --git a/app/src/main/res/layout/fragment_sensor_report_information_list.xml b/app/src/main/res/layout/fragment_sensor_report_information_list.xml new file mode 100644 index 00000000..1ca255e2 --- /dev/null +++ b/app/src/main/res/layout/fragment_sensor_report_information_list.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_record.xml b/app/src/main/res/layout/item_record.xml index 11db02e0..2afdfc9e 100644 --- a/app/src/main/res/layout/item_record.xml +++ b/app/src/main/res/layout/item_record.xml @@ -1,80 +1,50 @@ - - - - - + + + + android:textAppearance="@style/TextAppearance.MaterialAppTheme.Subtitle1" + tools:text="Station name" /> + android:textAppearance="@style/TextAppearance.MaterialAppTheme.Caption" + tools:text="Thursday" /> + + - - + - 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..129f5eee --- /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..decaa88a --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,33 @@ + + + + + + + + + \ 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..c86264c2 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -4,4 +4,20 @@ 360dp 90dp 125dp + 3dp + + + 156dp + + + 0dp + 4dp + 8dp + 12dp + 16dp + 20dp + 24dp + + + 64dp 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 9747ff64..2797f0a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,39 @@ 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 + Public Reports + + + 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 + Distance to closest station. + Device sync complete ********* @@ -88,4 +121,5 @@ Please manually reboot of CanAirIO device! key_device_status key_device_info + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 394ab9d8..e6a9486d 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -35,4 +35,55 @@ @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/data/TestData.kt b/app/src/test/java/hpsaturn/pollutionreporter/data/TestData.kt new file mode 100644 index 00000000..58aa3ff4 --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/data/TestData.kt @@ -0,0 +1,102 @@ +package hpsaturn.pollutionreporter.data + +import hpsaturn.pollutionreporter.reports.open.data.models.TracksData +import hpsaturn.pollutionreporter.reports.open.data.models.TracksInfo +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorDataPoint +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +import hpsaturn.pollutionreporter.util.toUnixTimeStamp + +object TestData { + + private const val latitude1 = 4.645594 + private const val longitude1 = -74.058881 + + private const val localDate1 = "Oct, Thu 03" + + private const val unixTimeStamp = 1605631331L + private val timestamp = unixTimeStamp.toUnixTimeStamp() + + val sensorReportInformation1 = SensorReportInformation( + "device1", + localDate1, + latitude1, + longitude1, + "sensor1", + 3 + ) + + val sensorReportInformation2 = SensorReportInformation( + "device2", + localDate1, + latitude1, + longitude1, + "sensor2", + 18 + ) + + val sensorReportInformation3 = SensorReportInformation( + "device3", + localDate1, + latitude1, + longitude1, + "sensor3", + 23 + ) + + val sensorReportInformationList = listOf( + sensorReportInformation1, sensorReportInformation2, sensorReportInformation3 + ) + + val sensorDataPoint1 = SensorDataPoint( + "track1", + 1.9, + 2.89, + 5.1, + latitude1, + longitude1, + timestamp + ) + + val trackData1 = TracksData( + "track1", + 1469.2, + 1.9, + 2.89, + 5.1, + latitude1, + longitude1, + unixTimeStamp + ) + + val trackInformation1 = TracksInfo( + localDate1, + "device1", + latitude1, + longitude1, + trackData1, + "sensor1", + 3 + ) + + val trackInformation2 = TracksInfo( + localDate1, + "device2", + latitude1, + longitude1, + trackData1, + "sensor2", + 18 + ) + + val trackInformation3 = TracksInfo( + localDate1, + "device3", + latitude1, + longitude1, + trackData1, + "sensor3", + 23 + ) + + val trackInformationList = listOf(trackInformation1, trackInformation2, trackInformation3) +} \ No newline at end of file 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/reports/open/data/mappers/SensorDataPointMapperTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorDataPointMapperTest.kt new file mode 100644 index 00000000..9f9e4530 --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorDataPointMapperTest.kt @@ -0,0 +1,33 @@ +package hpsaturn.pollutionreporter.reports.open.data.mappers + +import hpsaturn.pollutionreporter.data.TestData +import hpsaturn.pollutionreporter.util.toUnixTimeStamp +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class SensorDataPointMapperTest { + + private lateinit var tSensorDataPointMapper: SensorDataPointMapper + + @BeforeEach + fun setUp() { + tSensorDataPointMapper = SensorDataPointMapper() + } + + @Test + fun `should map TrackData to SensorReportInformation`() { + // act + val response = tSensorDataPointMapper(TestData.trackData1) + // assert + assertEquals(TestData.trackData1.id, response.pointId) + assertEquals(TestData.trackData1.p10, response.p10) + assertEquals(TestData.trackData1.p25, response.p25) + assertEquals(TestData.trackData1.p25, response.p25) + assertEquals(TestData.trackData1.spd, response.spd) + assertEquals(TestData.trackData1.spd, response.spd) + assertEquals(TestData.trackData1.latitude, response.latitude) + assertEquals(TestData.trackData1.longitude, response.longitude) + assertEquals(TestData.trackData1.timestamp.toUnixTimeStamp(), response.timestamp) + } +} \ No newline at end of file diff --git a/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorReportInformationMapperTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorReportInformationMapperTest.kt new file mode 100644 index 00000000..514a16d0 --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/mappers/SensorReportInformationMapperTest.kt @@ -0,0 +1,29 @@ +package hpsaturn.pollutionreporter.reports.open.data.mappers + +import hpsaturn.pollutionreporter.data.TestData +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +internal class SensorReportInformationMapperTest { + + private lateinit var tSensorReportInformationMapper: SensorReportInformationMapper + + @BeforeEach + fun setUp() { + tSensorReportInformationMapper = SensorReportInformationMapper() + } + + @Test + fun `should map TrackInfo to SensorReportInformation`() { + // act + val response = tSensorReportInformationMapper(TestData.trackInformation1) + // assert + assertEquals(TestData.trackInformation1.deviceId, response.deviceId) + assertEquals(TestData.trackInformation1.date, response.date) + assertEquals(TestData.trackInformation1.lastLat, response.lastLatitude) + assertEquals(TestData.trackInformation1.lastLon, response.lastLongitude) + assertEquals(TestData.trackInformation1.name, response.name) + assertEquals(TestData.trackInformation1.size, response.numberOfPoints) + } +} \ No newline at end of file diff --git a/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/repositories/OpenSensorReportsRepositoryImplTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/repositories/OpenSensorReportsRepositoryImplTest.kt new file mode 100644 index 00000000..5a4524b1 --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/data/repositories/OpenSensorReportsRepositoryImplTest.kt @@ -0,0 +1,63 @@ +package hpsaturn.pollutionreporter.reports.open.data.repositories + +import hpsaturn.pollutionreporter.core.data.mappers.Mapper +import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult +import hpsaturn.pollutionreporter.core.domain.entities.Success +import hpsaturn.pollutionreporter.data.TestData +import hpsaturn.pollutionreporter.reports.open.data.models.TracksInfo +import hpsaturn.pollutionreporter.reports.open.data.services.PublicSensorReportService +import hpsaturn.pollutionreporter.reports.open.domain.entities.SensorReportInformation +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 + +@ExperimentalCoroutinesApi +@ExtendWith(MockKExtension::class) +internal class OpenSensorReportsRepositoryImplTest { + private lateinit var repository: OpenSensorReportsRepositoryImpl + + @MockK + private lateinit var mockSensorReportService: PublicSensorReportService + + @MockK + private lateinit var mockMapper: Mapper + + @BeforeEach + fun setUp() { + repository = OpenSensorReportsRepositoryImpl(mockSensorReportService, mockMapper) + } + + @Test + fun `should return remote data when the call to remote data source is ok`() = runBlockingTest { + // arrange + every { mockMapper(any()) } returnsMany TestData.sensorReportInformationList + coEvery { mockSensorReportService.getTracksInfo() } returns TestData.trackInformationList + // act + val result = repository.getPublicSensorReports() + // assert + coVerify { mockSensorReportService.getTracksInfo() } + verify { mockMapper(TestData.trackInformation1) } + assertEquals(Success(TestData.sensorReportInformationList), result) + } + + @Test + fun `should wrap the in ErrorResponse in case service throws an error`() = runBlockingTest { + // arrange + val tException = Exception() + coEvery { mockSensorReportService.getTracksInfo() } throws tException + // act + val result = repository.getPublicSensorReports() + // assert + assertEquals(ErrorResult(tException), result) + verify(exactly = 0) { mockMapper(any()) } + } +} \ No newline at end of file diff --git a/app/src/test/java/hpsaturn/pollutionreporter/reports/open/domain/usecases/LoadOpenSensorReportsTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/domain/usecases/LoadOpenSensorReportsTest.kt new file mode 100644 index 00000000..76759c34 --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/domain/usecases/LoadOpenSensorReportsTest.kt @@ -0,0 +1,52 @@ +package hpsaturn.pollutionreporter.reports.open.domain.usecases + +import hpsaturn.pollutionreporter.core.domain.entities.Success +import hpsaturn.pollutionreporter.data.TestData +import hpsaturn.pollutionreporter.reports.open.domain.repositories.OpenSensorReportsRepository +import hpsaturn.pollutionreporter.util.InstantExecutorExtension +import hpsaturn.pollutionreporter.util.MainCoroutineTestExtension +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 +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.extension.RegisterExtension + +@ExperimentalCoroutinesApi +@Extensions(ExtendWith(MockKExtension::class), ExtendWith(InstantExecutorExtension::class)) +internal class LoadOpenSensorReportsTest { + + private lateinit var useCase: LoadOpenSensorReports + + @MockK + private lateinit var mockOpenSensorReportsRepository: OpenSensorReportsRepository + + @JvmField + @RegisterExtension + val coroutineRule = MainCoroutineTestExtension() + + @BeforeEach + fun setUp() { + useCase = LoadOpenSensorReports(mockOpenSensorReportsRepository, coroutineRule.dispatcher) + } + + @Test + fun `should call the repository to fetch public reports`() = coroutineRule.runBlockingTest { + // arrange + coEvery { + mockOpenSensorReportsRepository.getPublicSensorReports() + } returns Success(TestData.sensorReportInformationList) + // act + val result = useCase() + // assert + assertEquals(Success(TestData.sensorReportInformationList), result) + coVerify { mockOpenSensorReportsRepository.getPublicSensorReports() } + } + +} \ No newline at end of file diff --git a/app/src/test/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsViewModelTest.kt b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsViewModelTest.kt new file mode 100644 index 00000000..2617fc9d --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/reports/open/presentation/OpenSensorReportsViewModelTest.kt @@ -0,0 +1,71 @@ +package hpsaturn.pollutionreporter.reports.open.presentation + +import hpsaturn.pollutionreporter.core.domain.entities.ErrorResult +import hpsaturn.pollutionreporter.core.domain.entities.InProgress +import hpsaturn.pollutionreporter.core.domain.entities.Success +import hpsaturn.pollutionreporter.data.TestData +import hpsaturn.pollutionreporter.reports.open.domain.usecases.LoadOpenSensorReports +import hpsaturn.pollutionreporter.util.InstantExecutorExtension +import hpsaturn.pollutionreporter.util.getValueForTest +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 + +@ExperimentalCoroutinesApi +@Extensions(ExtendWith(MockKExtension::class), ExtendWith(InstantExecutorExtension::class)) +internal class OpenSensorReportsViewModelTest { + + private lateinit var openSensorReportsViewModel: OpenSensorReportsViewModel + + @MockK + private lateinit var mockLoadOpenSensorReports: LoadOpenSensorReports + + private val tException = Exception() + + @BeforeEach + fun setUp() { + openSensorReportsViewModel = OpenSensorReportsViewModel(mockLoadOpenSensorReports) + } + + @Test + fun `should emit Success with the loaded public sensor report data`() { + // arrange + coEvery { mockLoadOpenSensorReports.invoke() } returns Success(TestData.sensorReportInformationList) + // act + val result = openSensorReportsViewModel.publicReports.getValueForTest() + // assert + assertEquals(Success(TestData.sensorReportInformationList), result) + coVerify { mockLoadOpenSensorReports() } + } + + @Test + fun `should emit InProgress if LoadPublicSensorReports returns InProgress`() { + // arrange + coEvery { mockLoadOpenSensorReports.invoke() } returns InProgress + // act + val result = openSensorReportsViewModel.publicReports.getValueForTest() + // assert + assertEquals(InProgress, result) + coVerify { mockLoadOpenSensorReports() } + } + + @Test + fun `should emit ErrorResult if LoadPublicSensorReports returns ErrorResult`() { + // arrange + coEvery { mockLoadOpenSensorReports.invoke() } returns ErrorResult(tException) + // act + val result = openSensorReportsViewModel.publicReports.getValueForTest() + // assert + assertEquals(ErrorResult(tException), result) + coVerify { mockLoadOpenSensorReports() } + } + + +} \ 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..dff11f73 --- /dev/null +++ b/app/src/test/java/hpsaturn/pollutionreporter/util/LiveDataTestUtil.kt @@ -0,0 +1,80 @@ +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) + } +} + +/** + * Gets the value of a [LiveData] safely. + */ +@Throws(InterruptedException::class) +fun LiveData.getValueForTest(): T? { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + override fun onChanged(o: T?) { + data = o + latch.countDown() + this@getValueForTest.removeObserver(this) + } + } + this.observeForever(observer) + latch.await(2, TimeUnit.SECONDS) + + return data +} 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 bd932416..b365ddea 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() @@ -8,9 +12,19 @@ buildscript { maven { url "https://jitpack.io" } } dependencies { + + 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" + classpath 'com.android.tools.build:gradle:4.1.2' classpath 'com.google.gms:google-services:4.3.5' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.4.1' + } } diff --git a/gradle.properties b/gradle.properties index 1a0cdd20..e933aaf8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,4 @@ mVersionCode=530 mVersionName=0.3.5 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 e614a437..8717a86e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,6 @@ + #Wed Jan 06 22:46:44 CET 2021 + distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME