From cca3f30e029e3076920daae9771fc97ba3736bbe Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Mon, 30 Mar 2020 16:44:34 +0100 Subject: [PATCH 1/6] Paging with network and database --- app/build.gradle | 1 + .../android/codelabs/paging/Injection.kt | 13 +++- .../paging/data/GithubPagingSource.kt | 57 -------------- .../paging/data/GithubRemoteMediator.kt | 77 +++++++++++++++++++ .../codelabs/paging/data/GithubRepository.kt | 14 +++- .../android/codelabs/paging/db/RepoDao.kt | 43 +++++++++++ .../codelabs/paging/db/RepoDatabase.kt | 53 +++++++++++++ .../paging/ui/SearchRepositoriesActivity.kt | 2 +- build.gradle | 2 +- 9 files changed, 197 insertions(+), 65 deletions(-) delete mode 100644 app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt create mode 100644 app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt create mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt create mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt diff --git a/app/build.gradle b/app/build.gradle index 7ad34dd9..8ffd7b16 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,6 +67,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.room:room-runtime:$roomVersion" + implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.paging:paging-runtime:$pagingVersion" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion" kapt "androidx.room:room-compiler:$roomVersion" diff --git a/app/src/main/java/com/example/android/codelabs/paging/Injection.kt b/app/src/main/java/com/example/android/codelabs/paging/Injection.kt index 619d00c9..fc727546 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/Injection.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/Injection.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.lifecycle.ViewModelProvider import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.data.GithubRepository +import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.ui.ViewModelFactory /** @@ -33,15 +34,19 @@ object Injection { * Creates an instance of [GithubRepository] based on the [GithubService] and a * [GithubLocalCache] */ - private fun provideGithubRepository(): GithubRepository { - return GithubRepository(GithubService.create()) + private fun provideGithubRepository(context: Context): GithubRepository { + return GithubRepository(GithubService.create(), provideDatabase(context)) + } + + private fun provideDatabase(context: Context): RepoDatabase { + return RepoDatabase.getInstance(context) } /** * Provides the [ViewModelProvider.Factory] that is then used to get a reference to * [ViewModel] objects. */ - fun provideViewModelFactory(): ViewModelProvider.Factory { - return ViewModelFactory(provideGithubRepository()) + fun provideViewModelFactory(context: Context): ViewModelProvider.Factory { + return ViewModelFactory(provideGithubRepository(context)) } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt deleted file mode 100644 index 5639b199..00000000 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.codelabs.paging.data - -import androidx.paging.PagingSource -import com.example.android.codelabs.paging.api.GithubService -import com.example.android.codelabs.paging.api.IN_QUALIFIER -import com.example.android.codelabs.paging.model.Repo -import kotlinx.coroutines.ExperimentalCoroutinesApi -import retrofit2.HttpException -import java.io.IOException - -// GitHub page API is 1 based: https://developer.github.com/v3/#pagination -private const val GITHUB_STARTING_PAGE_INDEX = 1 - -@ExperimentalCoroutinesApi -class GithubPagingSource( - private val service: GithubService, - private val query: String -) : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult { - val currentPage = params.key ?: GITHUB_STARTING_PAGE_INDEX - val apiQuery = query + IN_QUALIFIER - try { - val apiResponse = service.searchRepos(apiQuery, currentPage, params.loadSize) - return if (apiResponse.isSuccessful) { - val repos = apiResponse.body()?.items ?: emptyList() - LoadResult.Page( - data = repos, - prevKey = if (currentPage == GITHUB_STARTING_PAGE_INDEX) null else currentPage - 1, - // if we don't get any results, we consider that we're at the last page - nextKey = if (repos.isEmpty()) null else currentPage + 1 - ) - } else { - LoadResult.Error(IOException(apiResponse.message())) - } - } catch (exception: IOException) { - return LoadResult.Error(exception) - } catch (exception: HttpException) { - return LoadResult.Error(exception) - } - } -} diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt new file mode 100644 index 00000000..6528e858 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.codelabs.paging.data + +import android.util.Log +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.withTransaction +import com.example.android.codelabs.paging.api.GithubService +import com.example.android.codelabs.paging.api.IN_QUALIFIER +import com.example.android.codelabs.paging.db.RepoDatabase +import com.example.android.codelabs.paging.model.Repo +import retrofit2.HttpException +import java.io.IOException + +// GitHub page API is 1 based: https://developer.github.com/v3/#pagination +const val GITHUB_STARTING_PAGE_INDEX = 1 + +class GithubRemoteMediator( + private val query: String, + private val service: GithubService, + private val repoDatabase: RepoDatabase +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + Log.d("GithubRemoteMediator", "load type: $loadType ") + // Get the last accessed item from the current PagingState, which holds all loaded + // pages from DB. + val dbPage = when (loadType) { + LoadType.REFRESH -> GITHUB_STARTING_PAGE_INDEX + LoadType.START -> state.pages.firstOrNull()?.prevKey + LoadType.END -> state.pages.lastOrNull()?.nextKey + } ?: return MediatorResult.Success(canRequestMoreData = false) + + val page = dbPage / state.config.pageSize + 1 + + Log.d("GithubRemoteMediator", "dbPage: $dbPage, page: $page") + + val apiQuery = query + IN_QUALIFIER + try { + val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize) + + return if (apiResponse.isSuccessful) { + val repos = apiResponse.body()?.items ?: emptyList() + repoDatabase.withTransaction { + if (loadType == LoadType.REFRESH) { + repoDatabase.reposDao().clearRepos() + } + repoDatabase.reposDao().insert(repos) + } + Log.d("GithubRemoteMediator", "data: ${repos.size}") + MediatorResult.Success(canRequestMoreData = repos.isNotEmpty()) + } else { + MediatorResult.Error(IOException(apiResponse.message())) + } + } catch (exception: IOException) { + return MediatorResult.Error(exception) + } catch (exception: HttpException) { + return MediatorResult.Error(exception) + } + } +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index 2cf1b249..562ce6d7 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -21,6 +21,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingDataFlow import com.example.android.codelabs.paging.api.GithubService +import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.model.Repo import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -29,7 +30,10 @@ import kotlinx.coroutines.flow.Flow * Repository class that works with local and remote data sources. */ @ExperimentalCoroutinesApi -class GithubRepository(private val service: GithubService) { +class GithubRepository( + private val service: GithubService, + private val database: RepoDatabase +) { /** * Search repositories whose names match the query, exposed as a stream of data that will emit @@ -38,9 +42,15 @@ class GithubRepository(private val service: GithubService) { fun getSearchResultStream(query: String): Flow> { Log.d("GithubRepository", "New query: $query") + val pagingSourceFactory = database.reposDao().reposByName().asPagingSourceFactory() return PagingDataFlow( config = PagingConfig(pageSize = NETWORK_PAGE_SIZE), - pagingSourceFactory = { GithubPagingSource(service, query) } + remoteMediator = GithubRemoteMediator( + query, + service, + database + ), + pagingSourceFactory = pagingSourceFactory ) } diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt new file mode 100644 index 00000000..1c5c78f9 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.codelabs.paging.db + +import androidx.paging.DataSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.android.codelabs.paging.model.Repo + +/** + * Room data access object for accessing the [Repo] table. + */ +@Dao +interface RepoDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(posts: List) + + // Do a similar query as the search API: + // Look for repos that contain the query string in the name or in the description + // and order those results descending, by the number of stars and then by name + @Query("SELECT * FROM repos ORDER BY stars DESC, name ASC") + fun reposByName(): DataSource.Factory + + @Query("DELETE FROM repos") + suspend fun clearRepos() +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt new file mode 100644 index 00000000..200d2e72 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.codelabs.paging.db + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import android.content.Context +import com.example.android.codelabs.paging.model.Repo + +/** + * Database schema that holds the list of repos. + */ +@Database( + entities = [Repo::class], + version = 1, + exportSchema = false +) +abstract class RepoDatabase : RoomDatabase() { + + abstract fun reposDao(): RepoDao + + companion object { + + @Volatile + private var INSTANCE: RepoDatabase? = null + + fun getInstance(context: Context): RepoDatabase = + INSTANCE ?: synchronized(this) { + INSTANCE + ?: buildDatabase(context).also { INSTANCE = it } + } + + private fun buildDatabase(context: Context) = + Room.databaseBuilder(context.applicationContext, + RepoDatabase::class.java, "Github.db") + .build() + } +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index 8ab04500..247de382 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -50,7 +50,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { setContentView(view) // get the view model - viewModel = ViewModelProvider(this, Injection.provideViewModelFactory()) + viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(applicationContext)) .get(SearchRepositoriesViewModel::class.java) // add dividers between RecyclerView's row items diff --git a/build.gradle b/build.gradle index 130aa572..8676d968 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ allprojects { repositories { google() jcenter() - maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6295087/artifacts/repository' } + maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6396248/artifacts/repository' } } } From 52cf5efa689c61ecd1709542206d892898f7475c Mon Sep 17 00:00:00 2001 From: Dustin Lam Date: Wed, 15 Apr 2020 13:17:34 -0700 Subject: [PATCH 2/6] Update snapshot repo --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8676d968..2ecafdbc 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ allprojects { repositories { google() jcenter() - maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6396248/artifacts/repository' } + maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6397162/artifacts/repository' } } } From 83bc78c4fe99a65d398fb39cfb3182a84e397faa Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Fri, 17 Apr 2020 17:34:41 +0100 Subject: [PATCH 3/6] Saving the prev and next keys in database --- .../paging/data/GithubRemoteMediator.kt | 44 +++++++++++++------ .../android/codelabs/paging/db/RemoteKeys.kt | 27 ++++++++++++ .../codelabs/paging/db/RemoteKeysDao.kt | 44 +++++++++++++++++++ .../codelabs/paging/db/RepoDatabase.kt | 3 +- 4 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt create mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt index 6528e858..7d6ab1b8 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -23,6 +23,7 @@ import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.api.IN_QUALIFIER +import com.example.android.codelabs.paging.db.RemoteKeys import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.model.Repo import retrofit2.HttpException @@ -39,17 +40,27 @@ class GithubRemoteMediator( override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { Log.d("GithubRemoteMediator", "load type: $loadType ") - // Get the last accessed item from the current PagingState, which holds all loaded - // pages from DB. - val dbPage = when (loadType) { - LoadType.REFRESH -> GITHUB_STARTING_PAGE_INDEX - LoadType.START -> state.pages.firstOrNull()?.prevKey - LoadType.END -> state.pages.lastOrNull()?.nextKey - } ?: return MediatorResult.Success(canRequestMoreData = false) - val page = dbPage / state.config.pageSize + 1 + if (loadType == LoadType.REFRESH) { + repoDatabase.withTransaction { + repoDatabase.reposDao().clearRepos() + repoDatabase.remoteKeysDao().clearRemoteKeys() + } + } + val remoteKeys = repoDatabase.remoteKeysDao().remoteKeys() + ?: RemoteKeys(null, GITHUB_STARTING_PAGE_INDEX) + + val page = when (loadType) { + LoadType.REFRESH -> remoteKeys.nextKey + LoadType.START -> remoteKeys.prevKey + LoadType.END -> remoteKeys.nextKey + } + + if (page == null) { + return MediatorResult.Success(canRequestMoreData = false) + } - Log.d("GithubRemoteMediator", "dbPage: $dbPage, page: $page") + Log.d("GithubRemoteMediator", "page: $page") val apiQuery = query + IN_QUALIFIER try { @@ -57,14 +68,21 @@ class GithubRemoteMediator( return if (apiResponse.isSuccessful) { val repos = apiResponse.body()?.items ?: emptyList() + val canRequestMoreData = repos.isNotEmpty() + val newRemoteKeys = RemoteKeys( + prevKey = remoteKeys.nextKey, + nextKey = if (canRequestMoreData) { + (remoteKeys.nextKey ?: GITHUB_STARTING_PAGE_INDEX) + 1 + } else { + null + } + ) repoDatabase.withTransaction { - if (loadType == LoadType.REFRESH) { - repoDatabase.reposDao().clearRepos() - } + repoDatabase.remoteKeysDao().replace(newRemoteKeys) repoDatabase.reposDao().insert(repos) } Log.d("GithubRemoteMediator", "data: ${repos.size}") - MediatorResult.Success(canRequestMoreData = repos.isNotEmpty()) + MediatorResult.Success(canRequestMoreData = canRequestMoreData) } else { MediatorResult.Error(IOException(apiResponse.message())) } diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt new file mode 100644 index 00000000..2df9d61b --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.codelabs.paging.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "remote_keys") +data class RemoteKeys( + @PrimaryKey(autoGenerate = true) + val prevKey: Int?, + val nextKey: Int? +) diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt new file mode 100644 index 00000000..0657ba14 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.codelabs.paging.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** + * Room data access object for accessing the [RemoteKeys] table + */ +@Dao +interface RemoteKeysDao { + + @Insert + suspend fun insert(remoteKey: RemoteKeys) + + @Query("SELECT * FROM remote_keys LIMIT 1") + suspend fun remoteKeys(): RemoteKeys? + + @Transaction + suspend fun replace(remoteKey: RemoteKeys) { + clearRemoteKeys() + insert(remoteKey) + } + + @Query("DELETE FROM remote_keys") + suspend fun clearRemoteKeys() +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt index 200d2e72..f70d0424 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt @@ -26,13 +26,14 @@ import com.example.android.codelabs.paging.model.Repo * Database schema that holds the list of repos. */ @Database( - entities = [Repo::class], + entities = [Repo::class, RemoteKeys::class], version = 1, exportSchema = false ) abstract class RepoDatabase : RoomDatabase() { abstract fun reposDao(): RepoDao + abstract fun remoteKeysDao(): RemoteKeysDao companion object { From 15f922567f3e13b0a7da75a211d598c1713b7324 Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Mon, 20 Apr 2020 19:59:51 +0100 Subject: [PATCH 4/6] Making the RemoteMediator key conversion work --- .../android/codelabs/paging/Injection.kt | 1 + .../paging/data/GithubRemoteMediator.kt | 133 ++++++++++++++---- .../codelabs/paging/data/GithubRepository.kt | 17 ++- .../android/codelabs/paging/db/RemoteKeys.kt | 3 +- .../codelabs/paging/db/RemoteKeysDao.kt | 16 +-- .../android/codelabs/paging/db/RepoDao.kt | 8 +- .../codelabs/paging/ui/ReposAdapter.kt | 2 +- .../paging/ui/SearchRepositoriesActivity.kt | 3 +- build.gradle | 2 +- 9 files changed, 130 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/example/android/codelabs/paging/Injection.kt b/app/src/main/java/com/example/android/codelabs/paging/Injection.kt index fc727546..3727d395 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/Injection.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/Injection.kt @@ -22,6 +22,7 @@ import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.data.GithubRepository import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.ui.ViewModelFactory +import kotlinx.coroutines.Dispatchers /** * Class that handles object creation. diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt index 7d6ab1b8..684c40df 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -28,6 +28,7 @@ import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.model.Repo import retrofit2.HttpException import java.io.IOException +import java.io.InvalidObjectException // GitHub page API is 1 based: https://developer.github.com/v3/#pagination const val GITHUB_STARTING_PAGE_INDEX = 1 @@ -38,51 +39,79 @@ class GithubRemoteMediator( private val repoDatabase: RepoDatabase ) : RemoteMediator() { + /** + * The paging library will call this method when there is no more data in the database to + * load from. + * + * For every item we retrieve we store the previous and next keys, so we know how to request + * more data, based on that specific item. + * + * state.pages holds the pages that were loaded until now and saved in the database. + * + * If the load type is REFRESH it means that we are in one of the following situations: + * * it's the first time when the query was triggered and there was nothing saved yet in the + * database + * * a swipe to refresh was called + * If the load type is START, we need to rely on the first item available in the pages + * retrieved so far to compute they key for the previous page. + * If the load type is END, we need to rely on the last item available in the pages + * retrieved so far to compute they key for the next page. + */ override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { - Log.d("GithubRemoteMediator", "load type: $loadType ") - - if (loadType == LoadType.REFRESH) { - repoDatabase.withTransaction { - repoDatabase.reposDao().clearRepos() - repoDatabase.remoteKeysDao().clearRemoteKeys() - } - } - val remoteKeys = repoDatabase.remoteKeysDao().remoteKeys() - ?: RemoteKeys(null, GITHUB_STARTING_PAGE_INDEX) - val page = when (loadType) { - LoadType.REFRESH -> remoteKeys.nextKey - LoadType.START -> remoteKeys.prevKey - LoadType.END -> remoteKeys.nextKey - } - - if (page == null) { - return MediatorResult.Success(canRequestMoreData = false) + LoadType.REFRESH -> { + val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) + remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX + } + LoadType.START -> { + val remoteKeys = getRemoteKeyForFirstItem(state) + if (remoteKeys == null) { + // The LoadType is START so some data was loaded before, + // so we should have been able to get remote keys + // If the remoteKeys are null, then we're an invalid state and we have a bug + throw InvalidObjectException("Remote key and the prevKey should not be null") + } + // If the previous key is null, then we can't request more data + val prevKey = remoteKeys.prevKey + if (prevKey == null) { + return MediatorResult.Success(endOfPaginationReached = false) + } + remoteKeys.prevKey + } + LoadType.END -> { + val remoteKeys = getRemoteKeyForLastItem(state) + if (remoteKeys?.nextKey == null) { + // The LoadType is END, so some data was loaded before, + // so we should have been able to get remote keys + // If the remoteKeys are null, then we're an invalid state and we have a bug + throw InvalidObjectException("Remote key should not be null for $loadType") + } + remoteKeys.nextKey + } } - Log.d("GithubRemoteMediator", "page: $page") - val apiQuery = query + IN_QUALIFIER + try { val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize) return if (apiResponse.isSuccessful) { val repos = apiResponse.body()?.items ?: emptyList() val canRequestMoreData = repos.isNotEmpty() - val newRemoteKeys = RemoteKeys( - prevKey = remoteKeys.nextKey, - nextKey = if (canRequestMoreData) { - (remoteKeys.nextKey ?: GITHUB_STARTING_PAGE_INDEX) + 1 - } else { - null - } - ) repoDatabase.withTransaction { - repoDatabase.remoteKeysDao().replace(newRemoteKeys) + if (loadType == LoadType.REFRESH) { + repoDatabase.remoteKeysDao().clearRemoteKeys() + repoDatabase.reposDao().clearRepos() + } + val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1 + val nextKey = if (canRequestMoreData) page + 1 else null + val keys = repos.map { + RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey) + } + repoDatabase.remoteKeysDao().insertAll(keys) repoDatabase.reposDao().insert(repos) } - Log.d("GithubRemoteMediator", "data: ${repos.size}") - MediatorResult.Success(canRequestMoreData = canRequestMoreData) + MediatorResult.Success(endOfPaginationReached = canRequestMoreData) } else { MediatorResult.Error(IOException(apiResponse.message())) } @@ -92,4 +121,46 @@ class GithubRemoteMediator( return MediatorResult.Error(exception) } } + + /** + * Get the remote keys of on the last item retrieved + */ + private suspend fun getRemoteKeyForLastItem(state: PagingState): RemoteKeys? { + // Get the last page that was retrieved, that contained items. + // From that last page, get the last item + return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull() + ?.let { repo -> + // Get the remote keys of the last item retrieved + repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id) + } + } + + /** + * Get the remote keys of the first item retrieved + */ + private suspend fun getRemoteKeyForFirstItem(state: PagingState): RemoteKeys? { + // Get the first page that was retrieved, that contained items. + // From that first page, get the first item + return state.pages.firstOrNull() { it.data.isNotEmpty() }?.data?.firstOrNull() + ?.let { repo -> + // Get the remote keys of the first items retrieved + repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id) + } + } + + /** + * Get the remote keys of the closest item to the anchor position + */ + private suspend fun getRemoteKeyClosestToCurrentPosition( + state: PagingState + ): RemoteKeys? { + // The paging library is trying to load data after the anchor position + // Get the item closest to the anchor position + return state.anchorPosition?.let { position -> + state.closestItemToPosition(position)?.id?.let { repoId -> + // Get the remote keys of the item + repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId) + } + } + } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index 562ce6d7..a3bdf1eb 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -17,14 +17,19 @@ package com.example.android.codelabs.paging.data import android.util.Log +import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import androidx.paging.PagingDataFlow +import androidx.room.withTransaction import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.model.Repo +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch /** * Repository class that works with local and remote data sources. @@ -34,7 +39,6 @@ class GithubRepository( private val service: GithubService, private val database: RepoDatabase ) { - /** * Search repositories whose names match the query, exposed as a stream of data that will emit * every time we get more data from the network. @@ -42,8 +46,11 @@ class GithubRepository( fun getSearchResultStream(query: String): Flow> { Log.d("GithubRepository", "New query: $query") - val pagingSourceFactory = database.reposDao().reposByName().asPagingSourceFactory() - return PagingDataFlow( + // appending '%' so we can allow other characters to be before and after the query string + val dbQuery = "%${query.replace(' ', '%')}%" + val pagingSourceFactory = database.reposDao().reposByName(dbQuery).asPagingSourceFactory() + + return Pager( config = PagingConfig(pageSize = NETWORK_PAGE_SIZE), remoteMediator = GithubRemoteMediator( query, @@ -51,7 +58,7 @@ class GithubRepository( database ), pagingSourceFactory = pagingSourceFactory - ) + ).flow } companion object { diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt index 2df9d61b..114630f8 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt @@ -21,7 +21,8 @@ import androidx.room.PrimaryKey @Entity(tableName = "remote_keys") data class RemoteKeys( - @PrimaryKey(autoGenerate = true) + @PrimaryKey + val repoId: Long, val prevKey: Int?, val nextKey: Int? ) diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt index 0657ba14..4cfb2d45 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt @@ -18,8 +18,8 @@ package com.example.android.codelabs.paging.db import androidx.room.Dao import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Transaction /** * Room data access object for accessing the [RemoteKeys] table @@ -27,17 +27,11 @@ import androidx.room.Transaction @Dao interface RemoteKeysDao { - @Insert - suspend fun insert(remoteKey: RemoteKeys) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(remoteKey: List) - @Query("SELECT * FROM remote_keys LIMIT 1") - suspend fun remoteKeys(): RemoteKeys? - - @Transaction - suspend fun replace(remoteKey: RemoteKeys) { - clearRemoteKeys() - insert(remoteKey) - } + @Query("SELECT * FROM remote_keys WHERE repoId = :repoId") + suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys? @Query("DELETE FROM remote_keys") suspend fun clearRemoteKeys() diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt index 1c5c78f9..94107d57 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt @@ -30,13 +30,15 @@ import com.example.android.codelabs.paging.model.Repo interface RepoDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(posts: List) + suspend fun insert(repos: List) // Do a similar query as the search API: // Look for repos that contain the query string in the name or in the description // and order those results descending, by the number of stars and then by name - @Query("SELECT * FROM repos ORDER BY stars DESC, name ASC") - fun reposByName(): DataSource.Factory + @Query("SELECT * FROM repos WHERE " + + "name LIKE :queryString OR description LIKE :queryString " + + "ORDER BY stars DESC, name ASC") + fun reposByName(queryString: String): DataSource.Factory @Query("DELETE FROM repos") suspend fun clearRepos() diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt index 4b354a93..e62f0b39 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt @@ -40,7 +40,7 @@ class ReposAdapter : PagingDataAdapter(UIMODEL_COMPARATOR) return when (getItem(position)) { is UiModel.RepoItem -> R.layout.repo_view_item is UiModel.SeparatorItem -> R.layout.separator_view_item - null -> throw UnsupportedOperationException("Unknown view") + null -> throw UnsupportedOperationException("Unknown view for position $position") } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index 247de382..b43a2c0a 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -77,7 +77,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { adapter.addLoadStateListener { loadType, loadState -> Log.d("SearchRepositoriesActivity", "adapter load: type = $loadType state = $loadState") if (loadType == LoadType.REFRESH) { - binding.list.visibility = toVisibility(loadState == LoadState.Idle) + binding.list.visibility = toVisibility(loadState is LoadState.NotLoading) binding.progressBar.visibility = toVisibility(loadState == LoadState.Loading) binding.retryButton.visibility = toVisibility(loadState is LoadState.Error) } else { @@ -130,7 +130,6 @@ class SearchRepositoriesActivity : AppCompatActivity() { searchJob?.cancel() searchJob = lifecycleScope.launch { viewModel.searchRepo(query).collect { - Log.d("SearchRepositoriesActivity", "query: $query, collecting $it") adapter.presentData(it) } } diff --git a/build.gradle b/build.gradle index 2ecafdbc..0471a6da 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ allprojects { repositories { google() jcenter() - maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6397162/artifacts/repository' } + maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6433189/artifacts/repository' } } } From 87bd8dbbc63b197b088452dd7d1e6b40fc029328 Mon Sep 17 00:00:00 2001 From: Dustin Lam Date: Wed, 29 Apr 2020 19:42:41 -0700 Subject: [PATCH 5/6] Fix RemoteMediator endOfPagination logic --- .../paging/data/GithubRemoteMediator.kt | 18 ++++++++---------- build.gradle | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt index 684c40df..8674226c 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -16,7 +16,6 @@ package com.example.android.codelabs.paging.data -import android.util.Log import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator @@ -63,7 +62,7 @@ class GithubRemoteMediator( val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX } - LoadType.START -> { + LoadType.PREPEND -> { val remoteKeys = getRemoteKeyForFirstItem(state) if (remoteKeys == null) { // The LoadType is START so some data was loaded before, @@ -71,22 +70,21 @@ class GithubRemoteMediator( // If the remoteKeys are null, then we're an invalid state and we have a bug throw InvalidObjectException("Remote key and the prevKey should not be null") } + // If the previous key is null, then we can't request more data - val prevKey = remoteKeys.prevKey - if (prevKey == null) { - return MediatorResult.Success(endOfPaginationReached = false) - } - remoteKeys.prevKey + remoteKeys.prevKey ?: return MediatorResult.Success(endOfPaginationReached = true) } - LoadType.END -> { + LoadType.APPEND -> { val remoteKeys = getRemoteKeyForLastItem(state) - if (remoteKeys?.nextKey == null) { + if (remoteKeys == null) { // The LoadType is END, so some data was loaded before, // so we should have been able to get remote keys // If the remoteKeys are null, then we're an invalid state and we have a bug throw InvalidObjectException("Remote key should not be null for $loadType") } - remoteKeys.nextKey + + // If the next key is null, then we can't request more data + remoteKeys.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) } } diff --git a/build.gradle b/build.gradle index 0471a6da..ed701b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -35,7 +35,7 @@ allprojects { repositories { google() jcenter() - maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6433189/artifacts/repository' } + maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6445242/artifacts/repository' } } } From 326529e3e6b1c8b0ff7c4e1b9d5a28f583564724 Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Thu, 30 Apr 2020 13:24:32 +0100 Subject: [PATCH 6/6] Rename insert DAO method --- .../android/codelabs/paging/data/GithubRemoteMediator.kt | 2 +- .../main/java/com/example/android/codelabs/paging/db/RepoDao.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt index 8674226c..412bc94c 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -107,7 +107,7 @@ class GithubRemoteMediator( RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey) } repoDatabase.remoteKeysDao().insertAll(keys) - repoDatabase.reposDao().insert(repos) + repoDatabase.reposDao().insertAll(repos) } MediatorResult.Success(endOfPaginationReached = canRequestMoreData) } else { diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt index 94107d57..09f38e36 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt @@ -30,7 +30,7 @@ import com.example.android.codelabs.paging.model.Repo interface RepoDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(repos: List) + suspend fun insertAll(repos: List) // Do a similar query as the search API: // Look for repos that contain the query string in the name or in the description