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