diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 2cbc39d5..d6efc561 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -6,7 +6,7 @@
-
+
-
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6621c1d7..bbb621e9 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index f1e70065..24901530 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -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
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/data/api/KtorLobstersApi.kt b/app/src/main/java/dev/msfjarvis/lobsters/data/api/KtorLobstersApi.kt
new file mode 100644
index 00000000..024a3a51
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/lobsters/data/api/KtorLobstersApi.kt
@@ -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 {
+ return client.get("${BASE_URL}/hottest.json?page=$page")
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/data/api/LobstersApi.kt b/app/src/main/java/dev/msfjarvis/lobsters/data/api/LobstersApi.kt
new file mode 100644
index 00000000..92651473
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/lobsters/data/api/LobstersApi.kt
@@ -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
+
+ companion object {
+ const val BASE_URL = "https://lobste.rs"
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/injection/ApiModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/injection/ApiModule.kt
deleted file mode 100644
index 6f26ebff..00000000
--- a/app/src/main/java/dev/msfjarvis/lobsters/injection/ApiModule.kt
+++ /dev/null
@@ -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)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/injection/KtorApiModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/injection/KtorApiModule.kt
new file mode 100644
index 00000000..e4684add
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/lobsters/injection/KtorApiModule.kt
@@ -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
+}
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/injection/KtorClientModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/injection/KtorClientModule.kt
new file mode 100644
index 00000000..ce801075
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/lobsters/injection/KtorClientModule.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/injection/UrlLauncherModule.kt b/app/src/main/java/dev/msfjarvis/lobsters/injection/UrlLauncherModule.kt
index 49f28c6d..21cc10eb 100644
--- a/app/src/main/java/dev/msfjarvis/lobsters/injection/UrlLauncherModule.kt
+++ b/app/src/main/java/dev/msfjarvis/lobsters/injection/UrlLauncherModule.kt
@@ -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 {
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/LobstersItem.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/LobstersItem.kt
index 8eac2e0f..818e9d5b 100644
--- a/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/LobstersItem.kt
+++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/posts/LobstersItem.kt
@@ -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())
diff --git a/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt b/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt
index 76c59379..5959f3f0 100644
--- a/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt
+++ b/app/src/main/java/dev/msfjarvis/lobsters/ui/viewmodel/LobstersViewModel.kt
@@ -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()
diff --git a/app/src/test/java/dev/msfjarvis/lobsters/data/api/KtorLobstersApiTest.kt b/app/src/test/java/dev/msfjarvis/lobsters/data/api/KtorLobstersApiTest.kt
new file mode 100644
index 00000000..c7fa3f3e
--- /dev/null
+++ b/app/src/test/java/dev/msfjarvis/lobsters/data/api/KtorLobstersApiTest.kt
@@ -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)
+ }
+}
diff --git a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt b/app/src/test/java/dev/msfjarvis/lobsters/util/TestUtils.kt
similarity index 86%
rename from lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt
rename to app/src/test/java/dev/msfjarvis/lobsters/util/TestUtils.kt
index 7c856a9b..b64d4556 100644
--- a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/TestUtils.kt
+++ b/app/src/test/java/dev/msfjarvis/lobsters/util/TestUtils.kt
@@ -1,4 +1,4 @@
-package dev.msfjarvis.lobsters.api
+package dev.msfjarvis.lobsters.util
import java.io.File
diff --git a/lobsters-api/src/test/resources/hottest.json b/app/src/test/resources/hottest.json
similarity index 100%
rename from lobsters-api/src/test/resources/hottest.json
rename to app/src/test/resources/hottest.json
diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt
index baeb360e..53835ef6 100644
--- a/buildSrc/src/main/java/Dependencies.kt
+++ b/buildSrc/src/main/java/Dependencies.kt
@@ -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 {
diff --git a/lobsters-api/.gitignore b/lobsters-api/.gitignore
deleted file mode 100644
index 42afabfd..00000000
--- a/lobsters-api/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/build
\ No newline at end of file
diff --git a/lobsters-api/build.gradle.kts b/lobsters-api/build.gradle.kts
deleted file mode 100644
index 08106d0f..00000000
--- a/lobsters-api/build.gradle.kts
+++ /dev/null
@@ -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)
-}
diff --git a/lobsters-api/src/main/AndroidManifest.xml b/lobsters-api/src/main/AndroidManifest.xml
deleted file mode 100644
index fce8aad8..00000000
--- a/lobsters-api/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
diff --git a/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/ApiClient.kt b/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/ApiClient.kt
deleted file mode 100644
index 3922f16a..00000000
--- a/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/ApiClient.kt
+++ /dev/null
@@ -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 getClient(baseUrl: String): T {
- return Retrofit.Builder()
- .baseUrl(baseUrl)
- .addConverterFactory(Json.asConverterFactory(MediaType.get("application/json")))
- .build()
- .create(T::class.java)
- }
-}
diff --git a/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt b/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt
deleted file mode 100644
index 4a6d1621..00000000
--- a/lobsters-api/src/main/java/dev/msfjarvis/lobsters/api/LobstersApi.kt
+++ /dev/null
@@ -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
-}
diff --git a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt b/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt
deleted file mode 100644
index bd2e19d7..00000000
--- a/lobsters-api/src/test/java/dev/msfjarvis/lobsters/api/LobstersApiTest.kt
+++ /dev/null
@@ -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("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()
- }
-}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1f24fb01..26accb08 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,3 +1,3 @@
rootProject.name = "lobste.rs"
-include(":app", ":lobsters-api", ":model")
+include(":app", ":model")
enableFeaturePreview("GRADLE_METADATA")