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")