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..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 @@ -20,7 +20,9 @@ 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 +import kotlinx.coroutines.Dispatchers /** * Class that handles object creation. @@ -33,15 +35,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..412bc94c --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -0,0 +1,164 @@ +/* + * 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 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.RemoteKeys +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 + +class GithubRemoteMediator( + private val query: String, + private val service: GithubService, + 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 { + val page = when (loadType) { + LoadType.REFRESH -> { + val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) + remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX + } + LoadType.PREPEND -> { + 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 + remoteKeys.prevKey ?: return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.APPEND -> { + val remoteKeys = getRemoteKeyForLastItem(state) + 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") + } + + // If the next key is null, then we can't request more data + remoteKeys.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) + } + } + + 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() + repoDatabase.withTransaction { + 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().insertAll(repos) + } + MediatorResult.Success(endOfPaginationReached = canRequestMoreData) + } else { + MediatorResult.Error(IOException(apiResponse.message())) + } + } catch (exception: IOException) { + return MediatorResult.Error(exception) + } catch (exception: HttpException) { + 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 2cf1b249..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,20 +17,28 @@ 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. */ @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 * every time we get more data from the network. @@ -38,10 +46,19 @@ class GithubRepository(private val service: GithubService) { fun getSearchResultStream(query: String): Flow> { Log.d("GithubRepository", "New query: $query") - 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), - pagingSourceFactory = { GithubPagingSource(service, query) } - ) + remoteMediator = GithubRemoteMediator( + query, + service, + 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 new file mode 100644 index 00000000..114630f8 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt @@ -0,0 +1,28 @@ +/* + * 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 + 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 new file mode 100644 index 00000000..4cfb2d45 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt @@ -0,0 +1,38 @@ +/* + * 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.OnConflictStrategy +import androidx.room.Query + +/** + * Room data access object for accessing the [RemoteKeys] table + */ +@Dao +interface RemoteKeysDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(remoteKey: List) + + @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 new file mode 100644 index 00000000..09f38e36 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt @@ -0,0 +1,45 @@ +/* + * 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 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 + // and order those results descending, by the number of stars and then by name + @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/db/RepoDatabase.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt new file mode 100644 index 00000000..f70d0424 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt @@ -0,0 +1,54 @@ +/* + * 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, RemoteKeys::class], + version = 1, + exportSchema = false +) +abstract class RepoDatabase : RoomDatabase() { + + abstract fun reposDao(): RepoDao + abstract fun remoteKeysDao(): RemoteKeysDao + + 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/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 8ab04500..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 @@ -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 @@ -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 130aa572..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/6295087/artifacts/repository' } + maven { url 'https://androidx-dev-prod.appspot.com/snapshots/builds/6445242/artifacts/repository' } } }