72: Migrate to Ktor for network operations r=msfjarvis a=msfjarvis

~~Still a work in progress, crashes during loading right now~~ Crashes are fixed and tests were added back, good to go now.

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
bors[bot] 2020-11-08 11:35:41 +00:00 committed by GitHub
commit f602fe8d7d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 184 additions and 149 deletions

View file

@ -6,7 +6,7 @@
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="false" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">

1
.idea/gradle.xml generated
View file

@ -13,7 +13,6 @@
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/buildSrc" />
<option value="$PROJECT_DIR$/lobsters-api" />
<option value="$PROJECT_DIR$/model" />
</set>
</option>

View file

@ -61,41 +61,45 @@ android {
dependencies {
kapt(Dependencies.ThirdParty.Roomigrant.compiler)
kapt(Dependencies.AndroidX.Hilt.daggerCompiler)
kapt(Dependencies.AndroidX.Hilt.daggerHiltCompiler)
kapt(Dependencies.AndroidX.Room.compiler)
implementation(project(":lobsters-api"))
kapt(Dependencies.ThirdParty.Roomigrant.compiler)
implementation(project(":model"))
implementation(Dependencies.AndroidX.coreKtx)
implementation(Dependencies.AndroidX.activityKtx)
implementation(Dependencies.AndroidX.appCompat)
implementation(Dependencies.AndroidX.browser)
implementation(Dependencies.AndroidX.coreKtx)
implementation(Dependencies.AndroidX.material)
implementation(Dependencies.AndroidX.Compose.compiler)
implementation(Dependencies.AndroidX.Compose.foundation)
implementation(Dependencies.AndroidX.Compose.foundationLayout)
implementation(Dependencies.AndroidX.Compose.foundationText)
implementation(Dependencies.AndroidX.Compose.runtime)
implementation(Dependencies.AndroidX.Compose.material)
implementation(Dependencies.AndroidX.Compose.compiler)
implementation(Dependencies.AndroidX.Compose.navigation)
implementation(Dependencies.AndroidX.Compose.runtime)
implementation(Dependencies.AndroidX.Compose.ui)
implementation(Dependencies.AndroidX.Compose.uiTooling)
implementation(Dependencies.AndroidX.Compose.uiText)
implementation(Dependencies.AndroidX.Compose.uiTextAndroid)
implementation(Dependencies.AndroidX.Compose.uiUnit)
implementation(Dependencies.AndroidX.Hilt.dagger)
implementation(Dependencies.AndroidX.Hilt.hiltLifecycleViewmodel)
implementation(Dependencies.AndroidX.Lifecycle.runtimeKtx)
implementation(Dependencies.AndroidX.Lifecycle.viewmodelKtx)
implementation(Dependencies.AndroidX.Compose.navigation)
implementation(Dependencies.AndroidX.Room.runtime)
implementation(Dependencies.AndroidX.Room.ktx)
implementation(Dependencies.ThirdParty.Roomigrant.runtime)
implementation(Dependencies.AndroidX.material)
implementation(Dependencies.AndroidX.Hilt.dagger)
implementation(Dependencies.ThirdParty.accompanist)
implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.Kotlin.Ktor.clientCore)
implementation(Dependencies.Kotlin.Ktor.clientJson)
implementation(Dependencies.Kotlin.Ktor.clientOkHttp)
implementation(Dependencies.Kotlin.Ktor.clientSerialization)
implementation(Dependencies.Kotlin.Serialization.json)
implementation(Dependencies.ThirdParty.accompanist)
implementation(Dependencies.ThirdParty.customtabs)
androidTestImplementation(Dependencies.Testing.daggerHilt)
implementation(Dependencies.ThirdParty.Roomigrant.runtime)
testImplementation(Dependencies.Testing.junit)
testImplementation(Dependencies.Kotlin.Ktor.clientTest)
androidTestImplementation(Dependencies.Testing.daggerHilt)
androidTestImplementation(Dependencies.Testing.uiTest)
}

View file

@ -8,3 +8,7 @@
-keepclasseswithmembers class dev.msfjarvis.lobsters.model.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Inline-based optimizations cause reflection to fail within Ktor (from what I can tell), so we turn
# this off for now.
-dontoptimize

View file

@ -0,0 +1,16 @@
package dev.msfjarvis.lobsters.data.api
import dev.msfjarvis.lobsters.data.api.LobstersApi.Companion.BASE_URL
import dev.msfjarvis.lobsters.model.LobstersPost
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import javax.inject.Inject
/**
* Ktor backed implementation of [LobstersApi]
*/
class KtorLobstersApi @Inject constructor(private val client: HttpClient) : LobstersApi {
override suspend fun getHottestPosts(page: Int): List<LobstersPost> {
return client.get("${BASE_URL}/hottest.json?page=$page")
}
}

View file

