Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 10 additions & 4 deletions app/src/main/java/com/example/android/codelabs/paging/Injection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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))
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<Int, Repo>() {

/**
* 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<Int, Repo>): 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could replace this with a checkNotNull which might be more idiomatic, it throws IllegalStateException which might be more apt here though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this will be obsolete once yigit's change lands.

https://android-review.googlesource.com/c/platform/frameworks/support/+/1295842

Might want to just wait for this.

// 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still gain anything by using the callback call adapter if we still need to check for HttpException and IOException? I feel like we should just use the suspend adapter from Retrofit, since that will likely be more relatable to users who are interested in using Coroutines. Sorry to flip back and forth on this, chris may disagree with me here though and in that case we can just keep this :D

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<Int, Repo>): 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<Int, Repo>): 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<Int, Repo>
): 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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,48 @@
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.
*/
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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?
)
Original file line number Diff line number Diff line change
@@ -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<RemoteKeys>)

@Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

@Query("DELETE FROM remote_keys")
suspend fun clearRemoteKeys()
}
Loading