diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt index 5f48fed26b..14afa2532a 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMConversationScreen.kt @@ -83,9 +83,6 @@ internal fun DMConversationScreen( toProfile: (MicroBlogKey) -> Unit, ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } val state by producePresenter( key = "dm_conversation_${accountType}_$roomKey", ) { @@ -94,6 +91,11 @@ internal fun DMConversationScreen( roomKey = roomKey, ) } + LaunchedEffect(state.pinCodePromptVisible) { + if (!state.pinCodePromptVisible) { + focusRequester.requestFocus() + } + } FlareScaffold( topBar = { @@ -184,59 +186,61 @@ internal fun DMConversationScreen( ) }, bottomBar = { - Surface { - Box { - HorizontalDivider( - modifier = - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth(), - color = FlareDividerDefaults.color, - thickness = FlareDividerDefaults.thickness, - ) - OutlinedTextField( - modifier = - Modifier - .padding( - bottom = LocalBottomBarHeight.current, - ).windowInsetsPadding( - WindowInsets.systemBars.only( - WindowInsetsSides.Horizontal, - ), - ).consumeWindowInsets( - PaddingValues( + if (!state.pinCodePromptVisible) { + Surface { + Box { + HorizontalDivider( + modifier = + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + color = FlareDividerDefaults.color, + thickness = FlareDividerDefaults.thickness, + ) + OutlinedTextField( + modifier = + Modifier + .padding( bottom = LocalBottomBarHeight.current, + ).windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Horizontal, + ), + ).consumeWindowInsets( + PaddingValues( + bottom = LocalBottomBarHeight.current, + ), + ).imePadding() + .fillMaxWidth() + .padding( + horizontal = screenHorizontalPadding, + vertical = 8.dp, + ).focusRequester( + focusRequester = focusRequester, ), - ).imePadding() - .fillMaxWidth() - .padding( - horizontal = screenHorizontalPadding, - vertical = 8.dp, - ).focusRequester( - focusRequester = focusRequester, - ), - state = state.text, - lineLimits = TextFieldLineLimits.SingleLine, - trailingIcon = { - IconButton( - onClick = { - state.send() - }, - enabled = state.canSend, - ) { - FAIcon( - FontAwesomeIcons.Solid.PaperPlane, - contentDescription = stringResource(id = R.string.send), + state = state.text, + lineLimits = TextFieldLineLimits.SingleLine, + trailingIcon = { + IconButton( + onClick = { + state.send() + }, + enabled = state.canSend, + ) { + FAIcon( + FontAwesomeIcons.Solid.PaperPlane, + contentDescription = stringResource(id = R.string.send), + ) + } + }, + shape = RoundedCornerShape(100), + placeholder = { + Text( + text = stringResource(id = R.string.dm_send_placeholder), ) - } - }, - shape = RoundedCornerShape(100), - placeholder = { - Text( - text = stringResource(id = R.string.dm_send_placeholder), - ) - }, - ) + }, + ) + } } } }, @@ -249,50 +253,59 @@ internal fun DMConversationScreen( } } } - LazyColumn( - state = listState, - reverseLayout = true, - contentPadding = contentPadding, - modifier = - Modifier - .consumeWindowInsets(contentPadding) - .fillMaxSize() - .imePadding() - .imeNestedScroll(), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), - ) { - items( - state.items, - key = { - it.id - }, + if (state.pinCodePromptVisible) { + DirectMessagePinCodeGate( + isVerifying = state.pinCodeVerifying, + errorMessage = state.pinCodeErrorMessage, + onSubmit = state::submitPinCode, + modifier = Modifier.padding(contentPadding), + ) + } else { + LazyColumn( + state = listState, + reverseLayout = true, + contentPadding = contentPadding, + modifier = + Modifier + .consumeWindowInsets(contentPadding) + .fillMaxSize() + .imePadding() + .imeNestedScroll(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), + ) { + items( + state.items, + key = { + it.id + }, // emptyContent = { // // }, // errorContent = { // // }, - loadingContent = { - DMLoadingItem() - }, - itemContent = { item -> - DMItem( - item = item, - onRetry = { - state.retry(item.key) - }, - modifier = - Modifier - .animateItem() - .padding( - horizontal = screenHorizontalPadding, - ), - onUserClicked = { - toProfile.invoke(it.key) - }, - ) - }, - ) + loadingContent = { + DMLoadingItem() + }, + itemContent = { item -> + DMItem( + item = item, + onRetry = { + state.retry(item.key) + }, + modifier = + Modifier + .animateItem() + .padding( + horizontal = screenHorizontalPadding, + ), + onUserClicked = { + toProfile.invoke(it.key) + }, + ) + }, + ) + } } } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt index 164b386909..7ac5741757 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DMListScreen.kt @@ -85,28 +85,37 @@ internal fun DMListScreen( ) }, ) { contentPadding -> - RefreshContainer( - modifier = - Modifier - .fillMaxSize(), - indicatorPadding = contentPadding, - isRefreshing = state.isRefreshing, - onRefresh = state::refresh, - content = { - LazyColumn( - contentPadding = contentPadding, - modifier = - Modifier - .padding(horizontal = screenHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), - ) { - dmList( - data = state.items, - onItemClicked = onItemClicked, - ) - } - }, - ) + if (state.pinCodePromptVisible) { + DirectMessagePinCodeGate( + isVerifying = state.pinCodeVerifying, + errorMessage = state.pinCodeErrorMessage, + onSubmit = state::submitPinCode, + modifier = Modifier.padding(contentPadding), + ) + } else { + RefreshContainer( + modifier = + Modifier + .fillMaxSize(), + indicatorPadding = contentPadding, + isRefreshing = state.isRefreshing, + onRefresh = state::refresh, + content = { + LazyColumn( + contentPadding = contentPadding, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + dmList( + data = state.items, + onItemClicked = onItemClicked, + ) + } + }, + ) + } } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt new file mode 100644 index 0000000000..f5ef133001 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt @@ -0,0 +1,87 @@ +package dev.dimension.flare.ui.screen.dm + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedSecureTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.dimension.flare.R + +@Composable +internal fun DirectMessagePinCodeGate( + isVerifying: Boolean, + errorMessage: String?, + onSubmit: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val pinCode = rememberTextFieldState() + val submit by rememberUpdatedState(onSubmit) + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.dm_pin_code_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(id = R.string.dm_pin_code_message), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedSecureTextField( + state = pinCode, + modifier = Modifier.fillMaxWidth(), + enabled = !isVerifying, + label = { + Text(text = stringResource(id = R.string.dm_pin_code_label)) + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + ), + ) + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + Button( + modifier = Modifier.fillMaxWidth(), + enabled = !isVerifying && pinCode.text.isNotBlank(), + onClick = { + submit(pinCode.text.toString()) + }, + ) { + Text(text = stringResource(id = android.R.string.ok)) + } + } + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cb5846843b..1c632153a9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -426,6 +426,9 @@ 正在发送 退出对话 显示个人资料 + 输入 XChat PIN + 此 XChat 身份需要先输入 PIN 才能加载私信。 + PIN 码 %1$s 的登录已过期 正在关注 关注者 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 109dbe96a1..a086c53750 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -489,6 +489,9 @@ Sending Leave conversation Show profile + Enter XChat PIN + This XChat identity requires a PIN before direct messages can be loaded. + PIN code Login expired for %1$s diff --git a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml index 09fb459770..41905c3768 100644 --- a/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml +++ b/desktopApp/src/main/composeResources/values-zh-rCN/strings.xml @@ -408,6 +408,9 @@ 发送中 退出对话 查看资料 + 输入 XChat PIN + 此 XChat 身份需要先输入 PIN 才能加载私信。 + PIN 码 启用混合时间轴标签页 将所有标签页的动态合并到一个“主页”标签页中。 添加分组 diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index 8effe299f6..63838725c8 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -453,6 +453,9 @@ Sending Leave conversation Show profile + Enter XChat PIN + This XChat identity requires a PIN before direct messages can be loaded. + PIN code Enable mixed timeline tab Combine posts from all tabs into a single Home tab. Add group diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt new file mode 100644 index 0000000000..04c203fd2e --- /dev/null +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DirectMessagePinCodeGate.kt @@ -0,0 +1,95 @@ +package dev.dimension.flare.ui.screen.dm + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import dev.dimension.flare.Res +import dev.dimension.flare.dm_pin_code_label +import dev.dimension.flare.dm_pin_code_message +import dev.dimension.flare.dm_pin_code_title +import dev.dimension.flare.ok +import io.github.composefluent.FluentTheme +import io.github.composefluent.component.AccentButton +import io.github.composefluent.component.SecureTextField +import io.github.composefluent.component.Text +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun DirectMessagePinCodeGate( + isVerifying: Boolean, + errorMessage: String?, + onSubmit: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val pinCode = rememberTextFieldState() + val submit by rememberUpdatedState(onSubmit) + + fun submitIfReady() { + if (!isVerifying && pinCode.text.isNotBlank()) { + submit(pinCode.text.toString()) + } + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.dm_pin_code_title), + ) + Text( + text = stringResource(Res.string.dm_pin_code_message), + ) + SecureTextField( + state = pinCode, + modifier = Modifier.fillMaxWidth(), + enabled = !isVerifying, + header = { + Text(text = stringResource(Res.string.dm_pin_code_label)) + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + ), + onKeyboardAction = { + submitIfReady() + }, + ) + if (errorMessage != null) { + Text( + text = errorMessage, + color = FluentTheme.colors.system.critical, + ) + } + AccentButton( + onClick = ::submitIfReady, + modifier = Modifier.fillMaxWidth(), + disabled = isVerifying || pinCode.text.isBlank(), + ) { + Text(text = stringResource(Res.string.ok)) + } + } + } +} diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt index f32eb6f00f..a0eb229c5a 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmConversationScreen.kt @@ -79,8 +79,10 @@ fun DmConversationScreen( ) } val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() + LaunchedEffect(state.pinCodePromptVisible) { + if (!state.pinCodePromptVisible) { + focusRequester.requestFocus() + } } val listState = rememberLazyListState() state.items.onSuccess { @@ -174,84 +176,96 @@ fun DmConversationScreen( } } } - FlareScrollBar( - state = listState, - reverseLayout = true, - ) { - LazyColumn( - state = listState, - reverseLayout = true, - contentPadding = PaddingValues(top = 8.dp), + if (state.pinCodePromptVisible) { + DirectMessagePinCodeGate( + isVerifying = state.pinCodeVerifying, + errorMessage = state.pinCodeErrorMessage, + onSubmit = state::submitPinCode, modifier = Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), + .weight(1f) + .padding(LocalWindowPadding.current), + ) + } else { + FlareScrollBar( + state = listState, + reverseLayout = true, ) { - stickyHeader { - TextField( - state = state.text, - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth() - .focusRequester(focusRequester), - lineLimits = TextFieldLineLimits.SingleLine, - trailing = { - SubtleButton( - onClick = { - state.send() - }, - disabled = !state.canSend, - iconOnly = true, - ) { - FAIcon( - FontAwesomeIcons.Solid.PaperPlane, - contentDescription = stringResource(Res.string.send), + LazyColumn( + state = listState, + reverseLayout = true, + contentPadding = PaddingValues(top = 8.dp), + modifier = + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), + ) { + stickyHeader { + TextField( + state = state.text, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth() + .focusRequester(focusRequester), + lineLimits = TextFieldLineLimits.SingleLine, + trailing = { + SubtleButton( + onClick = { + state.send() + }, + disabled = !state.canSend, + iconOnly = true, + ) { + FAIcon( + FontAwesomeIcons.Solid.PaperPlane, + contentDescription = stringResource(Res.string.send), + ) + } + }, + placeholder = { + Text( + text = stringResource(Res.string.dm_send_placeholder), ) - } + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Send, + ), + onKeyboardAction = { + if (state.canSend) { + state.send() + } + }, + ) + } + items( + state.items, + key = { + it.id }, - placeholder = { - Text( - text = stringResource(Res.string.dm_send_placeholder), - ) + loadingContent = { + DMLoadingItem() }, - keyboardOptions = - KeyboardOptions( - imeAction = ImeAction.Send, - ), - onKeyboardAction = { - if (state.canSend) { - state.send() - } + itemContent = { item -> + DMItem( + item = item, + onRetry = { + state.retry(item.key) + }, + modifier = + Modifier + .animateItem() + .padding( + horizontal = screenHorizontalPadding, + ), + onUserClicked = { + toProfile.invoke(it.key) + }, + ) }, ) } - items( - state.items, - key = { - it.id - }, - loadingContent = { - DMLoadingItem() - }, - itemContent = { item -> - DMItem( - item = item, - onRetry = { - state.retry(item.key) - }, - modifier = - Modifier - .animateItem() - .padding( - horizontal = screenHorizontalPadding, - ), - onUserClicked = { - toProfile.invoke(it.key) - }, - ) - }, - ) } } } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt index bce59680c9..39d20359c5 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/dm/DmListScreen.kt @@ -2,6 +2,7 @@ package dev.dimension.flare.ui.screen.dm import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -40,23 +41,34 @@ internal fun DmListScreen( RegisterTabCallback(listState, onRefresh = state::refresh) - Box { - FlareScrollBar(listState) { - LazyColumn( - contentPadding = LocalWindowPadding.current, - modifier = - Modifier - .padding(horizontal = screenHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(2.dp), - state = listState, - ) { - dmList( - data = state.items, - onItemClicked = onItemClicked, - ) + Box( + modifier = Modifier.fillMaxSize(), + ) { + if (state.pinCodePromptVisible) { + DirectMessagePinCodeGate( + isVerifying = state.pinCodeVerifying, + errorMessage = state.pinCodeErrorMessage, + onSubmit = state::submitPinCode, + modifier = Modifier.padding(LocalWindowPadding.current), + ) + } else { + FlareScrollBar(listState) { + LazyColumn( + contentPadding = LocalWindowPadding.current, + modifier = + Modifier + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(2.dp), + state = listState, + ) { + dmList( + data = state.items, + onItemClicked = onItemClicked, + ) + } } } - if (state.isRefreshing) { + if (!state.pinCodePromptVisible && state.isRefreshing) { ProgressBar( modifier = Modifier diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86c6f37fb4..37cbf6288e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ composemediaplayer = "0.9.0" composewebview = "1.0.0-beta-02" cryptographyProviderOptimal = "0.5.0" jna = "5.19.0" +juiceboxSdk = "0.3.6-SNAPSHOT" lifecycleViewmodelComposeVersion = "2.10.0" minSdk = "26" java = "25" @@ -79,6 +80,7 @@ composewebview = { module = "io.github.kdroidfilter:composewebview", version.ref core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptographyProviderOptimal" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +juicebox-sdk = { module = "moe.tlaster:juicebox-sdk-kmp", version.ref = "juiceboxSdk" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } diff --git a/iosApp/flare/Localizable.xcstrings b/iosApp/flare/Localizable.xcstrings index b5a1170090..6931ccc1eb 100644 --- a/iosApp/flare/Localizable.xcstrings +++ b/iosApp/flare/Localizable.xcstrings @@ -36848,6 +36848,54 @@ } } }, + "dm_pin_code_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PIN code" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "PIN 码" + } + } + } + }, + "dm_pin_code_message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This XChat identity requires a PIN before direct messages can be loaded." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此 XChat 身份需要先输入 PIN 才能加载私信。" + } + } + } + }, + "dm_pin_code_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter XChat PIN" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入 XChat PIN" + } + } + } + }, "dm_list_title" : { "localizations" : { "af" : { @@ -149833,4 +149881,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/iosApp/flare/UI/Screen/DMListScreen.swift b/iosApp/flare/UI/Screen/DMListScreen.swift index d1ccd73bc1..419bbcfa00 100644 --- a/iosApp/flare/UI/Screen/DMListScreen.swift +++ b/iosApp/flare/UI/Screen/DMListScreen.swift @@ -4,70 +4,88 @@ import SwiftUIBackports struct DMListScreen: View { let accountType: AccountType + @State private var pinCode: String = "" @StateObject private var presenter: KotlinPresenter var body: some View { - List { - PagingView(data: presenter.state.items) { item in - NavigationLink(value: Route.dmConversation(accountType, item.key, item.users.count == 1 ? item.users.first?.name.innerText ?? String(localized: "direct_messages_title") : String(localized: "direct_messages_title"))) { - HStack { - if item.hasUser, let image = item.users.first?.avatar { - AvatarView(data: image.url, customHeader: image.customHeaders) - .frame(width: 48, height: 48) - } else { - Image("fa-list") - } - VStack( - alignment: .leading, - spacing: 8 - ) { + Group { + if presenter.state.pinCodePromptVisible { + XChatPinCodeGate( + isVerifying: presenter.state.pinCodeVerifying, + errorMessage: presenter.state.pinCodeErrorMessage, + pinCode: $pinCode + ) { pinCode in + presenter.state.submitPinCode(pinCode: pinCode) + } + } else { + List { + PagingView(data: presenter.state.items) { item in + NavigationLink(value: Route.dmConversation(accountType, item.key, item.users.count == 1 ? item.users.first?.name.innerText ?? String(localized: "direct_messages_title") : String(localized: "direct_messages_title"))) { HStack { - if item.hasUser { - ForEach(item.users, id: \.key) { user in - RichText(text: user.name) - .lineLimit(1) - if (item.users.count == 1) { - Text(user.handle.canonical) + if item.hasUser, let image = item.users.first?.avatar { + AvatarView(data: image.url, customHeader: image.customHeaders) + .frame(width: 48, height: 48) + } else { + Image("fa-list") + } + VStack( + alignment: .leading, + spacing: 8 + ) { + HStack { + if item.hasUser { + ForEach(item.users, id: \.key) { user in + RichText(text: user.name) + .lineLimit(1) + if (item.users.count == 1) { + Text(user.handle.canonical) + .lineLimit(1) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + Spacer() + if let lasMessage = item.lastMessage { + DateTimeText(data: lasMessage.timestamp) .lineLimit(1) .font(.caption) .foregroundStyle(.secondary) } } + Text(item.lastMessageText) + .lineLimit(2) + .font(.caption) + .foregroundStyle(.secondary) } - Spacer() - if let lasMessage = item.lastMessage { - DateTimeText(data: lasMessage.timestamp) + if item.unreadCount > 0 { + Spacer() + Text("\(item.unreadCount)") .lineLimit(1) .font(.caption) - .foregroundStyle(.secondary) + .padding(6) + .background( + Circle() + .fill(Color.accentColor) + ) + .foregroundStyle(.white) } } - Text(item.lastMessageText) - .lineLimit(2) - .font(.caption) - .foregroundStyle(.secondary) - } - if item.unreadCount > 0 { - Spacer() - Text("\(item.unreadCount)") - .lineLimit(1) - .font(.caption) - .padding(6) - .background( - Circle() - .fill(Color.accentColor) - ) - .foregroundStyle(.white) } + } loadingContent: { + UiListPlaceholder() } } - } loadingContent: { - UiListPlaceholder() + .refreshable { + try? await presenter.state.refreshSuspend() + } } } - .refreshable { - try? await presenter.state.refreshSuspend() - } .navigationTitle("dm_list_title") + .onChange(of: presenter.state.pinCodePromptVisible) { _, newValue in + if !newValue { + pinCode = "" + } + } } } @@ -102,40 +120,59 @@ struct UserDMConversationScreen: View { struct DMConversationScreen: View { @State private var inputText: String = "" + @State private var pinCode: String = "" @Environment(\.openURL) private var openURL @StateObject private var presenter: KotlinPresenter var body: some View { - DMConversationMessagesView( - data: presenter.state.items, - onRetry: { key in - presenter.state.retry(key: key) - }, - onOpenURL: { url in - openURL(url) - } - ) - .background(Color(.systemGroupedBackground)) - .safeAreaInset(edge: .bottom) { - HStack { - TextField("dm_conversation_input_placeholder", text: $inputText) - .padding() + Group { + if presenter.state.pinCodePromptVisible { + XChatPinCodeGate( + isVerifying: presenter.state.pinCodeVerifying, + errorMessage: presenter.state.pinCodeErrorMessage, + pinCode: $pinCode + ) { pinCode in + presenter.state.submitPinCode(pinCode: pinCode) + } + .background(Color(.systemGroupedBackground)) + } else { + DMConversationMessagesView( + data: presenter.state.items, + onRetry: { key in + presenter.state.retry(key: key) + }, + onOpenURL: { url in + openURL(url) + } + ) + .background(Color(.systemGroupedBackground)) + .safeAreaInset(edge: .bottom) { + HStack { + TextField("dm_conversation_input_placeholder", text: $inputText) + .padding() + .backport + .glassEffect(.regularInteractive, in: .capsule, fallbackBackground: .regularMaterial) + Button(action: { + presenter.state.send(message: inputText) + inputText = "" + }) { + Image(systemName: "paperplane.fill") + .font(.title2) + } .backport - .glassEffect(.regularInteractive, in: .capsule, fallbackBackground: .regularMaterial) - Button(action: { - presenter.state.send(message: inputText) - inputText = "" - }) { - Image(systemName: "paperplane.fill") - .font(.title2) + .glassProminentButtonStyle() + .disabled(inputText.isEmpty) } + .padding([.horizontal, .bottom]) .backport - .glassProminentButtonStyle() - .disabled(inputText.isEmpty) + .glassEffectContainer() } - .padding([.horizontal, .bottom]) - .backport - .glassEffectContainer() } + } + .onChange(of: presenter.state.pinCodePromptVisible) { _, newValue in + if !newValue { + pinCode = "" + } + } } } @@ -144,3 +181,55 @@ extension DMConversationScreen { self._presenter = .init(wrappedValue: .init(presenter: DMConversationPresenter(accountType: accountType, roomKey: roomKey))) } } + +private struct XChatPinCodeGate: View { + let isVerifying: Bool + let errorMessage: String? + @Binding var pinCode: String + let onSubmit: (String) -> Void + + private var trimmedPinCode: String { + pinCode.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func submitIfReady() { + let value = trimmedPinCode + guard !isVerifying, !value.isEmpty else { return } + onSubmit(value) + } + + var body: some View { + VStack(spacing: 12) { + Text("dm_pin_code_title") + .font(.headline) + Text("dm_pin_code_message") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + SecureField("dm_pin_code_label", text: $pinCode) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .disabled(isVerifying) + .onSubmit(submitIfReady) + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + Button(action: submitIfReady) { + if isVerifying { + ProgressView() + } else { + Text("OK") + } + } + .buttonStyle(.borderedProminent) + .disabled(isVerifying || trimmedPinCode.isEmpty) + } + .frame(maxWidth: 360) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt index d818a2ebdd..ba2f923eda 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/DirectMessageDataSource.kt @@ -4,6 +4,7 @@ import androidx.paging.PagingData import dev.dimension.flare.common.CacheData import dev.dimension.flare.data.database.cache.model.DbMessageItem import dev.dimension.flare.data.datasource.microblog.handler.DirectMessageHandler +import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.ClickEvent @@ -25,6 +26,11 @@ import kotlin.uuid.Uuid public interface DirectMessageDataSource : AuthenticatedMicroblogDataSource { public val directMessageHandler: DirectMessageHandler + public val directMessagePinCodeStatus: Flow + get() = directMessageHandler.pinCodeStatus + + public suspend fun submitDirectMessagePinCode(pinCode: String): DirectMessagePinCodeStatus = directMessageHandler.submitPinCode(pinCode) + public fun directMessageList(scope: CoroutineScope): Flow> = directMessageHandler.list(scope) public fun directMessageConversation( diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt index 187cb66c97..f149db1c1f 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/handler/DirectMessageHandler.kt @@ -18,6 +18,7 @@ import dev.dimension.flare.data.database.cache.model.DbMessageRoomReference import dev.dimension.flare.data.datasource.microblog.createSendingDirectMessage import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageDelta import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageLoader +import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus import dev.dimension.flare.data.datasource.microblog.offsetPagingConfig import dev.dimension.flare.data.datasource.microblog.paging.DirectMessageItemDbPageLoader import dev.dimension.flare.data.datasource.microblog.paging.DirectMessageTimelineDbPageLoader @@ -55,6 +56,11 @@ public class DirectMessageHandler( private val accountType = AccountType.Specific(accountKey) private val inMemoryBadgeCount = MutableStateFlow(null) + public val pinCodeStatus: Flow + get() = loader.pinCodeStatus + + public suspend fun submitPinCode(pinCode: String): DirectMessagePinCodeStatus = loader.submitPinCode(pinCode) + public fun list(scope: CoroutineScope): Flow> = Pager( config = offsetPagingConfig, @@ -249,7 +255,12 @@ public class DirectMessageHandler( public val badgeCount: CacheData by lazy { Cacheable( fetchSource = { - inMemoryBadgeCount.value = loader.loadBadgeCount() + inMemoryBadgeCount.value = + if (loader.pinCodeStatus.first().canLoadDirectMessage) { + loader.loadBadgeCount() + } else { + 0 + } }, cacheSource = { inMemoryBadgeCount.filterNotNull() @@ -428,6 +439,11 @@ public class DirectMessageHandler( } } +private val DirectMessagePinCodeStatus.canLoadDirectMessage: Boolean + get() = + this == DirectMessagePinCodeStatus.NotRequired || + this == DirectMessagePinCodeStatus.Verified + private fun UiDMItem.toDbMessageItem(roomKey: MicroBlogKey): DbMessageItem = DbMessageItem( messageKey = key, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt index 21bb69ad62..e58a553cf9 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datasource/microblog/loader/DirectMessageLoader.kt @@ -18,6 +18,11 @@ public interface DirectMessageLoader { public val runtimeTransformer: Flow get() = flowOf(DirectMessageRuntimeTransformer()) + public val pinCodeStatus: Flow + get() = flowOf(DirectMessagePinCodeStatus.NotRequired) + + public suspend fun submitPinCode(pinCode: String): DirectMessagePinCodeStatus = DirectMessagePinCodeStatus.NotRequired + public suspend fun loadRooms( pageSize: Int, request: PagingRequest, @@ -66,3 +71,18 @@ public data class DirectMessageRuntimeTransformer( val room: (UiDMRoom) -> UiDMRoom = { it }, val item: (UiDMItem) -> UiDMItem = { it }, ) + +@HiddenFromObjC +public sealed interface DirectMessagePinCodeStatus { + public data object NotRequired : DirectMessagePinCodeStatus + + public data object Required : DirectMessagePinCodeStatus + + public data object Verifying : DirectMessagePinCodeStatus + + public data object Verified : DirectMessagePinCodeStatus + + public data class Error( + val message: String?, + ) : DirectMessagePinCodeStatus +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt index 23bf18ba70..3ad7aba806 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMConversationPresenter.kt @@ -5,11 +5,13 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.collectAsState import dev.dimension.flare.common.toPagingState import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource +import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.accountServiceProvider import dev.dimension.flare.model.AccountType @@ -17,13 +19,17 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.model.UiDMItem import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.collectAsUiState import dev.dimension.flare.ui.model.flatMap import dev.dimension.flare.ui.model.map import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.model.toUi import dev.dimension.flare.ui.presenter.PresenterBase import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.time.Duration.Companion.seconds @@ -39,31 +45,55 @@ public class DMConversationPresenter( override fun body(): DMConversationState { val serviceState = accountServiceProvider(accountType = accountType, repository = accountRepository) val scope = rememberCoroutineScope() + val pinCodeStatus = + serviceState.flatMap { service -> + require(service is DirectMessageDataSource) + val status = + remember(service) { + service.directMessagePinCodeStatus + }.collectAsUiState( + initial = UiState.Loading(), + ) + status.value + } + val canLoad = pinCodeStatus.canLoadDirectMessage val items = serviceState .map { service -> require(service is DirectMessageDataSource) - remember(service, roomKey) { - service.directMessageConversation(roomKey, scope = scope) - }.collectAsLazyPagingItems() + if (canLoad) { + remember(service, roomKey, canLoad) { + service.directMessageConversation(roomKey, scope = scope) + }.collectAsLazyPagingItems() + } else { + remember(service, roomKey, canLoad) { + flowOf(PagingData.empty()) + }.collectAsLazyPagingItems() + } }.toPagingState() val users = - serviceState - .flatMap { service -> - require(service is DirectMessageDataSource) - remember(service, roomKey) { - service.getDirectMessageConversationInfo(roomKey) - }.collectAsState().toUi() - }.map { - it.users - } - serviceState.onSuccess { - require(it is DirectMessageDataSource) - LaunchedEffect(Unit) { - while (true) { - delay(10.seconds) - runCatching { - it.fetchNewDirectMessageForConversation(roomKey) + if (canLoad) { + serviceState + .flatMap { service -> + require(service is DirectMessageDataSource) + remember(service, roomKey) { + service.getDirectMessageConversationInfo(roomKey) + }.collectAsState().toUi() + }.map { + it.users + } + } else { + UiState.Success(persistentListOf()) + } + if (canLoad) { + serviceState.onSuccess { + require(it is DirectMessageDataSource) + LaunchedEffect(Unit) { + while (true) { + delay(10.seconds) + runCatching { + it.fetchNewDirectMessageForConversation(roomKey) + } } } } @@ -73,7 +103,25 @@ public class DMConversationPresenter( override val users = users + override val pinCodeStatus = pinCodeStatus + + override val pinCodePromptVisible = pinCodeStatus.pinCodePromptVisible + + override val pinCodeVerifying = pinCodeStatus.pinCodeVerifying + + override val pinCodeErrorMessage = pinCodeStatus.pinCodeErrorMessage + + override fun submitPinCode(pinCode: String) { + serviceState.onSuccess { + require(it is DirectMessageDataSource) + scope.launch { + it.submitDirectMessagePinCode(pinCode) + } + } + } + override fun send(message: String) { + if (!canLoad) return serviceState.onSuccess { require(it is DirectMessageDataSource) it.sendDirectMessage(roomKey, message) @@ -81,6 +129,7 @@ public class DMConversationPresenter( } override fun retry(key: MicroBlogKey) { + if (!canLoad) return serviceState.onSuccess { require(it is DirectMessageDataSource) it.retrySendDirectMessage(key) @@ -101,6 +150,12 @@ public class DMConversationPresenter( public interface DMConversationState { public val items: PagingState public val users: UiState> + public val pinCodeStatus: UiState + public val pinCodePromptVisible: Boolean + public val pinCodeVerifying: Boolean + public val pinCodeErrorMessage: String? + + public fun submitPinCode(pinCode: String) public fun send(message: String) diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt index 2236c4a22f..e264a9fc84 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/dm/DMListPresenter.kt @@ -4,18 +4,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import dev.dimension.flare.common.PagingState import dev.dimension.flare.common.isRefreshing import dev.dimension.flare.common.onSuccess import dev.dimension.flare.common.toPagingState import dev.dimension.flare.data.datasource.microblog.DirectMessageDataSource +import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus import dev.dimension.flare.data.repository.AccountRepository import dev.dimension.flare.data.repository.accountServiceProvider import dev.dimension.flare.model.AccountType import dev.dimension.flare.ui.model.UiDMRoom +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.flatMap import dev.dimension.flare.ui.model.map +import dev.dimension.flare.ui.model.onSuccess import dev.dimension.flare.ui.presenter.PresenterBase +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -29,19 +37,54 @@ public class DMListPresenter( override fun body(): DMListState { val scope = rememberCoroutineScope() val serviceState = accountServiceProvider(accountType = accountType, repository = accountRepository) + val pinCodeStatus = + serviceState.flatMap { service -> + require(service is DirectMessageDataSource) + val status = + remember(service) { + service.directMessagePinCodeStatus + }.collectAsUiState( + initial = UiState.Loading(), + ) + status.value + } + val canLoad = pinCodeStatus.canLoadDirectMessage val items = serviceState .map { service -> require(service is DirectMessageDataSource) - remember(service) { - service.directMessageList(scope = scope) - }.collectAsLazyPagingItems() + if (canLoad) { + remember(service, canLoad) { + service.directMessageList(scope = scope) + }.collectAsLazyPagingItems() + } else { + remember(service, canLoad) { + flowOf(PagingData.empty()) + }.collectAsLazyPagingItems() + } }.toPagingState() return object : DMListState { override val items = items + override val pinCodeStatus = pinCodeStatus + + override val pinCodePromptVisible = pinCodeStatus.pinCodePromptVisible + + override val pinCodeVerifying = pinCodeStatus.pinCodeVerifying + + override val pinCodeErrorMessage = pinCodeStatus.pinCodeErrorMessage + override val isRefreshing = items.isRefreshing + override fun submitPinCode(pinCode: String) { + serviceState.onSuccess { + require(it is DirectMessageDataSource) + scope.launch { + it.submitDirectMessagePinCode(pinCode) + } + } + } + override suspend fun refreshSuspend() { items.onSuccess { refreshSuspend() @@ -54,7 +97,39 @@ public class DMListPresenter( @Immutable public interface DMListState { public val items: PagingState + public val pinCodeStatus: UiState + public val pinCodePromptVisible: Boolean + public val pinCodeVerifying: Boolean + public val pinCodeErrorMessage: String? public val isRefreshing: Boolean + public fun submitPinCode(pinCode: String) + public suspend fun refreshSuspend() } + +internal val UiState.canLoadDirectMessage: Boolean + get() = + (this as? UiState.Success) + ?.data + ?.let { + it == DirectMessagePinCodeStatus.NotRequired || + it == DirectMessagePinCodeStatus.Verified + } == true + +internal val UiState.pinCodePromptVisible: Boolean + get() = + (this as? UiState.Success) + ?.data + ?.let { + it != DirectMessagePinCodeStatus.NotRequired && + it != DirectMessagePinCodeStatus.Verified + } == true + +internal val UiState.pinCodeVerifying: Boolean + get() = (this as? UiState.Success)?.data == DirectMessagePinCodeStatus.Verifying + +internal val UiState.pinCodeErrorMessage: String? + get() = + ((this as? UiState.Success)?.data as? DirectMessagePinCodeStatus.Error) + ?.message diff --git a/social/xqt/build.gradle.kts b/social/xqt/build.gradle.kts index 69fac912f3..2bbb14f586 100644 --- a/social/xqt/build.gradle.kts +++ b/social/xqt/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { implementation(libs.paging.common) implementation(libs.paging.compose) implementation(libs.cryptography.provider.optimal) + implementation(libs.juicebox.sdk) } } val commonTest by getting { diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XChatDirectMessageMapper.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XChatDirectMessageMapper.kt new file mode 100644 index 0000000000..cd486f1c2d --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XChatDirectMessageMapper.kt @@ -0,0 +1,128 @@ +package dev.dimension.flare.data.datasource.xqt + +import dev.dimension.flare.data.network.xqt.xchat.XChatConversation +import dev.dimension.flare.data.network.xqt.xchat.XChatConversationType +import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEvent +import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEventKind +import dev.dimension.flare.data.network.xqt.xchat.XChatUser +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformType +import dev.dimension.flare.ui.model.ClickEvent +import dev.dimension.flare.ui.model.UiDMItem +import dev.dimension.flare.ui.model.UiDMRoom +import dev.dimension.flare.ui.model.UiHandle +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.toUiImage +import dev.dimension.flare.ui.render.toUi +import dev.dimension.flare.ui.render.toUiPlainText +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Instant + +internal fun XChatConversation.toUiDMRoom(accountKey: MicroBlogKey): UiDMRoom { + val usersById = participants.associate { it.userId to it.toUiProfile(accountKey.host) } + return UiDMRoom( + key = MicroBlogKey(conversationId, accountKey.host), + users = + participants + .mapNotNull { usersById[it.userId] } + .filter { it.key != accountKey } + .toImmutableList(), + lastMessage = + latestMessage?.toUiDMItem( + accountKey = accountKey, + users = usersById, + showSender = type == XChatConversationType.Group, + ), + unreadCount = unreadCount, + ) +} + +internal fun XChatDecodedEvent.toUiDMItem( + accountKey: MicroBlogKey, + users: Map, + showSender: Boolean, +): UiDMItem? { + val senderId = senderId ?: return null + val itemKey = MicroBlogKey(sequenceId ?: messageId ?: return null, accountKey.host) + val userKey = MicroBlogKey(senderId, accountKey.host) + val content = + when (kind) { + XChatDecodedEventKind.Message, + XChatDecodedEventKind.Edit, + -> UiDMItem.Message.Text((text?.takeIf { it.isNotBlank() } ?: encryptedPreviewText() ?: return null).toUiPlainText()) + + XChatDecodedEventKind.Delete -> UiDMItem.Message.Deleted + + else -> return null + } + return UiDMItem( + key = itemKey, + user = users[senderId] ?: fallbackXChatDirectMessageUser(userKey), + content = content, + timestamp = Instant.fromEpochMilliseconds(createdAtMillis ?: 0L).toUi(), + isFromMe = userKey == accountKey, + sendState = null, + showSender = showSender && userKey != accountKey, + remoteCursor = sequenceId, + ) +} + +internal fun XChatConversation.participantIds(accountId: String): List = + participants + .map { it.userId } + .takeIf { it.isNotEmpty() } + ?: conversationId + .split(":") + .takeIf { it.size == 2 } + .orEmpty() + .ifEmpty { listOf(accountId) } + +internal fun fallbackXChatDirectMessageUser(userKey: MicroBlogKey): UiProfile = + UiProfile( + key = userKey, + handle = UiHandle(raw = userKey.id, host = userKey.host), + avatar = null, + nameInternal = userKey.id.toUiPlainText(), + platformType = PlatformType.xQt, + clickEvent = ClickEvent.Noop, + banner = null, + description = null, + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + platformFansCount = null, + ), + mark = persistentListOf(), + bottomContent = null, + ) + +private fun XChatUser.toUiProfile(host: String): UiProfile = + UiProfile( + key = MicroBlogKey(userId, host), + handle = UiHandle(raw = screenName ?: userId, host = host), + avatar = avatarUrl.toUiImage(), + nameInternal = (name ?: screenName ?: userId).toUiPlainText(), + platformType = PlatformType.xQt, + clickEvent = ClickEvent.Noop, + banner = null, + description = null, + matrices = + UiProfile.Matrices( + fansCount = 0, + followsCount = 0, + statusesCount = 0, + platformFansCount = null, + ), + mark = persistentListOf(), + bottomContent = null, + ) + +private fun XChatDecodedEvent.encryptedPreviewText(): String? = + if (encrypted || decryptError) { + "Encrypted message" + } else { + null + } diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt index cf6da9ddd2..e473183892 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDataSource.kt @@ -99,6 +99,7 @@ private const val MAX_ASYNC_UPLOAD_SIZE = 10 internal class XQTDataSource( override val accountKey: MicroBlogKey, sourceCredentialFlow: Flow, + private val updateCredential: suspend (XQTCredential) -> Unit, ) : AuthenticatedMicroblogDataSource, NotificationTimelineDataSource, ComposeDataSource, @@ -141,6 +142,7 @@ internal class XQTDataSource( service = service, accountKey = accountKey, credentialFlow = credentialFlow, + updateCredential = updateCredential, ) } diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt index d86b7a2618..d42b161261 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/datasource/xqt/XQTDirectMessageLoader.kt @@ -1,14 +1,19 @@ package dev.dimension.flare.data.datasource.xqt -import dev.dimension.flare.data.database.cache.mapper.XQT import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageDelta import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageLoader +import dev.dimension.flare.data.datasource.microblog.loader.DirectMessagePinCodeStatus import dev.dimension.flare.data.datasource.microblog.loader.DirectMessageRuntimeTransformer import dev.dimension.flare.data.datasource.microblog.paging.PagingRequest import dev.dimension.flare.data.datasource.microblog.paging.PagingResult import dev.dimension.flare.data.network.xqt.XQTService -import dev.dimension.flare.data.network.xqt.model.AddToConversationRequest -import dev.dimension.flare.data.network.xqt.model.PostDmNew2Request +import dev.dimension.flare.data.network.xqt.xchat.XChatConversation +import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEvent +import dev.dimension.flare.data.network.xqt.xchat.XChatDecodedEventKind +import dev.dimension.flare.data.network.xqt.xchat.XChatLoadedIdentity +import dev.dimension.flare.data.network.xqt.xchat.XChatService +import dev.dimension.flare.data.network.xqt.xchat.recoverIdentityWithPin +import dev.dimension.flare.data.platform.XChatIdentityCredential import dev.dimension.flare.data.platform.XQTCredential import dev.dimension.flare.data.repository.tryRun import dev.dimension.flare.model.MicroBlogKey @@ -16,20 +21,34 @@ import dev.dimension.flare.model.PlatformType import dev.dimension.flare.ui.model.UiDMItem import dev.dimension.flare.ui.model.UiDMRoom import dev.dimension.flare.ui.model.UiMedia +import dev.dimension.flare.ui.model.UiProfile import dev.dimension.flare.ui.model.UiState import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlin.uuid.Uuid +import kotlin.time.Clock internal class XQTDirectMessageLoader( private val service: XQTService, private val accountKey: MicroBlogKey, private val credentialFlow: Flow, + private val updateCredential: suspend (XQTCredential) -> Unit, ) : DirectMessageLoader { override val platformType: PlatformType = PlatformType.xQt + private var cachedIdentityCredential: XChatIdentityCredential? = null + private var cachedIdentity: XChatLoadedIdentity? = null + private var recoveredIdentityCredential: XChatIdentityCredential? = null + private var messagePullVersion: Int? = null + private val roomParticipants = mutableMapOf>() + private val roomUsers = mutableMapOf>() + private val pinCodeOverride = MutableStateFlow(null) + override val runtimeTransformer: Flow = credentialFlow.map { credential -> DirectMessageRuntimeTransformer( @@ -38,49 +57,76 @@ internal class XQTDirectMessageLoader( ) } + override val pinCodeStatus: Flow = + combine( + credentialFlow, + pinCodeOverride, + ) { credential, override -> + when { + !credential.requiresPinCode() -> DirectMessagePinCodeStatus.NotRequired + override is DirectMessagePinCodeStatus.Verifying -> override + override is DirectMessagePinCodeStatus.Error -> override + else -> DirectMessagePinCodeStatus.Required + } + } + + override suspend fun submitPinCode(pinCode: String): DirectMessagePinCodeStatus { + val credential = credentialFlow.first() + val identity = credential.xchatIdentity + if (identity != null && identity.pinBacked != true) { + pinCodeOverride.value = DirectMessagePinCodeStatus.NotRequired + return DirectMessagePinCodeStatus.NotRequired + } + if (pinCode.isBlank()) { + return DirectMessagePinCodeStatus + .Error("PIN code is required") + .also { pinCodeOverride.value = it } + } + pinCodeOverride.value = DirectMessagePinCodeStatus.Verifying + return runCatching { + val recoveredIdentity = + if (identity == null) { + service.xchat.recoverIdentityWithPin( + userId = accountKey.id, + pinCode = pinCode, + ) + } else { + service.xchat.recoverIdentityWithPin(identity, pinCode) + } + val loadedIdentity = service.xchat.loadIdentity(recoveredIdentity) + updateCredential(credential.copy(xchatIdentity = recoveredIdentity)) + recoveredIdentityCredential = recoveredIdentity + cachedIdentityCredential = recoveredIdentity + cachedIdentity = loadedIdentity + messagePullVersion = null + roomParticipants.clear() + roomUsers.clear() + DirectMessagePinCodeStatus.Verified + }.getOrElse { throwable -> + DirectMessagePinCodeStatus + .Error(throwable.message ?: throwable::class.simpleName) + }.also { + pinCodeOverride.value = it + } + } + override suspend fun loadRooms( pageSize: Int, request: PagingRequest, ): PagingResult { - if (request == PagingRequest.Refresh) { - val response = service.getDMUserUpdates().inboxInitialState - return PagingResult( - data = - XQT.rooms( - accountKey = accountKey, - propertyEntries = response?.propertyEntries, - users = response?.users, - conversations = response?.conversations, - ), - nextKey = - if (response?.inboxTimelines?.trusted?.status == "AT_END") { - null - } else { - response?.inboxTimelines?.trusted?.minEntryId - }, - ) + val identity = requireIdentity() + val nextKey = (request as? PagingRequest.Append)?.nextKey + if (request is PagingRequest.Append && nextKey == null) { + return PagingResult(endOfPaginationReached = true) } - val maxId = - (request as? PagingRequest.Append)?.nextKey - ?: return PagingResult(endOfPaginationReached = true) - val response = - service.getDMInboxTimelineTrusted( - maxId = maxId, + val page = + loadInitialPage( + identity = identity, + maxLocalSequenceId = nextKey, ) return PagingResult( - data = - XQT.rooms( - accountKey = accountKey, - propertyEntries = response.inboxTimeline?.propertyEntries, - users = response.inboxTimeline?.users, - conversations = response.inboxTimeline?.conversations, - ), - nextKey = - if (response.inboxTimeline?.status == "AT_END") { - null - } else { - response.inboxTimeline?.minEntryId - }, + data = page.conversations.map { rememberConversation(it) }, + nextKey = page.nextKey, ) } @@ -89,85 +135,96 @@ internal class XQTDirectMessageLoader( pageSize: Int, request: PagingRequest, ): PagingResult { - val response = - service.getDMConversationTimeline( + val identity = requireIdentity() + val nextKey = (request as? PagingRequest.Append)?.nextKey + if (request is PagingRequest.Append && nextKey == null) { + return PagingResult(endOfPaginationReached = true) + } + val page = + service.xchat.conversationPage( conversationId = roomKey.id, - maxId = (request as? PagingRequest.Append)?.nextKey, + identity = identity, + before = nextKey, ) + val participantIds = participantIdsFor(roomKey.id, identity) + val users = usersFor(roomKey.id, participantIds) + val showSender = participantIds.size > 2 if (request == PagingRequest.Refresh) { - service.postDMConversationMarkRead( - conversationId = roomKey.id, - conversationId2 = roomKey.id, - lastReadEventId = response.conversationTimeline?.maxEntryId.orEmpty(), - ) + page.messages.maxSequenceId()?.let { sequenceId -> + tryRun { + service.xchat.markRead( + conversationId = roomKey.id, + sequenceId = sequenceId, + identity = identity, + participantIds = participantIds, + ) + } + } } return PagingResult( data = - XQT.messages( - accountKey = accountKey, - propertyEntries = response.conversationTimeline?.propertyEntries, - users = response.conversationTimeline?.users, - conversations = response.conversationTimeline?.conversations, - ), - nextKey = - if (response.conversationTimeline?.status == "AT_END") { - null - } else { - response.conversationTimeline?.minEntryId - }, + page.messages + .mapNotNull { + it.toUiDMItem( + accountKey = accountKey, + users = users, + showSender = showSender, + ) + }, + nextKey = if (page.hasMore) page.nextKey else null, ) } override suspend fun fetchRoomInfo(roomKey: MicroBlogKey): UiDMRoom { - val response = - service.getDMConversationTimeline( - conversationId = roomKey.id, - context = "FETCH_DM_CONVERSATION", - maxId = "0", - ) - return XQT - .rooms( - accountKey = accountKey, - propertyEntries = response.conversationTimeline?.propertyEntries, - users = response.conversationTimeline?.users, - conversations = response.conversationTimeline?.conversations, - ).single() + val identity = requireIdentity() + val conversation = + loadInitialPage(identity = identity) + .conversations + .firstOrNull { it.conversationId == roomKey.id } + return conversation + ?.let { rememberConversation(it) } + ?: fallbackRoom(roomKey.id, participantIdsFor(roomKey.id, identity)) } override suspend fun sendMessage( roomKey: MicroBlogKey, message: String, ): UiDMItem { - val response = - service.getDMConversationTimeline( + val identity = requireIdentity() + val participantIds = participantIdsFor(roomKey.id, identity) + val sendResult = + service.xchat.sendText( conversationId = roomKey.id, - context = "FETCH_DM_CONVERSATION", - maxId = "0", + text = message, + identity = identity, + participantIds = participantIds, ) - val sendResponse = - service.postDmNew2( - PostDmNew2Request( + val users = usersFor(roomKey.id, participantIds) + return ( + sendResult.decodedEvent + ?: XChatDecodedEvent( + sequenceId = sendResult.sequenceId, + messageId = sendResult.messageId, + senderId = accountKey.id, conversationId = roomKey.id, - requestId = Uuid.random().toString(), + createdAtMillis = Clock.System.now().toEpochMilliseconds(), + kind = XChatDecodedEventKind.Message, text = message, - ), - ) - return XQT - .messages( - accountKey = accountKey, - propertyEntries = sendResponse.propertyEntries, - users = sendResponse.users, - conversations = response.conversationTimeline?.conversations, - ).single() + ) + ).toUiDMItem( + accountKey = accountKey, + users = users, + showSender = participantIds.size > 2, + ) ?: error("XChat send response did not contain a renderable message") } override suspend fun deleteMessage( roomKey: MicroBlogKey, messageKey: MicroBlogKey, ) { - service.postDMMessageDeleteMutation( - messageId = messageKey.id, - requestId = Uuid.random().toString(), + service.xchat.deleteMessages( + conversationId = roomKey.id, + sequenceIds = listOf(messageKey.id), ) } @@ -175,99 +232,210 @@ internal class XQTDirectMessageLoader( roomKey: MicroBlogKey, cursor: String?, ): DirectMessageDelta { - val response = service.getDMConversationTimeline(conversationId = roomKey.id) - service.postDMConversationMarkRead( - conversationId = roomKey.id, - conversationId2 = roomKey.id, - lastReadEventId = response.conversationTimeline?.maxEntryId.orEmpty(), - ) + val identity = requireIdentity() + val page = + service.xchat.conversationPage( + conversationId = roomKey.id, + identity = identity, + ) + val participantIds = participantIdsFor(roomKey.id, identity) + val users = usersFor(roomKey.id, participantIds) + val newEvents = page.messages.filter { it.isAfter(cursor) } + newEvents.maxSequenceId()?.let { sequenceId -> + tryRun { + service.xchat.markRead( + conversationId = roomKey.id, + sequenceId = sequenceId, + identity = identity, + participantIds = participantIds, + ) + } + } return DirectMessageDelta( messages = - XQT.messages( - accountKey = accountKey, - propertyEntries = response.conversationTimeline?.propertyEntries, - users = response.conversationTimeline?.users, - conversations = response.conversationTimeline?.conversations, - ), + newEvents.mapNotNull { + it.toUiDMItem( + accountKey = accountKey, + users = users, + showSender = participantIds.size > 2, + ) + }, + deletedMessageKeys = + newEvents + .filter { it.kind == XChatDecodedEventKind.Delete } + .flatMap { it.targetSequenceIds } + .map { MicroBlogKey(it, accountKey.host) }, ) } override suspend fun leaveRoom(roomKey: MicroBlogKey) { - service.postDMConversationDelete(conversationId = roomKey.id) + roomParticipants.remove(roomKey.id) + roomUsers.remove(roomKey.id) } override fun createRoom(userKey: MicroBlogKey): Flow> = flow { - val accountIdLong = - accountKey.id.toLongOrNull() - ?: throw Exception("Invalid account key") - val userIdLong = - userKey.id.toLongOrNull() - ?: throw Exception("Invalid user key") - val roomId = - listOf( - accountIdLong, - userIdLong, - ).sortedBy { it } - .joinToString("-") - tryRun { - val response = - service.getDMConversationTimeline( - conversationId = roomId, - ) - if (response.conversationTimeline?.propertyEntries.isNullOrEmpty()) { - service - .postDMWelcomeMessagesAddToConversation( - requestId = Uuid.random().toString(), - body = - AddToConversationRequest( - conversationId = roomId, - ), - ) - service.getDMConversationTimeline( - conversationId = roomId, - ) - } else { - response - } - }.fold( - onSuccess = { response -> - emit( - UiState.Success( - XQT - .rooms( - accountKey = accountKey, - propertyEntries = response.conversationTimeline?.propertyEntries, - users = response.conversationTimeline?.users, - conversations = response.conversationTimeline?.conversations, - ).single(), - ), - ) - }, - onFailure = { - emit(UiState.Error(it)) - }, + val roomId = XChatService.conversationId1on1(accountKey.id, userKey.id) + val participantIds = listOf(accountKey.id, userKey.id) + roomParticipants[roomId] = participantIds + roomUsers[roomId] = usersFor(roomId, participantIds) + emit( + UiState.Success( + UiDMRoom( + key = MicroBlogKey(roomId, accountKey.host), + users = listOf(fallbackXChatDirectMessageUser(userKey)).toImmutableList(), + lastMessage = null, + unreadCount = 0, + ), + ), ) } override suspend fun canSend(userKey: MicroBlogKey): Boolean = tryRun { - val canDm = - service - .getDMPermissions(userKey.id) - .body() - ?.permissions - ?.idKeys - ?.get(userKey.id) - ?.canDm == true - if (!canDm) { - throw Exception("Cannot send DM") + requireIdentity() + val canMessage = service.xchat.canMessage(userKey.id) + if (!canMessage) { + throw Exception("Cannot send XChat message") } }.isSuccess - override suspend fun loadBadgeCount(): Int = service.getBadgeCount().dmUnreadCount?.toInt() ?: 0 + override suspend fun loadBadgeCount(): Int = + tryRun { + loadInitialPage(identity = requireIdentity()) + .conversations + .sumOf { it.unreadCount } + .toInt() + }.getOrDefault(0) + + private suspend fun requireIdentity(): XChatLoadedIdentity { + val credential = credentialFlow.first() + val identityCredential = + credential.xchatIdentity + ?: recoveredIdentityCredential + ?.takeIf { it.userId == accountKey.id && it.hasPrivateKeys } + ?: throw IllegalStateException("XChat PIN code is required") + val effectiveIdentityCredential = + if (identityCredential.requiresRecoveredPrivateKey()) { + recoveredIdentityCredential + ?.takeIf { it.pinLockKey == identityCredential.pinLockKey && it.hasPrivateKeys } + ?: throw IllegalStateException("XChat PIN code is required") + } else { + identityCredential + } + if (effectiveIdentityCredential != cachedIdentityCredential) { + cachedIdentityCredential = effectiveIdentityCredential + cachedIdentity = service.xchat.loadIdentity(effectiveIdentityCredential) + messagePullVersion = null + roomParticipants.clear() + roomUsers.clear() + } + return cachedIdentity ?: error("XChat identity cache was not initialized") + } + + private fun rememberConversation(conversation: XChatConversation): UiDMRoom { + val room = conversation.toUiDMRoom(accountKey) + val participantIds = conversation.participantIds(accountKey.id) + roomParticipants[conversation.conversationId] = participantIds + roomUsers[conversation.conversationId] = + ( + room.users + + participantIds.map { + fallbackXChatDirectMessageUser(MicroBlogKey(it, accountKey.host)) + } + ).distinctBy { it.key.id } + .associateBy { it.key.id } + return room + } + + private suspend fun participantIdsFor( + conversationId: String, + identity: XChatLoadedIdentity, + ): List = + roomParticipants[conversationId] + ?: conversationId + .split(":") + .takeIf { it.size == 2 } + ?: loadInitialPage(identity = identity) + .conversations + .firstOrNull { it.conversationId == conversationId } + ?.let { conversation -> + rememberConversation(conversation) + conversation.participantIds(accountKey.id) + } + ?: throw IllegalStateException("XChat conversation participants are not available") + + private fun usersFor( + conversationId: String, + participantIds: List, + ): Map = + roomUsers[conversationId] + ?: participantIds + .associateWith { fallbackXChatDirectMessageUser(MicroBlogKey(it, accountKey.host)) } + .also { roomUsers[conversationId] = it } + + private suspend fun loadInitialPage( + identity: XChatLoadedIdentity, + maxLocalSequenceId: String? = null, + ) = service + .xchat + .initialPage( + identity = identity, + maxLocalSequenceId = maxLocalSequenceId, + messagePullVersion = messagePullVersion, + ).also { page -> + messagePullVersion = page.messagePullVersion ?: messagePullVersion + } + + private fun fallbackRoom( + conversationId: String, + participantIds: List, + ): UiDMRoom = + UiDMRoom( + key = MicroBlogKey(conversationId, accountKey.host), + users = + participantIds + .filterNot { it == accountKey.id } + .map { fallbackXChatDirectMessageUser(MicroBlogKey(it, accountKey.host)) } + .toImmutableList(), + lastMessage = null, + unreadCount = 0, + ) + + private fun XQTCredential.requiresPinCode(): Boolean { + val identity = + xchatIdentity + ?: return recoveredIdentityCredential + ?.takeIf { it.userId == accountKey.id && it.hasPrivateKeys } == null + return identity.requiresRecoveredPrivateKey() && + recoveredIdentityCredential?.pinLockKey != identity.pinLockKey + } +} + +private fun List.maxSequenceId(): String? = + mapNotNull { it.sequenceId?.toULongOrNull() } + .maxOrNull() + ?.toString() + +private fun XChatDecodedEvent.isAfter(cursor: String?): Boolean { + val sequenceId = sequenceId ?: return cursor == null + val current = sequenceId.toULongOrNull() + val previous = cursor?.toULongOrNull() + return when { + cursor == null -> true + current != null && previous != null -> current > previous + else -> sequenceId > cursor + } } +private val XChatIdentityCredential.pinLockKey: String + get() = "$userId:$version" + +private val XChatIdentityCredential.hasPrivateKeys: Boolean + get() = identityPrivateJwk != null && signingPrivateJwk != null + +private fun XChatIdentityCredential.requiresRecoveredPrivateKey(): Boolean = pinBacked == true && !hasPrivateKeys + private fun UiDMRoom.withXqtMediaAuth( credential: XQTCredential, host: String, diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt index 2d06093b45..cbe93358ff 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/XQTService.kt @@ -34,6 +34,9 @@ import dev.dimension.flare.data.network.xqt.api.createV11PostApi import dev.dimension.flare.data.network.xqt.api.createV20GetApi import dev.dimension.flare.data.network.xqt.api.createVDmPostJsonPostApi import dev.dimension.flare.data.network.xqt.elonmusk114514.ElonMusk1145141919810 +import dev.dimension.flare.data.network.xqt.emusks.EmusksRawClient +import dev.dimension.flare.data.network.xqt.emusks.EmusksTransactionIdProvider +import dev.dimension.flare.data.network.xqt.xchat.XChatService import dev.dimension.flare.data.repository.LoginExpiredException import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.model.PlatformType @@ -140,6 +143,34 @@ internal class XQTService( accountKey = accountKey, chocolateFlow = chocolateFlow, ).createVDmPostJsonPostApi() { + private val emusks by lazy { + EmusksRawClient( + cookieProvider = { chocolateFlow?.firstOrNull() }, + transactionIdProvider = + EmusksTransactionIdProvider { method, path -> + runCatching { + ElonMusk1145141919810.senpaiSukissu( + method = method, + path = path, + ) + }.getOrDefault("") + }, + loginExpiredException = + accountKey?.let { key -> + { + LoginExpiredException( + key, + PlatformType.xQt, + ) + } + }, + ) + } + + internal val xchat: XChatService by lazy { + XChatService(emusks) + } + companion object { fun checkChocolate(value: String) = // value.contains("gt=") && diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksClientProfile.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksClientProfile.kt new file mode 100644 index 0000000000..7bcfb002f5 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksClientProfile.kt @@ -0,0 +1,49 @@ +package dev.dimension.flare.data.network.xqt.emusks + +internal data class EmusksTlsFingerprint( + val ja3: String, + val ja4r: String, + val userAgent: String, +) + +internal data class EmusksClientProfile( + val bearer: String, + val fingerprint: EmusksTlsFingerprint, + val defaultHeaders: Map, +) + +internal object EmusksClients { + private val chromeFingerprint = + EmusksTlsFingerprint( + userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + ja3 = + "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," + + "35-5-27-16-0-10-13-23-45-65037-17613-18-65281-51-43-11,4588-29-23-24,0", + ja4r = + "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030," + + "cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d," + + "ff01_0403,0804,0401,0503,0805,0501,0806,0601", + ) + + val Web: EmusksClientProfile = + EmusksClientProfile( + bearer = + "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D" + + "1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA", + fingerprint = chromeFingerprint, + defaultHeaders = + mapOf( + "accept-language" to "en-US,en;q=0.9", + "priority" to "u=1, i", + "sec-ch-ua" to "\"Not(A:Brand\";v=\"8\", \"Chromium\";v=\"144\"", + "sec-ch-ua-mobile" to "?0", + "sec-ch-ua-platform" to "\"macOS\"", + "sec-fetch-dest" to "empty", + "sec-fetch-mode" to "cors", + "sec-fetch-site" to "same-origin", + "sec-gpc" to "1", + ), + ) +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksCookie.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksCookie.kt new file mode 100644 index 0000000000..0a5bd2841a --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksCookie.kt @@ -0,0 +1,75 @@ +package dev.dimension.flare.data.network.xqt.emusks + +internal data class EmusksCookie( + val values: Map, +) { + val authToken: String? + get() = values[AUTH_TOKEN] + + val csrfToken: String? + get() = values[CSRF_TOKEN] + + val guestToken: String? + get() = values[GUEST_TOKEN] + + val hasAuthenticatedSession: Boolean + get() = authToken != null && csrfToken != null + + fun toHeader(): String = values.entries.joinToString("; ") { (name, value) -> "$name=$value" } + + fun plus(other: EmusksCookie): EmusksCookie = + EmusksCookie( + values = values + other.values, + ) + + companion object { + private const val AUTH_TOKEN = "auth_token" + private const val CSRF_TOKEN = "ct0" + private const val GUEST_TOKEN = "gt" + + fun parse(cookieHeader: String): EmusksCookie = + EmusksCookie( + values = + cookieHeader + .split(';') + .mapNotNull { part -> + val index = part.indexOf('=') + if (index <= 0) { + return@mapNotNull null + } + val name = part.substring(0, index).trim() + val value = part.substring(index + 1).trim() + if (name.isEmpty()) { + null + } else { + name to value + } + }.toMap(), + ) + + fun fromAuthToken(authToken: String): EmusksCookie = + EmusksCookie( + values = mapOf(AUTH_TOKEN to authToken), + ) + + fun fromSetCookieHeaders(setCookieHeaders: List): EmusksCookie = + EmusksCookie( + values = + setCookieHeaders + .mapNotNull { header -> + val cookiePair = header.substringBefore(';') + val index = cookiePair.indexOf('=') + if (index <= 0) { + return@mapNotNull null + } + val name = cookiePair.substring(0, index).trim() + val value = cookiePair.substring(index + 1).trim() + if (name.isEmpty()) { + null + } else { + name to value + } + }.toMap(), + ) + } +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksOperations.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksOperations.kt new file mode 100644 index 0000000000..19f70b80c0 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksOperations.kt @@ -0,0 +1,76 @@ +package dev.dimension.flare.data.network.xqt.emusks + +internal enum class EmusksHttpMethod { + GET, + POST, + PUT, + DELETE, +} + +internal data class EmusksGraphqlOperation( + val method: EmusksHttpMethod, + val queryId: String, +) + +internal data class EmusksRestOperation( + val method: EmusksHttpMethod, + val url: String, +) + +internal object EmusksGraphqlOperations { + private val operations = + mapOf( + "AddParticipantsMutation" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "oBwyQ0_xVbAQ8FAyG0pCRA"), + "DMConversationSearchTabGroupsQuery" to + EmusksGraphqlOperation(EmusksHttpMethod.GET, "8D8KoSq5q9d5Su3emu2dwg"), + "DMConversationSearchTabPeopleQuery" to + EmusksGraphqlOperation(EmusksHttpMethod.GET, "qno3lU4_eSHtSFoWQUhEag"), + "DMMessageDeleteMutation" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "BJ6DtxA2llfjnRoRjaiIiw"), + "DMMessageSearchTabQuery" to EmusksGraphqlOperation(EmusksHttpMethod.GET, "QUobOGFxSYwNxfh2zCpVGA"), + "DMPinnedInboxAppend_Mutation" to + EmusksGraphqlOperation(EmusksHttpMethod.POST, "o0aymgGiJY-53Y52YSUGVA"), + "DMPinnedInboxDelete_Mutation" to + EmusksGraphqlOperation(EmusksHttpMethod.POST, "_TQxP2Rb0expwVP9ktGrTQ"), + "DMPinnedInboxQuery" to EmusksGraphqlOperation(EmusksHttpMethod.GET, "_gBQBgClVuMQb8efxWkbbQ"), + "DmAllSearchSlice" to EmusksGraphqlOperation(EmusksHttpMethod.GET, "nIz5WMsrpV7s0uDu-gfOVw"), + "dmBlockUser" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "IYw9u1KEhrS-t-BXsau4Uw"), + "dmUnblockUser" to EmusksGraphqlOperation(EmusksHttpMethod.POST, "Krbs6Nak_o7liWQwfV1jOQ"), + "useDMReactionMutationAddMutation" to + EmusksGraphqlOperation(EmusksHttpMethod.POST, "VyDyV9pC2oZEj6g52hgnhA"), + "useDMReactionMutationRemoveMutation" to + EmusksGraphqlOperation(EmusksHttpMethod.POST, "bV_Nim3RYHsaJwMkTXJ6ew"), + ) + + operator fun get(name: String): EmusksGraphqlOperation? = operations[name] +} + +internal object EmusksV11Operations { + private val operations = + mapOf( + "dm/conversation" to + EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/conversation.json"), + "dm/inbox_initial_state" to + EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/inbox_initial_state.json"), + "dm/new2" to + EmusksRestOperation(EmusksHttpMethod.POST, "https://api.x.com/1.1/dm/new2.json"), + "dm/permissions" to + EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/permissions.json"), + "dm/update_last_seen_event_id" to + EmusksRestOperation(EmusksHttpMethod.POST, "https://api.x.com/1.1/dm/update_last_seen_event_id.json"), + "dm/user_updates" to + EmusksRestOperation(EmusksHttpMethod.GET, "https://api.x.com/1.1/dm/user_updates.json"), + "dm/welcome_messages/add_to_conversation" to + EmusksRestOperation( + EmusksHttpMethod.POST, + "https://api.x.com/1.1/dm/welcome_messages/add_to_conversation.json", + ), + ) + + operator fun get(name: String): EmusksRestOperation? = operations[name] +} + +internal object EmusksV2Operations { + private val operations = emptyMap() + + operator fun get(name: String): EmusksRestOperation? = operations[name] +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksRawClient.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksRawClient.kt new file mode 100644 index 0000000000..0071e4a9bb --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/emusks/EmusksRawClient.kt @@ -0,0 +1,676 @@ +package dev.dimension.flare.data.network.xqt.emusks + +import dev.dimension.flare.common.JSON_WITH_ENCODE_DEFAULT +import dev.dimension.flare.data.network.ktorClient +import de.jensklingenberg.ktorfit.Response +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +internal fun interface EmusksTransactionIdProvider { + suspend fun generate( + method: String, + path: String, + ): String +} + +internal enum class EmusksGraphqlEndpoint( + val base: String, + val referrer: String, + val secFetchSite: String, +) { + Main( + base = "https://api.x.com/graphql", + referrer = "https://x.com/", + secFetchSite = "same-site", + ), + MainTwitter( + base = "https://api.twitter.com/graphql", + referrer = "https://twitter.com/", + secFetchSite = "same-site", + ), + Web( + base = "https://x.com/i/api/graphql", + referrer = "https://x.com/", + secFetchSite = "same-origin", + ), + WebTwitter( + base = "https://twitter.com/i/api/graphql", + referrer = "https://twitter.com/", + secFetchSite = "same-origin", + ), +} + +internal class EmusksApiException( + message: String, +) : Exception(message) + +internal data class EmusksLoginResult( + val cookies: EmusksCookie, + val userId: String?, +) + +private data class EmusksJsonResponse( + val response: HttpResponse, + val text: String, + val json: JsonObject, +) + +private data class EmusksTextResponse( + val response: HttpResponse, + val text: String, +) + +internal class EmusksRawClient( + private val cookieProvider: suspend () -> String?, + private val clientProfile: EmusksClientProfile = EmusksClients.Web, + private val graphqlEndpoint: EmusksGraphqlEndpoint = EmusksGraphqlEndpoint.Web, + private val transactionIdProvider: EmusksTransactionIdProvider? = null, + private val actingAsUserId: String? = null, + private val loginExpiredException: (() -> Exception)? = null, + private val client: HttpClient = ktorClient(), +) { + suspend fun login(authToken: String): EmusksLoginResult { + if (authToken.length !in 20..50) { + throw EmusksApiException("invalid auth token length") + } + val response = + client.get("https://x.com/") { + accept(ContentType.Text.Html) + headers { + append(HttpHeaders.Cookie, EmusksCookie.fromAuthToken(authToken).toHeader()) + emusksBrowserHeaders(secFetchSite = "none") + append(HttpHeaders.Referrer, "https://x.com/") + append(HttpHeaders.UserAgent, clientProfile.fingerprint.userAgent) + } + } + val setCookies = response.headers.getAll(HttpHeaders.SetCookie).orEmpty() + val cookies = + EmusksCookie + .fromAuthToken(authToken) + .plus(EmusksCookie.fromSetCookieHeaders(setCookies)) + if (cookies.csrfToken == null) { + throw EmusksApiException("failed to log in") + } + val html = response.bodyAsText() + return EmusksLoginResult( + cookies = cookies, + userId = extractInitialUserId(html), + ) + } + + suspend fun graphqlJson( + queryName: String, + variables: JsonObject? = null, + features: JsonObject? = null, + fieldToggles: JsonObject? = null, + body: JsonObject? = null, + headers: Map = emptyMap(), + ): JsonObject = + graphqlRaw( + queryName = queryName, + variables = variables, + features = features, + fieldToggles = fieldToggles, + body = body, + headers = headers, + ).json + + private suspend fun graphqlRaw( + queryName: String, + variables: JsonObject? = null, + features: JsonObject? = null, + fieldToggles: JsonObject? = null, + body: JsonObject? = null, + headers: Map = emptyMap(), + ): EmusksJsonResponse { + val operation = + EmusksGraphqlOperations[queryName] + ?: throw EmusksApiException("graphql query $queryName not found") + val isPost = operation.method == EmusksHttpMethod.POST + val urlBuilder = URLBuilder("${graphqlEndpoint.base}/${operation.queryId}/$queryName") + var requestBody: JsonObject? = null + + if (isPost) { + requestBody = + buildJsonObject { + body?.forEach { (key, value) -> put(key, value) } + put("variables", mergeVariables(variables, body?.get("variables") as? JsonObject)) + put("queryId", operation.queryId) + if (features != null && features.isNotEmpty()) { + put("features", features) + } + if (fieldToggles != null && fieldToggles.isNotEmpty()) { + put("fieldToggles", fieldToggles) + } + } + } else { + variables?.takeIf { it.isNotEmpty() }?.let { + urlBuilder.parameters.append( + "variables", + JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), it), + ) + } + features?.takeIf { it.isNotEmpty() }?.let { + urlBuilder.parameters.append( + "features", + JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), it), + ) + } + fieldToggles?.takeIf { it.isNotEmpty() }?.let { + urlBuilder.parameters.append( + "fieldToggles", + JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), it), + ) + } + } + + return requestJsonResponse( + method = operation.method, + url = urlBuilder.buildString(), + referrer = graphqlEndpoint.referrer, + secFetchSite = graphqlEndpoint.secFetchSite, + body = requestBody, + extraHeaders = headers, + ) + } + + suspend fun graphql( + serializer: KSerializer, + queryName: String, + variables: JsonObject? = null, + features: JsonObject? = null, + fieldToggles: JsonObject? = null, + body: JsonObject? = null, + headers: Map = emptyMap(), + ): T = + JSON_WITH_ENCODE_DEFAULT.decodeFromString( + serializer, + JSON_WITH_ENCODE_DEFAULT.encodeToString( + JsonObject.serializer(), + graphqlJson( + queryName = queryName, + variables = variables, + features = features, + fieldToggles = fieldToggles, + body = body, + headers = headers, + ), + ), + ) + + suspend fun apolloGraphqlJson( + operationId: String, + operationName: String, + query: String, + variables: JsonObject = JsonObject(emptyMap()), + ): JsonObject = + json( + JsonObject.serializer(), + method = EmusksHttpMethod.POST, + url = "https://api.x.com/graphql/$operationId/$operationName", + body = + buildJsonObject { + put("operationName", operationName) + put("variables", variables) + put("query", query) + put("queryId", operationId) + }, + headers = + mapOf( + "Origin" to "https://chat.x.com", + "x-apollo-operation-id" to operationId, + "x-apollo-operation-name" to operationName, + "apollo-require-preflight" to "true", + ), + referrer = "https://chat.x.com/", + secFetchSite = "same-site", + ) + + suspend fun graphqlResponse( + serializer: KSerializer, + queryName: String, + variables: JsonObject? = null, + features: JsonObject? = null, + fieldToggles: JsonObject? = null, + body: JsonObject? = null, + headers: Map = emptyMap(), + ): Response = + graphqlRaw( + queryName = queryName, + variables = variables, + features = features, + fieldToggles = fieldToggles, + body = body, + headers = headers, + ).toResponse(serializer) + + suspend fun json( + serializer: KSerializer, + method: EmusksHttpMethod, + url: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + referrer: String = "https://x.com/", + secFetchSite: String = "same-origin", + ): T = + JSON_WITH_ENCODE_DEFAULT.decodeFromString( + serializer, + jsonRaw( + method = method, + url = url, + params = params, + body = body, + headers = headers, + referrer = referrer, + secFetchSite = secFetchSite, + ).text, + ) + + suspend fun jsonResponse( + serializer: KSerializer, + method: EmusksHttpMethod, + url: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + referrer: String = "https://x.com/", + secFetchSite: String = "same-origin", + ): Response = + jsonRaw( + method = method, + url = url, + params = params, + body = body, + headers = headers, + referrer = referrer, + secFetchSite = secFetchSite, + ).toResponse(serializer) + + suspend fun unitResponse( + method: EmusksHttpMethod, + url: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + referrer: String = "https://x.com/", + secFetchSite: String = "same-origin", + ): Response = + textRaw( + method = method, + url = url, + params = params, + body = body, + headers = headers, + referrer = referrer, + secFetchSite = secFetchSite, + ).toUnitResponse() + + suspend fun text( + method: EmusksHttpMethod, + url: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + referrer: String = "https://x.com/", + secFetchSite: String = "same-origin", + ): String = + textRaw( + method = method, + url = url, + params = params, + body = body, + headers = headers, + referrer = referrer, + secFetchSite = secFetchSite, + ).text + + suspend fun v1Json( + queryName: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + ): JsonObject { + val operation = + EmusksV11Operations[queryName] + ?: throw EmusksApiException("v1.1 endpoint $queryName not found") + return restJson(operation, params, body, headers) + } + + suspend fun v1Response( + serializer: KSerializer, + queryName: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + ): Response { + val operation = + EmusksV11Operations[queryName] + ?: throw EmusksApiException("v1.1 endpoint $queryName not found") + return restRaw(operation, params, body, headers).toResponse(serializer) + } + + suspend fun v2Json( + queryName: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + ): JsonObject { + val operation = + EmusksV2Operations[queryName] + ?: throw EmusksApiException("v2 endpoint $queryName not found") + return restJson(operation, params, body, headers) + } + + suspend fun v2Response( + serializer: KSerializer, + queryName: String, + params: Map = emptyMap(), + body: Any? = null, + headers: Map = emptyMap(), + ): Response { + val operation = + EmusksV2Operations[queryName] + ?: throw EmusksApiException("v2 endpoint $queryName not found") + return restRaw(operation, params, body, headers).toResponse(serializer) + } + + private suspend fun restJson( + operation: EmusksRestOperation, + params: Map, + body: Any?, + headers: Map, + ): JsonObject = restRaw(operation, params, body, headers).json + + private suspend fun restRaw( + operation: EmusksRestOperation, + params: Map, + body: Any?, + headers: Map, + ): EmusksJsonResponse { + val urlBuilder = URLBuilder(operation.url) + params.forEach { (key, value) -> urlBuilder.parameters.append(key, value) } + return requestJsonResponse( + method = operation.method, + url = urlBuilder.buildString(), + referrer = "https://x.com/", + secFetchSite = "same-origin", + body = body, + extraHeaders = headers, + ) + } + + private suspend fun requestJson( + method: EmusksHttpMethod, + url: String, + referrer: String, + secFetchSite: String, + body: Any?, + extraHeaders: Map, + ): JsonObject = requestJsonResponse(method, url, referrer, secFetchSite, body, extraHeaders).json + + private suspend fun jsonRaw( + method: EmusksHttpMethod, + url: String, + params: Map, + body: Any?, + headers: Map, + referrer: String, + secFetchSite: String, + ): EmusksJsonResponse { + val urlBuilder = URLBuilder(url) + params.forEach { (key, value) -> + if (value != null) { + urlBuilder.parameters.append(key, value) + } + } + return requestJsonResponse( + method = method, + url = urlBuilder.buildString(), + referrer = referrer, + secFetchSite = secFetchSite, + body = body, + extraHeaders = headers, + ) + } + + private suspend fun requestJsonResponse( + method: EmusksHttpMethod, + url: String, + referrer: String, + secFetchSite: String, + body: Any?, + extraHeaders: Map, + ): EmusksJsonResponse { + val textResponse = requestTextResponse(method, url, referrer, secFetchSite, body, extraHeaders) + val responseText = textResponse.text + if (textResponse.response.status == HttpStatusCode.Forbidden) { + loginExpiredException?.let { throw it() } + } + if (responseText.isBlank()) { + throw EmusksApiException("empty response") + } + val json = JSON_WITH_ENCODE_DEFAULT.decodeFromString(JsonElement.serializer(), responseText) + val jsonObject = json.jsonObject + val errors = jsonObject["errors"]?.jsonArray.orEmpty() + val errorCode = + errors + .firstOrNull() + ?.jsonObject + ?.get("code") + ?.jsonPrimitive + ?.longOrNull + if (errorCode == 215L) { + loginExpiredException?.let { throw it() } + } + val hasData = (jsonObject["data"] as? JsonObject)?.isNotEmpty() == true + if (errors.isNotEmpty() && !hasData) { + val message = + errors.joinToString(", ") { error -> + runCatching { + error.jsonObject["message"]?.jsonPrimitive?.content + }.getOrNull() ?: error.toString() + } + throw EmusksApiException(message) + } + return EmusksJsonResponse(textResponse.response, responseText, jsonObject) + } + + private suspend fun textRaw( + method: EmusksHttpMethod, + url: String, + params: Map, + body: Any?, + headers: Map, + referrer: String, + secFetchSite: String, + ): EmusksTextResponse { + val urlBuilder = URLBuilder(url) + params.forEach { (key, value) -> + if (value != null) { + urlBuilder.parameters.append(key, value) + } + } + return requestTextResponse( + method = method, + url = urlBuilder.buildString(), + referrer = referrer, + secFetchSite = secFetchSite, + body = body, + extraHeaders = headers, + ) + } + + private suspend fun requestTextResponse( + method: EmusksHttpMethod, + url: String, + referrer: String, + secFetchSite: String, + body: Any?, + extraHeaders: Map, + ): EmusksTextResponse { + val response = + client.request(url) { + this.method = method.toKtor() + val cookie = currentCookie() + val path = Url(url).encodedPath + val transactionId = transactionIdProvider?.generate(method.name, path) + headers { + append(HttpHeaders.Accept, "*/*") + append(HttpHeaders.Authorization, "Bearer ${clientProfile.bearer}") + append(HttpHeaders.Cookie, cookie.toHeader()) + append(HttpHeaders.Referrer, referrer) + append(HttpHeaders.UserAgent, clientProfile.fingerprint.userAgent) + append("x-twitter-client-language", "en") + append("x-twitter-active-user", "yes") + append("x-twitter-auth-type", "OAuth2Session") + cookie.csrfToken?.let { append("x-csrf-token", it) } + cookie.guestToken?.let { append("x-guest-token", it) } + actingAsUserId?.let { append("x-act-as-user-id", it) } + emusksBrowserHeaders(secFetchSite = secFetchSite) + transactionId?.takeIf { it.isNotBlank() }?.let { append("x-client-transaction-id", it) } + extraHeaders.forEach { (key, value) -> append(key, value) } + } + if (body != null) { + when (body) { + is JsonObject -> { + contentType(ContentType.Application.Json) + setBody(JSON_WITH_ENCODE_DEFAULT.encodeToString(JsonObject.serializer(), body)) + } + is FormDataContent -> setBody(body) + else -> setBody(body) + } + } + } + val responseText = response.bodyAsText() + if (response.status == HttpStatusCode.Forbidden) { + loginExpiredException?.let { throw it() } + } + return EmusksTextResponse(response, responseText) + } + + @Suppress("UNCHECKED_CAST") + private fun EmusksJsonResponse.toResponse(serializer: KSerializer): Response = + if (response.status.isSuccess()) { + Response.success( + JSON_WITH_ENCODE_DEFAULT.decodeFromString(serializer, text), + response, + ) as Response + } else { + Response.error(json, response) as Response + } + + @Suppress("UNCHECKED_CAST") + private fun EmusksTextResponse.toUnitResponse(): Response = + if (response.status.isSuccess()) { + Response.success(Unit, response) as Response + } else { + Response.error(text, response) as Response + } + + private suspend fun currentCookie(): EmusksCookie { + val cookieHeader = + cookieProvider() + ?: throw EmusksApiException("missing cookie") + val cookie = EmusksCookie.parse(cookieHeader) + if (cookie.authToken == null) { + throw EmusksApiException("missing auth_token") + } + return cookie + } + + private fun mergeVariables( + variables: JsonObject?, + bodyVariables: JsonObject?, + ): JsonObject = + buildJsonObject { + variables?.forEach { (key, value) -> put(key, value) } + bodyVariables?.forEach { (key, value) -> put(key, value) } + } + + private fun EmusksHttpMethod.toKtor(): HttpMethod = + when (this) { + EmusksHttpMethod.GET -> HttpMethod.Get + EmusksHttpMethod.POST -> HttpMethod.Post + EmusksHttpMethod.PUT -> HttpMethod.Put + EmusksHttpMethod.DELETE -> HttpMethod.Delete + } + + private fun io.ktor.http.HeadersBuilder.emusksBrowserHeaders(secFetchSite: String) { + clientProfile.defaultHeaders.forEach { (key, value) -> + append(key, if (key == "sec-fetch-site") secFetchSite else value) + } + } + + companion object { + fun formBody(fields: Map): FormDataContent = + FormDataContent( + Parameters.build { + fields.forEach { (key, value) -> + if (value != null) { + append(key, value) + } + } + }, + ) + + fun extractInitialUserId(html: String): String? { + val initialStateMatch = + Regex("""window\.__INITIAL_STATE__\s*=\s*(\{[\s\S]*?});""") + .find(html) + if (initialStateMatch != null) { + val initialState = + runCatching { + JSON_WITH_ENCODE_DEFAULT.decodeFromString( + JsonObject.serializer(), + initialStateMatch.groupValues[1], + ) + }.getOrNull() + val entities = + initialState + ?.get("entities") + ?.jsonObject + ?.get("users") + ?.jsonObject + ?.get("entities") + ?.jsonObject + val firstUser = entities?.values?.firstOrNull()?.jsonObject + val id = + firstUser?.get("id_str")?.jsonPrimitive?.content + ?: firstUser?.get("id")?.jsonPrimitive?.content + if (id != null) { + return id + } + } + return Regex("\"user_id\":\"([0-9]+)\"") + .find(html) + ?.groupValues + ?.get(1) + } + } +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatBase64.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatBase64.kt new file mode 100644 index 0000000000..b7b6bc0ddd --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatBase64.kt @@ -0,0 +1,19 @@ +package dev.dimension.flare.data.network.xqt.xchat + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +internal object XChatBase64 { + fun encode(bytes: ByteArray): String = Base64.Default.encode(bytes) + + fun decode(value: String): ByteArray = Base64.Default.decode(value) + + fun encodeUrl(bytes: ByteArray): String = Base64.UrlSafe.encode(bytes).trimEnd('=') + + fun decodeUrl(value: String): ByteArray { + val trimmed = value.trim() + val padding = (4 - trimmed.length % 4) % 4 + return Base64.UrlSafe.decode(trimmed + "=".repeat(padding)) + } +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatCrypto.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatCrypto.kt new file mode 100644 index 0000000000..53c00e9266 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatCrypto.kt @@ -0,0 +1,266 @@ +package dev.dimension.flare.data.network.xqt.xchat + +import dev.dimension.flare.data.platform.XChatIdentityCredential +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi +import dev.whyoleg.cryptography.algorithms.AES +import dev.whyoleg.cryptography.algorithms.EC +import dev.whyoleg.cryptography.algorithms.ECDH +import dev.whyoleg.cryptography.algorithms.ECDSA +import dev.whyoleg.cryptography.algorithms.SHA256 +import dev.whyoleg.cryptography.random.CryptographyRandom +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.uuid.Uuid + +internal data class XChatLoadedIdentity( + val userId: String, + val version: String, + val publicKeySpki: ByteArray, + val signingPublicKeyB64: String, + val identityPrivateKey: ECDH.PrivateKey, + val signingPrivateKey: ECDSA.PrivateKey, + val conversationKeys: MutableMap = mutableMapOf(), + val conversationTokens: MutableMap = mutableMapOf(), +) + +internal data class XChatConversationKey( + val key: ByteArray, + val version: String, +) + +internal object XChatCrypto { + suspend fun loadIdentity(identity: XChatIdentityCredential): XChatLoadedIdentity { + val provider = CryptographyProvider.Default + val ecdh = provider.get(ECDH) + val ecdsa = provider.get(ECDSA) + val identityPrivateJwk = identity.identityPrivateJwk ?: error("identityPrivateJwk is required") + val signingPrivateJwk = identity.signingPrivateJwk ?: error("signingPrivateJwk is required") + return XChatLoadedIdentity( + userId = identity.userId, + version = identity.version, + publicKeySpki = XChatBase64.decode(identity.publicKeyB64), + signingPublicKeyB64 = identity.signingPublicKeyB64, + identityPrivateKey = + ecdh + .privateKeyDecoder(EC.Curve.P256) + .decodeFromByteArray( + EC.PrivateKey.Format.RAW, + identityPrivateJwk.p256PrivateScalar("identityPrivateJwk"), + ), + signingPrivateKey = + ecdsa + .privateKeyDecoder(EC.Curve.P256) + .decodeFromByteArray( + EC.PrivateKey.Format.RAW, + signingPrivateJwk.p256PrivateScalar("signingPrivateJwk"), + ), + ) + } + + suspend fun eciesWrap( + conversationKey: ByteArray, + recipientSpki: ByteArray, + ): String { + val provider = CryptographyProvider.Default + val ecdh = provider.get(ECDH) + val recipientPublicKey = + ecdh + .publicKeyDecoder(EC.Curve.P256) + .decodeFromByteArray(EC.PublicKey.Format.DER, recipientSpki) + val ephemeral = ecdh.keyPairGenerator(EC.Curve.P256).generateKey() + val sharedSecret = + ephemeral + .privateKey + .sharedSecretGenerator() + .generateSharedSecretToByteArray(recipientPublicKey) + val ephemeralRaw = ephemeral.publicKey.encodeToByteArray(EC.PublicKey.Format.RAW) + val derived = sha256(sharedSecret + byteArrayOf(0, 0, 0, 1) + ephemeralRaw) + val encrypted = aesGcmEncrypt(derived.copyOfRange(0, 16), derived.copyOfRange(16, 32), conversationKey) + return XChatBase64.encode(ephemeralRaw + encrypted) + } + + suspend fun eciesUnwrap( + wrappedB64: String, + identityPrivateKey: ECDH.PrivateKey, + ): ByteArray { + val wrapped = XChatBase64.decode(wrappedB64) + val ephemeralRaw = wrapped.copyOfRange(0, 65) + val ciphertext = wrapped.copyOfRange(65, wrapped.size) + val ephemeralPublicKey = + CryptographyProvider + .Default + .get(ECDH) + .publicKeyDecoder(EC.Curve.P256) + .decodeFromByteArray(EC.PublicKey.Format.RAW, ephemeralRaw) + val sharedSecret = + identityPrivateKey + .sharedSecretGenerator() + .generateSharedSecretToByteArray(ephemeralPublicKey) + val derived = sha256(sharedSecret + byteArrayOf(0, 0, 0, 1) + ephemeralRaw) + return aesGcmDecrypt(derived.copyOfRange(0, 16), derived.copyOfRange(16, 32), ciphertext) + } + + fun encryptBody( + plaintext: ByteArray, + conversationKey: ByteArray, + ): ByteArray = + XChatSecretBox.encryptBody( + plaintext = plaintext, + key = conversationKey, + nonce = secureRandomBytes(24), + ) + + fun decryptBody( + frame: ByteArray, + conversationKey: ByteArray, + ): ByteArray? = XChatSecretBox.decryptBody(frame, conversationKey) + + suspend fun eventSignature( + identity: XChatLoadedIdentity, + conversationToken: String, + conversationId: String, + conversationKeyVersion: String, + frame: ByteArray, + ): String { + val payload = + listOf( + "MessageCreateEvent", + conversationToken, + identity.userId, + conversationId, + conversationKeyVersion, + XChatBase64.encodeUrl(frame), + ).joinToString(",") + val signature = + identity + .signingPrivateKey + .signatureGenerator(SHA256, ECDSA.SignatureFormat.RAW) + .generateSignature(payload.encodeToByteArray()) + return XChatBase64.encode( + XChatThrift.eventSignature( + signatureBase64Url = XChatBase64.encodeUrl(signature), + publicKeyVersion = identity.version, + signingPublicKeyB64 = identity.signingPublicKeyB64, + senderId = identity.userId, + ), + ) + } + + suspend fun actionSignature( + identity: XChatLoadedIdentity, + typeName: String, + conversationToken: String, + conversationId: String, + dataElements: List, + eventDetailBytes: ByteArray? = null, + ): JsonObject { + val payload = + ( + listOf( + typeName, + conversationToken, + identity.userId, + conversationId, + ) + dataElements + ).joinToString(",") + val signature = + identity + .signingPrivateKey + .signatureGenerator(SHA256, ECDSA.SignatureFormat.RAW) + .generateSignature(payload.encodeToByteArray()) + return buildJsonObject { + put("chat_fanout_behavior_version", "V1") + put("message_id", Uuid.random().toString()) + put( + "message_event_signature", + buildJsonObject { + put("signature", XChatBase64.encodeUrl(signature)) + put("public_key_version", identity.version) + put("signature_version", "7") + put("signing_public_key", identity.signingPublicKeyB64) + put( + "message_signing_key_info_list", + buildJsonArray { + add( + buildJsonObject { + put("member_id", identity.userId) + put("public_key_version", identity.version) + put("signing_public_key", identity.signingPublicKeyB64) + }, + ) + }, + ) + }, + ) + if (eventDetailBytes != null) { + put("encoded_message_event_detail", XChatBase64.encodeUrl(eventDetailBytes)) + } + put("signature_payload", payload) + } + } + + fun secureRandomBytes(size: Int): ByteArray = CryptographyRandom.nextBytes(size) + + suspend fun sha256(bytes: ByteArray): ByteArray = + CryptographyProvider + .Default + .get(SHA256) + .hasher() + .hash(bytes) + + @OptIn(DelicateCryptographyApi::class) + private suspend fun aesGcmEncrypt( + key: ByteArray, + iv: ByteArray, + plaintext: ByteArray, + ): ByteArray = + CryptographyProvider + .Default + .get(AES.GCM) + .keyDecoder() + .decodeFromByteArray(AES.Key.Format.RAW, key) + .cipher() + .encryptWithIv(iv, plaintext) + + @OptIn(DelicateCryptographyApi::class) + private suspend fun aesGcmDecrypt( + key: ByteArray, + iv: ByteArray, + ciphertext: ByteArray, + ): ByteArray = + CryptographyProvider + .Default + .get(AES.GCM) + .keyDecoder() + .decodeFromByteArray(AES.Key.Format.RAW, key) + .cipher() + .decryptWithIv(iv, ciphertext) + + private fun JsonObject.p256PrivateScalar(fieldName: String): ByteArray { + val encoded = + this["d"] + ?.jsonPrimitive + ?.contentOrNull + ?: error("$fieldName.d is required") + return XChatBase64 + .decodeUrl(encoded) + .toP256Scalar(fieldName) + } + + private fun ByteArray.toP256Scalar(fieldName: String): ByteArray = + when { + size == P256_PRIVATE_SCALAR_SIZE -> this + size < P256_PRIVATE_SCALAR_SIZE -> + ByteArray(P256_PRIVATE_SCALAR_SIZE).also { copyInto(it, P256_PRIVATE_SCALAR_SIZE - size) } + size == P256_PRIVATE_SCALAR_SIZE + 1 && first() == 0.toByte() -> copyOfRange(1, size) + else -> error("$fieldName.d must decode to a P-256 private scalar") + } + + private const val P256_PRIVATE_SCALAR_SIZE = 32 +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatJuicebox.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatJuicebox.kt new file mode 100644 index 0000000000..0a169fda46 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatJuicebox.kt @@ -0,0 +1,299 @@ +package dev.dimension.flare.data.network.xqt.xchat + +import dev.dimension.flare.common.JSON +import dev.dimension.flare.data.platform.XChatIdentityCredential +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import xyz.juicebox.sdk.kmp.AuthToken +import xyz.juicebox.sdk.kmp.Client +import xyz.juicebox.sdk.kmp.Configuration +import xyz.juicebox.sdk.kmp.RealmId + +internal suspend fun XChatService.recoverIdentityWithPin( + identity: XChatIdentityCredential, + pinCode: String, +): XChatIdentityCredential { + val publicKey = recoverablePublicKey(identity.userId, identity.version) + return recoverIdentityWithPin( + base = identity, + publicKey = publicKey, + pinCode = pinCode, + ) +} + +internal suspend fun XChatService.recoverIdentityWithPin( + userId: String, + pinCode: String, +): XChatIdentityCredential { + val publicKey = recoverablePublicKey(userId, version = null) + return recoverIdentityWithPin( + base = publicKey.toIdentityCredential(userId), + publicKey = publicKey, + pinCode = pinCode, + ) +} + +private suspend fun XChatService.recoverablePublicKey( + userId: String, + version: String?, +): XChatPublicKey = + publicKeys( + userIds = listOf(userId), + includeJuiceboxTokens = true, + ).firstOrNull() + ?.keys + .orEmpty() + .firstOrNull { publicKey -> + (version == null || publicKey.version == version) && + publicKey.hasJuiceboxTokens + } + ?: error("XChat public key metadata was not found") + +private suspend fun XChatService.recoverIdentityWithPin( + base: XChatIdentityCredential, + publicKey: XChatPublicKey, + pinCode: String, +): XChatIdentityCredential { + val tokenMap = + listOfNotNull( + publicKey.tokenMap, + publicKey.targetTokenMap, + ).firstOrNull { it.entries.isNotEmpty() } + ?: error("XChat Juicebox token map was not found") + val client = + Client( + configuration = tokenMap.toJuiceboxConfiguration(), + authTokens = tokenMap.toJuiceboxAuthTokens(), + ) + val secret = + client.recover( + pin = pinCode.encodeToByteArray(), + ) + return secret.toRecoveredIdentityCredential( + base = + base.copy( + publicKeyB64 = publicKey.publicKey ?: base.publicKeyB64, + signingPublicKeyB64 = publicKey.signingPublicKey ?: base.signingPublicKeyB64, + registrationMethod = publicKey.registrationMethod ?: base.registrationMethod, + pinBacked = true, + ), + ) +} + +private val XChatPublicKey.hasJuiceboxTokens: Boolean + get() = + tokenMap?.entries?.isNotEmpty() == true || + targetTokenMap?.entries?.isNotEmpty() == true + +private fun XChatPublicKey.toIdentityCredential(userId: String): XChatIdentityCredential = + XChatIdentityCredential( + userId = userId, + version = version ?: error("XChat public key metadata is missing version"), + publicKeyB64 = publicKey ?: error("XChat public key metadata is missing public key"), + signingPublicKeyB64 = signingPublicKey ?: error("XChat public key metadata is missing signing public key"), + registrationMethod = registrationMethod, + pinBacked = true, + ) + +private fun XChatJuiceboxTokenMap.toJuiceboxConfiguration(): Configuration { + val configurationJson = + if (entries.isNotEmpty()) { + buildJsonObject { + put( + "realms", + buildJsonArray { + entries.forEach { entry -> + add( + buildJsonObject { + put("id", entry.realmId) + put("address", entry.address) + entry.publicKey?.takeIf { it.isNotBlank() }?.let { put("public_key", it) } + }, + ) + } + }, + ) + put("register_threshold", registerThreshold ?: entries.size) + put("recover_threshold", recoverThreshold ?: registerThreshold ?: entries.size) + put("pin_hashing_mode", "Standard2019") + }.toString() + } else { + listOfNotNull( + keyStoreTokenMapJson, + realmStateString, + ).firstOrNull { it.trim().startsWith("{") } + ?: error("XChat Juicebox token map did not contain realm configuration") + } + return Configuration(configurationJson) +} + +private fun XChatJuiceboxTokenMap.toJuiceboxAuthTokens(): Map { + val tokens = + entries + .mapNotNull { entry -> + runCatching { + RealmId(entry.realmId) to AuthToken(entry.token) + }.getOrNull() + }.toMap() + require(tokens.isNotEmpty()) { "XChat Juicebox token map did not contain usable auth tokens" } + return tokens +} + +internal fun ByteArray.toRecoveredIdentityCredential(base: XChatIdentityCredential): XChatIdentityCredential { + val root = + runCatching { + JSON.parseToJsonElement(decodeToString()) + }.getOrNull() + if (root != null) { + return root.toRecoveredIdentityCredential(base) + } + rawP256ScalarsOrNull()?.let { scalars -> + return scalars.toRecoveredIdentityCredentialFromScalars(base) + } + error("Recovered XChat identity secret is not JSON or raw P-256 scalar data") +} + +internal fun ByteArray.rawP256ScalarsOrNull(): ByteArray? { + if (size == XCHAT_JUICEBOX_SECRET_SIZE) { + return this + } + val text = runCatching { decodeToString().trim() }.getOrNull() ?: return null + return runCatching { XChatBase64.decode(text) } + .getOrNull() + ?.takeIf { it.size == XCHAT_JUICEBOX_SECRET_SIZE } +} + +private fun ByteArray.toRecoveredIdentityCredentialFromScalars(base: XChatIdentityCredential): XChatIdentityCredential { + val identityScalar = copyOfRange(0, P256_SCALAR_SIZE) + val signingScalar = copyOfRange(P256_SCALAR_SIZE, XCHAT_JUICEBOX_SECRET_SIZE) + return base.copy( + identityPrivateJwk = + p256PrivateJwk( + privateScalar = identityScalar, + publicKeyB64 = base.publicKeyB64, + ), + signingPrivateJwk = + p256PrivateJwk( + privateScalar = signingScalar, + publicKeyB64 = base.signingPublicKeyB64, + ), + pinBacked = true, + ) +} + +private fun p256PrivateJwk( + privateScalar: ByteArray, + publicKeyB64: String, +): JsonObject { + val publicKey = XChatBase64.decode(publicKeyB64).p256UncompressedPublicKey() + val x = publicKey.copyOfRange(1, 1 + P256_SCALAR_SIZE) + val y = publicKey.copyOfRange(1 + P256_SCALAR_SIZE, P256_UNCOMPRESSED_PUBLIC_KEY_SIZE) + return buildJsonObject { + put("kty", "EC") + put("crv", "P-256") + put("d", XChatBase64.encodeUrl(privateScalar)) + put("x", XChatBase64.encodeUrl(x)) + put("y", XChatBase64.encodeUrl(y)) + } +} + +private fun ByteArray.p256UncompressedPublicKey(): ByteArray = + when { + size == P256_UNCOMPRESSED_PUBLIC_KEY_SIZE && first() == P256_UNCOMPRESSED_PUBLIC_KEY_PREFIX -> this + size > P256_UNCOMPRESSED_PUBLIC_KEY_SIZE && + this[size - P256_UNCOMPRESSED_PUBLIC_KEY_SIZE] == P256_UNCOMPRESSED_PUBLIC_KEY_PREFIX -> + copyOfRange(size - P256_UNCOMPRESSED_PUBLIC_KEY_SIZE, size) + else -> error("XChat public key is not an uncompressed P-256 public key") + } + +private const val P256_SCALAR_SIZE = 32 +private const val P256_UNCOMPRESSED_PUBLIC_KEY_SIZE = 65 +private const val P256_UNCOMPRESSED_PUBLIC_KEY_PREFIX: Byte = 0x04 +private const val XCHAT_JUICEBOX_SECRET_SIZE = P256_SCALAR_SIZE * 2 + +private fun JsonElement.toRecoveredIdentityCredential(base: XChatIdentityCredential): XChatIdentityCredential { + val identityElement = + when (this) { + is JsonObject -> + this["xchatIdentity"] + ?: this["identity"] + ?: this["keyStore"] + ?: this + else -> this + } + if (identityElement is JsonPrimitive) { + val nestedJson = identityElement.contentOrNull?.takeIf { it.trim().startsWith("{") } + if (nestedJson != null) { + return JSON.parseToJsonElement(nestedJson).toRecoveredIdentityCredential(base) + } + } + val decoded = + runCatching { + JSON.decodeFromJsonElement( + XChatIdentityCredential.serializer(), + identityElement, + ) + }.getOrNull() + if (decoded?.identityPrivateJwk != null && decoded.signingPrivateJwk != null) { + return base.copy( + identityPrivateJwk = decoded.identityPrivateJwk, + signingPrivateJwk = decoded.signingPrivateJwk, + registrationMethod = decoded.registrationMethod ?: base.registrationMethod, + pinBacked = true, + ) + } + val identityObject = identityElement as? JsonObject ?: error("Recovered XChat identity secret is not an object") + val privateKeys = identityObject.obj("privateKeys") ?: identityObject.obj("private_keys") + val identityPrivateJwk = + identityObject.firstObject( + "identityPrivateJwk", + "identity_private_jwk", + "identityPrivateKeyJwk", + "identity_private_key_jwk", + ) ?: privateKeys?.firstObject( + "identity", + "identityPrivateJwk", + "identity_private_jwk", + "identityPrivateKeyJwk", + "identity_private_key_jwk", + ) ?: error("Recovered XChat identity secret is missing identity private JWK") + val signingPrivateJwk = + identityObject.firstObject( + "signingPrivateJwk", + "signing_private_jwk", + "signingPrivateKeyJwk", + "signing_private_key_jwk", + ) ?: privateKeys?.firstObject( + "signing", + "signingPrivateJwk", + "signing_private_jwk", + "signingPrivateKeyJwk", + "signing_private_key_jwk", + ) ?: error("Recovered XChat identity secret is missing signing private JWK") + return base.copy( + identityPrivateJwk = identityPrivateJwk, + signingPrivateJwk = signingPrivateJwk, + pinBacked = true, + ) +} + +private fun JsonObject.obj(name: String): JsonObject? = + when (val value = this[name]) { + JsonNull, + null, + -> null + is JsonObject -> value + else -> null + } + +private fun JsonObject.firstObject(vararg names: String): JsonObject? = + names.firstNotNullOfOrNull { obj(it) } diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatOperations.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatOperations.kt new file mode 100644 index 0000000000..0a0e5f9bc3 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatOperations.kt @@ -0,0 +1,326 @@ +package dev.dimension.flare.data.network.xqt.xchat + +internal data class XChatOperation( + val id: String, + val document: String, +) + +internal object XChatOperations { + private const val TOKEN_MAP_FRAGMENT = + "fragment TokenMapFragment on KeyStoreTokenMap { __typename realm_state realm_state_string max_guess_count recover_threshold register_threshold token_map { __typename key value { __typename token address public_key } } key_store_token_map_json }" + + private val operations = + mapOf( + "GetPublicKeys" to + XChatOperation( + id = "GJQbOZALDO5D3Zp2IZhH6w", + document = + """ + query GetPublicKeys( + ${'$'}ids: [NumericString!]!, + ${'$'}include_juicebox_tokens: Boolean = false + ) { + user_results_by_rest_ids(rest_ids: ${'$'}ids, safety_level: XChat) { + __typename + ... on UserResults { + rest_id + id + result { + __typename + ... on User { + chat_permissions { + __typename + can_dm + can_dm_on_xchat + dm_blocking + passes_premium_check + } + ...UserPublicKeysFragment + } + } + } + } + } + $TOKEN_MAP_FRAGMENT + fragment UserPublicKeysFragment on User { + __typename + get_public_keys { + __typename + ... on GetPublicKeysResult { + public_keys_with_token_map { + __typename + public_key_with_metadata { + __typename + public_key { + __typename + public_key + signing_public_key + identity_public_key_signature + registration_method + } + version + } + token_map @include(if: ${'$'}include_juicebox_tokens) { + __typename + ...TokenMapFragment + } + target_token_map @include(if: ${'$'}include_juicebox_tokens) { + __typename + ...TokenMapFragment + } + } + is_managed_pin_user + registration_method + } + ... on GetPublicKeysError { + error + } + } + } + """.trimIndent(), + ), + "AddEncryptedConversationKeysMutation" to + XChatOperation( + id = "4V1KC8ue2tHHvRuIzeczdg", + document = + """ + mutation AddEncryptedConversationKeysMutation( + ${'$'}conversation_id: String!, + ${'$'}conversation_key_version: NumericString!, + ${'$'}conversation_participant_keys: [ApiConversationParticipantKeyInput!]!, + ${'$'}base64_encoded_key_rotation: String, + ${'$'}action_signatures: [ActionSignatureInput!], + ${'$'}ttl_msec: NumericString + ) { + xchat_add_encrypted_conversation_key( + conversation_id: ${'$'}conversation_id, + conversation_key_version: ${'$'}conversation_key_version, + conversation_participant_keys: ${'$'}conversation_participant_keys, + base64_encoded_key_rotation: ${'$'}base64_encoded_key_rotation, + safety_level: XChat, + action_signatures: ${'$'}action_signatures, + ttl_msec: ${'$'}ttl_msec + ) { + __typename + error_code + conversation_key_change_sequence_id + } + } + """.trimIndent(), + ), + "GenerateXChatTokenMutation" to + XChatOperation( + id = "Qh3fZRjPPtPoHYR_2sCZsA", + document = + """ + mutation GenerateXChatTokenMutation { + user_get_x_chat_auth_token(safety_level: XChat) { + __typename + error_code + token + } + } + """.trimIndent(), + ), + "DmAvPermissionsQuery" to + XChatOperation( + id = "kfX5AHDKZrivyHwCaz68mQ", + document = + """ + query DmAvPermissionsQuery(${'$'}recipient_ids: [NumericString!]!) { + get_av_permissions( + recipient_ids: ${'$'}recipient_ids, + safety_level: DirectMessagesConversationTimeline + ) { + __typename + result { + __typename + can_dm + error_code + } + } + } + """.trimIndent(), + ), + "SendMessageCreateMutation" to + XChatOperation( + id = "TWRPP7gnKwV_R8-tE-Dd3Q", + document = + """ + mutation SendMessageCreateMutation( + ${'$'}conversation_id: String!, + ${'$'}message_id: String!, + ${'$'}conversation_token: String, + ${'$'}encoded_message_create_event: String, + ${'$'}encoded_message_event_signature: String + ) { + xchat_send_create_message_event( + conversation_id: ${'$'}conversation_id, + conversation_token: ${'$'}conversation_token, + encoded_message_create_event: ${'$'}encoded_message_create_event, + encoded_message_event_signature: ${'$'}encoded_message_event_signature, + message_id: ${'$'}message_id, + safety_level: XChat + ) { + __typename + encoded_message_event + } + } + """.trimIndent(), + ), + "DeleteMessageMutation" to + XChatOperation( + id = "4gsDQKEmYkOtvsSIpHXdQA", + document = + """ + mutation DeleteMessageMutation( + ${'$'}sequence_ids: [String!], + ${'$'}conversation_id: String, + ${'$'}delete_message_action: DeleteMessageActionInput, + ${'$'}action_signatures: [ActionSignatureInput!] + ) { + xchat_delete_messages( + safety_level: XChat, + sequence_ids: ${'$'}sequence_ids, + conversation_id: ${'$'}conversation_id, + delete_message_action: ${'$'}delete_message_action, + action_signatures: ${'$'}action_signatures + ) { + __typename + error_code + } + } + """.trimIndent(), + ), + "GetInitialXChatPageQuery" to + XChatOperation( + id = "XbDtnn4iqMRO13uK4cgqCA", + document = + """ + query GetInitialXChatPageQuery( + ${'$'}max_local_sequence_id: NumericString, + ${'$'}query_settings: XChatPageQuerySettingsInput, + ${'$'}message_pull_version: Int, + ${'$'}include_participants_results_for_inbox_preview: Boolean! = true + ) { + get_initial_chat_page( + max_local_sequence_id: ${'$'}max_local_sequence_id, + query_settings: ${'$'}query_settings, + message_pull_version: ${'$'}message_pull_version, + safety_level: XChat + ) { + __typename + inboxCursor: cursor { + __typename + ... on XChatGetInboxPageContinueCursor { + cursor_id + graph_snapshot_id + graph_snapshot_restarted + } + ... on XChatGetInboxPageEndCursor { + inbox_exhausted + graph_snapshot_id + } + } + items { + __typename + latest_message_events + latest_notifiable_message_create_event + latest_non_notifiable_message_create_event + latest_conversation_key_change_events + latest_message_sequence_id + latest_read_events_per_participant { + __typename + participant_id_results { + __typename + rest_id + } + latest_mark_conversation_read_event + } + conversation_detail { + __typename + ... on XChatDirectConversationDetail { + conversation_id + is_muted + participants_results @include(if: ${'$'}include_participants_results_for_inbox_preview) { + __typename + ...XChatUserResultFragment + } + } + ... on XChatGroupConversationDetail { + conversation_id + is_muted + group_metadata { + __typename + group_name + group_avatar_url + } + group_members_results { + __typename + rest_id + } + participants_results @include(if: ${'$'}include_participants_results_for_inbox_preview) { + __typename + ...XChatUserResultFragment + } + } + } + } + encoded_message_events + message_requests_count + message_pull_version + max_user_sequence_id + } + } + fragment XChatUserResultFragment on UserResults { + __typename + rest_id + result { + __typename + ... on User { + rest_id + avatar { + __typename + image_url + } + core { + __typename + name + screen_name + created_at_ms + } + } + } + } + """.trimIndent(), + ), + "GetConversationPageQuery" to + XChatOperation( + id = "IVlXls9JTnbgQ1gxsGAfJA", + document = + """ + query GetConversationPageQuery( + ${'$'}conversation_id: String, + ${'$'}min_local_sequence_id: NumericString!, + ${'$'}min_conversation_key_version: NumericString!, + ${'$'}query_settings: XChatPageQuerySettingsInput + ) { + get_conversation_page( + conversation_id: ${'$'}conversation_id, + min_local_sequence_id: ${'$'}min_local_sequence_id, + min_conversation_key_version: ${'$'}min_conversation_key_version, + query_settings: ${'$'}query_settings, + safety_level: XChat + ) { + __typename + encoded_message_events + missing_conversation_key_change_events + has_more + } + } + """.trimIndent(), + ), + ) + + operator fun get(name: String): XChatOperation? = operations[name] +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatSecretBox.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatSecretBox.kt new file mode 100644 index 0000000000..bd99349fe4 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatSecretBox.kt @@ -0,0 +1,374 @@ +package dev.dimension.flare.data.network.xqt.xchat + +import kotlin.experimental.xor + +internal object XChatSecretBox { + fun encryptBody( + plaintext: ByteArray, + key: ByteArray, + nonce: ByteArray, + ): ByteArray { + require(key.size == KEY_SIZE) { "secretbox key must be $KEY_SIZE bytes" } + require(nonce.size == NONCE_SIZE) { "secretbox nonce must be $NONCE_SIZE bytes" } + val box = secretBox(plaintext, nonce, key) + return nonce + box + } + + fun decryptBody( + frame: ByteArray, + key: ByteArray, + ): ByteArray? { + require(key.size == KEY_SIZE) { "secretbox key must be $KEY_SIZE bytes" } + if (frame.size < NONCE_SIZE + TAG_SIZE) return null + val nonce = frame.copyOfRange(0, NONCE_SIZE) + val box = frame.copyOfRange(NONCE_SIZE, frame.size) + return secretBoxOpen(box, nonce, key) + } + + private fun secretBox( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val stream = xsalsa20Stream(message.size + ZERO_PREFIX_SIZE, nonce, key) + val cipher = ByteArray(message.size) + for (index in message.indices) { + cipher[index] = message[index] xor stream[index + ZERO_PREFIX_SIZE] + } + val tag = poly1305(cipher, stream.copyOfRange(0, AUTH_KEY_SIZE)) + return tag + cipher + } + + private fun secretBoxOpen( + box: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray? { + if (box.size < TAG_SIZE) return null + val tag = box.copyOfRange(0, TAG_SIZE) + val cipher = box.copyOfRange(TAG_SIZE, box.size) + val stream = xsalsa20Stream(cipher.size + ZERO_PREFIX_SIZE, nonce, key) + val expected = poly1305(cipher, stream.copyOfRange(0, AUTH_KEY_SIZE)) + if (!constantTimeEquals(tag, expected)) return null + return ByteArray(cipher.size) { index -> + cipher[index] xor stream[index + ZERO_PREFIX_SIZE] + } + } + + private fun xsalsa20Stream( + length: Int, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val subKey = hSalsa20(nonce.copyOfRange(0, 16), key) + return salsa20Stream(length, nonce.copyOfRange(16, 24), subKey) + } + + private fun salsa20Stream( + length: Int, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val output = ByteArray(length) + var counter = 0L + var offset = 0 + while (offset < length) { + val block = salsa20Block(nonce, key, counter) + val count = minOf(block.size, length - offset) + block.copyInto(output, offset, endIndex = count) + offset += count + counter++ + } + return output + } + + private fun salsa20Block( + nonce: ByteArray, + key: ByteArray, + counter: Long, + ): ByteArray { + val state = + intArrayOf( + load32(SIGMA, 0), + load32(key, 0), + load32(key, 4), + load32(key, 8), + load32(key, 12), + load32(SIGMA, 4), + load32(nonce, 0), + load32(nonce, 4), + counter.toInt(), + (counter ushr 32).toInt(), + load32(SIGMA, 8), + load32(key, 16), + load32(key, 20), + load32(key, 24), + load32(key, 28), + load32(SIGMA, 12), + ) + val working = salsaRounds(state) + val output = ByteArray(64) + for (index in 0 until 16) { + store32(output, index * 4, working[index] + state[index]) + } + return output + } + + private fun hSalsa20( + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val state = + intArrayOf( + load32(SIGMA, 0), + load32(key, 0), + load32(key, 4), + load32(key, 8), + load32(key, 12), + load32(SIGMA, 4), + load32(nonce, 0), + load32(nonce, 4), + load32(nonce, 8), + load32(nonce, 12), + load32(SIGMA, 8), + load32(key, 16), + load32(key, 20), + load32(key, 24), + load32(key, 28), + load32(SIGMA, 12), + ) + val working = salsaRounds(state) + val output = ByteArray(KEY_SIZE) + intArrayOf(0, 5, 10, 15, 6, 7, 8, 9).forEachIndexed { index, stateIndex -> + store32(output, index * 4, working[stateIndex]) + } + return output + } + + private fun salsaRounds(input: IntArray): IntArray { + val x = input.copyOf() + repeat(10) { + x[4] = x[4] xor rotateLeft(x[0] + x[12], 7) + x[8] = x[8] xor rotateLeft(x[4] + x[0], 9) + x[12] = x[12] xor rotateLeft(x[8] + x[4], 13) + x[0] = x[0] xor rotateLeft(x[12] + x[8], 18) + x[9] = x[9] xor rotateLeft(x[5] + x[1], 7) + x[13] = x[13] xor rotateLeft(x[9] + x[5], 9) + x[1] = x[1] xor rotateLeft(x[13] + x[9], 13) + x[5] = x[5] xor rotateLeft(x[1] + x[13], 18) + x[14] = x[14] xor rotateLeft(x[10] + x[6], 7) + x[2] = x[2] xor rotateLeft(x[14] + x[10], 9) + x[6] = x[6] xor rotateLeft(x[2] + x[14], 13) + x[10] = x[10] xor rotateLeft(x[6] + x[2], 18) + x[3] = x[3] xor rotateLeft(x[15] + x[11], 7) + x[7] = x[7] xor rotateLeft(x[3] + x[15], 9) + x[11] = x[11] xor rotateLeft(x[7] + x[3], 13) + x[15] = x[15] xor rotateLeft(x[11] + x[7], 18) + x[1] = x[1] xor rotateLeft(x[0] + x[3], 7) + x[2] = x[2] xor rotateLeft(x[1] + x[0], 9) + x[3] = x[3] xor rotateLeft(x[2] + x[1], 13) + x[0] = x[0] xor rotateLeft(x[3] + x[2], 18) + x[6] = x[6] xor rotateLeft(x[5] + x[4], 7) + x[7] = x[7] xor rotateLeft(x[6] + x[5], 9) + x[4] = x[4] xor rotateLeft(x[7] + x[6], 13) + x[5] = x[5] xor rotateLeft(x[4] + x[7], 18) + x[11] = x[11] xor rotateLeft(x[10] + x[9], 7) + x[8] = x[8] xor rotateLeft(x[11] + x[10], 9) + x[9] = x[9] xor rotateLeft(x[8] + x[11], 13) + x[10] = x[10] xor rotateLeft(x[9] + x[8], 18) + x[12] = x[12] xor rotateLeft(x[15] + x[14], 7) + x[13] = x[13] xor rotateLeft(x[12] + x[15], 9) + x[14] = x[14] xor rotateLeft(x[13] + x[12], 13) + x[15] = x[15] xor rotateLeft(x[14] + x[13], 18) + } + return x + } + + private fun poly1305( + message: ByteArray, + key: ByteArray, + ): ByteArray { + val r0 = load32Long(key, 0) and MASK_26 + val r1 = (load32Long(key, 3) ushr 2) and 0x3ffff03L + val r2 = (load32Long(key, 6) ushr 4) and 0x3ffc0ffL + val r3 = (load32Long(key, 9) ushr 6) and 0x3f03fffL + val r4 = (load32Long(key, 12) ushr 8) and 0x00fffffL + val s1 = r1 * 5 + val s2 = r2 * 5 + val s3 = r3 * 5 + val s4 = r4 * 5 + var h0 = 0L + var h1 = 0L + var h2 = 0L + var h3 = 0L + var h4 = 0L + var offset = 0 + while (offset < message.size) { + val block = ByteArray(16) + val blockSize = minOf(16, message.size - offset) + message.copyInto(block, endIndex = offset + blockSize, startIndex = offset) + val hibit = + if (blockSize == 16) { + 1L shl 24 + } else { + block[blockSize] = 1 + 0L + } + val t0 = load32Long(block, 0) + val t1 = load32Long(block, 4) + val t2 = load32Long(block, 8) + val t3 = load32Long(block, 12) + h0 += t0 and MASK_26 + h1 += ((t1 shl 6) or (t0 ushr 26)) and MASK_26 + h2 += ((t2 shl 12) or (t1 ushr 20)) and MASK_26 + h3 += ((t3 shl 18) or (t2 ushr 14)) and MASK_26 + h4 += (t3 ushr 8) or hibit + + val d0 = h0 * r0 + h1 * s4 + h2 * s3 + h3 * s2 + h4 * s1 + val d1 = h0 * r1 + h1 * r0 + h2 * s4 + h3 * s3 + h4 * s2 + val d2 = h0 * r2 + h1 * r1 + h2 * r0 + h3 * s4 + h4 * s3 + val d3 = h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * s4 + val d4 = h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0 + + var carry = d0 ushr 26 + h0 = d0 and MASK_26 + h1 = d1 + carry + carry = h1 ushr 26 + h1 = h1 and MASK_26 + h2 = d2 + carry + carry = h2 ushr 26 + h2 = h2 and MASK_26 + h3 = d3 + carry + carry = h3 ushr 26 + h3 = h3 and MASK_26 + h4 = d4 + carry + carry = h4 ushr 26 + h4 = h4 and MASK_26 + h0 += carry * 5 + carry = h0 ushr 26 + h0 = h0 and MASK_26 + h1 += carry + + offset += blockSize + } + + var carry = h1 ushr 26 + h1 = h1 and MASK_26 + h2 += carry + carry = h2 ushr 26 + h2 = h2 and MASK_26 + h3 += carry + carry = h3 ushr 26 + h3 = h3 and MASK_26 + h4 += carry + carry = h4 ushr 26 + h4 = h4 and MASK_26 + h0 += carry * 5 + carry = h0 ushr 26 + h0 = h0 and MASK_26 + h1 += carry + + var g0 = h0 + 5 + carry = g0 ushr 26 + g0 = g0 and MASK_26 + var g1 = h1 + carry + carry = g1 ushr 26 + g1 = g1 and MASK_26 + var g2 = h2 + carry + carry = g2 ushr 26 + g2 = g2 and MASK_26 + var g3 = h3 + carry + carry = g3 ushr 26 + g3 = g3 and MASK_26 + val g4 = h4 + carry - (1L shl 26) + if (g4 >= 0) { + h0 = g0 + h1 = g1 + h2 = g2 + h3 = g3 + h4 = g4 + } + + val tag = ByteArray(TAG_SIZE) + var f = (h0 or (h1 shl 26)) and UINT_MASK + f += load32Long(key, 16) + carry = f ushr 32 + store32(tag, 0, f) + f = ((h1 ushr 6) or (h2 shl 20)) and UINT_MASK + f += load32Long(key, 20) + carry + carry = f ushr 32 + store32(tag, 4, f) + f = ((h2 ushr 12) or (h3 shl 14)) and UINT_MASK + f += load32Long(key, 24) + carry + carry = f ushr 32 + store32(tag, 8, f) + f = ((h3 ushr 18) or (h4 shl 8)) and UINT_MASK + f += load32Long(key, 28) + carry + store32(tag, 12, f) + return tag + } + + private fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (index in a.indices) { + diff = diff or (a[index].toInt() xor b[index].toInt()) + } + return diff == 0 + } + + private fun rotateLeft( + value: Int, + bits: Int, + ): Int = (value shl bits) or (value ushr (32 - bits)) + + private fun load32( + bytes: ByteArray, + offset: Int, + ): Int = + bytes[offset].u() or + (bytes[offset + 1].u() shl 8) or + (bytes[offset + 2].u() shl 16) or + (bytes[offset + 3].u() shl 24) + + private fun load32Long( + bytes: ByteArray, + offset: Int, + ): Long = load32(bytes, offset).toLong() and UINT_MASK + + private fun store32( + bytes: ByteArray, + offset: Int, + value: Int, + ) { + bytes[offset] = value.toByte() + bytes[offset + 1] = (value ushr 8).toByte() + bytes[offset + 2] = (value ushr 16).toByte() + bytes[offset + 3] = (value ushr 24).toByte() + } + + private fun store32( + bytes: ByteArray, + offset: Int, + value: Long, + ) { + bytes[offset] = value.toByte() + bytes[offset + 1] = (value ushr 8).toByte() + bytes[offset + 2] = (value ushr 16).toByte() + bytes[offset + 3] = (value ushr 24).toByte() + } + + private fun Byte.u(): Int = toInt() and 0xff + + private const val KEY_SIZE = 32 + private const val NONCE_SIZE = 24 + private const val TAG_SIZE = 16 + private const val AUTH_KEY_SIZE = 32 + private const val ZERO_PREFIX_SIZE = 32 + private const val MASK_26 = 0x3ffffffL + private const val UINT_MASK = 0xffffffffL + private val SIGMA = "expand 32-byte k".encodeToByteArray() +} diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatService.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatService.kt new file mode 100644 index 0000000000..c01199376c --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatService.kt @@ -0,0 +1,1123 @@ +package dev.dimension.flare.data.network.xqt.xchat + +import dev.dimension.flare.data.network.xqt.emusks.EmusksApiException +import dev.dimension.flare.data.network.xqt.emusks.EmusksRawClient +import dev.dimension.flare.data.platform.XChatIdentityCredential +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import kotlin.time.Clock +import kotlin.uuid.Uuid + +internal class XChatService( + private val rawClient: EmusksRawClient, +) { + suspend fun loadIdentity(identity: XChatIdentityCredential): XChatLoadedIdentity = XChatCrypto.loadIdentity(identity) + + suspend fun publicKeys( + userIds: List, + includeJuiceboxTokens: Boolean = false, + ): List { + if (userIds.isEmpty()) return emptyList() + val response = + gql( + name = "GetPublicKeys", + variables = + buildJsonObject { + put( + "ids", + buildJsonArray { + userIds.forEach { add(JsonPrimitive(it)) } + }, + ) + put("include_juicebox_tokens", includeJuiceboxTokens) + }, + ) + return response + .obj("data") + ?.array("user_results_by_rest_ids") + .orEmpty() + .mapNotNull { it.asObjectOrNull()?.toUserPublicKeys() } + } + + suspend fun profile(userId: String): XChatUserPublicKeys? = publicKeys(listOf(userId)).firstOrNull() + + suspend fun publicKey(userId: String): XChatPublicKey? = profile(userId)?.keys?.firstOrNull() + + suspend fun permissions(userId: String): XChatPermissions? = profile(userId)?.permissions + + suspend fun isOnXChat(userId: String): Boolean = profile(userId)?.onXChat == true + + suspend fun canMessage(userId: String): Boolean = + permissions(userId)?.canDmOnXChat == true + + suspend fun fingerprint(publicKeyB64: String): String = + XChatCrypto + .sha256(XChatBase64.decode(publicKeyB64)) + .joinToString("") { byte -> + (byte.toInt() and 0xff).toString(radix = 16).padStart(2, '0') + } + .chunked(4) + .joinToString(":") + + suspend fun token(): String? = + gql(name = "GenerateXChatTokenMutation") + .obj("data") + ?.obj("user_get_x_chat_auth_token") + ?.string("token") + + suspend fun callPermissions(userIds: List): List { + if (userIds.isEmpty()) return emptyList() + val response = + gql( + name = "DmAvPermissionsQuery", + variables = + buildJsonObject { + put( + "recipient_ids", + buildJsonArray { + userIds.forEach { add(JsonPrimitive(it)) } + }, + ) + }, + ) + return response + .obj("data") + ?.obj("get_av_permissions") + ?.array("result") + .orEmpty() + .mapNotNull { it.asObjectOrNull()?.toCallPermission() } + } + + suspend fun initialPage( + identity: XChatLoadedIdentity?, + maxLocalSequenceId: String? = null, + messagePullVersion: Int? = null, + viewerId: String? = identity?.userId, + ): XChatInboxPage { + val response = + gql( + name = "GetInitialXChatPageQuery", + variables = + buildJsonObject { + put("max_local_sequence_id", maxLocalSequenceId?.let(::JsonPrimitive) ?: JsonNull) + put("query_settings", initialPageQuerySettings()) + put( + "message_pull_version", + JsonPrimitive(messagePullVersion ?: DEFAULT_INITIAL_MESSAGE_PULL_VERSION), + ) + }, + ) + val page = response.obj("data")?.obj("get_initial_chat_page") ?: JsonObject(emptyMap()) + val conversations = + page + .array("items") + .orEmpty() + .mapNotNull { item -> + item + .asObjectOrNull() + ?.toConversation(identity, viewerId) + } + val cursor = page.obj("cursor") ?: page.obj("inboxCursor") + val nextKey = + cursor?.string("max_local_sequence_id") + ?: cursor?.string("cursor_id") + return XChatInboxPage( + conversations = conversations, + nextKey = nextKey, + hasMore = nextKey != null || cursor?.boolean("inbox_exhausted") == false, + messageRequestsCount = page.int("message_requests_count"), + messagePullVersion = page.int("message_pull_version"), + maxUserSequenceId = page.string("max_user_sequence_id"), + ) + } + + suspend fun conversationPage( + conversationId: String, + identity: XChatLoadedIdentity, + before: String? = null, + ): XChatConversationPage { + val response = + gql( + name = "GetConversationPageQuery", + variables = + buildJsonObject { + put("conversation_id", conversationId) + put("min_local_sequence_id", before ?: MAX_SEQUENCE_ID) + put("min_conversation_key_version", MAX_SEQUENCE_ID) + put("query_settings", JsonNull) + }, + ) + val page = response.obj("data")?.obj("get_conversation_page") ?: JsonObject(emptyMap()) + val cKeyMap = + buildConversationKeyMap( + keyChangeB64s = page.array("missing_conversation_key_change_events").asStringList(), + identity = identity, + ) + identity.rememberConversationKeys(conversationId, cKeyMap) + identity.conversationKeys[conversationId]?.let { cached -> + if (cKeyMap[cached.version] == null) { + cKeyMap[cached.version] = cached.key + } + } + val messages = + page + .array("encoded_message_events") + .asStringList() + .mapNotNull { decodeMessageEvent(it, cKeyMap) } + return XChatConversationPage( + messages = messages, + hasMore = page.boolean("has_more") == true, + nextKey = messages.minSequenceId(), + ) + } + + suspend fun sendText( + conversationId: String, + text: String, + identity: XChatLoadedIdentity, + participantIds: List, + ttlMsec: Long? = null, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = XChatThrift.textHolder(text), + identity = identity, + participantIds = participantIds, + ttlMsec = ttlMsec, + ) + + suspend fun react( + conversationId: String, + sequenceId: String, + emoji: String, + identity: XChatLoadedIdentity, + participantIds: List, + attachmentId: String? = null, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = + XChatThrift.reactionAddHolder( + sequenceId = sequenceId, + emoji = emoji, + attachmentId = attachmentId, + ), + identity = identity, + participantIds = participantIds, + ) + + suspend fun unreact( + conversationId: String, + sequenceId: String, + emoji: String, + identity: XChatLoadedIdentity, + participantIds: List, + attachmentId: String? = null, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = + XChatThrift.reactionRemoveHolder( + sequenceId = sequenceId, + emoji = emoji, + attachmentId = attachmentId, + ), + identity = identity, + participantIds = participantIds, + ) + + suspend fun edit( + conversationId: String, + sequenceId: String, + text: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = + XChatThrift.editHolder( + sequenceId = sequenceId, + updatedText = text, + ), + identity = identity, + participantIds = participantIds, + ) + + suspend fun markRead( + conversationId: String, + sequenceId: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = + XChatThrift.markReadHolder( + sequenceId = sequenceId, + seenAtMillis = Clock.System.now().toEpochMilliseconds(), + ), + identity = identity, + participantIds = participantIds, + ) + + suspend fun markUnread( + conversationId: String, + sequenceId: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = XChatThrift.markUnreadHolder(sequenceId), + identity = identity, + participantIds = participantIds, + ) + + suspend fun pinConversation( + conversationId: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = XChatThrift.pinHolder(conversationId), + identity = identity, + participantIds = participantIds, + ) + + suspend fun unpinConversation( + conversationId: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = XChatThrift.unpinHolder(conversationId), + identity = identity, + participantIds = participantIds, + ) + + suspend fun setNickname( + conversationId: String, + targetUserId: String, + nickname: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = + XChatThrift.nicknameHolder( + userId = targetUserId, + nickname = nickname, + ), + identity = identity, + participantIds = participantIds, + ) + + suspend fun reportScreenCapture( + conversationId: String, + identity: XChatLoadedIdentity, + participantIds: List, + recording: Boolean = false, + ): XChatSendResult = + sendEntry( + conversationId = conversationId, + holderBytes = + XChatThrift.screenCaptureHolder( + if (recording) ScreenCaptureType.Recording else ScreenCaptureType.Screenshot, + ), + identity = identity, + participantIds = participantIds, + ) + + suspend fun deleteMessages( + conversationId: String, + sequenceIds: List, + forEveryone: Boolean = false, + identity: XChatLoadedIdentity? = null, + ): JsonObject { + if (sequenceIds.isEmpty()) return JsonObject(emptyMap()) + val actionSignatures = + if (forEveryone) { + val loadedIdentity = identity ?: error("XChat identity is required to delete for everyone") + val conversationToken = conversationToken(conversationId, loadedIdentity) + val signature = + XChatCrypto.actionSignature( + identity = loadedIdentity, + typeName = "MessageDeleteEvent", + conversationToken = conversationToken, + conversationId = conversationId, + dataElements = listOf("2") + sequenceIds, + eventDetailBytes = + XChatThrift.deleteEventDetail( + sequenceIds = sequenceIds, + actionValue = 2, + ), + ) + buildJsonArray { + add(signature) + } + } else { + JsonNull + } + val response = + gql( + name = "DeleteMessageMutation", + variables = + buildJsonObject { + put("conversation_id", conversationId) + put( + "sequence_ids", + buildJsonArray { + sequenceIds.forEach { add(JsonPrimitive(it)) } + }, + ) + put("delete_message_action", if (forEveryone) "DeleteForAll" else "DeleteForSelf") + put("action_signatures", actionSignatures) + }, + ) + return response.obj("data")?.obj("xchat_delete_messages") ?: JsonObject(emptyMap()) + } + + suspend fun gql( + name: String, + variables: JsonObject = JsonObject(emptyMap()), + ): JsonObject { + val operation = XChatOperations[name] ?: throw EmusksApiException("xchat operation $name not found") + return rawClient.apolloGraphqlJson( + operationId = operation.id, + operationName = name, + query = operation.document, + variables = variables, + ) + } + + private suspend fun sendEntry( + conversationId: String, + holderBytes: ByteArray, + identity: XChatLoadedIdentity, + participantIds: List, + shouldNotify: Boolean = true, + ttlMsec: Long? = null, + ): XChatSendResult { + val conversationKey = ensureConversationKey(conversationId, identity, participantIds) + val conversationToken = conversationToken(conversationId, identity) + val frame = XChatCrypto.encryptBody(holderBytes, conversationKey.key) + val messageCreateEvent = + XChatThrift.messageCreateEvent( + frame = frame, + conversationKeyVersion = conversationKey.version, + shouldNotify = shouldNotify, + ttlMsec = ttlMsec, + ) + val signature = + XChatCrypto.eventSignature( + identity = identity, + conversationToken = conversationToken, + conversationId = conversationId, + conversationKeyVersion = conversationKey.version, + frame = frame, + ) + val messageId = Uuid.random().toString() + val response = + gql( + name = "SendMessageCreateMutation", + variables = + buildJsonObject { + put("conversation_id", conversationId) + put("message_id", messageId) + put("conversation_token", conversationToken.takeIf { it.isNotEmpty() }?.let(::JsonPrimitive) ?: JsonNull) + put("encoded_message_create_event", XChatBase64.encode(messageCreateEvent)) + put("encoded_message_event_signature", signature) + }, + ) + val encodedEvent = + response + .obj("data") + ?.obj("xchat_send_create_message_event") + ?.string("encoded_message_event") + val decodedEvent = + encodedEvent?.let { + decodeMessageEvent( + eventB64 = it, + cKeyMap = mapOf(conversationKey.version to conversationKey.key), + ) + } + return XChatSendResult( + conversationId = conversationId, + messageId = messageId, + sequenceId = XChatThrift.readLeadingSequenceId(encodedEvent), + encodedMessageEvent = encodedEvent, + decodedEvent = decodedEvent, + ) + } + + private suspend fun ensureConversationKey( + conversationId: String, + identity: XChatLoadedIdentity, + participantIds: List, + ): XChatConversationKey { + identity.conversationKeys[conversationId]?.let { return it } + val distinctParticipantIds = (participantIds + identity.userId).distinct() + val recipientKeys = + publicKeys(distinctParticipantIds.filterNot { it == identity.userId }) + .mapNotNull { user -> + val key = user.keys.firstOrNull { it.publicKey != null && it.version != null } ?: return@mapNotNull null + XChatRecipientKey( + userId = user.userId ?: return@mapNotNull null, + publicKeySpki = XChatBase64.decode(key.publicKey ?: return@mapNotNull null), + version = key.version ?: return@mapNotNull null, + ) + } + val missingRecipients = distinctParticipantIds.filterNot { id -> id == identity.userId || recipientKeys.any { it.userId == id } } + if (missingRecipients.isNotEmpty()) { + throw EmusksApiException("xchat public key not found for ${missingRecipients.joinToString()}") + } + val conversationKeyBytes = XChatCrypto.secureRandomBytes(32) + val version = Clock.System.now().toEpochMilliseconds().toString() + val participantKeys = + listOf( + XChatRecipientKey( + userId = identity.userId, + publicKeySpki = identity.publicKeySpki, + version = identity.version, + ), + ) + recipientKeys + gql( + name = "AddEncryptedConversationKeysMutation", + variables = + buildJsonObject { + put("conversation_id", conversationId) + put("conversation_key_version", version) + put( + "conversation_participant_keys", + buildJsonArray { + participantKeys.forEach { recipient -> + add( + buildJsonObject { + put("user_id", recipient.userId) + put( + "encrypted_conversation_key", + XChatCrypto.eciesWrap(conversationKeyBytes, recipient.publicKeySpki), + ) + put("public_key_version", recipient.version) + }, + ) + } + }, + ) + }, + ) + return XChatConversationKey( + key = conversationKeyBytes, + version = version, + ).also { + identity.conversationKeys[conversationId] = it + } + } + + private suspend fun conversationToken( + conversationId: String, + identity: XChatLoadedIdentity, + ): String { + identity.conversationTokens[conversationId]?.let { return it } + val token = + runCatching { + val page = conversationPage(conversationId = conversationId, identity = identity) + page.messages + .firstNotNullOfOrNull { it.conversationToken } + .orEmpty() + }.getOrDefault("") + identity.conversationTokens[conversationId] = token + return token + } + + private suspend fun JsonObject.toConversation( + identity: XChatLoadedIdentity?, + viewerId: String?, + ): XChatConversation? { + val detail = obj("conversation_detail") ?: return null + val conversationId = detail.string("conversation_id") ?: return null + val isGroup = detail.string("__typename") == "XChatGroupConversationDetail" + val participantResults = + if (isGroup) { + ( + detail.array("participants_results").orEmpty() + + detail.array("group_members_results").orEmpty() + ).distinctBy { it.asObjectOrNull()?.string("rest_id") } + } else { + detail.array("participants_results").orEmpty() + } + val parsedParticipants = + participantResults + .mapNotNull { it.asObjectOrNull()?.toXChatUser() } + val participants = + if (isGroup || viewerId == null) { + parsedParticipants + } else { + val parsedIds = parsedParticipants.map { it.userId }.toSet() + parsedParticipants + + conversationId + .split(":") + .filter { it.isNotBlank() && it != viewerId && it !in parsedIds } + .map { XChatUser(userId = it, name = null, screenName = null, avatarUrl = null) } + } + if (!isGroup && viewerId != null && conversationId.isSelfConversation(viewerId, participants)) { + return null + } + if (boolean("is_deleted_by_viewer") == true) { + return null + } + val cKeyMap = + if (identity != null) { + buildConversationKeyMap( + keyChangeB64s = array("latest_conversation_key_change_events").asStringList(), + identity = identity, + ) + } else { + emptyMap() + } + identity?.rememberConversationKeys(conversationId, cKeyMap) + val latestEvents = + ( + listOfNotNull(string("latest_notifiable_message_create_event")) + + listOfNotNull(string("latest_non_notifiable_message_create_event")) + + array("latest_message_events").asStringList() + ).distinct() + .mapNotNull { decodeMessageEvent(it, cKeyMap) } + if (latestEvents.latestTimelineEvent()?.kind == XChatDecodedEventKind.ConversationDelete) { + return null + } + val latestMessage = latestEvents.latestDisplayableMessage() + return XChatConversation( + conversationId = conversationId, + type = if (isGroup) XChatConversationType.Group else XChatConversationType.Direct, + isMuted = detail.boolean("is_muted"), + groupName = detail.obj("group_metadata")?.string("group_name"), + groupAvatarUrl = detail.obj("group_metadata")?.string("group_avatar_url"), + participants = participants, + latestSequenceId = string("latest_message_sequence_id"), + latestMessage = latestMessage, + unreadCount = unreadCount(viewerId, latestMessage), + ) + } + + private fun JsonObject.unreadCount( + viewerId: String?, + latestMessage: XChatDecodedEvent?, + ): Long { + val message = latestMessage ?: return 0 + if (viewerId == null || message.senderId == viewerId) return 0 + val latestMessageIsDisplayable = message.kind == XChatDecodedEventKind.Message + val latestSequenceId = string("latest_message_sequence_id")?.toULongOrNull() ?: return 0 + val lastReadSequenceId = + array("latest_read_events_per_participant") + .orEmpty() + .firstNotNullOfOrNull { entry -> + val item = entry.asObjectOrNull() ?: return@firstNotNullOfOrNull null + val participantId = item.obj("participant_id_results")?.string("rest_id") + if (participantId != viewerId) return@firstNotNullOfOrNull null + XChatThrift + .readLeadingSequenceId(item.string("latest_mark_conversation_read_event")) + ?.toULongOrNull() + } ?: return if (latestMessageIsDisplayable) 1 else 0 + return if (latestSequenceId > lastReadSequenceId && latestMessageIsDisplayable) 1 else 0 + } + + private suspend fun buildConversationKeyMap( + keyChangeB64s: List, + identity: XChatLoadedIdentity, + ): MutableMap { + val map = mutableMapOf() + keyChangeB64s.forEach { keyChangeB64 -> + runCatching { + val keyChangeEvent = + XChatThrift + .parse(XChatBase64.decode(keyChangeB64)) + .struct(7) + ?.struct(3) + ?: return@runCatching + val version = keyChangeEvent.string(1) ?: return@runCatching + keyChangeEvent + .list(2) + .orEmpty() + .mapNotNull { it.asStruct() } + .firstOrNull { it.string(1) == identity.userId } + ?.string(2) + ?.let { wrappedKey -> + map[version] = XChatCrypto.eciesUnwrap(wrappedKey, identity.identityPrivateKey) + } + } + } + return map + } + + private fun XChatLoadedIdentity.rememberConversationKeys( + conversationId: String, + keys: Map, + ) { + val latest = + keys.maxWithOrNull( + compareBy> { + it.key.toULongOrNull() ?: ULong.MIN_VALUE + }.thenBy { it.key }, + ) ?: return + conversationKeys[conversationId] = + XChatConversationKey( + key = latest.value, + version = latest.key, + ) + } + + internal fun decodeMessageEvent( + eventB64: String, + cKeyMap: Map, + ): XChatDecodedEvent? = + runCatching { + val event = XChatThrift.parse(XChatBase64.decode(eventB64)) + val detail = event.struct(7).orEmpty() + val conversationToken = event.string(5) + val base = + XChatDecodedEvent( + sequenceId = event.string(1), + messageId = event.string(2), + senderId = event.string(3), + conversationId = event.string(4), + conversationToken = conversationToken, + createdAtMillis = event.string(6)?.toLongOrNull() ?: event.long(6), + kind = detail.kind(), + ) + val messageCreateEvent = detail.struct(1) ?: return@runCatching base.withDeleteTargets(detail) + val frame = messageCreateEvent.binary(100) + val conversationKeyVersion = messageCreateEvent.string(101) + if (frame == null) { + return@runCatching base.copy( + kind = XChatDecodedEventKind.Message, + conversationKeyVersion = conversationKeyVersion, + encrypted = conversationKeyVersion != null, + ) + } + val plaintext = + if (conversationKeyVersion == null) { + frame + } else { + val conversationKey = + cKeyMap[conversationKeyVersion] + ?: return@runCatching base.copy( + kind = XChatDecodedEventKind.Message, + conversationKeyVersion = conversationKeyVersion, + encrypted = true, + ) + XChatCrypto.decryptBody( + frame = frame, + conversationKey = conversationKey, + ) ?: return@runCatching base.copy( + kind = XChatDecodedEventKind.Message, + conversationKeyVersion = conversationKeyVersion, + decryptError = true, + ) + } + base + .copy(conversationKeyVersion = conversationKeyVersion) + .withEntry(XChatThrift.parse(plaintext).struct(1).orEmpty()) + }.getOrNull() + + private fun XChatDecodedEvent.withDeleteTargets(detail: XChatThriftStruct): XChatDecodedEvent { + val deleteEvent = detail.struct(7) ?: return this + return copy( + targetSequenceIds = + deleteEvent + .list(1) + .orEmpty() + .mapNotNull { (it as? XChatThriftValue.Binary)?.bytes?.decodeToString() }, + ) + } + + private fun XChatDecodedEvent.withEntry(entry: XChatThriftStruct): XChatDecodedEvent = + when { + entry.struct(1) != null -> + copy( + kind = XChatDecodedEventKind.Message, + text = entry.struct(1)?.string(1), + ) + entry.struct(2) != null -> + copy( + kind = XChatDecodedEventKind.ReactionAdd, + targetSequenceIds = listOfNotNull(entry.struct(2)?.string(1)), + emoji = entry.struct(2)?.string(2), + ) + entry.struct(3) != null -> + copy( + kind = XChatDecodedEventKind.ReactionRemove, + targetSequenceIds = listOfNotNull(entry.struct(3)?.string(1)), + emoji = entry.struct(3)?.string(2), + ) + entry.struct(4) != null -> + copy( + kind = XChatDecodedEventKind.Edit, + targetSequenceIds = listOfNotNull(entry.struct(4)?.string(1)), + text = entry.struct(4)?.string(2), + ) + entry.struct(5) != null -> + copy( + kind = XChatDecodedEventKind.Read, + targetSequenceIds = listOfNotNull(entry.struct(5)?.string(1)), + ) + entry.struct(6) != null -> + copy( + kind = XChatDecodedEventKind.Unread, + targetSequenceIds = listOfNotNull(entry.struct(6)?.string(1)), + ) + entry.struct(10) != null -> + copy( + kind = XChatDecodedEventKind.AvCallEnded, + targetSequenceIds = emptyList(), + ) + entry.struct(11) != null -> + copy(kind = XChatDecodedEventKind.AvCallMissed) + entry.struct(16) != null -> + copy(kind = XChatDecodedEventKind.AvCallStarted) + else -> this + } + + private fun XChatThriftStruct.kind(): XChatDecodedEventKind = + when { + struct(1) != null -> XChatDecodedEventKind.Message + struct(3) != null -> XChatDecodedEventKind.ConversationKeyChange + struct(6) != null -> XChatDecodedEventKind.Typing + struct(7) != null -> XChatDecodedEventKind.Delete + struct(8) != null -> XChatDecodedEventKind.ConversationDelete + struct(9) != null -> XChatDecodedEventKind.MetadataChange + struct(12) != null -> XChatDecodedEventKind.Read + struct(13) != null -> XChatDecodedEventKind.Unread + else -> XChatDecodedEventKind.Unknown + } + + private fun List.latestDisplayableMessage(): XChatDecodedEvent? = + filter { it.hasRenderableContent() } + .latestTimelineEvent() + ?: filter { it.isEncryptedPlaceholder() } + .latestTimelineEvent() + + private fun List.latestTimelineEvent(): XChatDecodedEvent? = + maxWithOrNull( + compareBy { + it.sequenceId?.toULongOrNull() ?: ULong.MIN_VALUE + }.thenBy { + it.createdAtMillis ?: Long.MIN_VALUE + }, + ) + + private fun XChatDecodedEvent.hasRenderableContent(): Boolean = + when (kind) { + XChatDecodedEventKind.Message, + XChatDecodedEventKind.Edit, + -> !text.isNullOrBlank() + XChatDecodedEventKind.Delete -> true + else -> false + } + + private fun XChatDecodedEvent.isEncryptedPlaceholder(): Boolean = + when (kind) { + XChatDecodedEventKind.Message, + XChatDecodedEventKind.Edit, + -> text.isNullOrBlank() && (encrypted || decryptError) + else -> false + } + + private fun String.isSelfConversation( + viewerId: String, + participants: List, + ): Boolean { + val ids = split(":").filter { it.isNotBlank() } + participants.map { it.userId } + return ids.isNotEmpty() && ids.all { it == viewerId } + } + + private fun initialPageQuerySettings(): JsonObject = + buildJsonObject { + put("conversation_event_limit", 200) + put("inbox_conversation_event_limit", 20) + put("inbox_conversation_limit", 20) + put("user_event_limit", 500) + } + + companion object { + const val MAX_SEQUENCE_ID: String = "9223372036854775807" + const val DEFAULT_INITIAL_MESSAGE_PULL_VERSION: Int = 1761251295 + + fun conversationId1on1( + a: String, + b: String, + ): String { + val first = a.toULongOrNull() + val second = b.toULongOrNull() + val values = + if (first != null && second != null) { + listOf(first to a, second to b).sortedBy { it.first }.map { it.second } + } else { + listOf(a, b).sorted() + } + return values.joinToString(":") + } + } +} + +internal data class XChatInboxPage( + val conversations: List, + val nextKey: String?, + val hasMore: Boolean, + val messageRequestsCount: Int?, + val messagePullVersion: Int?, + val maxUserSequenceId: String?, +) + +internal data class XChatConversationPage( + val messages: List, + val hasMore: Boolean, + val nextKey: String?, +) + +internal data class XChatSendResult( + val conversationId: String, + val messageId: String, + val sequenceId: String?, + val encodedMessageEvent: String?, + val decodedEvent: XChatDecodedEvent?, +) + +internal data class XChatConversation( + val conversationId: String, + val type: XChatConversationType, + val isMuted: Boolean?, + val groupName: String?, + val groupAvatarUrl: String?, + val participants: List, + val latestSequenceId: String?, + val latestMessage: XChatDecodedEvent?, + val unreadCount: Long, +) + +internal enum class XChatConversationType { + Direct, + Group, +} + +internal data class XChatUser( + val userId: String, + val name: String?, + val screenName: String?, + val avatarUrl: String?, +) + +internal data class XChatDecodedEvent( + val sequenceId: String?, + val messageId: String?, + val senderId: String?, + val conversationId: String?, + val conversationToken: String? = null, + val createdAtMillis: Long?, + val kind: XChatDecodedEventKind, + val text: String? = null, + val conversationKeyVersion: String? = null, + val targetSequenceIds: List = emptyList(), + val emoji: String? = null, + val encrypted: Boolean = false, + val decryptError: Boolean = false, +) + +internal enum class XChatDecodedEventKind { + Message, + ConversationKeyChange, + Typing, + Delete, + ConversationDelete, + MetadataChange, + Read, + Unread, + ReactionAdd, + ReactionRemove, + Edit, + AvCallStarted, + AvCallEnded, + AvCallMissed, + Unknown, +} + +internal data class XChatUserPublicKeys( + val userId: String?, + val onXChat: Boolean, + val permissions: XChatPermissions?, + val keys: List, +) + +internal data class XChatPermissions( + val canDm: Boolean?, + val canDmOnXChat: Boolean?, + val dmBlocking: Boolean?, + val passesPremiumCheck: Boolean?, +) + +internal data class XChatCallPermission( + val canDm: Boolean?, + val errorCode: String?, +) + +internal data class XChatPublicKey( + val version: String?, + val publicKey: String?, + val signingPublicKey: String?, + val identityPublicKeySignature: String?, + val registrationMethod: String?, + val tokenMap: XChatJuiceboxTokenMap?, + val targetTokenMap: XChatJuiceboxTokenMap?, +) + +internal data class XChatJuiceboxTokenMap( + val realmState: String?, + val realmStateString: String?, + val maxGuessCount: Int?, + val recoverThreshold: Int?, + val registerThreshold: Int?, + val keyStoreTokenMapJson: String?, + val entries: List, +) + +internal data class XChatJuiceboxTokenEntry( + val realmId: String, + val token: String, + val address: String, + val publicKey: String?, +) + +private data class XChatRecipientKey( + val userId: String, + val publicKeySpki: ByteArray, + val version: String, +) + +private fun JsonObject.toUserPublicKeys(): XChatUserPublicKeys { + val result = obj("result") + val publicKeysResult = result?.obj("get_public_keys") + val keys = + publicKeysResult + ?.array("public_keys_with_token_map") + .orEmpty() + .mapNotNull { entry -> + val entryObject = entry.asObjectOrNull() ?: return@mapNotNull null + val metadata = entryObject.obj("public_key_with_metadata") ?: return@mapNotNull null + val publicKey = metadata.obj("public_key") + XChatPublicKey( + version = metadata.string("version"), + publicKey = publicKey?.string("public_key"), + signingPublicKey = publicKey?.string("signing_public_key"), + identityPublicKeySignature = publicKey?.string("identity_public_key_signature"), + registrationMethod = publicKey?.string("registration_method"), + tokenMap = entryObject.obj("token_map")?.toJuiceboxTokenMap(), + targetTokenMap = entryObject.obj("target_token_map")?.toJuiceboxTokenMap(), + ) + } + return XChatUserPublicKeys( + userId = string("rest_id"), + onXChat = keys.isNotEmpty(), + permissions = result?.obj("chat_permissions")?.toPermissions(), + keys = keys, + ) +} + +private fun JsonObject.toPermissions(): XChatPermissions = + XChatPermissions( + canDm = boolean("can_dm"), + canDmOnXChat = boolean("can_dm_on_xchat"), + dmBlocking = boolean("dm_blocking"), + passesPremiumCheck = boolean("passes_premium_check"), + ) + +private fun JsonObject.toCallPermission(): XChatCallPermission = + XChatCallPermission( + canDm = boolean("can_dm"), + errorCode = string("error_code"), + ) + +private fun JsonObject.toJuiceboxTokenMap(): XChatJuiceboxTokenMap = + XChatJuiceboxTokenMap( + realmState = string("realm_state"), + realmStateString = string("realm_state_string"), + maxGuessCount = int("max_guess_count"), + recoverThreshold = int("recover_threshold"), + registerThreshold = int("register_threshold"), + keyStoreTokenMapJson = string("key_store_token_map_json"), + entries = + array("token_map") + .orEmpty() + .mapNotNull { entry -> + val entryObject = entry.asObjectOrNull() ?: return@mapNotNull null + val value = entryObject.obj("value") ?: return@mapNotNull null + XChatJuiceboxTokenEntry( + realmId = entryObject.string("key") ?: return@mapNotNull null, + token = value.string("token") ?: return@mapNotNull null, + address = value.string("address") ?: return@mapNotNull null, + publicKey = value.string("public_key"), + ) + }, + ) + +private fun JsonObject.toXChatUser(): XChatUser? { + val userId = string("rest_id") ?: return null + val result = obj("result") + return XChatUser( + userId = userId, + name = result?.obj("core")?.string("name"), + screenName = result?.obj("core")?.string("screen_name"), + avatarUrl = result?.obj("avatar")?.string("image_url"), + ) +} + +private fun List.minSequenceId(): String? = + mapNotNull { it.sequenceId?.toULongOrNull() } + .minOrNull() + ?.toString() + +private fun JsonArray?.asStringList(): List = + this + .orEmpty() + .mapNotNull { it.jsonPrimitive.contentOrNull } + +private fun JsonObject.obj(name: String): JsonObject? = get(name).asObjectOrNull() + +private fun JsonObject.array(name: String): JsonArray? = get(name) as? JsonArray + +private fun JsonObject.string(name: String): String? = + get(name) + ?.jsonPrimitive + ?.contentOrNull + +private fun JsonObject.boolean(name: String): Boolean? = + get(name) + ?.jsonPrimitive + ?.booleanOrNull + +private fun JsonObject.int(name: String): Int? = + get(name) + ?.jsonPrimitive + ?.intOrNull + +private fun JsonObject.long(name: String): Long? = + get(name) + ?.jsonPrimitive + ?.longOrNull + +private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatThrift.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatThrift.kt new file mode 100644 index 0000000000..26086e0482 --- /dev/null +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/network/xqt/xchat/XChatThrift.kt @@ -0,0 +1,411 @@ +package dev.dimension.flare.data.network.xqt.xchat + +internal typealias XChatThriftStruct = Map + +internal sealed interface XChatThriftValue { + data class Bool( + val value: Boolean, + ) : XChatThriftValue + + data class I32( + val value: Int, + ) : XChatThriftValue + + data class I64( + val value: Long, + ) : XChatThriftValue + + data class Binary( + val bytes: ByteArray, + ) : XChatThriftValue + + data class Struct( + val fields: XChatThriftStruct, + ) : XChatThriftValue + + data class ListValue( + val values: List, + ) : XChatThriftValue +} + +internal object XChatThrift { + fun parse(bytes: ByteArray): XChatThriftStruct = readStruct(bytes, 0).first + + fun readLeadingSequenceId(base64: String?): String? = + runCatching { + val bytes = XChatBase64.decode(base64 ?: return null) + if (bytes.size < 7 || bytes[0].u() != T_STRING || bytes[1].u() != 0 || bytes[2].u() != 1) { + return null + } + val length = readI32(bytes, 3) + bytes.copyOfRange(7, 7 + length).decodeToString() + }.getOrNull() + + fun readStringField( + base64: String?, + fieldId: Int, + ): String? = + runCatching { + val bytes = XChatBase64.decode(base64 ?: return null) + var offset = 0 + while (offset < bytes.size) { + val type = bytes[offset++].u() + if (type == T_STOP) break + val id = (bytes[offset++].u() shl 8) or bytes[offset++].u() + if (type == T_STRING) { + val length = readI32(bytes, offset) + offset += 4 + val value = bytes.copyOfRange(offset, offset + length).decodeToString() + offset += length + if (id == fieldId) return value + } else { + offset = skipValue(bytes, offset, type) + } + } + null + }.getOrNull() + + fun textHolder(text: String): ByteArray = + holder( + variantId = 1, + structBytes = fStr(1, text) + STOP, + ) + + fun reactionAddHolder( + sequenceId: String, + emoji: String, + attachmentId: String? = null, + ): ByteArray = + reactionHolder( + variantId = 2, + sequenceId = sequenceId, + emoji = emoji, + attachmentId = attachmentId, + ) + + fun reactionRemoveHolder( + sequenceId: String, + emoji: String, + attachmentId: String? = null, + ): ByteArray = + reactionHolder( + variantId = 3, + sequenceId = sequenceId, + emoji = emoji, + attachmentId = attachmentId, + ) + + fun editHolder( + sequenceId: String, + updatedText: String, + ): ByteArray = + holder( + variantId = 4, + structBytes = fStr(1, sequenceId) + fStr(2, updatedText) + STOP, + ) + + fun markReadHolder( + sequenceId: String, + seenAtMillis: Long, + ): ByteArray = + holder( + variantId = 5, + structBytes = fStr(1, sequenceId) + fI64(2, seenAtMillis) + STOP, + ) + + fun markUnreadHolder(sequenceId: String): ByteArray = + holder( + variantId = 6, + structBytes = fStr(1, sequenceId) + STOP, + ) + + fun pinHolder(conversationId: String): ByteArray = + holder( + variantId = 7, + structBytes = fStr(1, conversationId) + STOP, + ) + + fun unpinHolder(conversationId: String): ByteArray = + holder( + variantId = 8, + structBytes = fStr(1, conversationId) + STOP, + ) + + fun screenCaptureHolder(type: ScreenCaptureType): ByteArray = + holder( + variantId = 9, + structBytes = fI32(1, type.value) + STOP, + ) + + fun nicknameHolder( + userId: String, + nickname: String, + ): ByteArray = + holder( + variantId = 14, + structBytes = fI64(1, userId.toLong()) + fStr(2, nickname) + STOP, + ) + + fun messageCreateEvent( + frame: ByteArray, + conversationKeyVersion: String, + shouldNotify: Boolean = true, + ttlMsec: Long? = null, + ): ByteArray = + fBin(100, frame) + + fStr(101, conversationKeyVersion) + + fBool(102, shouldNotify) + + (ttlMsec?.let { fI64(103, it) } ?: ByteArray(0)) + + STOP + + fun eventSignature( + signatureBase64Url: String, + publicKeyVersion: String, + signingPublicKeyB64: String, + senderId: String, + ): ByteArray { + val keyInfo = + fStr(1, senderId) + + fStr(2, publicKeyVersion) + + fStr(3, signingPublicKeyB64) + + STOP + return fStr(1, signatureBase64Url) + + fStr(2, publicKeyVersion) + + fStr(3, "7") + + fStr(4, signingPublicKeyB64) + + listOfStructs(5, listOf(keyInfo)) + + STOP + } + + fun deleteEventDetail( + sequenceIds: List, + actionValue: Int, + ): ByteArray { + val deleteEvent = listOfStrings(1, sequenceIds) + fI32(2, actionValue) + STOP + return fStruct(7, deleteEvent) + STOP + } + + private fun holder( + variantId: Int, + structBytes: ByteArray, + ): ByteArray { + val entryContents = fStruct(variantId, structBytes) + STOP + return fStruct(1, entryContents) + STOP + } + + private fun reactionHolder( + variantId: Int, + sequenceId: String, + emoji: String, + attachmentId: String?, + ): ByteArray = + holder( + variantId = variantId, + structBytes = + fStr(1, sequenceId) + + fStr(2, emoji) + + (attachmentId?.let { fStr(3, it) } ?: ByteArray(0)) + + STOP, + ) + + private fun readStruct( + bytes: ByteArray, + start: Int, + ): Pair { + val fields = mutableMapOf() + var offset = start + while (offset < bytes.size) { + val type = bytes[offset++].u() + if (type == T_STOP) break + val fieldId = (bytes[offset++].u() shl 8) or bytes[offset++].u() + val (value, nextOffset) = readValue(bytes, offset, type) + fields[fieldId] = value + offset = nextOffset + } + return fields to offset + } + + private fun readValue( + bytes: ByteArray, + offset: Int, + type: Int, + ): Pair = + when (type) { + T_BOOL -> XChatThriftValue.Bool(bytes[offset].u() != 0) to offset + 1 + T_I32 -> XChatThriftValue.I32(readI32(bytes, offset)) to offset + 4 + T_I64 -> XChatThriftValue.I64(readI64(bytes, offset)) to offset + 8 + T_STRING -> { + val length = readI32(bytes, offset) + val start = offset + 4 + XChatThriftValue.Binary(bytes.copyOfRange(start, start + length)) to start + length + } + T_STRUCT -> { + val (struct, nextOffset) = readStruct(bytes, offset) + XChatThriftValue.Struct(struct) to nextOffset + } + T_LIST -> { + val elementType = bytes[offset].u() + val count = readI32(bytes, offset + 1) + var currentOffset = offset + 5 + val values = buildList { + repeat(count) { + val (value, nextOffset) = readValue(bytes, currentOffset, elementType) + add(value) + currentOffset = nextOffset + } + } + XChatThriftValue.ListValue(values) to currentOffset + } + else -> error("Unknown thrift type $type at $offset") + } + + private fun skipValue( + bytes: ByteArray, + offset: Int, + type: Int, + ): Int = + when (type) { + T_BOOL -> offset + 1 + T_I32 -> offset + 4 + T_I64 -> offset + 8 + T_STRING -> offset + 4 + readI32(bytes, offset) + T_STRUCT -> readStruct(bytes, offset).second + T_LIST -> { + val elementType = bytes[offset].u() + val count = readI32(bytes, offset + 1) + var currentOffset = offset + 5 + repeat(count) { + currentOffset = skipValue(bytes, currentOffset, elementType) + } + currentOffset + } + else -> error("Unknown thrift type $type at $offset") + } + + private fun fStr( + id: Int, + value: String, + ): ByteArray = fBin(id, value.encodeToByteArray()) + + private fun fBin( + id: Int, + value: ByteArray, + ): ByteArray = fieldHeader(T_STRING, id) + i32be(value.size) + value + + private fun fBool( + id: Int, + value: Boolean, + ): ByteArray = fieldHeader(T_BOOL, id) + byteArrayOf((if (value) 1 else 0).toByte()) + + private fun fI32( + id: Int, + value: Int, + ): ByteArray = fieldHeader(T_I32, id) + i32be(value) + + private fun fI64( + id: Int, + value: Long, + ): ByteArray = fieldHeader(T_I64, id) + i64be(value) + + private fun fStruct( + id: Int, + bytes: ByteArray, + ): ByteArray = fieldHeader(T_STRUCT, id) + bytes + + private fun listOfStructs( + id: Int, + structs: List, + ): ByteArray = + fieldHeader(T_LIST, id) + + byteArrayOf(T_STRUCT.toByte()) + + i32be(structs.size) + + structs.fold(ByteArray(0)) { acc, bytes -> acc + bytes } + + private fun listOfStrings( + id: Int, + values: List, + ): ByteArray = + fieldHeader(T_LIST, id) + + byteArrayOf(T_STRING.toByte()) + + i32be(values.size) + + values.fold(ByteArray(0)) { acc, value -> + val bytes = value.encodeToByteArray() + acc + i32be(bytes.size) + bytes + } + + private fun fieldHeader( + type: Int, + id: Int, + ): ByteArray = byteArrayOf(type.toByte(), (id ushr 8).toByte(), id.toByte()) + + private fun i32be(value: Int): ByteArray = + byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) + + private fun i64be(value: Long): ByteArray = + ByteArray(8) { index -> + (value ushr ((7 - index) * 8)).toByte() + } + + private fun readI32( + bytes: ByteArray, + offset: Int, + ): Int = + (bytes[offset].u() shl 24) or + (bytes[offset + 1].u() shl 16) or + (bytes[offset + 2].u() shl 8) or + bytes[offset + 3].u() + + private fun readI64( + bytes: ByteArray, + offset: Int, + ): Long { + var value = 0L + repeat(8) { index -> + value = (value shl 8) or bytes[offset + index].u().toLong() + } + return value + } + + private fun Byte.u(): Int = toInt() and 0xff + + private const val T_STOP = 0 + private const val T_BOOL = 2 + private const val T_I32 = 8 + private const val T_I64 = 10 + private const val T_STRING = 11 + private const val T_STRUCT = 12 + private const val T_LIST = 15 + private val STOP = byteArrayOf(T_STOP.toByte()) +} + +internal enum class ScreenCaptureType( + val value: Int, +) { + Unknown(0), + Screenshot(1), + Recording(2), +} + +internal fun XChatThriftStruct.struct(id: Int): XChatThriftStruct? = + (this[id] as? XChatThriftValue.Struct)?.fields + +internal fun XChatThriftStruct.list(id: Int): List? = + (this[id] as? XChatThriftValue.ListValue)?.values + +internal fun XChatThriftStruct.binary(id: Int): ByteArray? = + (this[id] as? XChatThriftValue.Binary)?.bytes + +internal fun XChatThriftStruct.string(id: Int): String? = binary(id)?.decodeToString() + +internal fun XChatThriftStruct.long(id: Int): Long? = (this[id] as? XChatThriftValue.I64)?.value + +internal fun XChatThriftStruct.int(id: Int): Int? = (this[id] as? XChatThriftValue.I32)?.value + +internal fun XChatThriftStruct.boolean(id: Int): Boolean? = (this[id] as? XChatThriftValue.Bool)?.value + +internal fun XChatThriftValue.asStruct(): XChatThriftStruct? = (this as? XChatThriftValue.Struct)?.fields diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt index bb9cd318d0..51b287f835 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XQTCredential.kt @@ -1,12 +1,34 @@ package dev.dimension.flare.data.platform import androidx.compose.runtime.Immutable +import dev.dimension.flare.common.JSON import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject @Immutable @Serializable @SerialName("XQTCredential") internal data class XQTCredential( val chocolate: String, + val xchatIdentity: XChatIdentityCredential? = null, ) + +@Immutable +@Serializable +internal data class XChatIdentityCredential( + val userId: String, + val version: String, + val publicKeyB64: String, + val signingPublicKeyB64: String, + val identityPrivateJwk: JsonObject? = null, + val signingPrivateJwk: JsonObject? = null, + val registrationMethod: String? = null, + val pinBacked: Boolean? = null, +) + +internal fun XChatIdentityCredential.toJsonString(): String = + JSON.encodeToString( + XChatIdentityCredential.serializer(), + this, + ) diff --git a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt index d36f8595f9..979d3e5db5 100644 --- a/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt +++ b/social/xqt/src/commonMain/kotlin/dev/dimension/flare/data/platform/XqtPlatformSpec.kt @@ -118,6 +118,12 @@ public data object XqtPlatformSpec : XQTDataSource( accountKey = context.accountKey, sourceCredentialFlow = context.credentialFlow(XQTCredential.serializer()), + updateCredential = { + context.updateCredential( + serializer = XQTCredential.serializer(), + credential = it, + ) + }, ) override fun guestDataSource(