@ -0,0 +1,15 @@
package dev.msfjarvis.lobsters.data.api
import dev.msfjarvis.lobsters.model.LobstersPost
/**
* Simple interface defining an API for lobste.rs
*/
interface LobstersApi {
suspend fun getHottestPosts(page: Int): List<LobstersPost>
companion object {
const val BASE_URL = "https://lobste.rs"
}
}

View file

@ -1,19 +0,0 @@
package dev.msfjarvis.lobsters.injection
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dev.msfjarvis.lobsters.api.ApiClient
import dev.msfjarvis.lobsters.api.LobstersApi
@InstallIn(ActivityComponent::class)
@Module
object ApiModule {
const val LOBSTERS_URL = "https://lobste.rs"
@Provides
fun provideLobstersApi(): LobstersApi {
return ApiClient.getClient(LOBSTERS_URL)
}
}

View file

@ -0,0 +1,14 @@
package dev.msfjarvis.lobsters.injection
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dev.msfjarvis.lobsters.data.api.KtorLobstersApi
import dev.msfjarvis.lobsters.data.api.LobstersApi
@Module
@InstallIn(ActivityComponent::class)
abstract class KtorApiModule {
@Binds abstract fun bindLobstersApi(realApi: KtorLobstersApi): LobstersApi
}

View file

@ -0,0 +1,26 @@
package dev.msfjarvis.lobsters.injection
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.features.json.serializer.KotlinxSerializer
@Module
@InstallIn(ApplicationComponent::class)
object KtorClientModule {
@Provides
fun provideClient() = HttpClient(OkHttp) {
install(JsonFeature) {
serializer = KotlinxSerializer()
}
engine {
config {
followSslRedirects(true)
}
}
}
}

View file

