From ac6c901335af2f2e8a067a71f99fc7e091f3c291 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 17:04:04 +0900 Subject: [PATCH 1/8] add relogin flow --- .../flare/common/ComposeInAppNotification.kt | 11 + .../component/InAppNotificationComponent.kt | 21 +- .../dev/dimension/flare/ui/route/Route.kt | 16 ++ .../ui/screen/home/AccountSelectionModal.kt | 3 + .../screen/home/DeepLinkAccountPickerModal.kt | 4 + .../flare/ui/screen/home/HomeScreen.kt | 3 + .../ServiceSelectEntryBuilder.kt | 20 +- .../serviceselect/ServiceSelectScreen.kt | 48 ++++- .../serviceselect/WebCookieLoginScreen.kt | 43 +++- .../ui/screen/settings/AccountsScreen.kt | 15 +- .../settings/SettingsSelectEntryBuilder.kt | 3 + .../Sources/FlareAppleUI/ListErrorView.swift | 21 +- .../Resources/Localizable.xcstrings | 50 ++++- .../ios/UI/Component/ListErrorUIView.swift | 20 +- .../UI/Component/SwiftInAppNotification.swift | 19 +- appleApp/ios/UI/FlareRoot.swift | 93 ++++++++ appleApp/ios/UI/Route/Route.swift | 17 ++ appleApp/ios/UI/Route/Router.swift | 1 + .../UI/Screen/BackportWebLoginScreen.swift | 58 ++++- .../UI/Screen/ServiceSelectionScreen.swift | 114 +++++++++- appleApp/ios/UI/Screen/WebLoginScreen.swift | 52 ++++- appleApp/macos/App/RootView.swift | 72 +++++-- .../macos/Common/SwiftInAppNotification.swift | 11 +- appleApp/macos/UI/Navigation/Route.swift | 17 ++ appleApp/macos/UI/Navigation/Router.swift | 1 + .../UI/Screen/ServiceSelectionScreen.swift | 199 +++++++++++++++++- .../flare/ui/component/ErrorContent.kt | 16 +- .../login/ServiceSelectionScreenContent.kt | 156 +++++++++++++- .../main/kotlin/dev/dimension/flare/App.kt | 3 + .../flare/ui/component/AccountItem.kt | 13 +- .../ui/component/ComposeInAppNotification.kt | 24 +++ .../dev/dimension/flare/ui/route/Route.kt | 16 ++ .../dev/dimension/flare/ui/route/Router.kt | 24 ++- .../ui/screen/home/DeepLinkAccountPicker.kt | 4 + .../serviceselect/ServiceSelectScreen.kt | 26 ++- .../serviceselect/WebViewLoginScreen.kt | 38 +++- .../ui/screen/settings/SettingsScreen.kt | 3 + .../flare/ui/presenter/login/LoginApi.kt | 62 ++++++ .../ui/presenter/login/LoginContextTest.kt | 77 +++++++ .../ui/presenter/login/ReloginPresenter.kt | 52 +++++ .../dimension/flare/ui/route/DeeplinkRoute.kt | 7 + .../flare/ui/route/DeeplinkRouteTest.kt | 16 ++ .../presenter/login/BlueskyLoginProvider.kt | 24 ++- .../ui/presenter/login/FanboxLoginProvider.kt | 32 ++- .../presenter/login/MastodonLoginProvider.kt | 12 +- .../presenter/login/MisskeyLoginProvider.kt | 12 +- .../ui/presenter/login/NostrLoginProvider.kt | 28 ++- .../ui/presenter/login/PixivLoginProvider.kt | 4 +- .../ui/presenter/login/VVOLoginProvider.kt | 35 ++- .../ui/presenter/login/XQTLoginProvider.kt | 35 ++- 50 files changed, 1519 insertions(+), 132 deletions(-) create mode 100644 feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginContextTest.kt create mode 100644 feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ReloginPresenter.kt diff --git a/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt b/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt index d42f2b31df..e5c2224d29 100644 --- a/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt +++ b/app/src/main/java/dev/dimension/flare/common/ComposeInAppNotification.kt @@ -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 @@ -22,6 +23,7 @@ internal sealed interface Notification { val messageId: Int, val success: Boolean, val args: List = emptyList(), + val reloginTarget: ReloginTarget? = null, ) : Notification } @@ -66,6 +68,15 @@ internal class ComposeInAppNotification : InAppNotification { null }, ), + reloginTarget = + if (throwable is LoginExpiredException) { + ReloginTarget( + accountKey = throwable.accountKey, + platformType = throwable.platformType, + ) + } else { + null + }, ), ) } diff --git a/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt b/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt index bdf376aec3..80f4df2fa2 100644 --- a/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt +++ b/app/src/main/java/dev/dimension/flare/ui/component/InAppNotificationComponent.kt @@ -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 @@ -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 @@ -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() } @@ -74,7 +79,8 @@ internal fun InAppNotificationComponent( showNotification, modifier = modifier - .systemBarsPadding(), + .systemBarsPadding() + .padding(horizontal = screenHorizontalPadding), enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically(), ) { @@ -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)) + } + } } } } diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index f9a4efb450..9583b1e1c4 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -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 @@ -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, val callback: (cookies: String?) -> Boolean, ) : ServiceSelect } @@ -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 } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/AccountSelectionModal.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/AccountSelectionModal.kt index 8baba9bb99..9ac1218ab0 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/AccountSelectionModal.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/AccountSelectionModal.kt @@ -52,6 +52,9 @@ internal fun AccountSelectionModal( toLogin = { navigate(Route.ServiceSelect.Selection) }, + toRelogin = { + navigate(Route.ServiceSelect.Relogin(it)) + }, trailingContent = { user -> state.activeAccount.onSuccess { RadioButton( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt index fc10a5f564..49f998612e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt @@ -68,6 +68,10 @@ internal fun DeepLinkAccountPickerModal( onNavigate(route) }, toLogin = {}, + toRelogin = { + onDismissRequest() + onNavigate(Route.ServiceSelect.Relogin(it)) + }, modifier = Modifier .listCard( diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt index 8248726f78..524ca343fa 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/HomeScreen.kt @@ -510,6 +510,9 @@ internal fun HomeScreen(afterInit: () -> Unit) { } InAppNotificationComponent( modifier = Modifier.align(Alignment.TopCenter), + onRelogin = { + state.navigate(Route.ServiceSelect.Relogin(it)) + }, ) } }.onLoading { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectEntryBuilder.kt index 16219902c3..c98926efe9 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectEntryBuilder.kt @@ -10,10 +10,27 @@ internal fun EntryProviderScope.serviceSelectEntryBuilder( ) { entry { ServiceSelectScreen( - onWebViewLogin = { url, callback -> + onWebViewLogin = { url, initialCookies, callback -> navigate( Route.ServiceSelect.WebCookieLogin( url = url, + initialCookies = initialCookies, + callback = callback, + ), + ) + }, + onBack = onBack, + ) + } + + entry { args -> + ReloginScreen( + target = args.target, + onWebViewLogin = { url, initialCookies, callback -> + navigate( + Route.ServiceSelect.WebCookieLogin( + url = url, + initialCookies = initialCookies, callback = callback, ), ) @@ -25,6 +42,7 @@ internal fun EntryProviderScope.serviceSelectEntryBuilder( entry { args -> WebCookieLoginScreen( url = args.url, + initialCookies = args.initialCookies, callback = args.callback, onBack = onBack, ) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 5aa2264f67..f1c40a58f6 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -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, cookieCallback: (cookies: String?) -> Boolean) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -54,3 +57,46 @@ internal fun ServiceSelectScreen( ) } } + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ReloginScreen( + target: ReloginTarget, + onWebViewLogin: (url: String, initialCookies: List, 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, + ) + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt index 7a6a0084e0..1c3716ebf2 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt @@ -16,19 +16,24 @@ 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 = 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, callback: (String?) -> Boolean, onBack: () -> Unit, ) { @@ -62,17 +67,45 @@ 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, userAgent) + } }, ) } } + +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") + } + } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt index b739c4d097..606875033e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt @@ -77,6 +77,7 @@ import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.login.ReloginTarget import dev.dimension.flare.ui.presenter.settings.AccountsState.AccountItem import dev.dimension.flare.ui.theme.screenHorizontalPadding import dev.dimension.flare.ui.theme.segmentedShapes2 @@ -90,6 +91,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun AccountsScreen( onBack: () -> Unit, toLogin: () -> Unit, + toRelogin: (ReloginTarget) -> Unit, toNostrRelays: (MicroBlogKey) -> Unit, ) { val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -196,6 +198,7 @@ internal fun AccountsScreen( showMenu = true }, toLogin = toLogin, + toRelogin = toRelogin, trailingContent = { user -> Row( verticalAlignment = Alignment.CenterVertically, @@ -356,6 +359,7 @@ fun AccountItem( userState: UiState, onClick: (MicroBlogKey) -> Unit, toLogin: () -> Unit, + toRelogin: (ReloginTarget) -> Unit = { toLogin() }, modifier: Modifier = Modifier, trailingContent: @Composable (UiProfile) -> Unit = { }, headlineContent: @Composable (UiProfile) -> Unit = { @@ -465,7 +469,16 @@ fun AccountItem( trailingContent = if (throwable is LoginExpiredException) { { - TextButton(onClick = toLogin) { + TextButton( + onClick = { + toRelogin( + ReloginTarget( + accountKey = throwable.accountKey, + platformType = throwable.platformType, + ), + ) + }, + ) { Text(text = stringResource(id = R.string.login_expired_relogin)) } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt index c206481f24..91ebf0a96c 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt @@ -70,6 +70,9 @@ internal fun EntryProviderScope.settingsSelectEntryBuilder( toLogin = { navigate(Route.ServiceSelect.Selection) }, + toRelogin = { + navigate(Route.ServiceSelect.Relogin(it)) + }, toNostrRelays = { navigate(Route.Settings.NostrRelays(it)) } diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/ListErrorView.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/ListErrorView.swift index 4d41f6f955..be1cc05fe6 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/ListErrorView.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/ListErrorView.swift @@ -23,7 +23,10 @@ public struct ListErrorView: View { .multilineTextAlignment(.center) .font(.headline) Button { - if let url = URL(string: DeeplinkRoute.Login.shared.toUri()) { + if let url = reloginURL( + accountKey: expiredError.accountKey, + platformType: expiredError.platformType + ) { openURL(url) } } label: { @@ -31,7 +34,7 @@ public struct ListErrorView: View { } .backport .glassProminentButtonStyle() - } else if error is RequireReLoginException { + } else if let reloginError = error as? RequireReLoginException { Image(systemName: "person.badge.shield.exclamationmark") .resizable() .scaledToFit() @@ -41,7 +44,10 @@ public struct ListErrorView: View { .font(.headline) Text("permission_denied_message", bundle: FlareAppleUILocalization.bundle) Button { - if let url = URL(string: DeeplinkRoute.Login.shared.toUri()) { + if let url = reloginURL( + accountKey: reloginError.accountKey, + platformType: reloginError.platformType + ) { openURL(url) } } label: { @@ -73,4 +79,13 @@ public struct ListErrorView: View { } .padding() } + + private func reloginURL(accountKey: MicroBlogKey, platformType: PlatformType) -> URL? { + URL( + string: DeeplinkRoute.Relogin( + accountKey: accountKey, + platformType: platformType + ).toUri() + ) + } } diff --git a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings index 7f6a6dd125..3aefc0466b 100644 --- a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings +++ b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings @@ -194,6 +194,19 @@ } } }, + "%@" : { + + }, + "%@@%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@@%2$@" + } + } + } + }, "%d" : { "extractionState" : "stale", "localizations" : { @@ -50904,6 +50917,39 @@ } } }, + "login_expired" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Login session expired" + } + } + } + }, + "login_expired_message" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log in again to continue using this account." + } + } + } + }, + "login_expired_relogin" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log in again" + } + } + } + }, "login_title" : { "localizations" : { "af" : { @@ -99128,13 +99174,13 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "已保存" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "已儲存" } } diff --git a/appleApp/ios/UI/Component/ListErrorUIView.swift b/appleApp/ios/UI/Component/ListErrorUIView.swift index 5e3365d782..8adb7cae3a 100644 --- a/appleApp/ios/UI/Component/ListErrorUIView.swift +++ b/appleApp/ios/UI/Component/ListErrorUIView.swift @@ -10,9 +10,7 @@ import KotlinSharedUI final class ListErrorUIView: UIView { var onOpenURL: ((URL) -> Void)? private var onRetry: (() -> Void)? - private var loginDeeplinkURL: URL? { - URL(string: DeeplinkRoute.Login.shared.toUri()) - } + private var actionURL: URL? private let iconView: UIImageView = { let iv = UIImageView() @@ -93,18 +91,21 @@ final class ListErrorUIView: UIView { iconView.image = UIImage(systemName: "person.badge.shield.exclamationmark") titleLabel.text = String(format: String(localized: "error_login_expired %@"), "\(expired.accountKey)") button.setTitle(String(localized: "error_login_expired_action"), for: .normal) + actionURL = reloginURL(accountKey: expired.accountKey, platformType: expired.platformType) mode = .openLogin - } else if error is RequireReLoginException { + } else if let reloginError = error as? RequireReLoginException { iconView.image = UIImage(systemName: "person.badge.shield.exclamationmark") titleLabel.text = String(localized: "permission_denied_title") messageLabel.text = String(localized: "permission_denied_message") messageLabel.isHidden = false button.setTitle(String(localized: "error_login_expired_action"), for: .normal) + actionURL = reloginURL(accountKey: reloginError.accountKey, platformType: reloginError.platformType) mode = .openLogin } else { iconView.image = UIImage(systemName: "exclamationmark.triangle.text.page") titleLabel.text = String(localized: "error") button.setTitle(String(localized: "action_retry"), for: .normal) + actionURL = nil mode = .retry if let message = error.message { detailLabel.text = message @@ -118,7 +119,16 @@ final class ListErrorUIView: UIView { case .retry: onRetry?() case .openLogin: - if let url = loginDeeplinkURL { onOpenURL?(url) } + if let url = actionURL { onOpenURL?(url) } } } + + private func reloginURL(accountKey: MicroBlogKey, platformType: PlatformType) -> URL? { + URL( + string: DeeplinkRoute.Relogin( + accountKey: accountKey, + platformType: platformType + ).toUri() + ) + } } diff --git a/appleApp/ios/UI/Component/SwiftInAppNotification.swift b/appleApp/ios/UI/Component/SwiftInAppNotification.swift index 5bc6103f82..41a7abaa63 100644 --- a/appleApp/ios/UI/Component/SwiftInAppNotification.swift +++ b/appleApp/ios/UI/Component/SwiftInAppNotification.swift @@ -1,12 +1,20 @@ import FlareAppleCore import KotlinSharedUI +import Combine import Drops import Foundation import SwiftUI -class SwiftInAppNotification: InAppNotification { +struct LoginExpiredToast: Identifiable { + let id = UUID() + let accountKey: MicroBlogKey + let platformType: PlatformType +} + +final class SwiftInAppNotification: ObservableObject, InAppNotification { private init() {} static let shared = SwiftInAppNotification() + @Published var loginExpiredToast: LoginExpiredToast? func onError(message: Message, throwable: KotlinThrowable) { DispatchQueue.main.async { @@ -21,12 +29,9 @@ class SwiftInAppNotification: InAppNotification { ) case .loginExpired: if let expiredError = throwable as? LoginExpiredException { - Drops.show( - .init( - title: .init(localized: "notification_login_expired"), - subtitle: .init(localized: "error_login_expired \(expiredError.accountKey)"), - icon: UIImage(systemName: "person.badge.shield.exclamationmark") - ) + self.loginExpiredToast = LoginExpiredToast( + accountKey: expiredError.accountKey, + platformType: expiredError.platformType ) } else { Drops.show(.init(stringLiteral: .init(localized: "notification_login_expired"))) diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index cb128496db..0206a25793 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -12,7 +12,9 @@ struct FlareRoot: View { @StateObject private var notificationBadgePresenter = KotlinPresenter(presenter: AllNotificationBadgePresenter()) @StateObject private var secondaryTabsPresenter = KotlinPresenter(presenter: SecondaryTabsPresenter()) @StateObject private var aiAgentEnabledPresenter = KotlinPresenter(presenter: AiAgentEnabledPresenter()) + @StateObject private var inAppNotification = SwiftInAppNotification.shared @State var selectedTab: String? + @State private var reloginRoute: Route? var body: some View { StateView(state: homeTabsPresenter.state.tabs) { tabs in @@ -66,6 +68,32 @@ struct FlareRoot: View { .backport .tabBarMinimizeBehavior(.onScrollDown) .background(Color(.systemGroupedBackground)) + .overlay(alignment: .top) { + if let toast = inAppNotification.loginExpiredToast { + LoginExpiredToastOverlay( + toast: toast, + onRelogin: { + reloginRoute = .relogin(toast.accountKey, toast.platformType) + inAppNotification.loginExpiredToast = nil + }, + onDismiss: { + inAppNotification.loginExpiredToast = nil + } + ) + .padding(.horizontal, 16) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: inAppNotification.loginExpiredToast?.id) + .sheet(item: $reloginRoute) { route in + NavigationStack { + route.view( + onNavigate: { reloginRoute = $0 }, + goBack: { reloginRoute = nil } + ) + } + } } loadingContent: { SplashScreen() } @@ -114,7 +142,9 @@ struct BackportFlareRoot: View { @Environment(\.globalAppearance) private var globalAppearance @StateObject private var homeTabsPresenter = KotlinPresenter(presenter: HomeTabsPresenter()) @StateObject private var notificationBadgePresenter = KotlinPresenter(presenter: AllNotificationBadgePresenter()) + @StateObject private var inAppNotification = SwiftInAppNotification.shared @State var selectedTab: String? + @State private var reloginRoute: Route? var body: some View { StateView(state: homeTabsPresenter.state.tabs) { tabs in @@ -137,12 +167,75 @@ struct BackportFlareRoot: View { } } .background(Color(.systemGroupedBackground)) + .overlay(alignment: .top) { + if let toast = inAppNotification.loginExpiredToast { + LoginExpiredToastOverlay( + toast: toast, + onRelogin: { + reloginRoute = .relogin(toast.accountKey, toast.platformType) + inAppNotification.loginExpiredToast = nil + }, + onDismiss: { + inAppNotification.loginExpiredToast = nil + } + ) + .padding(.horizontal, 16) + .padding(.top, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: inAppNotification.loginExpiredToast?.id) + .sheet(item: $reloginRoute) { route in + NavigationStack { + route.view( + onNavigate: { reloginRoute = $0 }, + goBack: { reloginRoute = nil } + ) + } + } } loadingContent: { SplashScreen() } } } +private struct LoginExpiredToastOverlay: View { + let toast: LoginExpiredToast + let onRelogin: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "person.badge.shield.exclamationmark") + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "login_expired", defaultValue: "Login session expired")) + .font(.headline) + Text("\(toast.accountKey)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button { + onRelogin() + } label: { + Text(String(localized: "login_expired_relogin", defaultValue: "Log in again")) + } + .buttonStyle(.borderedProminent) + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(radius: 12) + } +} + private func homeTabKey(_ tab: HomeTabsPresenterStateHomeTabs) -> String { tab.name.lowercased() } diff --git a/appleApp/ios/UI/Route/Route.swift b/appleApp/ios/UI/Route/Route.swift index eb1501372c..65601834a1 100644 --- a/appleApp/ios/UI/Route/Route.swift +++ b/appleApp/ios/UI/Route/Route.swift @@ -16,6 +16,10 @@ enum Route: Hashable, Identifiable { return lhsIndex == rhsIndex && lhsPreview == rhsPreview && lhsMedias.map { $0.url } == rhsMedias.map { $0.url } + case (.relogin(let lhsAccountKey, let lhsPlatformType), .relogin(let rhsAccountKey, let rhsPlatformType)): + return lhsAccountKey.id == rhsAccountKey.id && + lhsAccountKey.host == rhsAccountKey.host && + lhsPlatformType == rhsPlatformType default: return lhs.hashValue == rhs.hashValue } @@ -31,6 +35,11 @@ enum Route: Hashable, Identifiable { hasher.combine(medias.map { $0.url }) hasher.combine(selectedIndex) hasher.combine(preview) + case .relogin(let accountKey, let platformType): + hasher.combine("relogin") + hasher.combine(accountKey.id) + hasher.combine(accountKey.host) + hasher.combine(platformType) default: hasher.combine(String(describing: self)) } @@ -55,6 +64,11 @@ enum Route: Hashable, Identifiable { .navigationTitle(item.title.text) case .serviceSelect: ServiceSelectionScreen(toHome: { goBack() }) + case .relogin(let accountKey, let platformType): + ReloginScreen( + target: ReloginTarget(accountKey: accountKey, platformType: platformType), + toHome: { goBack() } + ) case .statusDetail(let accountType, let statusKey): StatusDetailScreen(accountType: accountType, statusKey: statusKey) case .galleryDetail(let accountType, let statusKey): @@ -274,6 +288,7 @@ enum Route: Hashable, Identifiable { case statusVVOStatus(AccountType, MicroBlogKey) case statusShareSheet(AccountType, MicroBlogKey, String, String?, String?) case serviceSelect + case relogin(MicroBlogKey, PlatformType) case accountManagement case nostrRelays(MicroBlogKey) case localFilter @@ -421,6 +436,8 @@ enum Route: Hashable, Identifiable { return Route.search(search.accountType, search.query) case .status(let status): return fromStatus(status) + case .relogin(let data): + return Route.relogin(data.accountKey, data.platformType) case .login: return Route.serviceSelect case .deepLinkAccountPicker(let picker): diff --git a/appleApp/ios/UI/Route/Router.swift b/appleApp/ios/UI/Route/Router.swift index f33d154bba..2c29508e85 100644 --- a/appleApp/ios/UI/Route/Router.swift +++ b/appleApp/ios/UI/Route/Router.swift @@ -102,6 +102,7 @@ struct Router: View { .composeQuote, .composeReply, .composeVVOReplyComment, + .relogin, .tabSettings, .statusBlueskyReport, .statusMisskeyReport, diff --git a/appleApp/ios/UI/Screen/BackportWebLoginScreen.swift b/appleApp/ios/UI/Screen/BackportWebLoginScreen.swift index 6ac884eaa8..ec9c4456fc 100644 --- a/appleApp/ios/UI/Screen/BackportWebLoginScreen.swift +++ b/appleApp/ios/UI/Screen/BackportWebLoginScreen.swift @@ -1,13 +1,18 @@ import SwiftUI import WebKit import Combine +import KotlinSharedUI struct BackportWebLoginScreen: View { @Environment(\.dismiss) var dismiss @StateObject private var viewModel: BackportWebLoginViewModel let url: String - init(onCookie: @escaping (String) -> Void, url: String) { - self._viewModel = .init(wrappedValue: .init(onCookie: onCookie, url: url)) + init( + onCookie: @escaping (String) -> Void, + url: String, + initialCookies: [WebCookieSeed] = [] + ) { + self._viewModel = .init(wrappedValue: .init(onCookie: onCookie, url: url, initialCookies: initialCookies)) self.url = url } @@ -56,7 +61,11 @@ class BackportWebLoginViewModel: ObservableObject { let onCookie: (String) -> Void let delegate: WKDelegate private var observers = [NSKeyValueObservation]() - init(onCookie: @escaping (String) -> Void, url: String) { + init( + onCookie: @escaping (String) -> Void, + url: String, + initialCookies: [WebCookieSeed] + ) { self.onCookie = onCookie self.url = url self.delegate = WKDelegate { @@ -65,21 +74,23 @@ class BackportWebLoginViewModel: ObservableObject { onCookie(cookieString) } } - clearCookie() + clearCookie(initialCookies: initialCookies) } var configuration: WKWebViewConfiguration { let configuration = WKWebViewConfiguration() configuration.defaultWebpagePreferences.allowsContentJavaScript = true return configuration } - func clearCookie() { + func clearCookie(initialCookies: [WebCookieSeed]) { let dataStore = WKWebsiteDataStore.default() dataStore.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in dataStore.removeData( ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: records, completionHandler: { - self.canShowWebView = true + dataStore.httpCookieStore.setCookies(initialCookies) { + self.canShowWebView = true + } } ) } @@ -97,3 +108,38 @@ class BackportWebLoginViewModel: ObservableObject { observers.removeAll() } } + +private extension WKHTTPCookieStore { + func setCookies(_ seeds: [WebCookieSeed], completion: @escaping () -> Void) { + guard !seeds.isEmpty else { + completion() + return + } + let group = DispatchGroup() + for seed in seeds { + guard let cookie = HTTPCookie(seed: seed) else { + continue + } + group.enter() + setCookie(cookie) { + group.leave() + } + } + group.notify(queue: .main, execute: completion) + } +} + +private extension HTTPCookie { + convenience init?(seed: WebCookieSeed) { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: seed.name, + .value: seed.value, + .domain: seed.domain, + .path: seed.path, + ] + if seed.secure { + properties[.secure] = "TRUE" + } + self.init(properties: properties) + } +} diff --git a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift index a75cc77246..3a3a5d3640 100644 --- a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift @@ -286,14 +286,100 @@ struct ServiceSelectionScreen: View { } } +struct ReloginScreen: View { + let target: ReloginTarget + let toHome: () -> Void + + @StateObject private var presenter: KotlinPresenter + @State private var selectedMethod: LoginMethodType? + + init(target: ReloginTarget, toHome: @escaping () -> Void) { + self.target = target + self.toHome = toHome + self._presenter = .init(wrappedValue: .init(presenter: ReloginPresenter(target: target, onSuccess: toHome))) + } + + var body: some View { + List { + Section { + header + .listRowBackground(Color.clear) + } + + Section { + loginContent(state: presenter.state) + .id("\(target.accountKey)-\(selectedMethod.map { String(describing: $0) } ?? "default")") + } + } + .listStyle(.insetGrouped) + .navigationTitle(ServiceSelectCopy.loginExpired) + } + + private var header: some View { + VStack(spacing: 8) { + Image(fontAwesome: presenter.state.platformIcon().fontAwesomeIcon) + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + Text(ServiceSelectCopy.loginExpired) + .font(.title2.weight(.semibold)) + .multilineTextAlignment(.center) + Text("\(target.accountKey.id)@\(target.accountKey.host)") + .font(.headline) + .multilineTextAlignment(.center) + Text(ServiceSelectCopy.loginExpiredMessage) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func loginContent(state: ReloginState) -> some View { + let methods = state.methods + if let firstMethod = methods.first { + let selectedMethod = selectedMethod ?? firstMethod.type + VStack(spacing: 14) { + if methods.count > 1 { + Picker("", selection: Binding( + get: { selectedMethod }, + set: { method in + withAnimation(ServiceSelectionAnimation.standard) { + self.selectedMethod = method + } + } + )) { + ForEach(methods.indices, id: \.self) { index in + Text(methods[index].title.text).tag(methods[index].type) + } + } + .pickerStyle(.segmented) + } + + let handler = state.createLoginHandler(methodType: selectedMethod) + LoginFlowView(handler: handler, autoStart: methods.count == 1) + .id("\(target.accountKey)-\(selectedMethod)") + + LoginAgreementView(urlString: state.agreementUrl()) + } + } + } +} + private struct LoginFlowView: View { @Environment(\.webAuthenticationSession) private var webAuthenticationSession + let autoStart: Bool + @StateObject private var presenter: KotlinPresenter @State private var qrContent: String? @State private var webCookieUrl: String? + @State private var webCookieInitialCookies: [WebCookieSeed] = [] + @State private var didAutoStart = false - init(handler: LoginMethodHandler) { + init(handler: LoginMethodHandler, autoStart: Bool = false) { + self.autoStart = autoStart self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler))) } @@ -351,8 +437,15 @@ private struct LoginFlowView: View { } case .openWebCookieLogin(let webCookie): webCookieUrl = webCookie.url + webCookieInitialCookies = webCookie.initialCookies } } + .onAppear { + autoStartIfNeeded() + } + .onChange(of: flowAnimationKey) { _, _ in + autoStartIfNeeded() + } .sheet(isPresented: Binding( get: { webCookieUrl != nil }, set: { value in @@ -367,13 +460,13 @@ private struct LoginFlowView: View { guard presenter.state.canResume(value: cookie) else { return } presenter.state.resume(value: cookie) self.webCookieUrl = nil - }, url: webCookieUrl) + }, url: webCookieUrl, initialCookies: webCookieInitialCookies) } else { BackportWebLoginScreen(onCookie: { cookie in guard presenter.state.canResume(value: cookie) else { return } presenter.state.resume(value: cookie) self.webCookieUrl = nil - }, url: webCookieUrl) + }, url: webCookieUrl, initialCookies: webCookieInitialCookies) } } } @@ -410,6 +503,19 @@ private struct LoginFlowView: View { ].joined(separator: "|") } + private func autoStartIfNeeded() { + guard autoStart, + !didAutoStart, + !presenter.state.flowState.loading, + presenter.state.flowState.fields.isEmpty, + let action = presenter.state.flowState.actions.first(where: { $0.enabled }) + else { + return + } + didAutoStart = true + presenter.state.perform(actionId: action.id) + } + private func authenticate(url: String) { Task { guard let authURL = URL(string: url) else { return } @@ -616,6 +722,8 @@ private enum ServiceSelectCopy { static let nostrQRLinkLabel = String(localized: "nostr_login_qr_link_label", defaultValue: "Nostr Connect link") static let eulaPrivacyPolicy = String(localized: "eula_privacy_policy", defaultValue: "EULA and Privacy Policy") static let loginAgreementPrefix = String(localized: "login_agreement_prefix", defaultValue: "By logging in, you agree to the ") + static let loginExpired = String(localized: "login_expired", defaultValue: "Login session expired") + static let loginExpiredMessage = String(localized: "login_expired_message", defaultValue: "Log in again to continue using this account.") static let search = String(localized: "search") static let clear = String(localized: "Clear") diff --git a/appleApp/ios/UI/Screen/WebLoginScreen.swift b/appleApp/ios/UI/Screen/WebLoginScreen.swift index 6aebb8622d..a1ddd3ec83 100644 --- a/appleApp/ios/UI/Screen/WebLoginScreen.swift +++ b/appleApp/ios/UI/Screen/WebLoginScreen.swift @@ -8,8 +8,12 @@ struct WebLoginScreen: View { @Environment(\.dismiss) var dismiss @StateObject private var viewModel: WebLoginViewModel let url: String - init(onCookie: @escaping (String) -> Void, url: String) { - self._viewModel = .init(wrappedValue: .init(onCookie: onCookie, url: url)) + init( + onCookie: @escaping (String) -> Void, + url: String, + initialCookies: [WebCookieSeed] = [] + ) { + self._viewModel = .init(wrappedValue: .init(onCookie: onCookie, url: url, initialCookies: initialCookies)) self.url = url } var body: some View { @@ -76,6 +80,7 @@ class WebLoginViewModel: ObservableObject { init( onCookie: @escaping (String) -> Void, url: String, + initialCookies: [WebCookieSeed], ) { var conf = WebPage.Configuration() conf.defaultNavigationPreferences.allowsContentJavaScript = true @@ -84,7 +89,7 @@ class WebLoginViewModel: ObservableObject { self.page = WebPage(configuration: conf, navigationDecider: decider) self.onCookie = onCookie self.page.customUserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1" - clearCookie() + clearCookie(initialCookies: initialCookies) } @Published var canShowWebView = false @@ -98,16 +103,53 @@ class WebLoginViewModel: ObservableObject { } } - func clearCookie() { + func clearCookie(initialCookies: [WebCookieSeed]) { let dataStore = WKWebsiteDataStore.default() dataStore.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in dataStore.removeData( ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: records, completionHandler: { - self.canShowWebView = true + dataStore.httpCookieStore.setCookies(initialCookies) { + self.canShowWebView = true + } } ) } } } + +private extension WKHTTPCookieStore { + func setCookies(_ seeds: [WebCookieSeed], completion: @escaping () -> Void) { + guard !seeds.isEmpty else { + completion() + return + } + let group = DispatchGroup() + for seed in seeds { + guard let cookie = HTTPCookie(seed: seed) else { + continue + } + group.enter() + setCookie(cookie) { + group.leave() + } + } + group.notify(queue: .main, execute: completion) + } +} + +private extension HTTPCookie { + convenience init?(seed: WebCookieSeed) { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: seed.name, + .value: seed.value, + .domain: seed.domain, + .path: seed.path, + ] + if seed.secure { + properties[.secure] = "TRUE" + } + self.init(properties: properties) + } +} diff --git a/appleApp/macos/App/RootView.swift b/appleApp/macos/App/RootView.swift index 658b858a3b..262c6d0f3e 100644 --- a/appleApp/macos/App/RootView.swift +++ b/appleApp/macos/App/RootView.swift @@ -24,6 +24,7 @@ struct RootView: View { @State private var homeExpanded: Bool = true @State private var showDraftBoxPopover = false @State private var showLogin = false + @State private var reloginRoute: Route? @State private var homeSidebarTabEditor: HomeSidebarTabEditor? var body: some View { @@ -188,6 +189,10 @@ struct RootView: View { .overlay(alignment: .top) { LoginExpiredToastOverlay( toast: inAppNotification.loginExpiredToast, + onRelogin: { toast in + reloginRoute = .relogin(toast.accountKey, toast.platformType) + inAppNotification.dismissLoginExpiredToast(id: toast.id) + }, onDismiss: { id in inAppNotification.dismissLoginExpiredToast(id: id) } @@ -207,6 +212,14 @@ struct RootView: View { ServiceSelectionScreen(toHome: { showLogin = false }) } } + .sheet(item: $reloginRoute) { route in + NavigationStack { + route.view( + onNavigate: { reloginRoute = $0 }, + goBack: { reloginRoute = nil } + ) + } + } .sheet(item: $homeSidebarTabEditor) { editor in HomeSidebarTabEditSheet( tab: editor.tab, @@ -265,6 +278,7 @@ private struct HomeSidebarTabEditor: Identifiable { private struct LoginExpiredToastOverlay: View { let toast: LoginExpiredToast? + let onRelogin: (LoginExpiredToast) -> Void let onDismiss: (UUID) -> Void @State private var showNotification = false @@ -272,11 +286,16 @@ private struct LoginExpiredToastOverlay: View { var body: some View { Group { if let toast, showNotification { - LoginExpiredToastView(accountKey: toast.accountKey) + LoginExpiredToastView( + toast: toast, + onRelogin: { + onRelogin(toast) + }, + onDismiss: { + dismiss(toast) + } + ) .transition(.move(edge: .top).combined(with: .opacity)) - .onTapGesture { - dismiss(toast) - } } } .padding(.top, 14) @@ -311,18 +330,9 @@ private struct LoginExpiredToastOverlay: View { } private struct LoginExpiredToastView: View { - let accountKey: String? - - private var message: String { - guard let accountKey, !accountKey.isEmpty else { - return NSLocalizedString("notification_login_expired", comment: "") - } - - return String.localizedStringWithFormat( - NSLocalizedString("error_login_expired %@", comment: ""), - accountKey - ) - } + let toast: LoginExpiredToast + let onRelogin: () -> Void + let onDismiss: () -> Void var body: some View { HStack(spacing: 12) { @@ -331,15 +341,31 @@ private struct LoginExpiredToastView: View { .foregroundStyle(.red) .frame(width: 20, height: 20) - Text(message) - .font(.callout) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "login_expired", defaultValue: "Login session expired")) + .font(.callout.weight(.semibold)) + Text("\(toast.accountKey)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button { + onRelogin() + } label: { + Text(String(localized: "login_expired_relogin", defaultValue: "Log in again")) + } + Button { + onDismiss() + } label: { + Image(fontAwesome: .xmark) + } + .buttonStyle(.plain) } .padding(12) - .frame(maxWidth: 420, alignment: .leading) - .background(.background, in: Capsule()) - .contentShape(Capsule()) + .frame(maxWidth: 520, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .shadow(color: .black.opacity(0.18), radius: 8, y: 4) } } diff --git a/appleApp/macos/Common/SwiftInAppNotification.swift b/appleApp/macos/Common/SwiftInAppNotification.swift index f34fb0d3e6..6e6a0c17b1 100644 --- a/appleApp/macos/Common/SwiftInAppNotification.swift +++ b/appleApp/macos/Common/SwiftInAppNotification.swift @@ -5,7 +5,8 @@ import UserNotifications struct LoginExpiredToast: Identifiable { let id = UUID() - let accountKey: String? + let accountKey: MicroBlogKey + let platformType: PlatformType } final class SwiftInAppNotification: NSObject, ObservableObject, InAppNotification, UNUserNotificationCenterDelegate { @@ -25,7 +26,9 @@ final class SwiftInAppNotification: NSObject, ObservableObject, InAppNotificatio switch message { case .loginExpired: progressNotifications.remove(messageKey(for: message)) - showLoginExpiredToast(accountKey: (throwable as? LoginExpiredException).map { "\($0.accountKey)" }) + if let expired = throwable as? LoginExpiredException { + showLoginExpiredToast(accountKey: expired.accountKey, platformType: expired.platformType) + } return default: break @@ -111,8 +114,8 @@ final class SwiftInAppNotification: NSObject, ObservableObject, InAppNotificatio publishLoginExpiredToast(nil) } - private func showLoginExpiredToast(accountKey: String?) { - publishLoginExpiredToast(LoginExpiredToast(accountKey: accountKey)) + private func showLoginExpiredToast(accountKey: MicroBlogKey, platformType: PlatformType) { + publishLoginExpiredToast(LoginExpiredToast(accountKey: accountKey, platformType: platformType)) } private func publishLoginExpiredToast(_ toast: LoginExpiredToast?) { diff --git a/appleApp/macos/UI/Navigation/Route.swift b/appleApp/macos/UI/Navigation/Route.swift index 4b3f8b157e..0ee79313ff 100644 --- a/appleApp/macos/UI/Navigation/Route.swift +++ b/appleApp/macos/UI/Navigation/Route.swift @@ -9,6 +9,7 @@ enum Route: Hashable, Identifiable { case accountNotification(MicroBlogKey) case discover case serviceSelect + case relogin(MicroBlogKey, PlatformType) case localHistory case agentHistory case directMessages @@ -71,6 +72,10 @@ enum Route: Hashable, Identifiable { return lhsIndex == rhsIndex && lhsPreview == rhsPreview && lhsMedias.map { $0.url } == rhsMedias.map { $0.url } + case (.relogin(let lhsAccountKey, let lhsPlatformType), .relogin(let rhsAccountKey, let rhsPlatformType)): + return lhsAccountKey.id == rhsAccountKey.id && + lhsAccountKey.host == rhsAccountKey.host && + lhsPlatformType == rhsPlatformType default: return lhs.hashValue == rhs.hashValue } @@ -85,6 +90,11 @@ enum Route: Hashable, Identifiable { case .timeline(let item): hasher.combine("timeline") hasher.combine(item.id) + case .relogin(let accountKey, let platformType): + hasher.combine("relogin") + hasher.combine(accountKey.id) + hasher.combine(accountKey.host) + hasher.combine(platformType) case .mediaRaw(let medias, let selectedIndex, let preview): hasher.combine("mediaRaw") hasher.combine(medias.map { $0.url }) @@ -114,6 +124,11 @@ enum Route: Hashable, Identifiable { } case .serviceSelect: ServiceSelectionScreen(toHome: goBack) + case .relogin(let accountKey, let platformType): + ReloginScreen( + target: ReloginTarget(accountKey: accountKey, platformType: platformType), + toHome: goBack + ) case .localHistory: LocalHistoryContentScreen( onAskAi: { query, target in @@ -387,6 +402,8 @@ extension Route { static func fromDeepLinkRoute(deeplinkRoute: DeeplinkRoute) -> Route? { switch onEnum(of: deeplinkRoute) { + case .relogin(let data): + .relogin(data.accountKey, data.platformType) case .login: .serviceSelect case .timeline(let data): diff --git a/appleApp/macos/UI/Navigation/Router.swift b/appleApp/macos/UI/Navigation/Router.swift index cee8f45fa4..f2eedba2c3 100644 --- a/appleApp/macos/UI/Navigation/Router.swift +++ b/appleApp/macos/UI/Navigation/Router.swift @@ -259,6 +259,7 @@ struct Router: View { private func isSheetRoute(_ route: Route) -> Bool { switch route { case .serviceSelect, + .relogin, .deepLinkAccountPicker, .statusAddReaction, .statusShareSheet, diff --git a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift index 33f19b669d..6ad5bf1227 100644 --- a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift @@ -305,18 +305,134 @@ struct ServiceSelectionScreen: View { } } +struct ReloginScreen: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + + let target: ReloginTarget + let toHome: () -> Void + + @StateObject private var presenter: KotlinPresenter + @State private var selectedMethod: LoginMethodType? + + init(target: ReloginTarget, toHome: @escaping () -> Void) { + self.target = target + self.toHome = toHome + self._presenter = .init(wrappedValue: .init(presenter: ReloginPresenter(target: target, onSuccess: toHome))) + } + + var body: some View { + Form { + Section { + header + } + Section { + loginContent(state: presenter.state) + .id("\(target.accountKey)-\(selectedMethod.map { String(describing: $0) } ?? "default")") + } + } + .formStyle(.grouped) + .navigationTitle(ServiceSelectCopy.loginExpired) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Label { + Text("Cancel") + } icon: { + Image(fontAwesome: .xmark) + } + } + } + } + } + + private var header: some View { + HStack(spacing: 10) { + Image(fontAwesome: presenter.state.platformIcon().fontAwesomeIcon) + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(ServiceSelectCopy.loginExpired) + .font(.title3.weight(.semibold)) + Text("\(target.accountKey.id)@\(target.accountKey.host)") + .font(.headline) + Text(ServiceSelectCopy.loginExpiredMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + @ViewBuilder + private func loginContent(state: ReloginState) -> some View { + let methods = state.methods + if let firstMethod = methods.first { + let selectedMethod = Binding( + get: { self.selectedMethod ?? firstMethod.type }, + set: { method in + withAnimation(MacOSServiceSelectionAnimation.standard) { + self.selectedMethod = method + } + } + ) + VStack(alignment: .leading, spacing: 12) { + if methods.count > 1 { + Picker("", selection: selectedMethod) { + ForEach(methods.indices, id: \.self) { index in + Text(methods[index].title.text).tag(methods[index].type) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + + let handler = state.createLoginHandler(methodType: selectedMethod.wrappedValue) + LoginFlowView( + handler: handler, + authenticateURL: authenticate(url:), + autoStart: methods.count == 1 + ) + .id("\(target.accountKey)-\(selectedMethod.wrappedValue)") + + LoginAgreementView(urlString: state.agreementUrl()) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func authenticate(url: String) async -> String? { + guard let authURL = URL(string: url) else { + return nil + } + let response = try? await webAuthenticationSession.authenticate( + using: authURL, + callbackURLScheme: authURL.isPixivOAuthUrl ? "pixiv" : APPSCHEMA + ) + return response?.absoluteString + } +} + private struct LoginFlowView: View { let authenticateURL: (String) async -> String? + let autoStart: Bool @StateObject private var presenter: KotlinPresenter @State private var qrContent: String? @State private var webCookieUrl: String? + @State private var webCookieInitialCookies: [WebCookieSeed] = [] + @State private var didAutoStart = false init( handler: LoginMethodHandler, - authenticateURL: @escaping (String) async -> String? + authenticateURL: @escaping (String) async -> String?, + autoStart: Bool = false ) { self.authenticateURL = authenticateURL + self.autoStart = autoStart self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler))) } @@ -373,8 +489,15 @@ private struct LoginFlowView: View { } case .openWebCookieLogin(let webCookie): webCookieUrl = webCookie.url + webCookieInitialCookies = webCookie.initialCookies } } + .onAppear { + autoStartIfNeeded() + } + .onChange(of: flowAnimationKey) { _, _ in + autoStartIfNeeded() + } .sheet(isPresented: Binding( get: { webCookieUrl != nil }, set: { value in @@ -391,7 +514,7 @@ private struct LoginFlowView: View { } presenter.state.resume(value: cookie) self.webCookieUrl = nil - }, url: webCookieUrl) + }, url: webCookieUrl, initialCookies: webCookieInitialCookies) .toolbar { ToolbarItem(placement: .cancellationAction) { Button { @@ -441,6 +564,19 @@ private struct LoginFlowView: View { ].joined(separator: "|") } + private func autoStartIfNeeded() { + guard autoStart, + !didAutoStart, + !presenter.state.flowState.loading, + presenter.state.flowState.fields.isEmpty, + let action = presenter.state.flowState.actions.first(where: { $0.enabled }) + else { + return + } + didAutoStart = true + presenter.state.perform(actionId: action.id) + } + private func authenticate(url: String) { Task { if let responseString = await authenticateURL(url) { @@ -629,8 +765,12 @@ private struct MacOSWebLoginScreen: View { @StateObject private var viewModel: MacOSWebLoginViewModel private let url: String - init(onCookie: @escaping (String) -> Void, url: String) { - self._viewModel = .init(wrappedValue: .init(onCookie: onCookie, url: url)) + init( + onCookie: @escaping (String) -> Void, + url: String, + initialCookies: [WebCookieSeed] = [] + ) { + self._viewModel = .init(wrappedValue: .init(onCookie: onCookie, url: url, initialCookies: initialCookies)) self.url = url } @@ -683,14 +823,18 @@ private final class MacOSWebLoginViewModel: ObservableObject { let delegate: MacOSCookieNavigationDelegate - init(onCookie: @escaping (String) -> Void, url: String) { + init( + onCookie: @escaping (String) -> Void, + url: String, + initialCookies: [WebCookieSeed] + ) { self.delegate = MacOSCookieNavigationDelegate { WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in let cookieString = MacOSWebLoginViewModel.cookieHeaderString(from: cookies, for: URL(string: url)) onCookie(cookieString) } } - clearCookie() + clearCookie(initialCookies: initialCookies) } var configuration: WKWebViewConfiguration { @@ -699,14 +843,16 @@ private final class MacOSWebLoginViewModel: ObservableObject { return configuration } - private func clearCookie() { + private func clearCookie(initialCookies: [WebCookieSeed]) { let dataStore = WKWebsiteDataStore.default() dataStore.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in dataStore.removeData( ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: records, completionHandler: { - self.canShowWebView = true + dataStore.httpCookieStore.setCookies(initialCookies) { + self.canShowWebView = true + } } ) } @@ -725,6 +871,41 @@ private final class MacOSWebLoginViewModel: ObservableObject { } } +private extension WKHTTPCookieStore { + func setCookies(_ seeds: [WebCookieSeed], completion: @escaping () -> Void) { + guard !seeds.isEmpty else { + completion() + return + } + let group = DispatchGroup() + for seed in seeds { + guard let cookie = HTTPCookie(seed: seed) else { + continue + } + group.enter() + setCookie(cookie) { + group.leave() + } + } + group.notify(queue: .main, execute: completion) + } +} + +private extension HTTPCookie { + convenience init?(seed: WebCookieSeed) { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: seed.name, + .value: seed.value, + .domain: seed.domain, + .path: seed.path, + ] + if seed.secure { + properties[.secure] = "TRUE" + } + self.init(properties: properties) + } +} + private extension URL { var isPixivOAuthUrl: Bool { scheme == "https" && @@ -745,6 +926,8 @@ private enum ServiceSelectCopy { static let nostrQRLinkLabel = String(localized: "nostr_login_qr_link_label", defaultValue: "Nostr Connect link") static let eulaPrivacyPolicy = String(localized: "eula_privacy_policy", defaultValue: "EULA and Privacy Policy") static let loginAgreementPrefix = String(localized: "login_agreement_prefix", defaultValue: "By logging in, you agree to the ") + static let loginExpired = String(localized: "login_expired", defaultValue: "Login session expired") + static let loginExpiredMessage = String(localized: "login_expired_message", defaultValue: "Log in again to continue using this account.") static let search = String(localized: "search") static let clear = String(localized: "Clear") diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt index 97e1ca83a6..75212aa374 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/component/ErrorContent.kt @@ -89,7 +89,13 @@ private fun RequireReLoginError( modifier = modifier .clickable { - uriHandler.openUri(DeeplinkRoute.Login.toUri()) + uriHandler.openUri( + DeeplinkRoute + .Relogin( + accountKey = error.accountKey, + platformType = error.platformType, + ).toUri(), + ) }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), @@ -118,7 +124,13 @@ private fun LoginExpiredError( modifier = modifier .clickable { - uriHandler.openUri(DeeplinkRoute.Login.toUri()) + uriHandler.openUri( + DeeplinkRoute + .Relogin( + accountKey = error.accountKey, + platformType = error.platformType, + ).toUri(), + ) }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt index 4b210fa1c9..933057d719 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -54,6 +55,8 @@ import dev.dimension.flare.common.onSuccess import dev.dimension.flare.compose.ui.Res import dev.dimension.flare.compose.ui.eula_privacy_policy import dev.dimension.flare.compose.ui.login_agreement +import dev.dimension.flare.compose.ui.login_expired +import dev.dimension.flare.compose.ui.login_expired_message import dev.dimension.flare.compose.ui.mastodon_login_verify_message import dev.dimension.flare.compose.ui.nostr_login_qr_button import dev.dimension.flare.compose.ui.nostr_login_qr_hint @@ -95,6 +98,9 @@ import dev.dimension.flare.ui.presenter.login.LoginField import dev.dimension.flare.ui.presenter.login.LoginFieldType import dev.dimension.flare.ui.presenter.login.LoginFlowPresenter import dev.dimension.flare.ui.presenter.login.LoginMethodSpec +import dev.dimension.flare.ui.presenter.login.ReloginPresenter +import dev.dimension.flare.ui.presenter.login.ReloginTarget +import dev.dimension.flare.ui.presenter.login.WebCookieSeed import dev.dimension.flare.ui.theme.PlatformTheme import dev.dimension.flare.ui.theme.screenHorizontalPadding import io.github.alexzhirkevich.qrose.rememberQrCodePainter @@ -105,7 +111,7 @@ import org.jetbrains.compose.resources.stringResource @Composable public fun ServiceSelectionScreenContent( - onWebViewLogin: (url: String, cookieCallback: (cookies: String?) -> Boolean) -> Unit, + onWebViewLogin: (url: String, initialCookies: List, cookieCallback: (cookies: String?) -> Boolean) -> Unit, onBack: (() -> Unit), openUri: (String) -> Unit, registerDeeplinkCallback: @Composable ((url: String) -> Boolean) -> Unit, @@ -289,13 +295,157 @@ public fun ServiceSelectionScreenContent( } } +@Composable +public fun ReloginScreenContent( + target: ReloginTarget, + onWebViewLogin: (url: String, initialCookies: List, cookieCallback: (cookies: String?) -> Boolean) -> Unit, + onBack: (() -> Unit), + openUri: (String) -> Unit, + registerDeeplinkCallback: @Composable ((url: String) -> Boolean) -> Unit, + contentPadding: PaddingValues, + listState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), +) { + val state by producePresenter("relogin_${target.accountKey}_${target.platformType.name}") { + remember(target) { ReloginPresenter(target, onBack) }.body() + } + val methods = state.methods + if (methods.isEmpty()) return + var selectedMethod by remember(target) { mutableStateOf(methods.first().type) } + val selectedSpec = methods.firstOrNull { it.type == selectedMethod } ?: methods.first() + val handler = + remember(target, selectedMethod) { + state.createLoginHandler(selectedMethod) + } + val loginState by producePresenter("relogin_flow_${target.accountKey}_${target.platformType.name}_$selectedMethod") { + remember(handler) { LoginFlowPresenter(handler) }.body() + } + var qrContent by remember(handler) { mutableStateOf(null) } + var autoStarted by rememberSaveable( + target.accountKey.id, + target.accountKey.host, + target.platformType.name, + selectedMethod.name, + ) { mutableStateOf(false) } + + registerDeeplinkCallback { + if (loginState.canResume(it)) { + loginState.resume(it) + true + } else { + false + } + } + LaunchedEffect(loginState.effects) { + loginState.effects.collect { effect -> + when (effect) { + is LoginEffect.OpenUrl -> { + openUri(effect.url) + } + + is LoginEffect.ShowQr -> { + qrContent = effect.content + } + + is LoginEffect.OpenWebCookieLogin -> { + onWebViewLogin(effect.url, effect.initialCookies) { cookies -> + if (cookies.isNullOrBlank()) { + false + } else if (!loginState.canResume(cookies)) { + false + } else { + loginState.resume(cookies) + true + } + } + } + } + } + } + LaunchedEffect( + methods, + selectedMethod, + loginState.flowState.actions, + loginState.flowState.fields, + loginState.flowState.loading, + ) { + if (methods.size != 1 || autoStarted || loginState.flowState.loading || loginState.flowState.fields.isNotEmpty()) { + return@LaunchedEffect + } + loginState.flowState.actions.firstOrNull { it.enabled }?.let { action -> + autoStarted = true + loginState.perform(action.id) + } + } + + LazyStatusVerticalStaggeredGrid( + state = listState, + modifier = Modifier.fillMaxSize(), + columns = StaggeredGridCells.Adaptive(300.dp), + contentPadding = contentPadding, + forceCardMode = true, + ) { + item(span = StaggeredGridItemSpan.FullLine) { + Column( + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FAIcon( + imageVector = state.platformIcon().toImageVector(), + contentDescription = null, + modifier = Modifier.size(36.dp), + ) + PlatformText( + text = stringResource(Res.string.login_expired), + style = PlatformTheme.typography.headline, + textAlign = TextAlign.Center, + ) + PlatformText( + text = "${target.accountKey.id}@${target.accountKey.host}", + textAlign = TextAlign.Center, + style = PlatformTheme.typography.title, + ) + PlatformText( + text = stringResource(Res.string.login_expired_message), + textAlign = TextAlign.Center, + style = PlatformTheme.typography.caption, + ) + if (methods.size > 1) { + LoginMethodPicker( + methods = methods, + selectedSpec = selectedSpec, + onSelected = { + selectedMethod = it.type + }, + ) + } + LoginFlowContent( + state = loginState, + qrContent = qrContent, + onQrDismiss = { + qrContent = null + }, + ) + LoginAgreement( + platformType = target.platformType, + host = target.accountKey.host, + agreementUrl = { _, _ -> state.agreementUrl() }, + openUri = openUri, + ) + } + } + } +} + @Composable private fun GenericLoginContent( state: SelectionPresenter.State, platformType: PlatformType, host: String, openUri: (String) -> Unit, - onWebViewLogin: (url: String, cookieCallback: (cookies: String?) -> Boolean) -> Unit, + onWebViewLogin: (url: String, initialCookies: List, cookieCallback: (cookies: String?) -> Boolean) -> Unit, registerDeeplinkCallback: @Composable ((url: String) -> Boolean) -> Unit, ) { val methods = state.loginMethods(platformType) @@ -334,7 +484,7 @@ private fun GenericLoginContent( } is LoginEffect.OpenWebCookieLogin -> { - onWebViewLogin(effect.url) { cookies -> + onWebViewLogin(effect.url, effect.initialCookies) { cookies -> if (cookies.isNullOrBlank()) { false } else if (!loginState.canResume(cookies)) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt index 0c420e5317..325cd967c6 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/App.kt @@ -508,6 +508,9 @@ internal fun WindowScope.FlareApp(backButtonState: NavigationBackButtonState) { modifier = Modifier .align(Alignment.TopCenter), + onRelogin = { + state.navigate(Route.Relogin(it)) + }, ) } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt index 484489fd4b..90d4531719 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/AccountItem.kt @@ -20,6 +20,7 @@ import dev.dimension.flare.ui.model.UiState import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.login.ReloginTarget import io.github.composefluent.component.CardExpanderItem import io.github.composefluent.component.SubtleButton import io.github.composefluent.component.Text @@ -30,6 +31,7 @@ fun AccountItem( userState: UiState, onClick: (MicroBlogKey) -> Unit, toLogin: () -> Unit, + toRelogin: (ReloginTarget) -> Unit = { toLogin() }, modifier: Modifier = Modifier, trailingContent: @Composable (UiProfile?) -> Unit = { }, headlineContent: @Composable (UiProfile) -> Unit = { @@ -108,7 +110,16 @@ fun AccountItem( }, trailing = { if (throwable is LoginExpiredException) { - SubtleButton(onClick = toLogin) { + SubtleButton( + onClick = { + toRelogin( + ReloginTarget( + accountKey = throwable.accountKey, + platformType = throwable.platformType, + ), + ) + }, + ) { Text(text = stringResource(Res.string.login_expired_relogin)) } } else { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt index 10aa047473..2147a35325 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/component/ComposeInAppNotification.kt @@ -24,7 +24,10 @@ import dev.dimension.flare.common.Message import dev.dimension.flare.compose_notification_error import dev.dimension.flare.compose_notification_success import dev.dimension.flare.data.repository.LoginExpiredException +import dev.dimension.flare.login_expired_relogin import dev.dimension.flare.notification_login_expired +import dev.dimension.flare.ui.presenter.login.ReloginTarget +import io.github.composefluent.component.Button import io.github.composefluent.component.InfoBar import io.github.composefluent.component.InfoBarSeverity import io.github.composefluent.component.ProgressBar @@ -51,6 +54,7 @@ internal sealed interface Notification { val messageId: StringResource, val success: Boolean, val args: List = emptyList(), + val reloginTarget: ReloginTarget? = null, ) : Notification } @@ -89,6 +93,15 @@ internal class ComposeInAppNotification : InAppNotification { null }, ), + reloginTarget = + if (throwable is LoginExpiredException) { + ReloginTarget( + accountKey = throwable.accountKey, + platformType = throwable.platformType, + ) + } else { + null + }, ), ) } @@ -119,6 +132,7 @@ private val Message.errorTitle internal fun InAppNotificationComponent( modifier: Modifier = Modifier, notification: ComposeInAppNotification = koinInject(), + onRelogin: (ReloginTarget) -> Unit = {}, ) { val source by notification.source.collectAsState() val content = remember(source) { source.getContentIfNotHandled() } @@ -161,6 +175,16 @@ internal fun InAppNotificationComponent( ) }, message = { + it.reloginTarget?.let { target -> + Button( + onClick = { + showNotification = false + onRelogin(target) + }, + ) { + Text(stringResource(Res.string.login_expired_relogin)) + } + } }, severity = if (it.success) { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt index ece8292bbc..d2d009bf53 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Route.kt @@ -8,6 +8,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.UiRssSource +import dev.dimension.flare.ui.presenter.login.ReloginTarget +import dev.dimension.flare.ui.presenter.login.WebCookieSeed import dev.dimension.flare.ui.route.Route.Compose.New import dev.dimension.flare.ui.route.Route.Compose.Quote import dev.dimension.flare.ui.route.Route.Compose.Reply @@ -67,6 +69,10 @@ internal sealed interface Route : NavKey { data object ServiceSelect : ScreenRoute + data class Relogin( + val target: ReloginTarget, + ) : ScreenRoute + data object AppLogging : ScreenRoute data class AllLists( @@ -288,6 +294,7 @@ internal sealed interface Route : NavKey { data class WebViewLogin( val url: String, + val initialCookies: List, val callback: (cookies: String?) -> Boolean, ) : ScreenRoute @@ -362,6 +369,15 @@ internal sealed interface Route : NavKey { ServiceSelect } + is DeeplinkRoute.Relogin -> { + Relogin( + ReloginTarget( + accountKey = deeplinkRoute.accountKey, + platformType = deeplinkRoute.platformType, + ), + ) + } + DeeplinkRoute.Compose.New -> { New } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt index 819e7cdf98..5a57ce3a82 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/route/Router.kt @@ -87,6 +87,7 @@ import dev.dimension.flare.ui.screen.misskey.ChannelListScreen import dev.dimension.flare.ui.screen.rss.EditRssSourceScreen import dev.dimension.flare.ui.screen.rss.ImportOPMLScreen import dev.dimension.flare.ui.screen.rss.RssListScreen +import dev.dimension.flare.ui.screen.serviceselect.ReloginScreen import dev.dimension.flare.ui.screen.serviceselect.ServiceSelectScreen import dev.dimension.flare.ui.screen.serviceselect.WebViewLoginScreen import dev.dimension.flare.ui.screen.settings.AgentChatScreen @@ -654,10 +655,27 @@ internal fun Router( entry { ServiceSelectScreen( onBack = onBack, - onWebViewLogin = { url, callback -> + onWebViewLogin = { url, initialCookies, callback -> navigate( Route.WebViewLogin( url = url, + initialCookies = initialCookies, + callback = callback, + ), + ) + }, + ) + } + + entry { args -> + ReloginScreen( + target = args.target, + onBack = onBack, + onWebViewLogin = { url, initialCookies, callback -> + navigate( + Route.WebViewLogin( + url = url, + initialCookies = initialCookies, callback = callback, ), ) @@ -670,6 +688,9 @@ internal fun Router( toLogin = { navigate(Route.ServiceSelect) }, + toRelogin = { + navigate(Route.Relogin(it)) + }, toDraftBox = { navigate(Route.DraftBox) }, @@ -1089,6 +1110,7 @@ internal fun Router( entry { args -> WebViewLoginScreen( url = args.url, + initialCookies = args.initialCookies, callback = args.callback, onBack = onBack, ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt index 5160f63b94..58ef8c054b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt @@ -59,6 +59,10 @@ internal fun DeepLinkAccountPicker( onNavigate(route) }, toLogin = {}, + toRelogin = { + onDismissRequest() + onNavigate(Route.Relogin(it)) + }, ) } item { diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt index 32546d872c..45092c6220 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/ServiceSelectScreen.kt @@ -4,12 +4,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalUriHandler import dev.dimension.flare.LocalWindowPadding import dev.dimension.flare.common.OnDeepLink +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 @Composable internal fun ServiceSelectScreen( onBack: () -> Unit, - onWebViewLogin: (url: String, cookieCallback: (cookies: String?) -> Boolean) -> Unit, + onWebViewLogin: (url: String, initialCookies: List, cookieCallback: (cookies: String?) -> Boolean) -> Unit, ) { val uriHandler = LocalUriHandler.current ServiceSelectionScreenContent( @@ -24,3 +27,24 @@ internal fun ServiceSelectScreen( onBack = onBack, ) } + +@Composable +internal fun ReloginScreen( + target: ReloginTarget, + onBack: () -> Unit, + onWebViewLogin: (url: String, initialCookies: List, cookieCallback: (cookies: String?) -> Boolean) -> Unit, +) { + val uriHandler = LocalUriHandler.current + ReloginScreenContent( + target = target, + contentPadding = LocalWindowPadding.current, + onWebViewLogin = onWebViewLogin, + openUri = uriHandler::openUri, + registerDeeplinkCallback = { callback -> + OnDeepLink { + callback(it) + } + }, + onBack = onBack, + ) +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt index d59aec6609..3cc2a9199b 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/serviceselect/WebViewLoginScreen.kt @@ -3,22 +3,40 @@ package dev.dimension.flare.ui.screen.serviceselect import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import dev.dimension.flare.ui.presenter.login.WebCookieSeed +import io.github.kdroidfilter.webview.web.WebContent import io.github.kdroidfilter.webview.web.WebView -import io.github.kdroidfilter.webview.web.rememberWebViewState +import io.github.kdroidfilter.webview.web.WebViewState +import io.github.kdroidfilter.webview.wry.WebViewCookie import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds @Composable internal fun WebViewLoginScreen( url: String, + initialCookies: List, callback: (String?) -> Boolean, onBack: () -> Unit, ) { val state = - rememberWebViewState(url) { - desktopWebSettings.incognito = true + remember(url) { + WebViewState(WebContent.Url("about:blank")).apply { + webSettings.desktopWebSettings.incognito = true + } + } + LaunchedEffect(state.webView, url, initialCookies) { + val nativeWebView = state.webView?.nativeWebView ?: return@LaunchedEffect + while (!nativeWebView.isReady()) { + delay(50) + } + nativeWebView.clearAllCookies() + initialCookies.forEach { seed -> + nativeWebView.setCookie(seed.toDesktopCookie()) } + nativeWebView.loadUrl(url) + } LaunchedEffect(Unit) { while (true) { delay(2.seconds) @@ -42,3 +60,17 @@ internal fun WebViewLoginScreen( modifier = Modifier.fillMaxSize(), ) } + +private fun WebCookieSeed.toDesktopCookie(): WebViewCookie = + WebViewCookie( + name = name, + value = value, + domain = domain, + path = path, + expiresDateMs = null, + isSessionOnly = true, + maxAgeSec = null, + sameSite = null, + isSecure = secure, + isHttpOnly = null, + ) diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index a14840acd3..18a366683a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -246,6 +246,7 @@ import dev.dimension.flare.ui.presenter.ImportDataPresenter import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.login.ReloginTarget import dev.dimension.flare.ui.presenter.settings.AiAgentEnabledPresenter import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiReasoningEffortOption @@ -300,6 +301,7 @@ import java.util.Locale @Composable internal fun SettingsScreen( toLogin: () -> Unit, + toRelogin: (ReloginTarget) -> Unit, toDraftBox: () -> Unit, toLocalCache: () -> Unit, toAgentHistory: () -> Unit, @@ -394,6 +396,7 @@ internal fun SettingsScreen( state.accountState.setActiveAccount(it) }, toLogin = toLogin, + toRelogin = toRelogin, trailingContent = { user -> Row( verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, diff --git a/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt b/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt index 93cbc1d317..4921ceaf09 100644 --- a/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt +++ b/feature/login-api/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/LoginApi.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.presenter.login import dev.dimension.flare.data.network.nodeinfo.NodeData import dev.dimension.flare.data.network.nodeinfo.PlatformDetector +import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformRuntimeData import dev.dimension.flare.model.PlatformType import dev.dimension.flare.model.PlatformTypeMetadata @@ -60,6 +61,19 @@ public data class LoginFlowState( val error: String? = null, ) +public data class ReloginTarget( + val accountKey: MicroBlogKey, + val platformType: PlatformType, +) + +public data class WebCookieSeed( + val name: String, + val value: String, + val domain: String, + val path: String = "/", + val secure: Boolean = true, +) + public sealed interface LoginEffect { public data class OpenUrl( val url: String, @@ -71,6 +85,7 @@ public sealed interface LoginEffect { public data class OpenWebCookieLogin( val url: String, + val initialCookies: List = emptyList(), ) : LoginEffect } @@ -94,8 +109,55 @@ public data class LoginContext( val methodType: LoginMethodType, val onSuccess: suspend () -> Unit, val redirectUri: String? = null, + val reloginTarget: ReloginTarget? = null, ) +public fun LoginContext.requireReloginAccount(accountKey: MicroBlogKey) { + val target = reloginTarget ?: return + if (accountKey != target.accountKey) { + throw ReloginAccountMismatchException( + expected = target.accountKey, + actual = accountKey, + ) + } +} + +public class ReloginAccountMismatchException( + public val expected: MicroBlogKey, + public val actual: MicroBlogKey, +) : IllegalArgumentException( + "Relogin account mismatch: expected $expected, got $actual", + ) + +public fun cookieHeaderToWebCookieSeeds( + cookieHeader: String, + domain: String, + path: String = "/", + secure: Boolean = true, +): List = + cookieHeader + .split(";") + .mapNotNull { part -> + val trimmed = part.trim() + val separator = trimmed.indexOf('=') + if (separator <= 0) { + return@mapNotNull null + } + val name = trimmed.substring(0, separator).trim() + val value = trimmed.substring(separator + 1).trim() + if (name.isEmpty()) { + null + } else { + WebCookieSeed( + name = name, + value = value, + domain = domain, + path = path, + secure = secure, + ) + } + } + public interface LoginMethodHandler : AutoCloseable { public val state: StateFlow public val effects: Flow diff --git a/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginContextTest.kt b/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginContextTest.kt new file mode 100644 index 0000000000..ecf868e3ff --- /dev/null +++ b/feature/login-api/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/login/LoginContextTest.kt @@ -0,0 +1,77 @@ +package dev.dimension.flare.ui.presenter.login + +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LoginContextTest { + @Test + fun reloginAccountMatchIsAllowed() { + val accountKey = MicroBlogKey(id = "user-a", host = "example.com") + val context = + loginContext( + ReloginTarget( + accountKey = accountKey, + platformType = PlatformType.Mastodon, + ), + ) + + context.requireReloginAccount(accountKey) + } + + @Test + fun reloginAccountMismatchFails() { + val expected = MicroBlogKey(id = "user-a", host = "example.com") + val actual = MicroBlogKey(id = "user-b", host = "example.com") + val context = + loginContext( + ReloginTarget( + accountKey = expected, + platformType = PlatformType.Mastodon, + ), + ) + + val error = + assertFailsWith { + context.requireReloginAccount(actual) + } + + assertEquals(expected, error.expected) + assertEquals(actual, error.actual) + } + + @Test + fun normalLoginDoesNotRestrictAccount() { + val context = loginContext(reloginTarget = null) + + context.requireReloginAccount(MicroBlogKey(id = "any-user", host = "example.com")) + } + + @Test + fun cookieHeaderToSeedsSplitsNameValuePairs() { + val seeds = + cookieHeaderToWebCookieSeeds( + cookieHeader = "auth_token=abc; ct0=def=ghi; empty=; invalid", + domain = "x.com", + ) + + assertEquals( + listOf( + WebCookieSeed(name = "auth_token", value = "abc", domain = "x.com"), + WebCookieSeed(name = "ct0", value = "def=ghi", domain = "x.com"), + WebCookieSeed(name = "empty", value = "", domain = "x.com"), + ), + seeds, + ) + } + + private fun loginContext(reloginTarget: ReloginTarget?): LoginContext = + LoginContext( + host = "example.com", + methodType = LoginMethodType.OAuth, + onSuccess = {}, + reloginTarget = reloginTarget, + ) +} diff --git a/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ReloginPresenter.kt b/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ReloginPresenter.kt new file mode 100644 index 0000000000..92f0ef9c54 --- /dev/null +++ b/feature/login/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/ReloginPresenter.kt @@ -0,0 +1,52 @@ +package dev.dimension.flare.ui.presenter.login + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import dev.dimension.flare.di.koinInject +import dev.dimension.flare.ui.model.UiIcon +import dev.dimension.flare.ui.presenter.PresenterBase + +public class ReloginPresenter( + private val target: ReloginTarget, + private val onSuccess: () -> Unit, +) : PresenterBase() { + private val loginPlatformRegistry: LoginPlatformRegistry by koinInject() + + @Composable + override fun body(): ReloginState = + object : ReloginState { + override val target: ReloginTarget = this@ReloginPresenter.target + override val methods: List = loginPlatformRegistry.methods(target.platformType) + + override fun platformIcon(): UiIcon = loginPlatformRegistry.require(target.platformType).metadata.icon + + override fun agreementUrl(): String? = + loginPlatformRegistry + .require(target.platformType) + .agreementUrl(target.accountKey.host) + + override fun createLoginHandler(methodType: LoginMethodType): LoginMethodHandler = + loginPlatformRegistry.require(target.platformType).createHandler( + LoginContext( + host = target.accountKey.host, + methodType = methodType, + onSuccess = { + onSuccess() + }, + reloginTarget = target, + ), + ) + } +} + +@Immutable +public interface ReloginState { + public val target: ReloginTarget + public val methods: List + + public fun platformIcon(): UiIcon + + public fun agreementUrl(): String? + + public fun createLoginHandler(methodType: LoginMethodType): LoginMethodHandler +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt index a9856267e4..4a5848b8c4 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/route/DeeplinkRoute.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.route import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromHexString @@ -15,6 +16,12 @@ public sealed class DeeplinkRoute { @Serializable public data object Login : DeeplinkRoute() + @Serializable + public data class Relogin( + val accountKey: MicroBlogKey, + val platformType: PlatformType, + ) : DeeplinkRoute() + @Serializable public sealed class Timeline : DeeplinkRoute() { @Serializable diff --git a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt index f8c03bfacf..f3f3dff3d3 100644 --- a/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt +++ b/shared/src/commonTest/kotlin/dev/dimension/flare/ui/route/DeeplinkRouteTest.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.route import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType import kotlinx.collections.immutable.persistentMapOf import kotlin.test.Test import kotlin.test.assertEquals @@ -21,6 +22,21 @@ class DeeplinkRouteTest { assertEquals(route, parsed) } + @Test + fun testRelogin() { + val route = + DeeplinkRoute.Relogin( + accountKey = MicroBlogKey("expired", "example.com"), + platformType = PlatformType.Mastodon, + ) + val uri = route.toUri() + val parsed = DeeplinkRoute.parse(uri) + if (route != parsed) { + println("FAILURE DEBUG: Expected=$route, Actual=$parsed, URI=$uri") + } + assertEquals(route, parsed) + } + @Test fun testStatusDetail() { val route = diff --git a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt index 6afd80beb7..4fb2d8864c 100644 --- a/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt +++ b/social/bluesky/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/BlueskyLoginProvider.kt @@ -258,14 +258,16 @@ private class BlueskyPasswordLoginHandler( accessToken = response.accessJwt, refreshToken = response.refreshJwt, ) + val accountKey = + MicroBlogKey( + id = response.did.did, + host = Url(baseUrl).host, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( account = UiAccount( - accountKey = - MicroBlogKey( - id = response.did.did, - host = Url(baseUrl).host, - ), + accountKey = accountKey, platformType = PlatformType.Bluesky, ), credential = credential, @@ -503,14 +505,16 @@ private class BlueskyOAuthLoginHandler( } error("OAuth token is missing required scope(s): ${missingScopes.joinToString(" ")}") } + val accountKey = + MicroBlogKey( + id = token.subject.did, + host = host, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( account = UiAccount( - accountKey = - MicroBlogKey( - id = token.subject.did, - host = host, - ), + accountKey = accountKey, platformType = PlatformType.Bluesky, ), credential = credential, diff --git a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt index c1fe103612..74c33aed23 100644 --- a/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt +++ b/social/fanbox/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/FanboxLoginProvider.kt @@ -4,9 +4,11 @@ import dev.dimension.flare.data.network.fanbox.FanboxPlatformDetector import dev.dimension.flare.data.network.fanbox.FanboxService import dev.dimension.flare.data.network.nodeinfo.PlatformDetector import dev.dimension.flare.data.platform.FANBOX_HOST +import dev.dimension.flare.data.platform.FANBOX_WEB_HOST import dev.dimension.flare.data.platform.FanboxCredential import dev.dimension.flare.data.platform.FanboxPlatformSpec import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.data.repository.credentialFlow import dev.dimension.flare.di.koinInject import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -94,7 +97,30 @@ private class FanboxWebCookieLoginHandler( override suspend fun perform(actionId: String) { if (actionId != LOGIN_ACTION) return - _effects.emit(LoginEffect.OpenWebCookieLogin(FANBOX_LOGIN_URL)) + val initialCookies = + context.reloginTarget + ?.accountKey + ?.let { accountKey -> + accountService + .credentialFlow(accountKey) + .firstOrNull() + ?.sessionId + }?.takeIf { it.isNotBlank() } + ?.let { sessionId -> + listOf( + WebCookieSeed( + name = FANBOX_SESSION_COOKIE, + value = sessionId, + domain = FANBOX_WEB_HOST, + ), + ) + }.orEmpty() + _effects.emit( + LoginEffect.OpenWebCookieLogin( + url = FANBOX_LOGIN_URL, + initialCookies = initialCookies, + ), + ) } override suspend fun resume(value: String) { @@ -119,10 +145,12 @@ private class FanboxWebCookieLoginHandler( isSupporter = user.isSupporter, isCreator = user.isCreator, ) + val accountKey = MicroBlogKey(id = userId, host = FANBOX_HOST) + context.requireReloginAccount(accountKey) accountService.addAccount( account = UiAccount( - accountKey = MicroBlogKey(id = userId, host = FANBOX_HOST), + accountKey = accountKey, platformType = PlatformType.Fanbox, ), credential = credential, diff --git a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MastodonLoginProvider.kt b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MastodonLoginProvider.kt index 23f39657de..77a32b1d66 100644 --- a/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MastodonLoginProvider.kt +++ b/social/mastodon/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MastodonLoginProvider.kt @@ -230,6 +230,12 @@ private class MastodonOAuthLoginHandler( val id = user.id requireNotNull(id) { "Invalid user id" } val nodeInfo = NodeInfoService.fetchNodeInfo(host) + val accountKey = + MicroBlogKey( + id = id, + host = host, + ) + context.requireReloginAccount(accountKey) val forkType = if (nodeInfo in NodeInfoService.pleromaNodeInfoName) { MastodonCredential.ForkType.Pleroma @@ -238,11 +244,7 @@ private class MastodonOAuthLoginHandler( } accountService.addAccount( UiAccount( - accountKey = - MicroBlogKey( - id = id, - host = host, - ), + accountKey = accountKey, platformType = PlatformType.Mastodon, ), credential = diff --git a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MisskeyLoginProvider.kt b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MisskeyLoginProvider.kt index ae1a153f1d..91c5f97a94 100644 --- a/social/misskey/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MisskeyLoginProvider.kt +++ b/social/misskey/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/MisskeyLoginProvider.kt @@ -168,13 +168,15 @@ private class MisskeyOAuthLoginHandler( val id = response.user?.id requireNotNull(id) { "No user id" } val nodeInfo = NodeInfoService.fetchNodeInfo(host) + val accountKey = + MicroBlogKey( + id = id, + host = host, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( UiAccount( - accountKey = - MicroBlogKey( - id = id, - host = host, - ), + accountKey = accountKey, platformType = PlatformType.Misskey, ), credential = diff --git a/social/nostr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginProvider.kt b/social/nostr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginProvider.kt index 032c06975d..dc16b2acfe 100644 --- a/social/nostr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginProvider.kt +++ b/social/nostr/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/NostrLoginProvider.kt @@ -124,6 +124,7 @@ private class NostrCredentialLoginHandler( _state.value = state(loading = true) runCatching { loginWith( + context = context, accountService = accountService, imported = NostrService.importAccount( @@ -222,7 +223,7 @@ private class NostrQrLoginHandler( _state.value = state(loading = true) pendingQrLogin = null waiting = false - loginWith(accountService, imported) + loginWith(context, accountService, imported) session.close() context.onSuccess() } @@ -292,14 +293,16 @@ private class NostrExternalSignerLoginHandler( runCatching { NostrService.resolvePublicRelays(connection.pubkeyHex) }.getOrDefault(defaultNostrRelays) + val accountKey = + MicroBlogKey( + id = connection.pubkeyHex, + host = NostrService.NOSTR_HOST, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( account = UiAccount( - accountKey = - MicroBlogKey( - id = connection.pubkeyHex, - host = NostrService.NOSTR_HOST, - ), + accountKey = accountKey, platformType = PlatformType.Nostr, ), credential = @@ -340,6 +343,7 @@ private class NostrExternalSignerLoginHandler( } private suspend fun loginWith( + context: LoginContext, accountService: AccountService, imported: NostrService.ImportedAccount, ) { @@ -347,14 +351,16 @@ private suspend fun loginWith( runCatching { NostrService.resolvePublicRelays(imported.pubkeyHex) }.getOrDefault(defaultNostrRelays) + val accountKey = + MicroBlogKey( + id = imported.pubkeyHex, + host = NostrService.NOSTR_HOST, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( account = UiAccount( - accountKey = - MicroBlogKey( - id = imported.pubkeyHex, - host = NostrService.NOSTR_HOST, - ), + accountKey = accountKey, platformType = PlatformType.Nostr, ), credential = diff --git a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/PixivLoginProvider.kt b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/PixivLoginProvider.kt index 83188fc4b8..6f7b34b6fc 100644 --- a/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/PixivLoginProvider.kt +++ b/social/pixiv/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/PixivLoginProvider.kt @@ -138,10 +138,12 @@ private class PixivOAuthLoginHandler( redirectUri = request.redirectUri, ) val credential = response.toCredential() + val accountKey = credential.accountKey() + context.requireReloginAccount(accountKey) accountService.addAccount( account = UiAccount( - accountKey = credential.accountKey(), + accountKey = accountKey, platformType = PlatformType.Pixiv, ), credential = credential, diff --git a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt index c5c6aa59bb..3aeb1a9c14 100644 --- a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt +++ b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt @@ -7,6 +7,7 @@ import dev.dimension.flare.data.platform.VVoCredential import dev.dimension.flare.data.platform.VvoPlatformSpec import dev.dimension.flare.data.repository.AccountService import dev.dimension.flare.data.repository.addAccount +import dev.dimension.flare.data.repository.credentialFlow import dev.dimension.flare.di.koinInject import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf private const val LOGIN_ACTION = "login" @@ -69,7 +71,26 @@ private class VVOWebCookieLoginHandler( override suspend fun perform(actionId: String) { if (actionId != LOGIN_ACTION) return - _effects.emit(LoginEffect.OpenWebCookieLogin("https://$vvoHost/login?backURL=https://$vvoHost/")) + val initialCookies = + context.reloginTarget + ?.accountKey + ?.let { accountKey -> + accountService + .credentialFlow(accountKey) + .firstOrNull() + ?.chocolate + }?.let { chocolate -> + cookieHeaderToWebCookieSeeds( + cookieHeader = chocolate, + domain = vvoHost, + ) + }.orEmpty() + _effects.emit( + LoginEffect.OpenWebCookieLogin( + url = "https://$vvoHost/message", + initialCookies = initialCookies, + ), + ) } override suspend fun resume(value: String) { @@ -99,13 +120,15 @@ private class VVOWebCookieLoginHandler( requireNotNull(st) { "st is null" } val profile = service.profileInfo(uid, st) requireNotNull(profile.data) { "profile is null" } + val accountKey = + MicroBlogKey( + id = uid, + host = vvoHost, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( UiAccount( - accountKey = - MicroBlogKey( - id = uid, - host = vvoHost, - ), + accountKey = accountKey, platformType = PlatformType.VVo, ), credential = diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/XQTLoginProvider.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/XQTLoginProvider.kt index fd94ed2a08..a6b670fb19 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/XQTLoginProvider.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/XQTLoginProvider.kt @@ -9,6 +9,7 @@ import dev.dimension.flare.data.platform.XQTCredential import dev.dimension.flare.data.platform.XqtPlatformSpec import dev.dimension.flare.data.repository.AccountService import dev.dimension.flare.data.repository.addAccount +import dev.dimension.flare.data.repository.credentialFlow import dev.dimension.flare.di.koinInject import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -23,6 +24,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf private const val LOGIN_ACTION = "login" @@ -89,7 +91,26 @@ private class XQTWebCookieLoginHandler( override suspend fun perform(actionId: String) { if (actionId != LOGIN_ACTION) return - _effects.emit(LoginEffect.OpenWebCookieLogin("https://$xqtHost/i/flow/login")) + val initialCookies = + context.reloginTarget + ?.accountKey + ?.let { accountKey -> + accountService + .credentialFlow(accountKey) + .firstOrNull() + ?.chocolate + }?.let { chocolate -> + cookieHeaderToWebCookieSeeds( + cookieHeader = chocolate, + domain = xqtHost, + ) + }.orEmpty() + _effects.emit( + LoginEffect.OpenWebCookieLogin( + url = "https://$xqtHost/i/flow/login", + initialCookies = initialCookies, + ), + ) } override suspend fun resume(value: String) { @@ -123,13 +144,15 @@ private class XQTWebCookieLoginHandler( ?.result requireNotNull(account) require(account is User) + val accountKey = + MicroBlogKey( + id = account.restId, + host = xqtHost, + ) + context.requireReloginAccount(accountKey) accountService.addAccount( UiAccount( - accountKey = - MicroBlogKey( - id = account.restId, - host = xqtHost, - ), + accountKey = accountKey, platformType = PlatformType.xQt, ), credential = From 5fe0566570f666199b0e30508dee416b85481e2e Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 17:30:33 +0900 Subject: [PATCH 2/8] update vvo login --- .../data/network/vvo/model/TimelineData.kt | 1 + .../ui/presenter/login/VVOLoginProvider.kt | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt index b611ffd285..db8985dd88 100644 --- a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt +++ b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/data/network/vvo/model/TimelineData.kt @@ -486,6 +486,7 @@ internal data class Config( val login: Boolean? = null, val st: String? = null, val uid: String? = null, + val loginUrl: String? = null, ) internal object VVODateSerializer : KSerializer { diff --git a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt index 3aeb1a9c14..f935c29656 100644 --- a/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt +++ b/social/vvo/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/login/VVOLoginProvider.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map private const val LOGIN_ACTION = "login" @@ -85,12 +86,30 @@ private class VVOWebCookieLoginHandler( domain = vvoHost, ) }.orEmpty() - _effects.emit( - LoginEffect.OpenWebCookieLogin( - url = "https://$vvoHost/message", - initialCookies = initialCookies, - ), - ) + val accountKey = context.reloginTarget?.accountKey + if (accountKey != null) { + val service = + VVOService( + accountService + .credentialFlow(accountKey) + .map { it.chocolate }, + ) + val config = service.config() + val loginUrl = config.data?.loginUrl ?: "https://$vvoHost/message" + _effects.emit( + LoginEffect.OpenWebCookieLogin( + url = loginUrl, + initialCookies = initialCookies, + ), + ) + } else { + _effects.emit( + LoginEffect.OpenWebCookieLogin( + url = "https://$vvoHost/message", + initialCookies = initialCookies, + ), + ) + } } override suspend fun resume(value: String) { From ad7640e79c920dbde7db986cd3a6138ad7ad5ee2 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 19:24:28 +0900 Subject: [PATCH 3/8] update relogin flow --- .../UI/Screen/ServiceSelectionScreen.swift | 27 ++---------------- .../UI/Screen/ServiceSelectionScreen.swift | 28 ++----------------- .../login/ServiceSelectionScreenContent.kt | 22 --------------- 3 files changed, 4 insertions(+), 73 deletions(-) diff --git a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift index 3a3a5d3640..9a52e07460 100644 --- a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift @@ -358,7 +358,7 @@ struct ReloginScreen: View { } let handler = state.createLoginHandler(methodType: selectedMethod) - LoginFlowView(handler: handler, autoStart: methods.count == 1) + LoginFlowView(handler: handler) .id("\(target.accountKey)-\(selectedMethod)") LoginAgreementView(urlString: state.agreementUrl()) @@ -370,16 +370,12 @@ struct ReloginScreen: View { private struct LoginFlowView: View { @Environment(\.webAuthenticationSession) private var webAuthenticationSession - let autoStart: Bool - @StateObject private var presenter: KotlinPresenter @State private var qrContent: String? @State private var webCookieUrl: String? @State private var webCookieInitialCookies: [WebCookieSeed] = [] - @State private var didAutoStart = false - init(handler: LoginMethodHandler, autoStart: Bool = false) { - self.autoStart = autoStart + init(handler: LoginMethodHandler) { self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler))) } @@ -440,12 +436,6 @@ private struct LoginFlowView: View { webCookieInitialCookies = webCookie.initialCookies } } - .onAppear { - autoStartIfNeeded() - } - .onChange(of: flowAnimationKey) { _, _ in - autoStartIfNeeded() - } .sheet(isPresented: Binding( get: { webCookieUrl != nil }, set: { value in @@ -503,19 +493,6 @@ private struct LoginFlowView: View { ].joined(separator: "|") } - private func autoStartIfNeeded() { - guard autoStart, - !didAutoStart, - !presenter.state.flowState.loading, - presenter.state.flowState.fields.isEmpty, - let action = presenter.state.flowState.actions.first(where: { $0.enabled }) - else { - return - } - didAutoStart = true - presenter.state.perform(actionId: action.id) - } - private func authenticate(url: String) { Task { guard let authURL = URL(string: url) else { return } diff --git a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift index 6ad5bf1227..7aa8d7c1ac 100644 --- a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift @@ -393,8 +393,7 @@ struct ReloginScreen: View { let handler = state.createLoginHandler(methodType: selectedMethod.wrappedValue) LoginFlowView( handler: handler, - authenticateURL: authenticate(url:), - autoStart: methods.count == 1 + authenticateURL: authenticate(url:) ) .id("\(target.accountKey)-\(selectedMethod.wrappedValue)") @@ -418,21 +417,17 @@ struct ReloginScreen: View { private struct LoginFlowView: View { let authenticateURL: (String) async -> String? - let autoStart: Bool @StateObject private var presenter: KotlinPresenter @State private var qrContent: String? @State private var webCookieUrl: String? @State private var webCookieInitialCookies: [WebCookieSeed] = [] - @State private var didAutoStart = false init( handler: LoginMethodHandler, - authenticateURL: @escaping (String) async -> String?, - autoStart: Bool = false + authenticateURL: @escaping (String) async -> String? ) { self.authenticateURL = authenticateURL - self.autoStart = autoStart self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler))) } @@ -492,12 +487,6 @@ private struct LoginFlowView: View { webCookieInitialCookies = webCookie.initialCookies } } - .onAppear { - autoStartIfNeeded() - } - .onChange(of: flowAnimationKey) { _, _ in - autoStartIfNeeded() - } .sheet(isPresented: Binding( get: { webCookieUrl != nil }, set: { value in @@ -564,19 +553,6 @@ private struct LoginFlowView: View { ].joined(separator: "|") } - private func autoStartIfNeeded() { - guard autoStart, - !didAutoStart, - !presenter.state.flowState.loading, - presenter.state.flowState.fields.isEmpty, - let action = presenter.state.flowState.actions.first(where: { $0.enabled }) - else { - return - } - didAutoStart = true - presenter.state.perform(actionId: action.id) - } - private func authenticate(url: String) { Task { if let responseString = await authenticateURL(url) { diff --git a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt index 933057d719..2eadb09ff1 100644 --- a/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt +++ b/compose-ui/src/commonMain/kotlin/dev/dimension/flare/ui/screen/login/ServiceSelectionScreenContent.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -320,12 +319,6 @@ public fun ReloginScreenContent( remember(handler) { LoginFlowPresenter(handler) }.body() } var qrContent by remember(handler) { mutableStateOf(null) } - var autoStarted by rememberSaveable( - target.accountKey.id, - target.accountKey.host, - target.platformType.name, - selectedMethod.name, - ) { mutableStateOf(false) } registerDeeplinkCallback { if (loginState.canResume(it)) { @@ -361,21 +354,6 @@ public fun ReloginScreenContent( } } } - LaunchedEffect( - methods, - selectedMethod, - loginState.flowState.actions, - loginState.flowState.fields, - loginState.flowState.loading, - ) { - if (methods.size != 1 || autoStarted || loginState.flowState.loading || loginState.flowState.fields.isNotEmpty()) { - return@LaunchedEffect - } - loginState.flowState.actions.firstOrNull { it.enabled }?.let { action -> - autoStarted = true - loginState.perform(action.id) - } - } LazyStatusVerticalStaggeredGrid( state = listState, From b3051fa35b830804ae5d9aab1b3267e92953d962 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 19:30:24 +0900 Subject: [PATCH 4/8] revert ios inapp notification --- .../UI/Component/SwiftInAppNotification.swift | 20 ++++- appleApp/ios/UI/FlareRoot.swift | 83 +++---------------- 2 files changed, 27 insertions(+), 76 deletions(-) diff --git a/appleApp/ios/UI/Component/SwiftInAppNotification.swift b/appleApp/ios/UI/Component/SwiftInAppNotification.swift index 41a7abaa63..6690dfdfe2 100644 --- a/appleApp/ios/UI/Component/SwiftInAppNotification.swift +++ b/appleApp/ios/UI/Component/SwiftInAppNotification.swift @@ -14,10 +14,11 @@ struct LoginExpiredToast: Identifiable { final class SwiftInAppNotification: ObservableObject, InAppNotification { private init() {} static let shared = SwiftInAppNotification() - @Published var loginExpiredToast: LoginExpiredToast? + var onRelogin: ((LoginExpiredToast) -> Void)? func onError(message: Message, throwable: KotlinThrowable) { - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self else { return } switch message { case .compose: Drops.show( @@ -29,10 +30,23 @@ final class SwiftInAppNotification: ObservableObject, InAppNotification { ) case .loginExpired: if let expiredError = throwable as? LoginExpiredException { - self.loginExpiredToast = LoginExpiredToast( + let toast = LoginExpiredToast( accountKey: expiredError.accountKey, platformType: expiredError.platformType ) + Drops.show( + .init( + title: String(localized: "login_expired", defaultValue: "Login session expired"), + subtitle: "\(expiredError.accountKey)", + subtitleNumberOfLines: 1, + icon: UIImage(fontAwesome: .circleExclamation), + action: .init(icon: UIImage(fontAwesome: .arrowsRotate)) { [weak self] in + Drops.hideCurrent() + self?.onRelogin?(toast) + }, + duration: .seconds(5) + ) + ) } else { Drops.show(.init(stringLiteral: .init(localized: "notification_login_expired"))) } diff --git a/appleApp/ios/UI/FlareRoot.swift b/appleApp/ios/UI/FlareRoot.swift index 0206a25793..863ed679fe 100644 --- a/appleApp/ios/UI/FlareRoot.swift +++ b/appleApp/ios/UI/FlareRoot.swift @@ -68,24 +68,6 @@ struct FlareRoot: View { .backport .tabBarMinimizeBehavior(.onScrollDown) .background(Color(.systemGroupedBackground)) - .overlay(alignment: .top) { - if let toast = inAppNotification.loginExpiredToast { - LoginExpiredToastOverlay( - toast: toast, - onRelogin: { - reloginRoute = .relogin(toast.accountKey, toast.platformType) - inAppNotification.loginExpiredToast = nil - }, - onDismiss: { - inAppNotification.loginExpiredToast = nil - } - ) - .padding(.horizontal, 16) - .padding(.top, 12) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .animation(.easeInOut(duration: 0.2), value: inAppNotification.loginExpiredToast?.id) .sheet(item: $reloginRoute) { route in NavigationStack { route.view( @@ -94,6 +76,11 @@ struct FlareRoot: View { ) } } + .onAppear { + inAppNotification.onRelogin = { toast in + reloginRoute = .relogin(toast.accountKey, toast.platformType) + } + } } loadingContent: { SplashScreen() } @@ -167,24 +154,6 @@ struct BackportFlareRoot: View { } } .background(Color(.systemGroupedBackground)) - .overlay(alignment: .top) { - if let toast = inAppNotification.loginExpiredToast { - LoginExpiredToastOverlay( - toast: toast, - onRelogin: { - reloginRoute = .relogin(toast.accountKey, toast.platformType) - inAppNotification.loginExpiredToast = nil - }, - onDismiss: { - inAppNotification.loginExpiredToast = nil - } - ) - .padding(.horizontal, 16) - .padding(.top, 12) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } - .animation(.easeInOut(duration: 0.2), value: inAppNotification.loginExpiredToast?.id) .sheet(item: $reloginRoute) { route in NavigationStack { route.view( @@ -193,49 +162,17 @@ struct BackportFlareRoot: View { ) } } + .onAppear { + inAppNotification.onRelogin = { toast in + reloginRoute = .relogin(toast.accountKey, toast.platformType) + } + } } loadingContent: { SplashScreen() } } } -private struct LoginExpiredToastOverlay: View { - let toast: LoginExpiredToast - let onRelogin: () -> Void - let onDismiss: () -> Void - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "person.badge.shield.exclamationmark") - .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text(String(localized: "login_expired", defaultValue: "Login session expired")) - .font(.headline) - Text("\(toast.accountKey)") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Button { - onRelogin() - } label: { - Text(String(localized: "login_expired_relogin", defaultValue: "Log in again")) - } - .buttonStyle(.borderedProminent) - Button { - onDismiss() - } label: { - Image(systemName: "xmark") - } - .buttonStyle(.plain) - } - .padding(12) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .shadow(radius: 12) - } -} - private func homeTabKey(_ tab: HomeTabsPresenterStateHomeTabs) -> String { tab.name.lowercased() } From 0f8db743236f0994e03153813a7de4439d6d5590 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 19:43:32 +0900 Subject: [PATCH 5/8] add close button in ios relogin --- .../ios/UI/Screen/ServiceSelectionScreen.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift index 9a52e07460..20ed886863 100644 --- a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift @@ -289,6 +289,7 @@ struct ServiceSelectionScreen: View { struct ReloginScreen: View { let target: ReloginTarget let toHome: () -> Void + @Environment(\.dismiss) private var dismiss @StateObject private var presenter: KotlinPresenter @State private var selectedMethod: LoginMethodType? @@ -313,6 +314,19 @@ struct ReloginScreen: View { } .listStyle(.insetGrouped) .navigationTitle(ServiceSelectCopy.loginExpired) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Label { + Text("Cancel") + } icon: { + Image(fontAwesome: .xmark) + } + } + } + } } private var header: some View { From 69a5d2bf461a638a1426e573b54bb3ac9c83b684 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 19:55:01 +0900 Subject: [PATCH 6/8] update android cookit fetchint --- .../serviceselect/WebCookieLoginScreen.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt index 1c3716ebf2..7f79bafff8 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt @@ -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 @@ -39,12 +40,16 @@ internal fun WebCookieLoginScreen( ) { 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 @@ -94,6 +99,34 @@ internal fun WebCookieLoginScreen( } } +private fun cookieLookupUrls( + loadedUrl: String, + originalUrl: String, +): List { + 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? = + 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 = From 91beb87a3fc17a8d647c469af51598b86f532ee2 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 2 Jul 2026 00:18:47 +0900 Subject: [PATCH 7/8] fix lint --- .../serviceselect/WebCookieLoginScreen.kt | 4 +-- .../UI/Screen/ServiceSelectionScreen.swift | 24 +++++++++-------- .../UI/Screen/ServiceSelectionScreen.swift | 26 +++++++++++-------- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt index 7f79bafff8..2087759479 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/serviceselect/WebCookieLoginScreen.kt @@ -21,7 +21,7 @@ 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", "Pragma" to "no-cache", @@ -92,7 +92,7 @@ internal fun WebCookieLoginScreen( ) } cookieManager.flush() - webView.loadUrl(url, userAgent) + webView.loadUrl(url, USER_AGENT_HEADERS) } }, ) diff --git a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift index 20ed886863..935d264228 100644 --- a/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/ios/UI/Screen/ServiceSelectionScreen.swift @@ -168,13 +168,14 @@ struct ServiceSelectionScreen: View { .transition(ServiceSelectionAnimation.inline) } - let handler = state.createLoginHandler( - platformType: node.platformType, - host: node.host, - methodType: selectedMethod, - redirectUri: nil, - ) - LoginFlowView(handler: handler) + LoginFlowView { + state.createLoginHandler( + platformType: node.platformType, + host: node.host, + methodType: selectedMethod, + redirectUri: nil, + ) + } .id("\(key)-\(selectedMethod)") .transition(ServiceSelectionAnimation.panel) @@ -371,8 +372,9 @@ struct ReloginScreen: View { .pickerStyle(.segmented) } - let handler = state.createLoginHandler(methodType: selectedMethod) - LoginFlowView(handler: handler) + LoginFlowView { + state.createLoginHandler(methodType: selectedMethod) + } .id("\(target.accountKey)-\(selectedMethod)") LoginAgreementView(urlString: state.agreementUrl()) @@ -389,8 +391,8 @@ private struct LoginFlowView: View { @State private var webCookieUrl: String? @State private var webCookieInitialCookies: [WebCookieSeed] = [] - init(handler: LoginMethodHandler) { - self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler))) + init(handler: @escaping () -> LoginMethodHandler) { + self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler()))) } var body: some View { diff --git a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift index 7aa8d7c1ac..417a627b2f 100644 --- a/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift +++ b/appleApp/macos/UI/Screen/ServiceSelectionScreen.swift @@ -167,14 +167,17 @@ struct ServiceSelectionScreen: View { .foregroundStyle(.secondary) } - let handler = state.createLoginHandler( - platformType: node.platformType, - host: node.host, - methodType: selectedMethod.wrappedValue, - redirectUri: nil, + LoginFlowView( + handler: { + state.createLoginHandler( + platformType: node.platformType, + host: node.host, + methodType: selectedMethod.wrappedValue, + redirectUri: nil, + ) + }, + authenticateURL: authenticate(url:) ) - - LoginFlowView(handler: handler, authenticateURL: authenticate(url:)) .id("\(key)-\(selectedMethod.wrappedValue)") LoginAgreementView( @@ -390,9 +393,10 @@ struct ReloginScreen: View { .labelsHidden() } - let handler = state.createLoginHandler(methodType: selectedMethod.wrappedValue) LoginFlowView( - handler: handler, + handler: { + state.createLoginHandler(methodType: selectedMethod.wrappedValue) + }, authenticateURL: authenticate(url:) ) .id("\(target.accountKey)-\(selectedMethod.wrappedValue)") @@ -424,11 +428,11 @@ private struct LoginFlowView: View { @State private var webCookieInitialCookies: [WebCookieSeed] = [] init( - handler: LoginMethodHandler, + handler: @escaping () -> LoginMethodHandler, authenticateURL: @escaping (String) async -> String? ) { self.authenticateURL = authenticateURL - self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler))) + self._presenter = .init(wrappedValue: .init(presenter: LoginFlowPresenter(handler: handler()))) } var body: some View { From af4eb2ce2daf1084e95517073a1efa50e6374cb9 Mon Sep 17 00:00:00 2001 From: Tlaster Date: Thu, 2 Jul 2026 00:49:51 +0900 Subject: [PATCH 8/8] fix lint --- .../dev/dimension/flare/ui/screen/settings/AccountsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt index 606875033e..aa72edc8c8 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AccountsScreen.kt @@ -359,8 +359,8 @@ fun AccountItem( userState: UiState, onClick: (MicroBlogKey) -> Unit, toLogin: () -> Unit, - toRelogin: (ReloginTarget) -> Unit = { toLogin() }, modifier: Modifier = Modifier, + toRelogin: (ReloginTarget) -> Unit = { toLogin() }, trailingContent: @Composable (UiProfile) -> Unit = { }, headlineContent: @Composable (UiProfile) -> Unit = { RichText(text = it.name, maxLines = 1)