Skip to content

Commit

Permalink
Initial Login Infrastructure (#78)
Browse files Browse the repository at this point in the history
This PR adds one core dependency and a bunch of infrastrucutre to
support login and login-based actions.

Because HN doesn't have official API support for Login, Upvotes,
Commenting, etc, the only way that clients can perform those actions is
by requesting various web routes and scraping the HTML for data.

Added `jsoup` as a dependency to make working with HTML a lot nicer, as
it also allows you to query HTML documents using CSS selectors.

Also created a `WebClient` which is reponsible for all things that
require scraping the Hacker News website.

Lastly I `CookieJar` to our OkHttp client so that it can store the "auth
cookie" when we successfully login. We need this cooking on all
subsequent web client requests in order to get access to actions.
  • Loading branch information
Rahkeen authored Jul 23, 2024
1 parent 827b2ad commit d315a62
Show file tree
Hide file tree
Showing 17 changed files with 694 additions and 65 deletions.
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,18 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.browser)
implementation(libs.androidx.datastore)

implementation(libs.okhttp)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.jsoup)

implementation(libs.androidx.room)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)

implementation(libs.accompanist.webview)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.emergetools.hackernews.data.LocalCustomTabsIntent
import com.emergetools.hackernews.features.bookmarks.BookmarksNavigation
import com.emergetools.hackernews.features.bookmarks.bookmarksRoutes
import com.emergetools.hackernews.features.comments.commentsRoutes
import com.emergetools.hackernews.features.login.loginRoutes
import com.emergetools.hackernews.features.settings.settingsRoutes
import com.emergetools.hackernews.features.stories.Stories
import com.emergetools.hackernews.features.stories.StoriesDestinations.Feed
Expand Down Expand Up @@ -121,7 +122,8 @@ fun App() {
storiesGraph(navController)
commentsRoutes()
bookmarksRoutes(navController)
settingsRoutes()
settingsRoutes(navController)
loginRoutes(navController)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,33 @@ package com.emergetools.hackernews

import android.app.Application
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import androidx.room.Room
import com.emergetools.hackernews.data.BookmarkDao
import com.emergetools.hackernews.data.HackerNewsBaseDataSource
import com.emergetools.hackernews.data.HackerNewsDatabase
import com.emergetools.hackernews.data.HackerNewsSearchClient
import com.emergetools.hackernews.data.HackerNewsWebClient
import com.emergetools.hackernews.data.ItemRepository
import com.emergetools.hackernews.data.LocalCookieJar
import com.emergetools.hackernews.data.UserStorage
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.time.Duration

class HackerNewsApplication: Application() {
private val json = Json { ignoreUnknownKeys = true }
private val httpClient = OkHttpClient.Builder()
.readTimeout(Duration.ofSeconds(30))
.build()

private val baseClient = HackerNewsBaseDataSource(json, httpClient)
val searchClient = HackerNewsSearchClient(json, httpClient)
val itemRepository = ItemRepository(baseClient)
private lateinit var httpClient: OkHttpClient
private lateinit var baseClient: HackerNewsBaseDataSource

lateinit var bookmarkDao: BookmarkDao
lateinit var userStorage: UserStorage
lateinit var searchClient: HackerNewsSearchClient
lateinit var webClient: HackerNewsWebClient
lateinit var itemRepository: ItemRepository

override fun onCreate() {
super.onCreate()
Expand All @@ -32,19 +38,40 @@ class HackerNewsApplication: Application() {
HackerNewsDatabase::class.java,
"hackernews",
).build()

bookmarkDao = db.bookmarkDao()

userStorage = UserStorage(applicationContext)

httpClient = OkHttpClient.Builder()
.readTimeout(Duration.ofSeconds(30))
.cookieJar(LocalCookieJar(userStorage))
.build()

baseClient = HackerNewsBaseDataSource(json, httpClient)
searchClient = HackerNewsSearchClient(json, httpClient)
webClient = HackerNewsWebClient(httpClient)
itemRepository = ItemRepository(baseClient)
}
}

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user")

fun Context.itemRepository(): ItemRepository {
return (this.applicationContext as HackerNewsApplication).itemRepository
}

fun Context.userStorage(): UserStorage {
return (this.applicationContext as HackerNewsApplication).userStorage
}

fun Context.searchClient(): HackerNewsSearchClient {
return (this.applicationContext as HackerNewsApplication).searchClient
}

fun Context.webClient(): HackerNewsWebClient {
return (this.applicationContext as HackerNewsApplication).webClient
}

fun Context.bookmarkDao(): BookmarkDao {
return (this.applicationContext as HackerNewsApplication).bookmarkDao
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.emergetools.hackernews.data

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup

const val BASE_WEB_URL = "https://news.ycombinator.com/"
private const val LOGIN_URL = BASE_WEB_URL + "login"
private const val ITEM_URL = BASE_WEB_URL + "item"

data class ItemPage(
val id: Long,
val upvoted: Boolean,
val upvoteUrl: String
)

enum class LoginResponse {
Success,
Failed
}

class HackerNewsWebClient(
private val httpClient: OkHttpClient,
) {
suspend fun login(username: String, password: String): LoginResponse {
return withContext(Dispatchers.IO) {
val response = httpClient.newCall(
Request.Builder()
.url(LOGIN_URL)
.post(
FormBody.Builder()
.add("acct", username)
.add("pw", password)
.build()
)
.build()
).execute()

val document = Jsoup.parse(response.body?.string()!!)

val body = document.body()
val firstElement = body.firstChild()
val loginFailed = firstElement?.toString()?.contains("Bad login") ?: false

if (loginFailed) {
LoginResponse.Failed
} else {
LoginResponse.Success
}
}
}
suspend fun getItemPage(itemId: Long): ItemPage {
return withContext(Dispatchers.IO) {
// request page
val response = httpClient.newCall(
Request
.Builder()
.url("$ITEM_URL?id=$itemId")
.build()
).execute()

val document = Jsoup.parse(response.body?.string()!!)
val upvoteElement = document.select("#up_$itemId")
val upvoteHref = upvoteElement.attr("href")

ItemPage(
id = itemId,
upvoted = upvoteElement.hasClass("nosee"),
upvoteUrl = BASE_WEB_URL + upvoteHref
)
}
}

suspend fun upvoteItem(url: String): Boolean {
return withContext(Dispatchers.IO) {
val response = httpClient.newCall(
Request.Builder()
.url(url)
.build()
).execute()

response.isSuccessful
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.emergetools.hackernews.data

import android.util.Log
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl

class LocalCookieJar(private val userStorage: UserStorage): CookieJar {

override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
Log.d("Cookie Jar", "Url: $url, cookie = ${cookies[0]}")
cookies.firstOrNull { it.name == "user" }?.let { authCookie ->
runBlocking { userStorage.saveCookie(authCookie.value) }
}
}

override fun loadForRequest(url: HttpUrl): List<Cookie> {
val authCookie = runBlocking { userStorage.getCookie().first() }
Log.d("Cookie Jar", "Cookie: user=$authCookie" )
return if (authCookie != null) {
val cookie = Cookie.Builder()
.name("user")
.value(authCookie)
.domain("news.ycombinator.com")
.build()
listOf(cookie)
} else {
emptyList()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.emergetools.hackernews.data

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.emergetools.hackernews.dataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class UserStorage(private val appContext: Context) {
private val cookieKey = stringPreferencesKey("Cookie")

suspend fun saveCookie(cookie: String) {
appContext.dataStore.edit { store ->
store[cookieKey] = cookie
}
}

fun getCookie(): Flow<String?> {
return appContext.dataStore.data.map { it[cookieKey] }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.emergetools.hackernews.data.HackerNewsSearchClient
import com.emergetools.hackernews.data.HackerNewsWebClient
import com.emergetools.hackernews.data.ItemPage
import com.emergetools.hackernews.data.ItemResponse
import com.emergetools.hackernews.data.relativeTimeStamp
import kotlinx.coroutines.Dispatchers
Expand All @@ -28,13 +30,21 @@ sealed interface CommentsState {
}

data class Content(
val id: Long,
val title: String,
val author: String,
val points: Int,
val text: String?,
val page: ItemPage,
override val comments: List<CommentState>,
): CommentsState {
override val headerState = HeaderState.Content(title, author, points, text)
override val headerState = HeaderState.Content(
title,
author,
points,
page.upvoted,
text,
)
}
}

Expand Down Expand Up @@ -62,14 +72,19 @@ sealed interface HeaderState {
val title: String,
val author: String,
val points: Int,
val body: String?
val upvoted: Boolean,
val body: String?,
): HeaderState
}

sealed interface CommentsAction {
data object LikePostTapped: CommentsAction
}

class CommentsViewModel(
private val itemId: Long,
private val searchClient: HackerNewsSearchClient
private val searchClient: HackerNewsSearchClient,
private val webClient: HackerNewsWebClient
) : ViewModel() {
private val internalState = MutableStateFlow<CommentsState>(CommentsState.Loading)
val state = internalState.asStateFlow()
Expand All @@ -78,17 +93,46 @@ class CommentsViewModel(
viewModelScope.launch {
withContext(Dispatchers.IO) {
val response = searchClient.api.getItem(itemId)
val page = webClient.getItemPage(itemId)
Log.d("CommentsViewModel", "Item Page: $page")
val comments = response.children.map { rootComment ->
rootComment.createCommentState(0)
}
internalState.update {
CommentsState.Content(
id = itemId,
title = response.title ?: "",
author = response.author ?: "",
points = response.points ?: 0,
text = response.text,
comments = comments
page = page,
comments = comments,
)
}
}
}
}

fun actions(action: CommentsAction) {
when (action) {
CommentsAction.LikePostTapped -> {
Log.d("CommentsViewModel", "Post Liked: $itemId")
val current = internalState.value
if (current is CommentsState.Content && !current.page.upvoted && current.page.upvoteUrl.isNotEmpty()) {
// eager ui update
internalState.value = current.copy(
points = current.points + 1,
page = current.page.copy(
upvoted = true
)
)
viewModelScope.launch {
val success = webClient.upvoteItem(current.page.upvoteUrl)
if (success) {
val refreshedPage = webClient.getItemPage(itemId)
Log.d("CommentsViewModel", "Refreshed Item Page: $refreshedPage")
}
}
}
}
}
Expand All @@ -115,10 +159,11 @@ class CommentsViewModel(
@Suppress("UNCHECKED_CAST")
class Factory(
private val itemId: Long,
private val searchClient: HackerNewsSearchClient
private val searchClient: HackerNewsSearchClient,
private val webClient: HackerNewsWebClient,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CommentsViewModel(itemId, searchClient) as T
return CommentsViewModel(itemId, searchClient, webClient) as T
}
}
}
Loading

0 comments on commit d315a62

Please sign in to comment.