Skip to content

Commit a2e47cd

Browse files
authored
Merge pull request #2267 from DimensionDev/feature/relogin-flow
Feature/relogin flow
2 parents 9ff5611 + af4eb2c commit a2e47cd

51 files changed

Lines changed: 1492 additions & 150 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dev.dimension.flare.common
33
import androidx.annotation.StringRes
44
import dev.dimension.flare.R
55
import dev.dimension.flare.data.repository.LoginExpiredException
6+
import dev.dimension.flare.ui.presenter.login.ReloginTarget
67
import kotlinx.coroutines.flow.MutableStateFlow
78
import kotlinx.coroutines.flow.asStateFlow
89
import org.koin.core.annotation.Single
@@ -22,6 +23,7 @@ internal sealed interface Notification {
2223
val messageId: Int,
2324
val success: Boolean,
2425
val args: List<Any> = emptyList(),
26+
val reloginTarget: ReloginTarget? = null,
2527
) : Notification
2628
}
2729

@@ -66,6 +68,15 @@ internal class ComposeInAppNotification : InAppNotification {
6668
null
6769
},
6870
),
71+
reloginTarget =
72+
if (throwable is LoginExpiredException) {
73+
ReloginTarget(
74+
accountKey = throwable.accountKey,
75+
platformType = throwable.platformType,
76+
)
77+
} else {
78+
null
79+
},
6980
),
7081
)
7182
}

app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
1717
import androidx.compose.material3.LinearProgressIndicator
1818
import androidx.compose.material3.MaterialTheme
1919
import androidx.compose.material3.Text
20+
import androidx.compose.material3.TextButton
2021
import androidx.compose.runtime.Composable
2122
import androidx.compose.runtime.LaunchedEffect
2223
import androidx.compose.runtime.collectAsState
@@ -32,8 +33,11 @@ import compose.icons.FontAwesomeIcons
3233
import compose.icons.fontawesomeicons.Solid
3334
import compose.icons.fontawesomeicons.solid.CircleCheck
3435
import compose.icons.fontawesomeicons.solid.CircleExclamation
36+
import dev.dimension.flare.R
3537
import dev.dimension.flare.common.ComposeInAppNotification
3638
import dev.dimension.flare.common.Notification
39+
import dev.dimension.flare.ui.presenter.login.ReloginTarget
40+
import dev.dimension.flare.ui.theme.screenHorizontalPadding
3741
import kotlinx.coroutines.delay
3842
import org.koin.compose.koinInject
3943
import kotlin.time.Duration.Companion.milliseconds
@@ -43,6 +47,7 @@ import kotlin.time.Duration.Companion.seconds
4347
internal fun InAppNotificationComponent(
4448
modifier: Modifier = Modifier,
4549
notification: ComposeInAppNotification = koinInject(),
50+
onRelogin: (ReloginTarget) -> Unit = {},
4651
) {
4752
val source by notification.source.collectAsState()
4853
val content = remember(source) { source.getContentIfNotHandled() }
@@ -74,7 +79,8 @@ internal fun InAppNotificationComponent(
7479
showNotification,
7580
modifier =
7681
modifier
77-
.systemBarsPadding(),
82+
.systemBarsPadding()
83+
.padding(horizontal = screenHorizontalPadding),
7884
enter = fadeIn() + slideInVertically(),
7985
exit = fadeOut() + slideOutVertically(),
8086
) {
@@ -111,7 +117,20 @@ internal fun InAppNotificationComponent(
111117
Spacer(modifier = Modifier.width(12.dp))
112118
Text(
113119
stringResource(it.messageId, *it.args.toTypedArray()),
120+
modifier = Modifier.weight(1f),
114121
)
122+
it.reloginTarget?.let { target ->
123+
Spacer(modifier = Modifier.width(8.dp))
124+
TextButton(
125+
onClick = {
126+
showText = false
127+
showNotification = false
128+
onRelogin(target)
129+
},
130+
) {
131+
Text(stringResource(R.string.login_expired_relogin))
132+
}
133+
}
115134
}
116135
}
117136
}

