diff --git a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt index 4f40ecb3..a8956796 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt @@ -17,7 +17,7 @@ package com.example.android.codelabs.paging.api import android.util.Log -import com.example.android.codelabs.paging.model.RepoSearchResult +import com.example.android.codelabs.paging.data.RepoSearchResult import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor.Level @@ -53,7 +53,7 @@ suspend fun searchRepos( Log.d(TAG, "got a response $response") if (response.isSuccessful) { val repos = response.body()?.items ?: emptyList() - return RepoSearchResult.Success(repos) + return RepoSearchResult.Success(repos, response.body()?.total ?: 0) } else { return RepoSearchResult.Error(IOException(response.message() ?: "Unknown error")) } @@ -82,7 +82,7 @@ interface GithubService { fun create(): GithubService { val logger = HttpLoggingInterceptor() - logger.level = Level.BASIC + logger.level = Level.BODY val client = OkHttpClient.Builder() .addInterceptor(logger) 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 new file mode 100644 index 00000000..4dc22071 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt @@ -0,0 +1,40 @@ +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.searchRepos +import com.example.android.codelabs.paging.model.Repo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview + +@ExperimentalCoroutinesApi +@FlowPreview +class GithubPagingSource( + private val service: GithubService, + private val query: String +) : PagingSource() { + + private var _totalReposCount = 0 + val totalReposCount: Int + get() = _totalReposCount + + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key ?: 0 + val apiResponse = searchRepos(service, query, position, GithubRepository.NETWORK_PAGE_SIZE) + return when (apiResponse) { + is RepoSearchResult.Success -> { + _totalReposCount = apiResponse.totalReposCount + LoadResult.Page( + data = apiResponse.data, + prevKey = if (position == 0) null else -1, + // if we don't get any results, we consider that we're at the last page + nextKey = if (apiResponse.data.isEmpty()) null else position + 1 + ) + } + is RepoSearchResult.Error -> { + _totalReposCount = -1 + LoadResult.Error(apiResponse.error) + } + } + } +} \ No newline at end of file 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 991a2299..e6fc95df 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,15 +17,15 @@ package com.example.android.codelabs.paging.data import android.util.Log +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.api.searchRepos import com.example.android.codelabs.paging.model.Repo -import com.example.android.codelabs.paging.model.RepoSearchResult +import com.example.android.codelabs.paging.model.SearchResult import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.ConflatedBroadcastChannel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow /** * Repository class that works with local and remote data sources. @@ -34,66 +34,21 @@ import kotlinx.coroutines.flow.asFlow @ExperimentalCoroutinesApi class GithubRepository(private val service: GithubService) { - // keep the list of all results received - private val inMemoryCache = mutableListOf() - - // keep channel of results. The channel allows us to broadcast updates so - // the subscriber will have the latest data - private val searchResults = ConflatedBroadcastChannel() - - // keep the last requested page. When the request is successful, increment the page number. - private var lastRequestedPage = 1 - - // avoid triggering multiple requests in the same time - private var isRequestInProgress = false - /** * 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. */ - suspend fun getSearchResultStream(query: String): Flow { + fun getSearchResultStream(query: String): SearchResult { Log.d("GithubRepository", "New query: $query") - lastRequestedPage = 1 - requestAndSaveData(query) - - return searchResults.asFlow() - } - - suspend fun requestMore(query: String) { - requestAndSaveData(query) - } - - private suspend fun requestAndSaveData(query: String) { - if (isRequestInProgress) return - - isRequestInProgress = true - val apiResponse = searchRepos(service, query, lastRequestedPage, NETWORK_PAGE_SIZE) - Log.d("GithubRepository", "response $apiResponse") - // add the new result list to the existing list - when (apiResponse) { - is RepoSearchResult.Success -> { - inMemoryCache.addAll(apiResponse.data) - val reposByName = reposByName(query) - searchResults.offer(RepoSearchResult.Success(reposByName)) - } - is RepoSearchResult.Error -> { - searchResults.offer(RepoSearchResult.Error(apiResponse.error)) - } - } - lastRequestedPage++ - isRequestInProgress = false - } - private fun reposByName(query: String): List { - // from the in memory cache select only the repos whose name or description matches - // the query. Then order the results. - return inMemoryCache.filter { - it.name.contains(query, true) || - (it.description != null && it.description.contains(query, true)) - }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) + val pagingSource = GithubPagingSource(service, query) + return SearchResult(PagingDataFlow( + config = PagingConfig(pageSize = NETWORK_PAGE_SIZE), + pagingSourceFactory = { pagingSource } + ), pagingSource.totalReposCount) } companion object { - private const val NETWORK_PAGE_SIZE = 50 + const val NETWORK_PAGE_SIZE = 50 } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt b/app/src/main/java/com/example/android/codelabs/paging/data/RepoSearchResult.kt similarity index 81% rename from app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt rename to app/src/main/java/com/example/android/codelabs/paging/data/RepoSearchResult.kt index d00c1f07..77c24336 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/RepoSearchResult.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.example.android.codelabs.paging.model +package com.example.android.codelabs.paging.data +import com.example.android.codelabs.paging.model.Repo import java.lang.Exception /** @@ -23,6 +24,6 @@ import java.lang.Exception * and a String of network error state. */ sealed class RepoSearchResult { - data class Success(val data: List) : RepoSearchResult() + data class Success(val data: List, val totalReposCount: Int) : RepoSearchResult() data class Error(val error: Exception) : RepoSearchResult() } diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/SearchResult.kt b/app/src/main/java/com/example/android/codelabs/paging/model/SearchResult.kt new file mode 100644 index 00000000..bf4cd070 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/model/SearchResult.kt @@ -0,0 +1,6 @@ +package com.example.android.codelabs.paging.model + +import androidx.paging.PagingData +import kotlinx.coroutines.flow.Flow + +data class SearchResult(val pagingDataFlow: Flow>, val totalResultCount: Int) \ No newline at end of file diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/HeaderViewHolder.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/HeaderViewHolder.kt new file mode 100644 index 00000000..52d8d880 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/HeaderViewHolder.kt @@ -0,0 +1,40 @@ +package com.example.android.codelabs.paging.ui + +/* + * 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. + */ + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.android.codelabs.paging.R + +class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val description: TextView = view.findViewById(R.id.separator_description) + + fun bind(separatorText: String) { + description.text = separatorText + } + + companion object { + fun create(parent: ViewGroup): HeaderViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.header_view_item, parent, false) + return HeaderViewHolder(view) + } + } +} \ No newline at end of file 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 d959d265..7523214f 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 @@ -17,32 +17,63 @@ package com.example.android.codelabs.paging.ui import android.view.ViewGroup +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.example.android.codelabs.paging.model.Repo +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.example.android.codelabs.paging.R +import java.lang.UnsupportedOperationException /** * Adapter for the list of repositories. */ -class ReposAdapter : ListAdapter(REPO_COMPARATOR) { +class ReposAdapter : PagingDataAdapter(UIMODEL_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { - return RepoViewHolder.create(parent) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + R.layout.repo_view_item -> { + RepoViewHolder.create(parent) + } + R.layout.separator_view_item -> { + SeparatorViewHolder.create(parent) + } + R.layout.header_view_item -> { + HeaderViewHolder.create(parent) + } + else -> throw UnsupportedOperationException("Can't handle view type $viewType") + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is UiModel.RepoItem -> R.layout.repo_view_item + is UiModel.SeparatorItem -> R.layout.separator_view_item + is UiModel.HeaderItem -> R.layout.header_view_item + null -> throw UnsupportedOperationException("Can't handle null items") + } } - override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { - val repoItem = getItem(position) - if (repoItem != null) { - (holder as RepoViewHolder).bind(repoItem) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val uiModel = getItem(position) + uiModel.let { + when (uiModel) { + is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo) + is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description) + is UiModel.HeaderItem -> (holder as HeaderViewHolder).bind(uiModel.description) + } } } companion object { - private val REPO_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean = - oldItem.fullName == newItem.fullName + private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean { + return if (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem) { + oldItem.repo.fullName == newItem.repo.fullName + } else { + false + } + } - override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean = + override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean = oldItem == newItem } } 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 bef1f07b..0602dabf 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 @@ -17,22 +17,23 @@ package com.example.android.codelabs.paging.ui import android.os.Bundle +import android.util.Log import android.view.KeyEvent import android.view.View import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.observe +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.OnScrollListener import com.example.android.codelabs.paging.Injection import com.example.android.codelabs.paging.databinding.ActivitySearchRepositoriesBinding -import com.example.android.codelabs.paging.model.RepoSearchResult import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch @FlowPreview @ExperimentalCoroutinesApi @@ -41,6 +42,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { private lateinit var binding: ActivitySearchRepositoriesBinding private lateinit var viewModel: SearchRepositoriesViewModel private val adapter = ReposAdapter() + private var searchJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -55,11 +57,20 @@ class SearchRepositoriesActivity : AppCompatActivity() { // add dividers between RecyclerView's row items val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) binding.list.addItemDecoration(decoration) - setupScrollListener() - initAdapter() + binding.list.adapter = adapter + adapter.addLoadStateListener { loadType, loadState -> + Log.d("SearchRepositoriesActivity", "adapter load: type = $loadType state = $loadState") + if (loadState is LoadState.Error) { + Toast.makeText( + this, + "\uD83D\uDE28 Wooops $loadState.message}", + Toast.LENGTH_LONG + ).show() + } + } val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY - viewModel.searchRepo(query) + search(query) initSearch(query) } @@ -68,25 +79,6 @@ class SearchRepositoriesActivity : AppCompatActivity() { outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString()) } - private fun initAdapter() { - binding.list.adapter = adapter - viewModel.repoResult.observe(this) { result -> - when (result) { - is RepoSearchResult.Success -> { - showEmptyList(result.data.isEmpty()) - adapter.submitList(result.data) - } - is RepoSearchResult.Error -> { - Toast.makeText( - this, - "\uD83D\uDE28 Wooops $result.message}", - Toast.LENGTH_LONG - ).show() - } - } - } - } - private fun initSearch(query: String) { binding.searchRepo.setText(query) @@ -112,8 +104,21 @@ class SearchRepositoriesActivity : AppCompatActivity() { binding.searchRepo.text.trim().let { if (it.isNotEmpty()) { binding.list.scrollToPosition(0) - viewModel.searchRepo(it.toString()) - adapter.submitList(null) + search(it.toString()) + // TODO how to clear the list + // might not need it because of how Paging works +// adapter.collectFrom(PagingData.empty()) + } + } + } + + private fun search(query: String) { + // Make sure we cancel the previous job before creating a new one + searchJob?.cancel() + searchJob = lifecycleScope.launch { + viewModel.searchRepo(query).collect { + Log.d("SearchRepositoriesActivity", "query: $query, collecting $it") + adapter.collectFrom(it) } } } @@ -128,20 +133,6 @@ class SearchRepositoriesActivity : AppCompatActivity() { } } - private fun setupScrollListener() { - val layoutManager = binding.list.layoutManager as LinearLayoutManager - binding.list.addOnScrollListener(object : OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - val totalItemCount = layoutManager.itemCount - val visibleItemCount = layoutManager.childCount - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - - viewModel.listScrolled(visibleItemCount, lastVisibleItem, totalItemCount) - } - }) - } - companion object { private const val LAST_SEARCH_QUERY: String = "last_search_query" private const val DEFAULT_QUERY = "Android" diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt index e7f893a3..31bfd49c 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt @@ -16,10 +16,16 @@ package com.example.android.codelabs.paging.ui -import androidx.lifecycle.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import com.example.android.codelabs.paging.data.GithubRepository -import com.example.android.codelabs.paging.model.RepoSearchResult -import kotlinx.coroutines.* +import com.example.android.codelabs.paging.model.Repo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** * ViewModel for the [SearchRepositoriesActivity] screen. @@ -29,33 +35,46 @@ import kotlinx.coroutines.* @FlowPreview class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() { - companion object { - private const val VISIBLE_THRESHOLD = 5 - } - - private val queryLiveData = MutableLiveData() - val repoResult: LiveData = queryLiveData.switchMap { - liveData { - val repos = repository.getSearchResultStream(it).asLiveData(Dispatchers.Main) - emitSource(repos) - } - } + @Volatile + private var lastQueryValue: String? = null + @Volatile + private var lastSearchResult: Flow>? = null /** * Search a repository based on a query string. */ - fun searchRepo(queryString: String) { - queryLiveData.postValue(queryString) - } - - fun listScrolled(visibleItemCount: Int, lastVisibleItemPosition: Int, totalItemCount: Int) { - if (visibleItemCount + lastVisibleItemPosition + VISIBLE_THRESHOLD >= totalItemCount) { - val immutableQuery = queryLiveData.value - if (immutableQuery != null) { - viewModelScope.launch { - repository.requestMore(immutableQuery) - } - } + fun searchRepo(queryString: String): Flow> { + val result = lastSearchResult + if (queryString == lastQueryValue && result != null) { + return result } + lastQueryValue = queryString + val searchResult = repository.getSearchResultStream(queryString) + val newResult: Flow> = searchResult.pagingDataFlow + .map { pagingData -> pagingData.map { UiModel.RepoItem(it) as UiModel } } + .map { + it.insertSeparators { before, after -> + if (before == null && after is UiModel.RepoItem) { + UiModel.SeparatorItem("${after.repo.stars / 10_000}0.000+ stars") + } + if (before is UiModel.RepoItem && after is UiModel.RepoItem + && before.repo.stars / 10_000 > after.repo.stars / 10_000) { + UiModel.SeparatorItem("${after.repo.stars / 10_000}0.000+ stars") + } else { + // no separator + null + } + } + it.addHeader(UiModel.HeaderItem("Total repositories: ${searchResult.totalResultCount}")) + } + .cachedIn(viewModelScope) + lastSearchResult = newResult + return newResult } +} + +sealed class UiModel { + data class RepoItem(val repo: Repo) : UiModel() + data class SeparatorItem(val description: String) : UiModel() + data class HeaderItem(val description: String) : UiModel() } \ No newline at end of file diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt new file mode 100644 index 00000000..5283b397 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt @@ -0,0 +1,40 @@ +package com.example.android.codelabs.paging.ui + +/* + * 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. + */ + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.android.codelabs.paging.R + +class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val description: TextView = view.findViewById(R.id.separator_description) + + fun bind(separatorText: String) { + description.text = separatorText + } + + companion object { + fun create(parent: ViewGroup): SeparatorViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.separator_view_item, parent, false) + return SeparatorViewHolder(view) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/header_view_item.xml b/app/src/main/res/layout/header_view_item.xml new file mode 100644 index 00000000..417098b3 --- /dev/null +++ b/app/src/main/res/layout/header_view_item.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/separator_view_item.xml b/app/src/main/res/layout/separator_view_item.xml new file mode 100644 index 00000000..fdae76f4 --- /dev/null +++ b/app/src/main/res/layout/separator_view_item.xml @@ -0,0 +1,34 @@ + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3a48c031..db02f375 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ allprojects { repositories { google() jcenter() + maven { url 'https://ci.android.com/builds/submitted/6229151/androidx_snapshot/latest/repository' } } } @@ -51,7 +52,7 @@ ext { materialVersion = '1.1.0' archComponentsVersion = '2.2.0' roomVersion = '2.2.4' - pagingVersion = '2.1.0-alpha01' + pagingVersion = '3.0.0-SNAPSHOT' retrofitVersion = '2.6.0' okhttpLoggingInterceptorVersion = '4.0.0' coroutines = '1.3.0'