Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dev.dimension.flare.common
import androidx.annotation.StringRes
import dev.dimension.flare.R
import dev.dimension.flare.data.repository.LoginExpiredException
import dev.dimension.flare.ui.presenter.login.ReloginTarget
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.Single
Expand All @@ -22,6 +23,7 @@ internal sealed interface Notification {
val messageId: Int,
val success: Boolean,
val args: List<Any> = emptyList(),
val reloginTarget: ReloginTarget? = null,
) : Notification
}

Expand Down Expand Up @@ -66,6 +68,15 @@ internal class ComposeInAppNotification : InAppNotification {
null
},
),
reloginTarget =
if (throwable is LoginExpiredException) {
ReloginTarget(
accountKey = throwable.accountKey,
platformType = throwable.platformType,
)
} else {
null
},
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
Expand All @@ -32,8 +33,11 @@ import compose.icons.FontAwesomeIcons
import compose.icons.fontawesomeicons.Solid
import compose.icons.fontawesomeicons.solid.CircleCheck
import compose.icons.fontawesomeicons.solid.CircleExclamation
import dev.dimension.flare.R
import dev.dimension.flare.common.ComposeInAppNotification
import dev.dimension.flare.common.Notification
import dev.dimension.flare.ui.presenter.login.ReloginTarget
import dev.dimension.flare.ui.theme.screenHorizontalPadding
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import kotlin.time.Duration.Companion.milliseconds
Expand All @@ -43,6 +47,7 @@ import kotlin.time.Duration.Companion.seconds
internal fun InAppNotificationComponent(
modifier: Modifier = Modifier,
notification: ComposeInAppNotification = koinInject(),
onRelogin: (ReloginTarget) -> Unit = {},
) {
val source by notification.source.collectAsState()
val content = remember(source) { source.getContentIfNotHandled() }
Expand Down Expand Up @@ -74,7 +79,8 @@ internal fun InAppNotificationComponent(
showNotification,
modifier =
modifier
.systemBarsPadding(),
.systemBarsPadding()
.padding(horizontal = screenHorizontalPadding),
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
) {
Expand Down Expand Up @@ -111,7 +117,20 @@ internal fun InAppNotificationComponent(
Spacer(modifier = Modifier.width(12.dp))
Text(
stringResource(it.messageId, *it.args.toTypedArray()),
modifier = Modifier.weight(1f),
)
it.reloginTarget?.let { target ->
Spacer(modifier = Modifier.width(8.dp))
TextButton(
onClick = {
showText = false
showNotification = false
onRelogin(target)
},
) {
Text(stringResource(R.string.login_expired_relogin))
}
}
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/dev/dimension/flare/ui/route/Route.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import dev.dimension.flare.feature.agent.localhistory.LocalHistoryAgentTarget
import dev.dimension.flare.model.AccountType
import dev.dimension.flare.model.MicroBlogKey
import dev.dimension.flare.ui.model.UiMedia
import dev.dimension.flare.ui.presenter.login.ReloginTarget
import dev.dimension.flare.ui.presenter.login.WebCookieSeed
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.serialization.Serializable
Expand Down Expand Up @@ -202,8 +204,13 @@ internal sealed interface Route : NavKey {
sealed interface ServiceSelect : Route {
data object Selection : ServiceSelect

data class Relogin(
val target: ReloginTarget,
) : ServiceSelect

data class WebCookieLogin(
val url: String,
val initialCookies: List<WebCookieSeed>,
val callback: (cookies: String?) -> Boolean,
) : ServiceSelect
}
Expand Down Expand Up @@ -548,6 +555,15 @@ internal sealed interface Route : NavKey {
ServiceSelect.Selection
}

is DeeplinkRoute.Relogin -> {
ServiceSelect.Relogin(
ReloginTarget(
accountKey = deeplinkRoute.accountKey,
platformType = deeplinkRoute.platformType,
),
)
}

is DeeplinkRoute.Compose.New -> {
Compose.New
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ internal fun AccountSelectionModal(
toLogin = {
navigate(Route.ServiceSelect.Selection)
},
toRelogin = {
navigate(Route.ServiceSelect.Relogin(it))
},
trailingContent = { user ->
state.activeAccount.onSuccess {
RadioButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ internal fun DeepLinkAccountPickerModal(
onNavigate(route)
},
toLogin = {},
toRelogin = {
onDismissRequest()
onNavigate(Route.ServiceSelect.Relogin(it))
},
modifier =
Modifier
.listCard(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,9 @@ internal fun HomeScreen(afterInit: () -> Unit) {
}
InAppNotificationComponent(
modifier = Modifier.align(Alignment.TopCenter),
onRelogin = {
state.navigate(Route.ServiceSelect.Relogin(it))
},
)
}
}.onLoading {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,27 @@ internal fun EntryProviderScope<NavKey>.serviceSelectEntryBuilder(
) {
entry<Route.ServiceSelect.Selection> {
ServiceSelectScreen(
onWebViewLogin = { url, callback ->
onWebViewLogin = { url, initialCookies, callback ->
navigate(
Route.ServiceSelect.WebCookieLogin(
url = url,
initialCookies = initialCookies,
callback = callback,
),
)
},
onBack = onBack,
)
}

entry<Route.ServiceSelect.Relogin> { args ->
ReloginScreen(
target = args.target,
onWebViewLogin = { url, initialCookies, callback ->
navigate(
Route.ServiceSelect.WebCookieLogin(
url = url,
initialCookies = initialCookies,
callback = callback,
),
)
Expand All @@ -25,6 +42,7 @@ internal fun EntryProviderScope<NavKey>.serviceSelectEntryBuilder(
entry<Route.ServiceSelect.WebCookieLogin> { args ->
WebCookieLoginScreen(
url = args.url,
initialCookies = args.initialCookies,
callback = args.callback,
onBack = onBack,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import dev.dimension.flare.ui.common.isLoginCallbackDeepLink
import dev.dimension.flare.ui.component.BackButton
import dev.dimension.flare.ui.component.FlareScaffold
import dev.dimension.flare.ui.component.FlareTopAppBar
import dev.dimension.flare.ui.presenter.login.ReloginTarget
import dev.dimension.flare.ui.presenter.login.WebCookieSeed
import dev.dimension.flare.ui.screen.login.ReloginScreenContent
import dev.dimension.flare.ui.screen.login.ServiceSelectionScreenContent

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ServiceSelectScreen(
onWebViewLogin: (url: String, cookieCallback: (cookies: String?) -> Boolean) -> Unit,
onWebViewLogin: (url: String, initialCookies: List<WebCookieSeed>, cookieCallback: (cookies: String?) -> Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -54,3 +57,46 @@ internal fun ServiceSelectScreen(
)
}
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ReloginScreen(
target: ReloginTarget,
onWebViewLogin: (url: String, initialCookies: List<WebCookieSeed>, cookieCallback: (cookies: String?) -> Boolean) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
FlareScaffold(
modifier =
modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
FlareTopAppBar(
title = {
},
navigationIcon = {
BackButton(onBack = onBack)
},
scrollBehavior = scrollBehavior,
)
},
) { contentPadding ->
ReloginScreenContent(
target = target,
onWebViewLogin = onWebViewLogin,
contentPadding = contentPadding,
openUri = uriHandler::openUri,
registerDeeplinkCallback = { callback ->
OnNewIntent {
val url = it.dataString.orEmpty()
if (url.isLoginCallbackDeepLink()) {
callback.invoke(url)
}
}
},
onBack = onBack,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.dimension.flare.ui.screen.serviceselect

import android.net.Uri
import android.view.ViewGroup.LayoutParams
import android.webkit.CookieManager
import android.webkit.WebSettings
Expand All @@ -16,30 +17,39 @@ import androidx.compose.ui.draw.alpha
import com.kevinnzou.web.WebView
import com.kevinnzou.web.rememberWebViewState
import dev.dimension.flare.ui.component.FlareScaffold
import dev.dimension.flare.ui.presenter.login.WebCookieSeed
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds

private val userAgent =
private val USER_AGENT_HEADERS =
mapOf(
"user-agent" to "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.3",
"User-Agent" to "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.3",
"Pragma" to "no-cache",
"Cache-Control" to "no-cache",
)

private const val ANDROID_USER_AGENT =
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.3"

@Composable
internal fun WebCookieLoginScreen(
url: String,
initialCookies: List<WebCookieSeed>,
callback: (String?) -> Boolean,
onBack: () -> Unit,
) {
val webViewState = rememberWebViewState(url)
LaunchedEffect(url) {
val cookieManager = CookieManager.getInstance()
while (true) {
webViewState.lastLoadedUrl?.let { loadedUrl ->
val cookies =
CookieManager
.getInstance()
.getCookie(loadedUrl)
cookieManager.getCookieHeaderForUrls(
cookieLookupUrls(
loadedUrl = loadedUrl,
originalUrl = url,
),
)
if (callback(cookies)) {
onBack()
break
Expand All @@ -62,17 +72,73 @@ internal fun WebCookieLoginScreen(
.background(MaterialTheme.colorScheme.background)
.padding(it)
.fillMaxSize(),
onCreated = {
onCreated = { webView ->
WebStorage.getInstance().deleteAllData()
CookieManager.getInstance().removeAllCookies(null)
with(it.settings) {
userAgentString = userAgent.toString()
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(webView, true)
with(webView.settings) {
userAgentString = ANDROID_USER_AGENT
javaScriptEnabled = true
domStorageEnabled = true
javaScriptCanOpenWindowsAutomatically = false
cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK
}
cookieManager.removeAllCookies {
initialCookies.forEach { seed ->
cookieManager.setCookie(
seed.urlForCookieStore(),
seed.toAndroidCookieHeader(),
)
}
cookieManager.flush()
webView.loadUrl(url, USER_AGENT_HEADERS)
}
},
)
}
}

private fun cookieLookupUrls(
loadedUrl: String,
originalUrl: String,
): List<String> {
val loadedHost = loadedUrl.hostOrNull()
val originalHost = originalUrl.hostOrNull()
return if (loadedHost != null && originalHost != null && loadedHost.equals(originalHost, ignoreCase = true)) {
listOf(loadedUrl)
} else {
listOf(loadedUrl, originalUrl)
}
}

private fun CookieManager.getCookieHeaderForUrls(urls: List<String>): String? =
urls
.distinct()
.flatMap { url ->
getCookie(url)
?.split(";")
.orEmpty()
}.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("; ")
.takeIf { it.isNotBlank() }

private fun String.hostOrNull(): String? = runCatching { Uri.parse(this).host }.getOrNull()

private fun WebCookieSeed.urlForCookieStore(): String = "https://$domain$path"

private fun WebCookieSeed.toAndroidCookieHeader(): String =
buildString {
append(name)
append("=")
append(value)
append("; Domain=")
append(domain)
append("; Path=")
append(path)
if (secure) {
append("; Secure")
}
}
Loading
Loading