Reputation: 1268
What i'm trying to do
When my app starts, i'm using a fragment which uses an AutoCompleteTextView
and the Places SDK to fetch a Place
object when the user makes a selection. When that happens, i store that selected Place (as a WeatherLocation entity) through my Repository class in my Room database by calling repository.storeWeatherLocation(context,placeId)
and then fetching the weather details again if needed.
What's happening
The suspend fun storeWeatherLocationAsync
is calling the fetchCurrentWeather()
& fetchWeeklyWeather()
because from what i was able to log, the previousLocation
variable is null despite the database inspector showing that older weather location data is already present.
Crash details
My app is crashing stating that my LocationProvider's getCustomLocationLat()
is returning null (occurs in fetchCurrentWeather()
). The thing is that the location that the user selected is successfully stored in my Room database (checked using Database Inspector) so how is this function returning null?
UPDATE:
After doing some more testing with the debugger and logcat, I've found out that the WeatherLocation
data is being saved in Room when the app is running. Once it crashes and I reopen it though, that data is once again null. What am I missing here? Am i deleting the previous data somehow? Am i not actually caching it correctly in Room?
Database class:
@Database(
entities = [CurrentWeatherEntry::class,WeekDayWeatherEntry::class,WeatherLocation::class],
version = 16
)
abstract class ForecastDatabase : RoomDatabase() {
abstract fun currentWeatherDao() : CurrentWeatherDao
abstract fun weekDayWeatherDao() : WeekDayWeatherDao
abstract fun weatherLocationDao() : WeatherLocationDao
// Used to make sure that the ForecastDatabase class will be a singleton
companion object {
// Volatile == all of the threads will have immediate access to this property
@Volatile private var instance:ForecastDatabase? = null
private val LOCK = Any() // dummy object for thread monitoring
operator fun invoke(context:Context) = instance ?: synchronized(LOCK) {
// If the instance var hasn't been initialized, call buildDatabase()
// and assign it the returned object from the function call (it)
instance ?: buildDatabase(context).also { instance = it }
}
/**
* Creates an instance of the ForecastDatabase class
* using Room.databaseBuilder().
*/
private fun buildDatabase(context: Context) =
Room.databaseBuilder(context.applicationContext,
ForecastDatabase::class.java, "forecast.db")
//.addMigrations(MIGRATION_2_3) // specify an explicit Migration Technique
.fallbackToDestructiveMigration()
.build()
}
}
Here's the Repository class:
class ForecastRepositoryImpl(
private val currentWeatherDao: CurrentWeatherDao,
private val weekDayWeatherDao: WeekDayWeatherDao,
private val weatherLocationDao: WeatherLocationDao,
private val locationProvider: LocationProvider,
private val weatherNetworkDataSource: WeatherNetworkDataSource
) : ForecastRepository {
init {
weatherNetworkDataSource.apply {
// Persist downloaded data
downloadedCurrentWeatherData.observeForever { newCurrentWeather: CurrentWeatherResponse? ->
persistFetchedCurrentWeather(newCurrentWeather!!)
}
downloadedWeeklyWeatherData.observeForever { newWeeklyWeather: WeeklyWeatherResponse? ->
persistFetchedWeeklyWeather(newWeeklyWeather!!)
}
}
}
override suspend fun getCurrentWeather(): LiveData<CurrentWeatherEntry> {
return withContext(Dispatchers.IO) {
initWeatherData()
return@withContext currentWeatherDao.getCurrentWeather()
}
}
override suspend fun getWeekDayWeatherList(time: Long): LiveData<out List<WeekDayWeatherEntry>> {
return withContext(Dispatchers.IO) {
initWeatherData()
return@withContext weekDayWeatherDao.getFutureWeather(time)
}
}
override suspend fun getWeatherLocation(): LiveData<WeatherLocation> {
return withContext(Dispatchers.IO) {
return@withContext weatherLocationDao.getWeatherLocation()
}
}
private suspend fun initWeatherData() {
// retrieve the last weather location from room
val lastWeatherLocation = weatherLocationDao.getWeatherLocation().value
if (lastWeatherLocation == null ||
locationProvider.hasLocationChanged(lastWeatherLocation)
) {
fetchCurrentWeather()
fetchWeeklyWeather()
return
}
val lastFetchedTime = currentWeatherDao.getCurrentWeather().value?.zonedDateTime
if (isFetchCurrentNeeded(lastFetchedTime!!))
fetchCurrentWeather()
if (isFetchWeeklyNeeded())
fetchWeeklyWeather()
}
/**
* Checks if the current weather data should be re-fetched.
* @param lastFetchedTime The time at which the current weather data were last fetched
* @return True or false respectively
*/
private fun isFetchCurrentNeeded(lastFetchedTime: ZonedDateTime): Boolean {
val thirtyMinutesAgo = ZonedDateTime.now().minusMinutes(30)
return lastFetchedTime.isBefore(thirtyMinutesAgo)
}
/**
* Fetches the Current Weather data from the WeatherNetworkDataSource.
*/
private suspend fun fetchCurrentWeather() {
weatherNetworkDataSource.fetchCurrentWeather(
locationProvider.getPreferredLocationLat(),
locationProvider.getPreferredLocationLong()
)
}
private fun isFetchWeeklyNeeded(): Boolean {
val todayEpochTime = LocalDate.now().toEpochDay()
val futureWeekDayCount = weekDayWeatherDao.countFutureWeekDays(todayEpochTime)
return futureWeekDayCount < WEEKLY_FORECAST_DAYS_COUNT
}
private suspend fun fetchWeeklyWeather() {
weatherNetworkDataSource.fetchWeeklyWeather(
locationProvider.getPreferredLocationLat(),
locationProvider.getPreferredLocationLong()
)
}
override fun storeWeatherLocation(context:Context,placeId: String) {
GlobalScope.launch(Dispatchers.IO) {
storeWeatherLocationAsync(context,placeId)
}
}
override suspend fun storeWeatherLocationAsync(context: Context,placeId: String) {
var isFetchNeeded: Boolean // a flag variable
// Specify the fields to return.
val placeFields: List<Place.Field> =
listOf(Place.Field.ID, Place.Field.NAME,Place.Field.LAT_LNG)
// Construct a request object, passing the place ID and fields array.
val request = FetchPlaceRequest.newInstance(placeId, placeFields)
// Create the client
val placesClient = Places.createClient(context)
placesClient.fetchPlace(request).addOnSuccessListener { response ->
// Get the retrieved place object
val place = response.place
// Create a new WeatherLocation object using the place details
val newWeatherLocation = WeatherLocation(place.latLng!!.latitude,
place.latLng!!.longitude,place.name!!,place.id!!)
val previousLocation = weatherLocationDao.getWeatherLocation().value
if(previousLocation == null || ((newWeatherLocation.latitude != previousLocation.latitude) &&
(newWeatherLocation.longitude != previousLocation.longitude))) {
isFetchNeeded = true
// Store the weatherLocation in the database
persistWeatherLocation(newWeatherLocation)
// fetch the data
GlobalScope.launch(Dispatchers.IO) {
// fetch the weather data and wait for it to finish
withContext(Dispatchers.Default) {
if (isFetchNeeded) {
// fetch the weather data using the new location
fetchCurrentWeather()
fetchWeeklyWeather()
}
}
}
}
Log.d("REPOSITORY","storeWeatherLocationAsync : inside task called")
}.addOnFailureListener { exception ->
if (exception is ApiException) {
// Handle error with given status code.
Log.e("Repository", "Place not found: ${exception.statusCode}")
}
}
}
/**
* Caches the downloaded current weather data to the local
* database.
* @param fetchedCurrentWeather The most recently fetched current weather data
*/
private fun persistFetchedCurrentWeather(fetchedCurrentWeather: CurrentWeatherResponse) {
fetchedCurrentWeather.currentWeatherEntry.setTimezone(fetchedCurrentWeather.timezone)
// Using a GlobalScope since a Repository class doesn't have a lifecycle
GlobalScope.launch(Dispatchers.IO) {
currentWeatherDao.upsert(fetchedCurrentWeather.currentWeatherEntry)
}
}
/**
* Caches the selected location data to the local
* database.
* @param fetchedLocation The most recently fetched location data
*/
private fun persistWeatherLocation(fetchedLocation: WeatherLocation) {
GlobalScope.launch(Dispatchers.IO) {
weatherLocationDao.upsert(fetchedLocation)
}
}
/**
* Caches the downloaded weekly weather data to the local
* database.
* @param fetchedWeeklyWeather The most recently fetched weekly weather data
*/
private fun persistFetchedWeeklyWeather(fetchedWeeklyWeather: WeeklyWeatherResponse) {
fun deleteOldData() {
val time = LocalDate.now().toEpochDay()
weekDayWeatherDao.deleteOldEntries(time)
}
GlobalScope.launch(Dispatchers.IO) {
deleteOldData()
val weekDayEntriesList = fetchedWeeklyWeather.weeklyWeatherContainer.weekDayEntries
weekDayWeatherDao.insert(weekDayEntriesList)
}
}
}
and here's the LocationProvider impl:
class LocationProviderImpl(
private val fusedLocationProviderClient: FusedLocationProviderClient,
context: Context,
private val locationDao: WeatherLocationDao
) : PreferenceProvider(context), LocationProvider {
private val appContext = context.applicationContext
override suspend fun hasLocationChanged(lastWeatherLocation: WeatherLocation): Boolean {
return try {
hasDeviceLocationChanged(lastWeatherLocation)
} catch (e:LocationPermissionNotGrantedException) {
false
}
}
/**
* Makes the required checks to determine whether the device's location has
* changed or not.
* @param lastWeatherLocation The last known user selected location
* @return true if the device location has changed or false otherwise
*/
private suspend fun hasDeviceLocationChanged(lastWeatherLocation: WeatherLocation): Boolean {
if(!isUsingDeviceLocation()) return false // we don't have location permissions or setting's disabled
val currentDeviceLocation = getLastDeviceLocationAsync().await()
?: return false
// Check if the old and new locations are far away enough that an update is needed
val comparisonThreshold = 0.03
return abs(currentDeviceLocation.latitude - lastWeatherLocation.latitude) > comparisonThreshold
&& abs(currentDeviceLocation.longitude - lastWeatherLocation.longitude) > comparisonThreshold
}
/**
* Checks if the app has the location permission, and if that's the case
* it will fetch the device's last saved location.
* @return The device's last saved location as a Deferred<Location?>
*/
@SuppressLint("MissingPermission")
private fun getLastDeviceLocationAsync(): Deferred<Location?> {
return if(hasLocationPermission())
fusedLocationProviderClient.lastLocation.asDeferredAsync()
else
throw LocationPermissionNotGrantedException()
}
/**
* Checks if the user has granted the location
* permission.
*/
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(appContext,
Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
/**
* Returns the sharedPrefs value for the USE_DEVICE_LOCATION
* preference with a default value of "true".
*/
private fun isUsingDeviceLocation(): Boolean {
return preferences.getBoolean(USE_DEVICE_LOCATION_KEY,false)
}
private fun getCustomLocationLat() : Double {
val lat:Double? = locationDao.getWeatherLocation().value?.latitude
if(lat == null) Log.d("LOCATION_PROVIDER","lat is null = $lat")
return lat!!
}
private fun getCustomLocationLong():Double {
return locationDao.getWeatherLocation().value!!.longitude
}
override suspend fun getPreferredLocationLat(): Double {
if(isUsingDeviceLocation()) {
try {
val deviceLocation = getLastDeviceLocationAsync().await()
?: return getCustomLocationLat()
return deviceLocation.latitude
} catch (e:LocationPermissionNotGrantedException) {
return getCustomLocationLat()
}
} else {
return getCustomLocationLat()
}
}
override suspend fun getPreferredLocationLong(): Double {
if(isUsingDeviceLocation()) {
try {
val deviceLocation = getLastDeviceLocationAsync().await()
?: return getCustomLocationLong()
return deviceLocation.longitude
} catch (e:LocationPermissionNotGrantedException) {
return getCustomLocationLong()
}
} else {
return getCustomLocationLong()
}
}
}
Upvotes: 8
Views: 5822
Reputation: 166
You should not expect a Room LiveData
to return anything but null
from getValue()
until an Observer
has been added and has received its first value in its callback. LiveData
is primarily an observable data holder and those created by Room are both lazy and asynchronous by design such that they will not start doing the background database work to make values available until an Observer
is attached.
In situations like these from LocationProviderImpl
:
private fun getCustomLocationLat() : Double {
val lat:Double? = locationDao.getWeatherLocation().value?.latitude
if(lat == null) Log.d("LOCATION_PROVIDER","lat is null = $lat")
return lat!!
}
private fun getCustomLocationLong():Double {
return locationDao.getWeatherLocation().value!!.longitude
}
you should instead use a Dao
method with a more direct return type to retrieve the values, eg. in your Dao
instead of something like this:
@Query("<your query here>")
fun getWeatherLocation(): LiveData<LocationEntity>
create and use one of these:
@Query("<your query here>")
suspend fun getWeatherLocation(): LocationEntity?
@Query("<your query here>")
fun getWeatherLocationSync(): LocationEntity?
which don't return until the result has been retrieved.
Upvotes: 5
Reputation: 5103
Preface
It's too hard to be specific on your problem without full code and full understanding what does this code meant to do. If my following general suggestions (based on my guess and prediction) would be useless for you I recommend you either to add a link to you repository or simplify your use-case in order somebody could help you. But again - the more code you include in your minimal reproducible example the more chances you will not get specific answer.
My guess about the source of trouble
My guess (considering facts you described) that the main suspect in your trouble is overlay of your code's parts, that are asynchronous (for example, this case is about problem with LiveData. But the same can be with suspend functions invoked in different coroutines and so on). So what are conditions of the problem I talk about? They are next - you save your data in local db, then you read your data, both actions are asynchronous, and there is a little time passed between first and second event. I really haven't understood whether described conditions are present in your case. If they aren't, I didn't guess right :-)
My suggestions
Try to check if described behaviour really causes your problem. There are many ways to do it. One of them - to change the case, when second operation (reading from local db) will follow the first one (writing to it). To do so you can put your second-operation in coroutine and add before some delay (I think, delay(1000) would be enough). As I've understood your functions - getCustomLocationLat(), getCustomLocationLong() - are first candidates to make this trick (may be there are another functions but it'll be easier to you to know them). If after this test-case, your problem is solved - you could think what appropriate changes you could make to guarantee that the second event will always be after the first one (it could depend on answers to some questions - 1) could you put both events in one coroutine? 2) could you replace unpacking value from LiveData with LiveData's observing or Deferred?)
Upvotes: 2