diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt index 58b118462..c949c606a 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/network/ApiModule.kt @@ -19,6 +19,7 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdApi import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccApi import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiApi import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdApi import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaApi import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamApi import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceApi @@ -144,4 +145,8 @@ object ApiModule { @Provides @Singleton fun provideMetOfficeApi(): MetOfficeApi = MetOfficeApi.create() + + @Provides + @Singleton + fun provideImdApi(): ImdApi = ImdApi.create() } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt index 6a2adf029..f67e67c0d 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/di/weather/WeatherRepositoryModule.kt @@ -26,6 +26,8 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiApi import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoApi import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoRepository +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaApi import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamApi @@ -233,4 +235,12 @@ object WeatherRepositoryModule { weatherDao: WeatherDao, apiKeysDao: ApiKeysDao ): MetOfficeRepository = MetOfficeRepository(dao, weatherDao, api, apiKeysDao) + + @Provides + @Singleton + fun provideImdRepository( + dao: LocationsDao, + weatherDao: WeatherDao, + api: ImdApi + ): ImdRepository = ImdRepository(dao, weatherDao, api) } \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt index a55c978b0..66f9d8ea7 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/model/sources/WeatherSource.kt @@ -96,6 +96,12 @@ enum class WeatherSource( requiresUserApiKey = true, regionalButWorldwideSupport = true ), + IMD( + displayName = "IMD", + fullName = "India Meteorological Department", + displayLink = "https://mausam.imd.gov.in/", + countryNameRes = R.string.country_india + ), MET_NORWAY( displayName = "Met Norway", fullName = "Met Norway", @@ -132,6 +138,7 @@ private val weatherSourcesByCountry = buildMap { listOf("GB", "UK").forEach { put(it, listOf(WeatherSource.MET_OFFICE)) } put("NO", listOf(WeatherSource.MET_NORWAY)) put("FR", listOf(WeatherSource.METEO_FRANCE)) + put("IN", listOf(WeatherSource.IMD)) } diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/ImdApi.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/ImdApi.kt new file mode 100644 index 000000000..129835431 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/ImdApi.kt @@ -0,0 +1,51 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.imd + +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.json.ImdForecastJson +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query +import java.util.concurrent.TimeUnit + + +interface ImdApi { + + + @GET("test4_mme.php") + suspend fun fetchForecast( + @Query("lat_gfs") latitude: Double, + @Query("lon_gfs") longitude: Double, + @Query("date") date: String, + ): Response + + @GET("{path}") + suspend fun fetchTimestamps( + @Path("path") path: String, + ): Response + + + companion object { + const val BASE_URL = "https://mausamgram.imd.gov.in/" + + fun create(): ImdApi { + + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ImdApi::class.java) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/ImdRepository.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/ImdRepository.kt new file mode 100644 index 000000000..543644f32 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/ImdRepository.kt @@ -0,0 +1,111 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.imd + +import com.pranshulgg.weather_master_app.core.model.domain.AppException +import com.pranshulgg.weather_master_app.core.model.domain.location.Location +import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult +import com.pranshulgg.weather_master_app.core.model.weather.WeatherResultType +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.model.ImdForecastModel +import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamApi +import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.json.bundle.MeteoamWeatherBundle +import com.pranshulgg.weather_master_app.core.utils.weather.cache.isWeatherCacheSafe +import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnWeatherCache +import com.pranshulgg.weather_master_app.core.utils.weather.forecast.mergeHourlyWeather +import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao +import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherDao +import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.imd.toDomain +import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.meteoam.toDomain +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toCurrentWeatherEntity +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toDailyWeatherEntity +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toDomain +import com.pranshulgg.weather_master_app.data.local.mapper.weather.toHourlyWeatherEntity +import com.pranshulgg.weather_master_app.data.repository.WeatherRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + + +class ImdRepository @Inject constructor( + val dao: LocationsDao, + val weatherDao: WeatherDao, + val api: ImdApi +) : WeatherRepository { + + + override suspend fun getWeather( + location: Location, + isManualRefresh: Boolean, + isForceRefresh: Boolean + ): WeatherResult = + withContext( + Dispatchers.IO + ) { + val cache = dao.getWeatherDataForLocation(location.id) + + val shouldReturnCache = shouldReturnWeatherCache(cache, isManualRefresh, isForceRefresh) + + val existingHourly = weatherDao.getHourlyDataForLocation(location.id) + + when (shouldReturnCache) { + WeatherResultType.REFRESH_TOO_EARLY -> return@withContext WeatherResult.RefreshNotAvailable + WeatherResultType.SUCCESS -> return@withContext WeatherResult.Success(cache!!.toDomain()) + else -> {} + } + + return@withContext try { + + val imdTimeFrames = listOf("1hr", "3hr", "6hr") + + val timeStamps = imdTimeFrames.map { + api.fetchTimestamps("mmem_${it}.txt") + } + + val timeStampsBody = timeStamps.map { + it.body()?.string()?.substringBefore(",") + } + + val forecasts = timeStampsBody.mapIndexed { index, s -> + api.fetchForecast( + latitude = location.latitude, + longitude = location.longitude, + date = "${s}_${imdTimeFrames[index]}_0p125" + ) + } + + val final = ImdForecastModel( + forecast1hr = forecasts[0].body(), + forecast3hr = forecasts[1].body(), + forecast6hr = forecasts[2].body(), + timeStamp1 = timeStampsBody[0]!!, + timeStamp2 = timeStampsBody[1]!!, + timeStamp3 = timeStampsBody[2]!! + ) + + val domain = final.toDomain(location) + + val mergedHourly = mergeHourlyWeather( + existing = existingHourly, + incoming = domain.hourly.toHourlyWeatherEntity(location.id) + ) + weatherDao.insertWeather( + domain.current.toCurrentWeatherEntity(location.id), + mergedHourly, + domain.daily.toDailyWeatherEntity(location.id), + location.id + ) + WeatherResult.Success(domain) + + } catch (e: Exception) { + + val isCacheSafe = isWeatherCacheSafe(cache) + + WeatherResult.Error( + exception = e, + if (isCacheSafe) cache?.toDomain() else null + ) + + } + + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/json/ImdForecastJson.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/json/ImdForecastJson.kt new file mode 100644 index 000000000..5246c5904 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/json/ImdForecastJson.kt @@ -0,0 +1,13 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.imd.json + +import com.google.gson.annotations.SerializedName + +data class ImdForecastJson( + @SerializedName("rh") val humidity: List, + @SerializedName("temp") val temperature: List, + @SerializedName("wdir") val windDirection: List, + @SerializedName("wspd") val windSpeedMs: List, + @SerializedName("apcp") val precipitation: List, + @SerializedName("tcdc") val cloudCover: List, + + ) \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/model/ImdForecastModel.kt b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/model/ImdForecastModel.kt new file mode 100644 index 000000000..99b31f286 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/core/network/sources/weather/imd/model/ImdForecastModel.kt @@ -0,0 +1,15 @@ +package com.pranshulgg.weather_master_app.core.network.sources.weather.imd.model + +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.json.ImdForecastJson + + +data class ImdForecastModel( + val forecast1hr: ImdForecastJson?, + val forecast3hr: ImdForecastJson?, + val forecast6hr: ImdForecastJson?, + val timeStamp1: String, + val timeStamp2: String, + val timeStamp3: String, + + + ) \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/imd/ImdJsonMapper.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/imd/ImdJsonMapper.kt new file mode 100644 index 000000000..92b95e5b2 --- /dev/null +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/local/mapper/weather/sources/imd/ImdJsonMapper.kt @@ -0,0 +1,248 @@ +package com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.imd + +import com.pranshulgg.weather_master_app.core.model.domain.location.Location +import com.pranshulgg.weather_master_app.core.model.domain.weather.Weather +import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherCurrent +import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherDaily +import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherHourly +import com.pranshulgg.weather_master_app.core.model.weather.WeatherCondition +import com.pranshulgg.weather_master_app.core.model.weather.WindSpeedUnit +import com.pranshulgg.weather_master_app.core.model.weather.wind.WindDirection +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.model.ImdForecastModel +import com.pranshulgg.weather_master_app.core.utils.formatters.getCurrentTimeFor +import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId +import com.pranshulgg.weather_master_app.core.utils.formatters.toSafeDouble +import com.pranshulgg.weather_master_app.core.utils.weather.astronomy.getMoonTimings +import com.pranshulgg.weather_master_app.core.utils.weather.astronomy.getSunTimings +import com.pranshulgg.weather_master_app.core.utils.weather.calculations.computeApparentTemperature +import com.pranshulgg.weather_master_app.core.utils.weather.computing.computeDailyWeatherCondition +import com.pranshulgg.weather_master_app.core.utils.weather.forecast.findHourlyIndexForTime +import com.pranshulgg.weather_master_app.core.utils.weather.forecast.isSameDay +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.util.Locale +import java.util.TimeZone +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.hours + + +fun ImdForecastModel.toDomain(location: Location): Weather { + val zoneId = location.timezone + val currentTime = getCurrentTimeFor(zoneId) + + val formatter = SimpleDateFormat("yyyyMMddHH", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + val hourlyTemps = mutableMapOf() + val hourlyPrecipitation = mutableMapOf() + val hourlyHumidity = mutableMapOf() + val hourlyWindSpeedMs = mutableMapOf() + val hourlyWindDirection = mutableMapOf() + val hourlyCloudCover = mutableMapOf() + + var key: Long + + val forecasts = listOf(this.forecast6hr, this.forecast3hr, this.forecast1hr) + + val params = listOf( + mapOf( + "time" to formatter.parse(this.timeStamp3)?.time, + "interval" to 6.hours.inWholeMilliseconds, + "size" to 40 + ), + mapOf( + "time" to formatter.parse(this.timeStamp2)?.time, + "interval" to 3.hours.inWholeMilliseconds, + "size" to 40 + ), + mapOf( + "time" to formatter.parse(this.timeStamp1)?.time, + "interval" to 1.hours.inWholeMilliseconds, + "size" to 36 + ), + ) + + forecasts.forEachIndexed { index, forecast -> + val time = params[index]["time"] ?: return@forEachIndexed + val interval = params[index]["interval"] ?: return@forEachIndexed + val size = params[index]["size"] ?: return@forEachIndexed + + for (i in 1..size.toInt()) { + key = time + (interval * i) + hourlyTemps[key] = forecast?.temperature?.getOrNull(i).toSafeDouble() + hourlyPrecipitation[key] = forecast?.precipitation?.getOrNull(i).toSafeDouble() + hourlyHumidity[key] = forecast?.humidity?.getOrNull(i).toSafeDouble() + hourlyWindSpeedMs[key] = forecast?.windSpeedMs?.getOrNull(i).toSafeDouble() + hourlyWindDirection[key] = forecast?.windDirection?.getOrNull(i).toSafeDouble() + hourlyCloudCover[key] = forecast?.cloudCover?.getOrNull(i).toSafeDouble() + } + } + + val hourly = hourlyTemps.map { + WeatherHourly( + temperature = it.value, + rain = hourlyPrecipitation[it.key] ?: 0.0, + humidity = hourlyHumidity[it.key], + windSpeed = WindSpeedUnit.MPS.convert(hourlyWindSpeedMs[it.key], WindSpeedUnit.KPH), + windDirection = WindDirection.toWindDirectionFromDegrees(hourlyWindDirection[it.key]?.roundToInt()), + snowfall = null, + uvIndex = null, + pressureMsl = null, + visibility = null, + dewPoint = null, + weatherCondition = computeWeatherCondition( + hourlyPrecipitation[it.key], + hourlyCloudCover[it.key] + ), + time = it.key, + precipitationProbability = null + ) + } + + val currentHourlyIndex = findHourlyIndexForTime(hourly.map { it.time }, currentTime) + + val dailyDates = + hourly.map { Instant.ofEpochMilli(it.time).atZone(safeZoneId(zoneId)).toLocalDate() } + .distinct() + + val dailyDateFormatter: (LocalDate) -> Long = { + it.atStartOfDay(safeZoneId(zoneId)).toInstant().toEpochMilli() + } + + val sunTimings = getSunTimings( + dailyDates.map { + dailyDateFormatter(it) + }, + location.timezone, + location.latitude, + location.longitude + ) + + val moonTimings = getMoonTimings( + dailyDates.map { + dailyDateFormatter(it) + }, + location.timezone, + location.latitude, + location.longitude + ) + + return Weather( + location = location, + current = WeatherCurrent( + temperature = hourly[currentHourlyIndex].temperature, + humidity = hourly[currentHourlyIndex].humidity ?: 0.0, + windSpeed = hourly[currentHourlyIndex].windSpeed, + windDirection = hourly[currentHourlyIndex].windDirection, + pressureMsl = null, + visibility = null, + cloudCover = null, + uvIndex = null, + weatherCondition = hourly[currentHourlyIndex].weatherCondition, + feelsLike = computeApparentTemperature( + tempC = hourly[currentHourlyIndex].temperature, + humidity = hourly[currentHourlyIndex].humidity, + windMs = WindSpeedUnit.KPH.convert( + hourly[currentHourlyIndex].windSpeed, + WindSpeedUnit.MPS + ), + ), + time = currentTime, + dewPoint = null, + utcOffsetSeconds = null, + lastUpdatedInMilli = System.currentTimeMillis() + ), + hourly = hourly, + daily = dailyDates.mapIndexed { index, date -> + + val time = dailyDateFormatter(date) + + val dayHourly = hourlyForDay( + hourly, + time, + zoneId + ) + + + val temperatureMin = dayHourly.minOf { it.temperature ?: -1.0 }.takeIf { it != -1.0 } + val temperatureMax = dayHourly.maxOf { it.temperature ?: -1.0 }.takeIf { it != -1.0 } + + + val windSpeedAvg = + dayHourly.map { it.windSpeed ?: -1.0 }.average().takeIf { it != -1.0 } + + val windDirection = dayHourly.mapNotNull { it.windDirection }.maxOrNull() + + val rainSum = dayHourly.sumOf { it.rain } + + val icon = dayHourly.map { it.weatherCondition }.groupingBy { it } + .eachCount().maxByOrNull { it.value } + + val condition = computeDailyWeatherCondition( + dayHourly.map { it.weatherCondition }, + icon!!.key + ) + + val humidityAvg = dayHourly.map { it.humidity ?: -1.0 }.average().takeIf { it != -1.0 } + + WeatherDaily( + temperatureMin = temperatureMin, + temperatureMax = temperatureMax, + windSpeed = windSpeedAvg, + windDirection = windDirection, + rainSum = rainSum, + snowfallSum = null, + uvIndexMax = null, + weatherCondition = condition, + time = time, + precipitationProbabilityMax = null, + pressureMsl = null, + visibility = null, + humidity = humidityAvg, + dewPoint = null, + sunrise = sunTimings[index].sunrise ?: -0L, + sunset = sunTimings[index].sunset ?: -0L, + moonrise = moonTimings[index].moonrise ?: -0L, + moonset = moonTimings[index].moonset ?: -0L, + moonPhase = moonTimings[index].phase, + dawn = sunTimings[index].dawn ?: 0L, + dusk = sunTimings[index].dusk ?: 0L, + ) + }, + ) +} + + +// Pretty barebones +private fun computeWeatherCondition( + precipitationMm: Double?, + cloudCover: Double? +): WeatherCondition { + if (precipitationMm != null && precipitationMm >= 1.0) { + return WeatherCondition.RAIN + } + + return when { + cloudCover == null -> WeatherCondition.PARTLY_CLOUDY + cloudCover >= 70.0 -> WeatherCondition.OVERCAST + cloudCover >= 30.0 -> WeatherCondition.PARTLY_CLOUDY + cloudCover >= 10.0 -> WeatherCondition.MOSTLY_CLEAR + else -> WeatherCondition.CLEAR_SKY + } +} + +private fun hourlyForDay( + data: List, + time: Long, + zoneId: String +): List { + val startIndex = data.indexOfFirst { it.time >= time } + .takeIf { it != -1 } + ?: 0 + + val data = data.drop(maxOf(0, startIndex)).takeWhile { isSameDay(it.time, time, zoneId) } + + return data +} \ No newline at end of file diff --git a/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt b/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt index 53ca8684e..63dfaee6a 100644 --- a/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt +++ b/app/src/main/java/com/pranshulgg/weather_master_app/data/provider/WeatherRepositoryProvider.kt @@ -8,6 +8,7 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.dwd.DwdRep import com.pranshulgg.weather_master_app.core.network.sources.weather.eccc.EcccRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.fmi.FmiRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.gismeteo.GismeteoRepository +import com.pranshulgg.weather_master_app.core.network.sources.weather.imd.ImdRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.ipma.IpmaRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.meteoam.MeteoamRepository import com.pranshulgg.weather_master_app.core.network.sources.weather.meteofrance.MeteoFranceRepository @@ -34,7 +35,8 @@ class WeatherRepositoryProvider @Inject constructor( private val meteoamRepository: MeteoamRepository, private val ipmaRepository: IpmaRepository, private val gismeteoRepository: GismeteoRepository, - private val metOfficeRepository: MetOfficeRepository + private val metOfficeRepository: MetOfficeRepository, + private val imdRepository: ImdRepository ) { fun getRepository(source: WeatherSource): WeatherRepository { @@ -54,6 +56,7 @@ class WeatherRepositoryProvider @Inject constructor( WeatherSource.IPMA -> ipmaRepository WeatherSource.GISMETEO -> gismeteoRepository WeatherSource.MET_OFFICE -> metOfficeRepository + WeatherSource.IMD -> imdRepository } }