app/src/main/java/dev/dimension/flare/ui/route/Route.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import dev.dimension.flare.feature.agent.localhistory.LocalHistoryAgentTarget
1010
import dev.dimension.flare.model.AccountType
1111
import dev.dimension.flare.model.MicroBlogKey
1212
import dev.dimension.flare.ui.model.UiMedia
13+
import dev.dimension.flare.ui.presenter.login.ReloginTarget
14+
import dev.dimension.flare.ui.presenter.login.WebCookieSeed
1315
import kotlinx.collections.immutable.ImmutableMap
1416
import kotlinx.collections.immutable.toImmutableMap
1517
import kotlinx.serialization.Serializable
@@ -202,8 +204,13 @@ internal sealed interface Route : NavKey {
202204
sealed interface ServiceSelect : Route {
203205
data object Selection : ServiceSelect
204206

207+
data class Relogin(
208+
val target: ReloginTarget,
209+
) : ServiceSelect
210+
205211
data class WebCookieLogin(
206212
val url: String,
213+
val initialCookies: List<WebCookieSeed>,
207214
val callback: (cookies: String?) -> Boolean,
208215
) : ServiceSelect
209216
}
@@ -548,6 +555,15 @@ internal sealed interface Route : NavKey {
548555
ServiceSelect.Selection
549556
}
550557

558+
is DeeplinkRoute.Relogin -> {
559+
ServiceSelect.Relogin(
560+
ReloginTarget(
561+
accountKey = deeplinkRoute.accountKey,
562+
platformType = deeplinkRoute.platformType,
563+
),
564+
)
565+
}
566+
551567
is DeeplinkRoute.Compose.New -> {
552568
Compose.New
553569
}

app/src/main/java/dev/dimension/flare/ui/screen/home/AccountSelectionModal.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ internal fun AccountSelectionModal(
5252
toLogin = {
5353
navigate(Route.ServiceSelect.Selection)
5454
},
55+
toRelogin = {
56+
navigate(Route.ServiceSelect.Relogin(it))
57+
},
5558
trailingContent = { user ->
5659
state.activeAccount.onSuccess {
5760
RadioButton(

app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ internal fun DeepLinkAccountPickerModal(
6868
onNavigate(route)
6969
},
7070
toLogin = {},
71+
toRelogin = {
72+
onDismissRequest()
73+
onNavigate(Route.ServiceSelect.Relogin(it))
74+
},
7175
modifier =
7276
Modifier
7377
.listCard(

app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ internal fun HomeScreen(afterInit: () -> Unit) {
510510
}
511511
InAppNotificationComponent(
512512
modifier = Modifier.align(Alignment.TopCenter),
513+
onRelogin = {
514+
state.navigate(Route.ServiceSelect.Relogin(it))
515+
},
513516
)
514517
}
515518
}.onLoading {

app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectEntryBuilder.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,27 @@ internal fun EntryProviderScope<NavKey>.serviceSelectEntryBuilder(
1010
) {
1111
entry<Route.ServiceSelect.Selection> {
1212
ServiceSelectScreen(
13-
onWebViewLogin = { url, callback ->
13+
onWebViewLogin = { url, initialCookies, callback ->
1414
navigate(
1515
Route.ServiceSelect.WebCookieLogin(
1616
url = url,
17+
initialCookies = initialCookies,
18+
callback = callback,
19+
),
20+
)
21+
},
22+
onBack = onBack,
23+
)
24+
}
25+
26+
entry<Route.ServiceSelect.Relogin> { args ->
27+
ReloginScreen(
28+
target = args.target,
29+
onWebViewLogin = { url, initialCookies, callback ->
30+
navigate(
31+
Route.ServiceSelect.WebCookieLogin(
32+
url = url,
33+
initialCookies = initialCookies,
1734
callback = callback,
1835
),
1936
)
@@ -25,6 +42,7 @@ internal fun EntryProviderScope<NavKey>.serviceSelectEntryBuilder(
2542
entry<Route.ServiceSelect.WebCookieLogin> { args ->
2643
WebCookieLoginScreen(
2744
url = args.url,
45+
initialCookies = args.initialCookies,
2846
callback = args.callback,
2947
onBack = onBack,
3048
)

app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import dev.dimension.flare.ui.common.isLoginCallbackDeepLink
1212
import dev.dimension.flare.ui.component.BackButton
1313
import dev.dimension.flare.ui.component.FlareScaffold
1414
import dev.dimension.flare.ui.component.FlareTopAppBar
15+
import dev.dimension.flare.ui.presenter.login.ReloginTarget
16+
import dev.dimension.flare.ui.presenter.login.WebCookieSeed
17+
import dev.dimension.flare.ui.screen.login.ReloginScreenContent
1518
import dev.dimension.flare.ui.screen.login.ServiceSelectionScreenContent
1619

1720
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
1821
@Composable
1922
internal fun ServiceSelectScreen(
20-
onWebViewLogin: (url: String, cookieCallback: (cookies: String?) -> Boolean) -> Unit,
23+
onWebViewLogin: (url: String, initialCookies: List<WebCookieSeed>, cookieCallback: (cookies: String?) -> Boolean) -> Unit,
2124
onBack: () -> Unit,
2225
modifier: Modifier = Modifier,
2326
) {
@@ -54,3 +57,46 @@ internal fun ServiceSelectScreen(
5457
)
5558
}
5659
}
60+
61+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
62+
@Composable
63+
internal fun ReloginScreen(
64+
target: ReloginTarget,
65+
onWebViewLogin: (url: String, initialCookies: List<WebCookieSeed>, cookieCallback: (cookies: String?) -> Boolean) -> Unit,
66+
onBack: () -> Unit,
67+
modifier: Modifier = Modifier,
68+
) {
69+
val uriHandler = LocalUriHandler.current
70+
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
71+
FlareScaffold(
72+
modifier =
73+
modifier
74+
.nestedScroll(scrollBehavior.nestedScrollConnection),
75+
topBar = {
76+
FlareTopAppBar(
77+
title = {
78+
},
79+
navigationIcon = {
80+
BackButton(onBack = onBack)
81+
},
82+
scrollBehavior = scrollBehavior,
83+
)
84+
},
85+
) { contentPadding ->
86+
ReloginScreenContent(
87+
target = target,
88+
onWebViewLogin = onWebViewLogin,
89+
contentPadding = contentPadding,
90+
openUri = uriHandler::openUri,
91+
registerDeeplinkCallback = { callback ->
92+
OnNewIntent {
93+
val url = it.dataString.orEmpty()
94+
if (url.isLoginCallbackDeepLink()) {
95+
callback.invoke(url)
96+
}
97+
}
98+
},
99+
onBack = onBack,
100+
)
101+
}
102+
}

app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.dimension.flare.ui.screen.serviceselect
22

3+
import android.net.Uri
34
import android.view.ViewGroup.LayoutParams
45
import android.webkit.CookieManager
56
import android.webkit.WebSettings
@@ -16,30 +17,39 @@ import androidx.compose.ui.draw.alpha
1617
import com.kevinnzou.web.WebView
1718
import com.kevinnzou.web.rememberWebViewState
1819
import dev.dimension.flare.ui.component.FlareScaffold
20+
import dev.dimension.flare.ui.presenter.login.WebCookieSeed
1921
import kotlinx.coroutines.delay
2022
import kotlin.time.Duration.Companion.seconds
2123

22-
private val userAgent =
24+
private val USER_AGENT_HEADERS =
2325
mapOf(
24-
"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",
26+
"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",
2527
"Pragma" to "no-cache",
2628
"Cache-Control" to "no-cache",
2729
)
2830

31+
private const val ANDROID_USER_AGENT =
32+
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.3"
33+
2934
@Composable
3035
internal fun WebCookieLoginScreen(
3136
url: String,
37+
initialCookies: List<WebCookieSeed>,
3238
callback: (String?) -> Boolean,
3339
onBack: () -> Unit,
3440
) {
3541
val webViewState = rememberWebViewState(url)
3642
LaunchedEffect(url) {
43+
val cookieManager = CookieManager.getInstance()
3744
while (true) {
3845
webViewState.lastLoadedUrl?.let { loadedUrl ->
3946
val cookies =
40-
CookieManager
41-
.getInstance()
42-
.getCookie(loadedUrl)
47+
cookieManager.getCookieHeaderForUrls(
48+
cookieLookupUrls(
49+
loadedUrl = loadedUrl,
50+
originalUrl = url,
51+
),
52+
)
4353
if (callback(cookies)) {
4454
onBack()
4555
break
@@ -62,17 +72,73 @@ internal fun WebCookieLoginScreen(
6272
.background(MaterialTheme.colorScheme.background)
6373
.padding(it)
6474
.fillMaxSize(),
65-
onCreated = {
75+
onCreated = { webView ->
6676
WebStorage.getInstance().deleteAllData()
67-
CookieManager.getInstance().removeAllCookies(null)
68-
with(it.settings) {
69-
userAgentString = userAgent.toString()
77+
val cookieManager = CookieManager.getInstance()
78+
cookieManager.setAcceptCookie(true)
79+
cookieManager.setAcceptThirdPartyCookies(webView, true)
80+
with(webView.settings) {
81+
userAgentString = ANDROID_USER_AGENT
7082
javaScriptEnabled = true
7183
domStorageEnabled = true
7284
javaScriptCanOpenWindowsAutomatically = false
7385
cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK
7486
}
87+
cookieManager.removeAllCookies {
88+
initialCookies.forEach { seed ->
89+
cookieManager.setCookie(
90+
seed.urlForCookieStore(),
91+
seed.toAndroidCookieHeader(),
92+
)
93+
}
94+
cookieManager.flush()
95+
webView.loadUrl(url, USER_AGENT_HEADERS)
96+
}
7597
},
7698
)
7799
}
78100
}
101+
102+
private fun cookieLookupUrls(
103+
loadedUrl: String,
104+
originalUrl: String,
105+
): List<String> {
106+
val loadedHost = loadedUrl.hostOrNull()
107+
val originalHost = originalUrl.hostOrNull()
108+
return if (loadedHost != null && originalHost != null && loadedHost.equals(originalHost, ignoreCase = true)) {
109+
listOf(loadedUrl)
110+
} else {
111+
listOf(loadedUrl, originalUrl)
112+
}
113+
}
114+
115+
private fun CookieManager.getCookieHeaderForUrls(urls: List<String>): String? =
116+
urls
117+
.distinct()
118+
.flatMap { url ->
119+
getCookie(url)
120+
?.split(";")
121+
.orEmpty()
122+
}.map { it.trim() }
123+
.filter { it.isNotEmpty() }
124+
.distinct()
125+
.joinToString("; ")
126+
.takeIf { it.isNotBlank() }
127+
128+
private fun String.hostOrNull(): String? = runCatching { Uri.parse(this).host }.getOrNull()
129+
130+
private fun WebCookieSeed.urlForCookieStore(): String = "https://$domain$path"
131+
132+
private fun WebCookieSeed.toAndroidCookieHeader(): String =
133+
buildString {
134+
append(name)
135+
append("=")
136+
append(value)
137+
append("; Domain=")
138+
append(domain)
139+
append("; Path=")
140+
append(path)
141+
if (secure) {
142+
append("; Secure")
143+
}
144+
}

0 commit comments

Comments
 (0)