@ -9,8 +9,8 @@ import dagger.hilt.android.qualifiers.ActivityContext
import dev.msfjarvis.lobsters.ui.urllauncher.UrlLauncher
import dev.msfjarvis.lobsters.ui.urllauncher.UrlLauncherImpl
@InstallIn(ActivityComponent::class)
@Module
@InstallIn(ActivityComponent::class)
object UrlLauncherModule {
@Provides
fun provideUrlLauncher(@ActivityContext context: Context): UrlLauncher {

View file

@ -28,7 +28,6 @@ import androidx.compose.ui.unit.dp
import androidx.ui.tooling.preview.Preview
import coil.transform.CircleCropTransformation
import dev.chrisbanes.accompanist.coil.CoilImage
import dev.msfjarvis.lobsters.injection.ApiModule
import dev.msfjarvis.lobsters.model.LobstersPost
import dev.msfjarvis.lobsters.model.Submitter
import dev.msfjarvis.lobsters.ui.theme.LobstersTheme
@ -96,7 +95,7 @@ fun LazyItemScope.LobstersItem(
modifier = Modifier.wrapContentHeight(),
) {
CoilImage(
data = "${ApiModule.LOBSTERS_URL}/${post.submitterUser.avatarUrl}",
data = "https://lobste.rs/${post.submitterUser.avatarUrl}",
fadeIn = true,
requestBuilder = {
transformations(CircleCropTransformation())

View file

@ -3,7 +3,7 @@ package dev.msfjarvis.lobsters.ui.viewmodel
import androidx.hilt.lifecycle.ViewModelInject
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.msfjarvis.lobsters.api.LobstersApi
import dev.msfjarvis.lobsters.data.api.LobstersApi
import dev.msfjarvis.lobsters.data.source.PostsDatabase
import dev.msfjarvis.lobsters.model.LobstersPost
import kotlinx.coroutines.CoroutineExceptionHandler
@ -57,7 +57,6 @@ class LobstersViewModel @ViewModelInject constructor(
private fun getMorePostsInternal(firstLoad: Boolean) {
viewModelScope.launch(coroutineExceptionHandler) {
val newPosts = lobstersApi.getHottestPosts(apiPage)
.toList()
if (firstLoad) {
_posts.value = newPosts
postsDao.deleteAllPosts()

View file

@ -0,0 +1,78 @@
package dev.msfjarvis.lobsters.data.api
import dev.msfjarvis.lobsters.util.TestUtils
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.features.json.serializer.KotlinxSerializer
import io.ktor.http.fullPath
import io.ktor.http.headersOf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import kotlinx.coroutines.runBlocking
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.Test
class KtorLobstersApiTest {
companion object {
@JvmStatic
private lateinit var client: HttpClient
@JvmStatic
private lateinit var apiClient: LobstersApi
@JvmStatic
@BeforeClass
fun setUp() {
client = HttpClient(MockEngine) {
install(JsonFeature) {
serializer = KotlinxSerializer()
}
engine {
addHandler { request ->
when (request.url.fullPath) {
"/hottest.json?page=1" -> {
val responseHeaders = headersOf("Content-Type" to listOf("application/json"))
respond(TestUtils.getJson("hottest.json"), headers = responseHeaders)
}
else -> error("Unhandled ${request.url.fullPath}")
}
}
}
}
apiClient = KtorLobstersApi(client)
}
@JvmStatic
@AfterClass
fun tearDown() {
client.close()
}
}
@Test
fun `api gets correct number of items`() = runBlocking {
val posts = apiClient.getHottestPosts(1)
assertEquals(25, posts.size)
}
@Test
fun `no moderator posts in test data`() = runBlocking {
val posts = apiClient.getHottestPosts(1)
val moderatorPosts = posts.asSequence()
.filter { it.submitterUser.isModerator }
.toSet()
assertTrue(moderatorPosts.isEmpty())
}
@Test
fun `posts with no urls`() = runBlocking {
val posts = apiClient.getHottestPosts(1)
val commentsOnlyPosts = posts.asSequence()
.filter { it.url.isEmpty() }
.toSet()
assertEquals(2, commentsOnlyPosts.size)
}
}

View file

@ -1,4 +1,4 @@
package dev.msfjarvis.lobsters.api
package dev.msfjarvis.lobsters.util
import java.io.File

View file

@ -17,6 +17,16 @@ object Dependencies {
const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
}
object Ktor {
private const val version = "1.4.0"
const val clientCore = "io.ktor:ktor-client-core:$version"
const val clientJson = "io.ktor:ktor-client-json:$version"
const val clientSerialization = "io.ktor:ktor-client-serialization:$version"
const val clientOkHttp = "io.ktor:ktor-client-okhttp:$version"
const val clientTest = "io.ktor:ktor-client-mock:$version"
}
object Serialization {
private const val version = "1.0.1"
@ -77,13 +87,6 @@ object Dependencies {
const val accompanist = "dev.chrisbanes.accompanist:accompanist-coil:0.3.2"
const val customtabs = "saschpe.android:customtabs:3.0.2"
const val retrofitSerialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
object Retrofit {
private const val version = "2.9.0"
const val lib = "com.squareup.retrofit2:retrofit:$version"
}
object Roomigrant {
@ -98,7 +101,6 @@ object Dependencies {
const val daggerHilt = "com.google.dagger:hilt-android-testing:$DAGGER_HILT_VERSION"
const val junit = "junit:junit:4.13.1"
const val mockWebServer = "com.squareup.okhttp3:mockwebserver:3.14.9"
const val uiTest = "androidx.ui:ui-test:$COMPOSE_VERSION"
object AndroidX {

View file

@ -1 +0,0 @@
/build

View file

@ -1,16 +0,0 @@
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization") version "1.4.10"
`lobsters-plugin`
}
dependencies {
implementation(project(":model"))
implementation(Dependencies.Kotlin.Serialization.json)
implementation(Dependencies.ThirdParty.Retrofit.lib)
implementation(Dependencies.ThirdParty.retrofitSerialization)
testImplementation(Dependencies.Testing.junit)
testImplementation(Dependencies.Kotlin.Coroutines.core)
testImplementation(Dependencies.Testing.mockWebServer)
}

View file

@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="dev.msfjarvis.lobsters.api" />

View file

@ -1,16 +0,0 @@
package dev.msfjarvis.lobsters.api
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType
import retrofit2.Retrofit
object ApiClient {
inline fun <reified T> getClient(baseUrl: String): T {
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(Json.asConverterFactory(MediaType.get("application/json")))
.build()
.create(T::class.java)
}
}

View file

@ -1,10 +0,0 @@
package dev.msfjarvis.lobsters.api
import dev.msfjarvis.lobsters.model.LobstersPost
import retrofit2.http.GET
import retrofit2.http.Query
interface LobstersApi {
@GET("hottest.json")
suspend fun getHottestPosts(@Query("page") page: Int): List<LobstersPost>
}

View file

@ -1,57 +0,0 @@
package dev.msfjarvis.lobsters.api
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class LobstersApiTest {
private val webServer = MockWebServer()
private val apiData = TestUtils.getJson("hottest.json")
private val apiClient = ApiClient.getClient<LobstersApi>("http://localhost:8080")
@Before
fun setUp() {
webServer.start(8080)
webServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().setBody(apiData).setResponseCode(200)
}
}
}
@Test
fun `api gets correct number of items`() = runBlocking {
val posts = apiClient.getHottestPosts(1)
assertEquals(25, posts.size)
}
@Test
fun `no moderator posts in test data`() = runBlocking {
val posts = apiClient.getHottestPosts(1)
val moderatorPosts = posts.asSequence()
.filter { it.submitterUser.isModerator }
.toSet()
assertTrue(moderatorPosts.isEmpty())
}
@Test
fun `posts with no urls`() = runBlocking {
val posts = apiClient.getHottestPosts(1)
val commentsOnlyPosts = posts.asSequence()
.filter { it.url.isEmpty() }
.toSet()
assertEquals(2, commentsOnlyPosts.size)
}
@After
fun tearDown() {
webServer.shutdown()
}
}

View file

@ -1,3 +1,3 @@
rootProject.name = "lobste.rs"
include(":app", ":lobsters-api", ":model")
include(":app", ":model")
enableFeaturePreview("GRADLE_METADATA")