Stelios Papamichail
Stelios Papamichail

Reputation: 1268

Room Entity's data is stored correctly but is retrieved as null

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

Answers (2)

krage
krage

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

sergiy tykhonov
sergiy tykhonov

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

Related Questions