mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-17 21:27:01 +05:30
Merge #72
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:
commit
f602fe8d7d
23 changed files with 184 additions and 149 deletions
2
.idea/codeStyles/Project.xml
generated
2
.idea/codeStyles/Project.xml
generated
|
@ -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
1
.idea/gradle.xml
generated
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package dev.msfjarvis.lobsters.api
|
||||
package dev.msfjarvis.lobsters.util
|
||||
|
||||
import java.io.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 {
|
||||
|
|
1
lobsters-api/.gitignore
vendored
1
lobsters-api/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
/build
|
|
@ -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)
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="dev.msfjarvis.lobsters.api" />
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
rootProject.name = "lobste.rs"
|
||||
include(":app", ":lobsters-api", ":model")
|
||||
include(":app", ":model")
|
||||
enableFeaturePreview("GRADLE_METADATA")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue