diff --git a/backend/src/main/kotlin/com/storyteller_f/Backend.kt b/backend/src/main/kotlin/com/storyteller_f/Backend.kt index 6f576a4..601174a 100644 --- a/backend/src/main/kotlin/com/storyteller_f/Backend.kt +++ b/backend/src/main/kotlin/com/storyteller_f/Backend.kt @@ -48,10 +48,10 @@ fun readEnv(map: Map = emptyMap()): MergedEnv { return MergedEnv( listOf( map, + System.getenv(), readFileEnv("../${BackendConfig.FLAVOR}.env"), readFileEnv(".env"), readResourceEnv(".env"), - System.getenv() ) ) } diff --git a/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicSearchService.kt b/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicSearchService.kt index f3ee0ee..45a526c 100644 --- a/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicSearchService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/index/LuceneTopicSearchService.kt @@ -112,7 +112,15 @@ class LuceneTopicSearchService(private val path: Path) : TopicSearchService { try { DirectoryReader.open(it).use { reader -> val searcher = IndexSearcher(reader) - val combinedQuery = buildQuery(preTopicId, nextTopicId, word, rootType, parentType, rootIdList, parentIdList).build() + val combinedQuery = buildQuery( + preTopicId, + nextTopicId, + word, + rootType, + parentType, + rootIdList, + parentIdList + ) Napier.i { "lucene search query $combinedQuery" } @@ -145,7 +153,7 @@ class LuceneTopicSearchService(private val path: Path) : TopicSearchService { parent: ObjectType?, rootIdList: List?, parentIdList: List? - ): BooleanQuery.Builder { + ): BooleanQuery? { val analyzer = StandardAnalyzer() val combinedQuery = BooleanQuery .Builder() @@ -188,7 +196,7 @@ class LuceneTopicSearchService(private val path: Path) : TopicSearchService { parentIdList?.let { combinedQuery.add(LongPoint.newSetQuery("parentId", it), BooleanClause.Occur.MUST) } - return combinedQuery + return combinedQuery.build() } private fun useLucene(block: (FSDirectory) -> R): Result { diff --git a/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt b/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt index 33ca1df..f0948f5 100644 --- a/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt +++ b/backend/src/main/kotlin/com/storyteller_f/media/FileSystemMediaService.kt @@ -6,6 +6,7 @@ import io.github.aakira.napier.Napier import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime +import org.apache.http.client.utils.URIBuilder import org.apache.tika.Tika import java.io.File import java.net.URLConnection @@ -61,7 +62,7 @@ class FileSystemMediaService(private val url: String, base: String) : MediaServi if (file.exists()) { val item = stat(it, file) val dimension = getDimension(file, item.contentType) - MediaInfo("${url}amedia/$it", item, dimension) + MediaInfo(URIBuilder(url).setPath("amedia/$it").build().toString(), item, dimension) } else { null } diff --git a/builtin-bot/build.gradle.kts b/builtin-bot/build.gradle.kts index 4de794f..dd67c98 100644 --- a/builtin-bot/build.gradle.kts +++ b/builtin-bot/build.gradle.kts @@ -8,7 +8,7 @@ plugins { group = "com.storyteller_f.a" version = "1.0.0" application { - mainClass.set("com.storyteller_f.a.server.ApplicationKt") + mainClass.set("com.storyteller_f.a.built_in_bot.BuiltInBotKt") } dependencies { diff --git a/builtin-bot/src/main/kotlin/com/storyteller_f/a/built_in_bot/BuiltInBot.kt b/builtin-bot/src/main/kotlin/com/storyteller_f/a/built_in_bot/BuiltInBot.kt new file mode 100644 index 0000000..c9cddf1 --- /dev/null +++ b/builtin-bot/src/main/kotlin/com/storyteller_f/a/built_in_bot/BuiltInBot.kt @@ -0,0 +1,5 @@ +package com.storyteller_f.a.built_in_bot + +fun main() { + println("built in bot") +} diff --git a/cli/src/main/kotlin/com/storyteller_f/cli/CleanCommand.kt b/cli/src/main/kotlin/com/storyteller_f/cli/CleanCommand.kt index e568781..305784b 100644 --- a/cli/src/main/kotlin/com/storyteller_f/cli/CleanCommand.kt +++ b/cli/src/main/kotlin/com/storyteller_f/cli/CleanCommand.kt @@ -31,7 +31,7 @@ class PrintCommand : Subcommand("print", "print") { runBlocking { val result = backend.topicSearchService.searchDocument( 10, - parentType = null to ObjectType.COMMUNITY + parentType = ObjectType.COMMUNITY ).getOrThrow() Napier.i { "total ${result.total} ${result.list.size}" diff --git a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientWebSocket.kt b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientWebSocket.kt index 06190d3..12947e3 100644 --- a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientWebSocket.kt +++ b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/ClientWebSocket.kt @@ -109,11 +109,9 @@ class ClientWebSocket( } } }.onFailure { - Napier.e(it, tag = "pagination") { - "Exception in Client WebSocket" + Napier.e(it, tag = "ClientWebSocket") { + "Exception in startListenerWebSocket" } - connectionHandler.data.value = null - connectionHandler.state.value = null } } } diff --git a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt index db8ee1f..fdc92b7 100644 --- a/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt +++ b/client-lib/src/commonMain/kotlin/com/storyteller_f/a/client_lib/Request.kt @@ -427,10 +427,16 @@ suspend fun HttpClient.upload( suspend fun DefaultClientWebSocketSession.sendMessage( roomInfo: RoomInfo, input: String, - keyData: List>, + keyData: List>?, topicId: PrimaryKey?, + keyState: LoadingState?, + notifyPubKeyStillLoading: () -> Unit ) { val content = if (roomInfo.isPrivate) { + if (keyState !is LoadingState.Done || keyData == null) { + notifyPubKeyStillLoading() + return + } val (encrypted, aes) = encrypt(input) TopicContent.Encrypted(encrypted.toHexString(), keyData.associate { it.first to encryptAesKey(it.second, aes).toHexString() diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 6f63e6a..0cd40df 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -406,11 +406,15 @@ tasks.withType(KotlinCompile::class.java).configureEach { tasks.getByName("copyNonXmlValueResourcesForCommonMain").dependsOn("exportLibraryDefinitions") tasks.withType { - if (name == "testDebugUnitTest") { - exclude("**/device_based/*") - } else if (name == "testReleaseUnitTest") { - exclude("**/device_based/*", "**/jvm_based/*") - } else if (name == "desktopTest") { - exclude("**/device_based/*") + when (name) { + "testDebugUnitTest" -> { + exclude("**/device_based/*") + } + "testReleaseUnitTest" -> { + exclude("**/device_based/*", "**/jvm_based/*") + } + "desktopTest" -> { + exclude("**/device_based/*") + } } } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/jvm_based/UsingContextTest.android.kt b/composeApp/src/androidInstrumentedTest/kotlin/jvm_based/UsingContextTest.android.kt index fc196a2..c8fb6ee 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/jvm_based/UsingContextTest.android.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/jvm_based/UsingContextTest.android.kt @@ -1,3 +1,7 @@ package jvm_based -actual abstract class UsingContextTest actual constructor() \ No newline at end of file +actual abstract class UsingContextTest actual constructor() { + actual fun onActivity(block: () -> Unit) { + block() + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/MainActivity.kt index 3a9dbf5..61cc252 100644 --- a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/MainActivity.kt @@ -1,8 +1,6 @@ package com.storyteller_f.a.app import android.R -import android.app.Activity -import android.app.Application.ActivityLifecycleCallbacks import android.app.NotificationManager import android.os.Bundle import androidx.activity.ComponentActivity @@ -10,8 +8,6 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.core.view.WindowInsetsControllerCompat import com.kdroid.composenotification.builder.AndroidChannelConfig import com.kdroid.composenotification.builder.NotificationInitializer.notificationInitializer @@ -22,19 +18,11 @@ import io.github.vinceglb.filekit.core.FileKit class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - bindActivity(this) - FileKit.init(this) enableEdgeToEdge() WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = false - notificationInitializer( - defaultChannelConfig = AndroidChannelConfig( - channelId = "Regular", - channelName = "Regular", - channelDescription = "Regular", - channelImportance = NotificationManager.IMPORTANCE_DEFAULT, - smallIcon = R.drawable.ic_notification_overlay - ) - ) + + initFromContext() + setContent { App() } @@ -51,3 +39,17 @@ class MainActivity : ComponentActivity() { fun AppAndroidPreview() { App() } + +fun ComponentActivity.initFromContext() { + bindActivity(this) + FileKit.init(this) + notificationInitializer( + defaultChannelConfig = AndroidChannelConfig( + channelId = "Regular", + channelName = "Regular", + channelDescription = "Regular", + channelImportance = NotificationManager.IMPORTANCE_DEFAULT, + smallIcon = R.drawable.ic_notification_overlay + ) + ) +} diff --git a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/compontents/Permission.android.kt b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/compontents/Permission.android.kt index 1b88a7c..c37513d 100644 --- a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/compontents/Permission.android.kt +++ b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/compontents/Permission.android.kt @@ -8,7 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat -import com.storyteller_f.a.app.MainActivity +import androidx.lifecycle.Lifecycle import io.github.aakira.napier.Napier import java.lang.ref.WeakReference @@ -51,14 +51,17 @@ var mainAppRef: WeakReference? = null fun bindActivity(activity: ComponentActivity) { mainAppRef = WeakReference(activity) - val launcher = activity.registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - if (isGranted) { - requestQueue.removeAt(0) + val currentState = activity.lifecycle.currentState + if (currentState.isAtLeast(Lifecycle.State.CREATED) && !currentState.isAtLeast(Lifecycle.State.DESTROYED)) { + val launcher = activity.registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + requestQueue.removeAt(0) + } } + launcherRef = WeakReference(launcher) } - launcherRef = WeakReference(launcher) } fun unbindActivity() { diff --git a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/utils/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/utils/Platform.android.kt index 82bb09e..2485b32 100644 --- a/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/utils/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/storyteller_f/a/app/utils/Platform.android.kt @@ -1,7 +1,9 @@ package com.storyteller_f.a.app.utils +import androidx.activity.ComponentActivity import androidx.lifecycle.Lifecycle import com.storyteller_f.a.app.compontents.mainAppRef +import com.storyteller_f.a.app.initFromContext actual val platform: Platform get() { @@ -9,3 +11,9 @@ actual val platform: Platform val isActive = currentState?.isAtLeast(Lifecycle.State.RESUMED) == true return Platform(true, isActive) } + +actual fun initEnvironment(context: Any) { + if (context is ComponentActivity) { + context.initFromContext() + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/jvm_based/UsingContextTest.android.kt b/composeApp/src/androidUnitTest/kotlin/jvm_based/UsingContextTest.android.kt index 22056ee..e69aeaf 100644 --- a/composeApp/src/androidUnitTest/kotlin/jvm_based/UsingContextTest.android.kt +++ b/composeApp/src/androidUnitTest/kotlin/jvm_based/UsingContextTest.android.kt @@ -2,6 +2,8 @@ package jvm_based import android.content.ComponentName import android.content.ContentProvider +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario import com.storyteller_f.a.app.MainActivity import kotbase.CouchbaseLite import org.junit.Assume @@ -53,4 +55,17 @@ actual abstract class UsingContextTest { null } } + + actual fun onActivity(block: () -> Unit) { + // GIVEN + val scenario = ActivityScenario.launch(MainActivity::class.java) + + // WHEN + scenario.moveToState(Lifecycle.State.CREATED) + + // THEN + scenario.onActivity { + block() + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt index e799b00..ff7fb6b 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/App.kt @@ -180,7 +180,7 @@ fun App2(navigator: NavHostController, httpUrl: String, wsServerUrl: String, con setupRequest(httpUrl) } CompositionLocalProvider(LocalClient provides client) { - val ws = remeberWsClient(client, wsServerUrl, appNav) + val ws = rememberWsClient(client, wsServerUrl, appNav) CompositionLocalProvider(LocalWsClient provides ws) { GlobalDialog(globalDialogState) val toasterState = rememberToasterState() @@ -202,13 +202,17 @@ private fun buildWsListener( ) = object : ClientWsListener { override fun onReceived(frame: RoomFrame) { if (frame is RoomFrame.NewTopicInfo) { - val message = frame.topicInfo.content + val topicInfo = frame.topicInfo + val message = topicInfo.content if (message is TopicContent.Plain) { if (platform.isActive) { - if (appNav.toRoute()?.roomId != frame.topicInfo.parentId && - appNav.toRoute()?.topicId != frame.topicInfo.parentId + val roomScreen = appNav.toRoute() + val topicScreen = appNav.toRoute() + if (roomScreen?.roomId != topicInfo.parentId && + topicScreen?.topicId != topicInfo.parentId ) { - messageToasterState.show(message) + val nickname = topicInfo.extension?.authorInfo?.nickname + messageToasterState.show("$nickname: ${message.plain}") } } else if (hasPermission) { sendTopicNotification(message) @@ -216,12 +220,10 @@ private fun buildWsListener( } } } - - } @Composable -private fun remeberWsClient( +private fun rememberWsClient( client: HttpClient, wsServerUrl: String, appNav: AppNav @@ -230,7 +232,7 @@ private fun remeberWsClient( ClientWebSocket({ client.webSocketSession(buildUrl { takeFrom(wsServerUrl) - path("link") + appendPathSegments("link") }.toString()) { addRequestHeaders(LoginViewModel.session?.first) } @@ -248,7 +250,7 @@ private fun remeberWsClient( Toaster(messageToasterState, alignment = Alignment.TopCenter) val notificationProvider = getNotificationProvider() val hasPermission by notificationProvider.hasPermissionState - val listener = remember { + val listener = remember(hasPermission) { buildWsListener(appNav, messageToasterState, hasPermission) } remember.addListener(listener) @@ -291,7 +293,7 @@ fun LoginCheck(content: @Composable () -> Unit) { if (currentState is ClientSession.SignUpSuccess && user == null) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { if (tried) { - Column { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Button({ scope.launch { signOut(client) @@ -470,6 +472,7 @@ fun updateDocumentInParent(info: TopicInfo) { } fun updateDocument(collectionName: String, info: TopicInfo) { + assert(!info.isPrivate || info.content is TopicContent.Encrypted) getOrCreateCollection(collectionName).save( MutableDocument( info.id.toString(), @@ -488,7 +491,7 @@ val bus = MutableSharedFlow() inline fun AppNav.toRoute(): T? { if (!hasRoute(T::class)) return null - return currentDestination?.toRoute() + return currentDestination?.toRoute() } interface AppNav { @@ -632,6 +635,5 @@ private fun sendTopicNotification(message: TopicContent.Plain) { ) } ) { - } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt index da5ace6..03c4982 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/LoginPage.kt @@ -149,7 +149,7 @@ fun SelectFile(isSignUp: Boolean) { scope.launch { val f = FileKit.pickFile() if (f != null) { - startSign(String(f.readBytes()), appNav, client, isSignUp) + startSign(String(f.readBytes()).replace("\r\n", "\n"), appNav, client, isSignUp) } } }) { diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/BuildAnnotatedString.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/BuildAnnotatedString.kt new file mode 100644 index 0000000..cd41394 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/BuildAnnotatedString.kt @@ -0,0 +1,448 @@ +package com.storyteller_f.a.app.compontents + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import com.ashampoo.kim.Kim +import com.ashampoo.kim.common.convertToPhotoMetadata +import com.mikepenz.markdown.annotator.AnnotatorSettings +import com.mikepenz.markdown.annotator.appendAutoLink +import com.mikepenz.markdown.annotator.appendMarkdownLink +import com.mikepenz.markdown.compose.LocalImageTransformer +import com.mikepenz.markdown.compose.LocalMarkdownColors +import com.mikepenz.markdown.compose.LocalMarkdownExtendedSpans +import com.mikepenz.markdown.compose.LocalMarkdownTypography +import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans +import com.mikepenz.markdown.compose.extendedspans.drawBehind +import com.mikepenz.markdown.model.ImageTransformer +import com.mikepenz.markdown.utils.getUnescapedTextInNode +import com.storyteller_f.shared.model.MediaInfo +import com.storyteller_f.shared.utils.readInlineMath +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readByteArray +import org.intellij.markdown.IElementType +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.MarkdownTokenTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import org.intellij.markdown.flavours.gfm.GFMTokenTypes + +fun AnnotatedString.Builder.customBuildMarkdownAnnotatedString( + content: String, + children: List, + annotatorSettings: AnnotatorSettings, + density: Density, + inlineContentMap: MutableMap +) { + val annotate = annotatorSettings.annotator?.annotate + var skipIfNext: Any? = null + children.forEach { child -> + if (skipIfNext == null || skipIfNext != child.type) { + if (annotate == null || !annotate(content, child)) { + val parentType = child.parent?.type + + processCustomMarkdown( + child, + content, + annotatorSettings, + inlineContentMap, + parentType, + density + )?.let { + skipIfNext = it + } + } + } else { + skipIfNext = null + } + } +} + +@Suppress("LongMethod") +private fun AnnotatedString.Builder.processCustomMarkdown( + child: ASTNode, + content: String, + annotatorSettings: AnnotatorSettings, + inlineContentMap: MutableMap, + parentType: IElementType?, + density: Density +): Any? { + when (child.type) { + // Element types + MarkdownElementTypes.PARAGRAPH -> customBuildMarkdownAnnotatedString( + content = content, + node = child, + annotatorSettings = annotatorSettings, + density, + inlineContentMap + ) + + MarkdownElementTypes.IMAGE -> child.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.let { + val id = "image${child.startOffset}-${child.endOffset}" + val url = it.getUnescapedTextInNode(content) + inlineContentMap[id] = url + appendInlineContent(id, url) + } + + MarkdownElementTypes.EMPH -> { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + customBuildMarkdownAnnotatedString(content, child, annotatorSettings, density, inlineContentMap) + pop() + } + + MarkdownElementTypes.STRONG -> { + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + customBuildMarkdownAnnotatedString(content, child, annotatorSettings, density, inlineContentMap) + pop() + } + + GFMElementTypes.STRIKETHROUGH -> { + pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) + customBuildMarkdownAnnotatedString(content, child, annotatorSettings, density, inlineContentMap) + pop() + } + + MarkdownElementTypes.CODE_SPAN -> { + pushStyle(annotatorSettings.codeSpanStyle) + append(' ') + customBuildMarkdownAnnotatedString( + content, + child.children.innerList(), + annotatorSettings, + density, + inlineContentMap + ) + append(' ') + pop() + } + + MarkdownElementTypes.AUTOLINK -> appendAutoLink(content, child, annotatorSettings) + MarkdownElementTypes.INLINE_LINK -> appendMarkdownLink(content, child, annotatorSettings) + MarkdownElementTypes.SHORT_REFERENCE_LINK -> appendMarkdownLink(content, child, annotatorSettings) + MarkdownElementTypes.FULL_REFERENCE_LINK -> appendMarkdownLink(content, child, annotatorSettings) + + // Token Types + MarkdownTokenTypes.TEXT -> append(child.getUnescapedTextInNode(content)) + GFMTokenTypes.GFM_AUTOLINK -> if (child.parent == MarkdownElementTypes.LINK_TEXT) { + append(child.getUnescapedTextInNode(content)) + } else { + appendAutoLink(content, child, annotatorSettings) + } + + MarkdownTokenTypes.SINGLE_QUOTE -> append('\'') + MarkdownTokenTypes.DOUBLE_QUOTE -> append('\"') + MarkdownTokenTypes.LPAREN -> append('(') + MarkdownTokenTypes.RPAREN -> append(')') + MarkdownTokenTypes.LBRACKET -> append('[') + MarkdownTokenTypes.RBRACKET -> append(']') + MarkdownTokenTypes.LT -> append('<') + MarkdownTokenTypes.GT -> append('>') + MarkdownTokenTypes.COLON -> append(':') + MarkdownTokenTypes.EXCLAMATION_MARK -> append('!') + MarkdownTokenTypes.BACKTICK -> append('`') + MarkdownTokenTypes.HARD_LINE_BREAK -> { + append('\n') + return MarkdownTokenTypes.EOL + } + + MarkdownTokenTypes.EMPH -> when { + parentType != MarkdownElementTypes.EMPH && parentType != MarkdownElementTypes.STRONG -> { + append('*') + } + } + + MarkdownTokenTypes.EOL -> append('\n') + MarkdownTokenTypes.WHITE_SPACE -> if (length > 0) append(' ') + MarkdownTokenTypes.BLOCK_QUOTE -> { + return MarkdownTokenTypes.WHITE_SPACE + } + + GFMElementTypes.INLINE_MATH, GFMElementTypes.BLOCK_MATH -> { + appendMathContent(child, content, density, inlineContentMap) + } + } + return null +} + +private fun AnnotatedString.Builder.appendMathContent( + child: ASTNode, + content: String, + density: Density, + inlineContentMap: MutableMap +) { + val tex = readInlineMath(child, content) + val size = textUnitToPx(13.sp, density) + generateLatexImage( + if (child.type == GFMElementTypes.INLINE_MATH) Color.LightGray.toArgb() else 0, + Color.Black.toArgb(), + size, + tex + ).getOrNull()?.let { (r, path) -> + val id = "math${child.startOffset}-${child.endOffset}" + val url = "file:///$path" + inlineContentMap[id] = url + when { + !r -> append(tex) + child.type == GFMElementTypes.BLOCK_MATH -> { + val style = ParagraphStyle() + pushStyle(style) + appendInlineContent(id, url) + pop() + } + + else -> { + appendInlineContent(id, url) + } + } + } +} + +internal fun ASTNode.findChildOfTypeRecursive(type: IElementType): ASTNode? { + children.forEach { + if (it.type == type) { + return it + } else { + val found = it.findChildOfTypeRecursive(type) + if (found != null) { + return found + } + } + } + return null +} + +internal fun List.innerList(): List = this.subList(1, this.size - 1) + +@Composable +private fun buildInlineContentMap( + inlineContentMap: Map, + maxWidth: Int, + mediaMap: Map, + transformer: ImageTransformer +): Map { + val dimensionMap = buildInlineContentDimensions(inlineContentMap, mediaMap) + val density = LocalDensity.current.density + + return remember { + val map = mutableMapOf() + dimensionMap.forEach { (key, pair) -> + if (maxWidth == 0) { + InlineTextContent(Placeholder(0.sp, 0.sp, PlaceholderVerticalAlign.Bottom)) {} + } else if (pair != null) { + val width = minOf(maxWidth, pair.first) + val height = minOf(width * pair.second / pair.first, width * 2) + map[key] = InlineTextContent( + Placeholder( + pxToSp(width, density), + pxToSp(height, density), + PlaceholderVerticalAlign.Bottom + ) + ) { + val value = inlineContentMap[key] + transformer.transform(value.orEmpty())?.let { imageData -> + Image( + painter = imageData.painter, + contentDescription = imageData.contentDescription, + modifier = imageData.modifier, + alignment = imageData.alignment, + contentScale = imageData.contentScale, + alpha = imageData.alpha, + colorFilter = imageData.colorFilter + ) + } + } + } + } + map + } +} + +@Composable +private fun buildInlineContentDimensions( + inlineContentMap: Map, + mediaMap: Map +): Map?> = remember(inlineContentMap, mediaMap) { + inlineContentMap.mapValues { (_, value) -> + if (value.startsWith("file:///")) { + val metadata = SystemFileSystem.source(Path(value.substring(7))).buffered().use { + Kim.readMetadata(it.readByteArray())?.convertToPhotoMetadata() + } + if (metadata != null) { + val widthPx = metadata.widthPx + val heightPx = metadata.heightPx + if (widthPx != null && heightPx != null) { + widthPx to heightPx + } else { + null + } + } else { + null + } + } else { + val info = mediaMap[value] + val dimension = info?.dimension + if (dimension != null) { + dimension.width to dimension.height + } else { + null + } + } + } +} + +fun AnnotatedString.Builder.customBuildMarkdownAnnotatedString( + content: String, + node: ASTNode, + annotatorSettings: AnnotatorSettings, + density: Density, + inlineContentMap: MutableMap +) = customBuildMarkdownAnnotatedString( + content, + node.children, + annotatorSettings, + density, + inlineContentMap +) + +@Composable +fun CustomMarkdownText( + content: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = LocalMarkdownTypography.current.text, + extendedSpans: ExtendedSpans? = LocalMarkdownExtendedSpans.current.extendedSpans?.invoke(), + inlineContentMap: Map, + mediaMap: Map +) { + // extend the annotated string with `extended-spans` styles if provided + val extendedStyledText = if (extendedSpans != null) { + remember(content) { + extendedSpans.extend(content) + } + } else { + content + } + + // forward the `onTextLayout` to `extended-spans` if provided + val onTextLayout: (TextLayoutResult) -> Unit = if (extendedSpans != null) { + { result -> + extendedSpans.onTextLayout(result) + } + } else { + {} + } + + // call drawBehind with the `extended-spans` if provided + val extendedModifier = if (extendedSpans != null) { + modifier.drawBehind(extendedSpans) + } else { + modifier + } + + CustomMarkdownText(extendedStyledText, extendedModifier, style, onTextLayout, inlineContentMap, mediaMap) +} + +@Composable +fun CustomMarkdownText( + content: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = LocalMarkdownTypography.current.text, + onTextLayout: (TextLayoutResult) -> Unit, + inlineContentMap: Map, + mediaMap: Map +) { + val baseColor = LocalMarkdownColors.current.text + val layoutResult = remember { mutableStateOf(null) } + + val transformer = LocalImageTransformer.current + + BoxWithConstraints { + val width = convertDpToPx(maxWidth) + val inlineTextContentMap = buildInlineContentMap(inlineContentMap, width, mediaMap, transformer) + CustomMarkdownBasicText( + text = content, + modifier = modifier, + style = style, + color = baseColor, + inlineContent = inlineTextContentMap, + onTextLayout = { + layoutResult.value = it + onTextLayout.invoke(it) + } + ) + } +} + +@Composable +fun CustomMarkdownBasicText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign = TextAlign.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + // Note: This component is ported over from Material2 Text - to remove the dependency on Material + val overrideColorOrUnspecified = if (color.isSpecified) { + color + } else if (style.color.isSpecified) { + style.color + } else { + LocalMarkdownColors.current.text + } + + BasicText( + text = text, + modifier = modifier, + style = style.merge( + fontSize = fontSize, + fontWeight = fontWeight, + textAlign = textAlign, + lineHeight = lineHeight, + fontFamily = fontFamily, + textDecoration = textDecoration, + fontStyle = fontStyle, + letterSpacing = letterSpacing + ), + onTextLayout = onTextLayout, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + color = { overrideColorOrUnspecified } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/CodeFence.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/CodeFence.kt index c365778..5eb86ad 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/CodeFence.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/CodeFence.kt @@ -330,9 +330,11 @@ fun convertPxToDp(px: Int): Dp { // 获取当前屏幕密度 val density = LocalDensity.current.density // 将像素值转换为 dp - return (px / density).dp + return pxToDp(px, density) } +fun pxToDp(px: Int, density: Float) = (px / density).dp + @Composable fun convertPxToSp(px: Int): TextUnit { // 获取当前屏幕密度 diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/TopicContentField.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/TopicContentField.kt index ce2fe9f..1fadcb5 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/TopicContentField.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/compontents/TopicContentField.kt @@ -2,63 +2,28 @@ package com.storyteller_f.a.app.compontents import a.composeapp.generated.resources.Res import a.composeapp.generated.resources.permission_denied -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.ashampoo.kim.Kim -import com.ashampoo.kim.common.convertToPhotoMetadata -import com.mikepenz.markdown.annotator.AnnotatorSettings import com.mikepenz.markdown.annotator.annotatorSettings -import com.mikepenz.markdown.annotator.appendAutoLink -import com.mikepenz.markdown.annotator.appendMarkdownLink import com.mikepenz.markdown.compose.* import com.mikepenz.markdown.compose.components.markdownComponents -import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans -import com.mikepenz.markdown.compose.extendedspans.drawBehind import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography -import com.mikepenz.markdown.model.ImageTransformer import com.mikepenz.markdown.utils.* import com.storyteller_f.a.app.model.createMediaListViewModel import com.storyteller_f.shared.model.MediaInfo import com.storyteller_f.shared.model.TopicContent import com.storyteller_f.shared.model.TopicInfo -import com.storyteller_f.shared.utils.readInlineMath -import kotlinx.io.buffered -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem -import kotlinx.io.readByteArray -import org.intellij.markdown.IElementType -import org.intellij.markdown.MarkdownElementTypes -import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode -import org.intellij.markdown.flavours.gfm.GFMElementTypes -import org.intellij.markdown.flavours.gfm.GFMTokenTypes import org.jetbrains.compose.resources.stringResource @Composable @@ -160,401 +125,3 @@ fun CustomMarkdownParagraph( mediaMap = mediaMap ) } - -fun AnnotatedString.Builder.customBuildMarkdownAnnotatedString( - content: String, - children: List, - annotatorSettings: AnnotatorSettings, - density: Density, - inlineContentMap: MutableMap -) { - val annotate = annotatorSettings.annotator?.annotate - var skipIfNext: Any? = null - children.forEach { child -> - if (skipIfNext == null || skipIfNext != child.type) { - if (annotate == null || !annotate(content, child)) { - val parentType = child.parent?.type - - processCustomMarkdown( - child, - content, - annotatorSettings, - inlineContentMap, - parentType, - density - )?.let { - skipIfNext = it - } - } - } else { - skipIfNext = null - } - } -} - -@Suppress("LongMethod") -private fun AnnotatedString.Builder.processCustomMarkdown( - child: ASTNode, - content: String, - annotatorSettings: AnnotatorSettings, - inlineContentMap: MutableMap, - parentType: IElementType?, - density: Density -): Any? { - when (child.type) { - // Element types - MarkdownElementTypes.PARAGRAPH -> customBuildMarkdownAnnotatedString( - content = content, - node = child, - annotatorSettings = annotatorSettings, - density, - inlineContentMap - ) - - MarkdownElementTypes.IMAGE -> child.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.let { - val id = "image${child.startOffset}-${child.endOffset}" - val url = it.getUnescapedTextInNode(content) - inlineContentMap[id] = url - appendInlineContent(id, url) - } - - MarkdownElementTypes.EMPH -> { - pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) - customBuildMarkdownAnnotatedString(content, child, annotatorSettings, density, inlineContentMap) - pop() - } - - MarkdownElementTypes.STRONG -> { - pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) - customBuildMarkdownAnnotatedString(content, child, annotatorSettings, density, inlineContentMap) - pop() - } - - GFMElementTypes.STRIKETHROUGH -> { - pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) - customBuildMarkdownAnnotatedString(content, child, annotatorSettings, density, inlineContentMap) - pop() - } - - MarkdownElementTypes.CODE_SPAN -> { - pushStyle(annotatorSettings.codeSpanStyle) - append(' ') - customBuildMarkdownAnnotatedString( - content, - child.children.innerList(), - annotatorSettings, - density, - inlineContentMap - ) - append(' ') - pop() - } - - MarkdownElementTypes.AUTOLINK -> appendAutoLink(content, child, annotatorSettings) - MarkdownElementTypes.INLINE_LINK -> appendMarkdownLink(content, child, annotatorSettings) - MarkdownElementTypes.SHORT_REFERENCE_LINK -> appendMarkdownLink(content, child, annotatorSettings) - MarkdownElementTypes.FULL_REFERENCE_LINK -> appendMarkdownLink(content, child, annotatorSettings) - - // Token Types - MarkdownTokenTypes.TEXT -> append(child.getUnescapedTextInNode(content)) - GFMTokenTypes.GFM_AUTOLINK -> if (child.parent == MarkdownElementTypes.LINK_TEXT) { - append(child.getUnescapedTextInNode(content)) - } else { - appendAutoLink(content, child, annotatorSettings) - } - - MarkdownTokenTypes.SINGLE_QUOTE -> append('\'') - MarkdownTokenTypes.DOUBLE_QUOTE -> append('\"') - MarkdownTokenTypes.LPAREN -> append('(') - MarkdownTokenTypes.RPAREN -> append(')') - MarkdownTokenTypes.LBRACKET -> append('[') - MarkdownTokenTypes.RBRACKET -> append(']') - MarkdownTokenTypes.LT -> append('<') - MarkdownTokenTypes.GT -> append('>') - MarkdownTokenTypes.COLON -> append(':') - MarkdownTokenTypes.EXCLAMATION_MARK -> append('!') - MarkdownTokenTypes.BACKTICK -> append('`') - MarkdownTokenTypes.HARD_LINE_BREAK -> { - append('\n') - return MarkdownTokenTypes.EOL - } - - MarkdownTokenTypes.EMPH -> when { - parentType != MarkdownElementTypes.EMPH && parentType != MarkdownElementTypes.STRONG -> { - append('*') - } - } - - MarkdownTokenTypes.EOL -> append('\n') - MarkdownTokenTypes.WHITE_SPACE -> if (length > 0) append(' ') - MarkdownTokenTypes.BLOCK_QUOTE -> { - return MarkdownTokenTypes.WHITE_SPACE - } - - GFMElementTypes.INLINE_MATH, GFMElementTypes.BLOCK_MATH -> { - appendMathContent(child, content, density, inlineContentMap) - } - } - return null -} - -private fun AnnotatedString.Builder.appendMathContent( - child: ASTNode, - content: String, - density: Density, - inlineContentMap: MutableMap -) { - val tex = readInlineMath(child, content) - val size = textUnitToPx(13.sp, density) - generateLatexImage( - if (child.type == GFMElementTypes.INLINE_MATH) Color.LightGray.toArgb() else 0, - Color.Black.toArgb(), - size, - tex - ).getOrNull()?.let { (r, path) -> - val id = "math${child.startOffset}-${child.endOffset}" - val url = "file:///$path" - inlineContentMap[id] = url - when { - !r -> append(tex) - child.type == GFMElementTypes.BLOCK_MATH -> { - val style = ParagraphStyle() - pushStyle(style) - appendInlineContent(id, url) - pop() - } - - else -> { - appendInlineContent(id, url) - } - } - } -} - -internal fun ASTNode.findChildOfTypeRecursive(type: IElementType): ASTNode? { - children.forEach { - if (it.type == type) { - return it - } else { - val found = it.findChildOfTypeRecursive(type) - if (found != null) { - return found - } - } - } - return null -} - -internal fun List.innerList(): List = this.subList(1, this.size - 1) - -@Composable -fun CustomMarkdownText( - content: AnnotatedString, - modifier: Modifier = Modifier, - style: TextStyle = LocalMarkdownTypography.current.text, - extendedSpans: ExtendedSpans? = LocalMarkdownExtendedSpans.current.extendedSpans?.invoke(), - inlineContentMap: Map, - mediaMap: Map -) { - // extend the annotated string with `extended-spans` styles if provided - val extendedStyledText = if (extendedSpans != null) { - remember(content) { - extendedSpans.extend(content) - } - } else { - content - } - - // forward the `onTextLayout` to `extended-spans` if provided - val onTextLayout: (TextLayoutResult) -> Unit = if (extendedSpans != null) { - { result -> - extendedSpans.onTextLayout(result) - } - } else { - {} - } - - // call drawBehind with the `extended-spans` if provided - val extendedModifier = if (extendedSpans != null) { - modifier.drawBehind(extendedSpans) - } else { - modifier - } - - CustomMarkdownText(extendedStyledText, extendedModifier, style, onTextLayout, inlineContentMap, mediaMap) -} - -@Composable -fun CustomMarkdownText( - content: AnnotatedString, - modifier: Modifier = Modifier, - style: TextStyle = LocalMarkdownTypography.current.text, - onTextLayout: (TextLayoutResult) -> Unit, - inlineContentMap: Map, - mediaMap: Map -) { - val baseColor = LocalMarkdownColors.current.text - val layoutResult = remember { mutableStateOf(null) } - - val transformer = LocalImageTransformer.current - - BoxWithConstraints { - val width = convertDpToPx(maxWidth) - val inlineTextContentMap = buildInlineContentMap(inlineContentMap, width, mediaMap, transformer) - CustomMarkdownBasicText( - text = content, - modifier = modifier, - style = style, - color = baseColor, - inlineContent = inlineTextContentMap, - onTextLayout = { - layoutResult.value = it - onTextLayout.invoke(it) - } - ) - } -} - -@Composable -private fun buildInlineContentMap( - inlineContentMap: Map, - width: Int, - mediaMap: Map, - transformer: ImageTransformer -): Map { - val dimensionMap = buildInlineContentDimensions(inlineContentMap, mediaMap) - val density = LocalDensity.current.density - - return remember { - val map = mutableMapOf() - dimensionMap.forEach { (key, pair) -> - if (width == 0) { - InlineTextContent(Placeholder(0.sp, 0.sp, PlaceholderVerticalAlign.Bottom)) {} - } else if (pair != null) { - val width = minOf(width, pair.first) - val height = width * pair.second / pair.first - map[key] = InlineTextContent( - Placeholder( - pxToSp(width, density), - pxToSp(height, density), - PlaceholderVerticalAlign.Bottom - ) - ) { - val value = inlineContentMap[key] - transformer.transform(value.orEmpty())?.let { imageData -> - Image( - painter = imageData.painter, - contentDescription = imageData.contentDescription, - modifier = imageData.modifier, - alignment = imageData.alignment, - contentScale = imageData.contentScale, - alpha = imageData.alpha, - colorFilter = imageData.colorFilter - ) - } - } - } - } - map - } -} - -@Composable -private fun buildInlineContentDimensions( - inlineContentMap: Map, - mediaMap: Map -): Map?> = remember(inlineContentMap, mediaMap) { - inlineContentMap.mapValues { (_, value) -> - if (value.startsWith("file:///")) { - val metadata = SystemFileSystem.source(Path(value.substring(7))).buffered().use { - Kim.readMetadata(it.readByteArray())?.convertToPhotoMetadata() - } - if (metadata != null) { - val widthPx = metadata.widthPx - val heightPx = metadata.heightPx - if (widthPx != null && heightPx != null) { - widthPx to heightPx - } else { - null - } - } else { - null - } - } else { - val info = mediaMap[value] - val dimension = info?.dimension - if (dimension != null) { - dimension.width to dimension.height - } else { - null - } - } - } -} - -@Composable -fun CustomMarkdownBasicText( - text: AnnotatedString, - style: TextStyle, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign = TextAlign.Unspecified, - lineHeight: TextUnit = TextUnit.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - minLines: Int = 1, - inlineContent: Map = mapOf(), - onTextLayout: (TextLayoutResult) -> Unit = {}, -) { - // Note: This component is ported over from Material2 Text - to remove the dependency on Material - val overrideColorOrUnspecified = if (color.isSpecified) { - color - } else if (style.color.isSpecified) { - style.color - } else { - LocalMarkdownColors.current.text - } - - BasicText( - text = text, - modifier = modifier, - style = style.merge( - fontSize = fontSize, - fontWeight = fontWeight, - textAlign = textAlign, - lineHeight = lineHeight, - fontFamily = fontFamily, - textDecoration = textDecoration, - fontStyle = fontStyle, - letterSpacing = letterSpacing - ), - onTextLayout = onTextLayout, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - minLines = minLines, - inlineContent = inlineContent, - color = { overrideColorOrUnspecified } - ) -} - -fun AnnotatedString.Builder.customBuildMarkdownAnnotatedString( - content: String, - node: ASTNode, - annotatorSettings: AnnotatorSettings, - density: Density, - inlineContentMap: MutableMap -) = customBuildMarkdownAnnotatedString( - content, - node.children, - annotatorSettings, - density, - inlineContentMap -) diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/model/Models.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/model/Models.kt index b840359..cefefc2 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/model/Models.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/model/Models.kt @@ -8,7 +8,6 @@ import androidx.paging.RemoteMediator import com.storyteller_f.a.app.bus import com.storyteller_f.a.app.common.* import com.storyteller_f.a.app.compontents.DialogSaveState -import com.storyteller_f.a.app.pages.topic.processEncryptedTopic import com.storyteller_f.a.app.updateDocument import com.storyteller_f.a.app.updateDocumentInParent import com.storyteller_f.a.client_lib.* @@ -436,6 +435,7 @@ class RoomKeysViewModel(private val id: PrimaryKey, private: Boolean, client: Ht } } +@OptIn(ExperimentalPagingApi::class) class TitlesViewModel( client: HttpClient, uid: PrimaryKey, diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/community/MyCommunitiesPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/community/MyCommunitiesPage.kt index f1b011c..f5df020 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/community/MyCommunitiesPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/community/MyCommunitiesPage.kt @@ -33,9 +33,10 @@ fun MyCommunitiesPage() { @Composable fun CommunityList(items: LazyPagingItems, onClick: ((CommunityInfo) -> Unit)? = null) { StateView(items, modifier = Modifier.fillMaxSize()) { - CommunityConstrains(modifier = Modifier.fillMaxHeight()) { count, gridSpan, itemSpan -> + CommunityConstrains { count, gridSpan, itemSpan -> LazyVerticalGrid( GridCells.Fixed(count), + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp) diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/room/RoomPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/room/RoomPage.kt index 4b61e41..cf4523a 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/room/RoomPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/room/RoomPage.kt @@ -25,6 +25,7 @@ import app.cash.paging.compose.itemKey import com.dokar.sonner.ToastType import com.dokar.sonner.ToasterState import com.storyteller_f.a.app.* +import com.storyteller_f.a.app.common.StateView import com.storyteller_f.a.app.compontents.* import com.storyteller_f.a.app.model.* import com.storyteller_f.a.app.pages.community.CommunityRefCell @@ -33,10 +34,7 @@ import com.storyteller_f.a.app.pages.search.SearchScope import com.storyteller_f.a.app.pages.topic.MediaPicker import com.storyteller_f.a.app.pages.topic.insertContent import com.storyteller_f.a.client_lib.* -import com.storyteller_f.shared.model.MediaInfo -import com.storyteller_f.shared.model.RoomInfo -import com.storyteller_f.shared.model.TopicContent -import com.storyteller_f.shared.model.TopicInfo +import com.storyteller_f.shared.model.* import com.storyteller_f.shared.obj.RoomFrame import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey @@ -104,29 +102,46 @@ private fun RoomPageInternal( lazyListState: LazyListState, items: LazyPagingItems ) { - LazyColumn( - state = lazyListState, - modifier = modifier.padding(top = 10.dp), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 10.dp), - reverseLayout = true, - ) { - items( - count = items.itemCount, - key = items.itemKey { topicInfo -> - topicInfo.id.toString() - }, - ) { index -> - val next = if (index + 1 < items.itemCount) { - items[index + 1] - } else { - null + Box(modifier) { + StateView(items) { + LazyColumn( + state = lazyListState, + modifier = Modifier.padding(top = 10.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 10.dp), + reverseLayout = true, + ) { + items( + count = items.itemCount, + key = items.itemKey { topicInfo -> + topicInfo.id.toString() + }, + ) { index -> + val next = if (index + 1 < items.itemCount) { + items[index + 1] + } else { + null + } + val current = items[index] + current?.let { info -> + TopicCell( + info, + false, + next?.author != info.author + ) + } + } } - val current = items[index] - current?.let { info -> - TopicCell( - info, - false, - next?.author != info.author + } + if (lazyListState.firstVisibleItemScrollOffset > 0) { + val scope = rememberCoroutineScope() + IconButton({ + scope.launch { + lazyListState.animateScrollToItem(0, 0) + } + }, modifier = Modifier.align(Alignment.BottomStart)) { + Icon( + Icons.Default.ArrowCircleDown, + "move to newer topic", ) } } @@ -145,12 +160,13 @@ fun RoomInputGroup( var input by remember { mutableStateOf("") } + val my by LoginViewModel.user.collectAsState() val controller = remember { CustomAlertDialogController() } val wsClient = LocalWsClient.current - val listener = remember { - buildInputBoxContentListener(input) { + val listener = remember(input, my) { + buildInputBoxContentListener(input, my) { input = "" } } @@ -164,8 +180,9 @@ fun RoomInputGroup( val localState by wsClient.localState.collectAsState() val isSending = localState is LoadingState.Loading val updateInput: (String) -> Unit = { - if (!isSending) + if (!isSending) { input = it + } } if (roomInfo != null) { RoomInputGroupInternal(roomId, roomInfo, topicId, input, scrollToNew, scope, controller, wsClient, updateInput) @@ -225,19 +242,21 @@ private fun RoomInputGroupInternal( } } -private fun buildInputBoxContentListener(input: String, updateInput: (String) -> Unit): ClientWsListener { +private fun buildInputBoxContentListener( + input: String, + userInfo: UserInfo?, + updateInput: (String) -> Unit +): ClientWsListener { return object : ClientWsListener { override fun onReceived(frame: RoomFrame) { if (frame is RoomFrame.NewTopicInfo) { - val content = frame.topicInfo.content - if (content is TopicContent.Plain) { - if (content.plain == input) { - updateInput("") - } + val topicInfo = frame.topicInfo + val content = topicInfo.content + if (content is TopicContent.Plain && userInfo?.id == topicInfo.author && content.plain == input) { + updateInput("") } } } - } } @@ -256,17 +275,22 @@ class RoomMessageContext( private fun sendRoomTopic( c: RoomMessageContext ) { - val keyState = c.keysViewModel.handler.state.value - val keyData = c.keysViewModel.handler.data.value + val handler = c.keysViewModel.handler + val keyState = handler.state.value + val keyData = handler.data.value if (c.roomInfo.isJoined) { - sendMessage(c.roomInfo, c.input, c.scrollToNew, keyState, keyData, c.topicId, wsClient = c.wsClient) { - c.scope.launch { - c.toasterState.show( - getString(Res.string.private_room_pub_key_loading), - type = ToastType.Info, - duration = 1.seconds - ) + c.wsClient.useWebSocket { + sendMessage(c.roomInfo, c.input, keyData, c.topicId, keyState) { + c.scope.launch { + c.toasterState.show( + getString(Res.string.private_room_pub_key_loading), + type = ToastType.Info, + duration = 1.seconds + ) + } } + delay(500) + c.scrollToNew() } } else { c.scope.launch { @@ -344,29 +368,6 @@ fun CommonInputButton( } } -fun sendMessage( - roomInfo: RoomInfo?, - input: String, - scrollToNew: () -> Unit, - keyState: LoadingState?, - keyData: List>?, - topicId: PrimaryKey?, - wsClient: ClientWebSocket, - notifyPubKeyStillLoading: () -> Unit -) { - if (roomInfo != null) { - if (keyState !is LoadingState.Done || keyData == null) { - notifyPubKeyStillLoading() - return - } - wsClient.useWebSocket { - sendMessage(roomInfo, input, keyData, topicId) - delay(500) - scrollToNew() - } - } -} - @Composable fun InputGroupInternal( objectId: PrimaryKey, diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/topic/TopicPage.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/topic/TopicPage.kt index 3f2e5a2..55109ec 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/topic/TopicPage.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/topic/TopicPage.kt @@ -28,12 +28,9 @@ import com.storyteller_f.a.app.pages.room.RoomInputGroup import com.storyteller_f.a.app.pages.search.CustomSearchBar import com.storyteller_f.a.app.pages.search.SearchScope import com.storyteller_f.a.client_lib.* -import com.storyteller_f.shared.decrypt -import com.storyteller_f.shared.model.TopicContent import com.storyteller_f.shared.model.TopicInfo import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.PrimaryKey -import io.github.aakira.napier.Napier import io.ktor.client.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/user/UserDialog.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/user/UserDialog.kt index e643b9b..6af7037 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/user/UserDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/pages/user/UserDialog.kt @@ -14,15 +14,11 @@ import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.* import com.kdroid.composenotification.builder.getNotificationProvider -import com.storyteller_f.a.app.LocalAppNav -import com.storyteller_f.a.app.LocalClient -import com.storyteller_f.a.app.UserScreen +import com.storyteller_f.a.app.* import com.storyteller_f.a.app.compontents.ButtonNav import com.storyteller_f.a.app.compontents.CustomAlertDialog import com.storyteller_f.a.app.compontents.CustomAlertDialogController import com.storyteller_f.a.app.compontents.DialogContainer -import com.storyteller_f.a.app.globalDialogState -import com.storyteller_f.a.app.toRoute import com.storyteller_f.a.app.utils.clearStorage import com.storyteller_f.a.client_lib.LoginViewModel import com.storyteller_f.a.client_lib.getUserInfo @@ -59,33 +55,7 @@ fun UserDialogInternal(userInfo: UserInfo, clickCreate: () -> Unit, dismiss: () LaunchedEffect(null) { refreshMyInfo(my, client) } - ButtonNav(Icons.Default.Add, "Create") { - dismiss() - clickCreate() - } - val notificationProvider = getNotificationProvider() - val hasPermission by notificationProvider.hasPermissionState - - if (!hasPermission) { - ButtonNav(Icons.Default.Notifications, "Grant notification") { - notificationProvider.requestPermission( - onGranted = { - notificationProvider.updatePermissionState(true) - }, - onDenied = { - notificationProvider.updatePermissionState(false) - } - ) - } - } - val title = stringResource(Res.string.sign_out_prompt) - ButtonNav(Icons.Default.Settings, stringResource(Res.string.settings)) { - dismiss() - appNav.gotoUserSetting() - } - ButtonNav(Icons.AutoMirrored.Default.Logout, stringResource(Res.string.sign_out)) { - controller.showTitle(title) - } + UserDialogMenuList(dismiss, clickCreate, appNav, controller) } } } @@ -99,6 +69,42 @@ fun UserDialogInternal(userInfo: UserInfo, clickCreate: () -> Unit, dismiss: () } } +@Composable +private fun UserDialogMenuList( + dismiss: () -> Unit, + clickCreate: () -> Unit, + appNav: AppNav, + controller: CustomAlertDialogController +) { + ButtonNav(Icons.Default.Add, "Create") { + dismiss() + clickCreate() + } + val notificationProvider = getNotificationProvider() + val hasPermission by notificationProvider.hasPermissionState + + if (!hasPermission) { + ButtonNav(Icons.Default.Notifications, "Grant notification") { + notificationProvider.requestPermission( + onGranted = { + notificationProvider.updatePermissionState(true) + }, + onDenied = { + notificationProvider.updatePermissionState(false) + } + ) + } + } + val title = stringResource(Res.string.sign_out_prompt) + ButtonNav(Icons.Default.Settings, stringResource(Res.string.settings)) { + dismiss() + appNav.gotoUserSetting() + } + ButtonNav(Icons.AutoMirrored.Default.Logout, stringResource(Res.string.sign_out)) { + controller.showTitle(title) + } +} + suspend fun signOut(client: HttpClient) { globalDialogState.use { client.signOut() diff --git a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/utils/Platform.kt b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/utils/Platform.kt index a0cf03d..e8ae596 100644 --- a/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/utils/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/storyteller_f/a/app/utils/Platform.kt @@ -3,3 +3,5 @@ package com.storyteller_f.a.app.utils class Platform(val hasNativeBack: Boolean, val isActive: Boolean = true) expect val platform: Platform + +expect fun initEnvironment(context: Any) diff --git a/composeApp/src/commonTest/kotlin/device_based/AppTest.kt b/composeApp/src/commonTest/kotlin/device_based/AppTest.kt index 4a413d7..bf7d538 100644 --- a/composeApp/src/commonTest/kotlin/device_based/AppTest.kt +++ b/composeApp/src/commonTest/kotlin/device_based/AppTest.kt @@ -1,8 +1,11 @@ package device_based +import androidx.compose.runtime.* import androidx.compose.ui.test.* +import coil3.compose.LocalPlatformContext import com.storyteller_f.a.app.AppInternal import com.storyteller_f.a.app.setupRequest +import com.storyteller_f.a.app.utils.initEnvironment import com.storyteller_f.a.client_lib.getClient import com.storyteller_f.shared.getPlatform import io.ktor.client.plugins.* @@ -18,10 +21,20 @@ class AppTest { @OptIn(ExperimentalTestApi::class) @Test fun myTest() { - test { + runServer { runComposeUiTest { setContent { - AppInternal(it, it.replace("http", "ws")) + val current = LocalPlatformContext.current + var initDone by remember { + mutableStateOf(false) + } + LaunchedEffect(null) { + initEnvironment(current) + initDone = true + } + if (initDone) { + AppInternal(it, it.replace("http", "ws")) + } } onNodeWithTag("me").performClick() @@ -30,7 +43,7 @@ class AppTest { } - private fun test(block: (String) -> Unit) { + private fun runServer(block: (String) -> Unit) { val ip = "localhost" runBlocking { val testClient = getClient { diff --git a/composeApp/src/commonTest/kotlin/jvm_based/JvmUiTestBuilder.kt b/composeApp/src/commonTest/kotlin/jvm_based/JvmUiTestBuilder.kt index 3a14c0f..6191a3a 100644 --- a/composeApp/src/commonTest/kotlin/jvm_based/JvmUiTestBuilder.kt +++ b/composeApp/src/commonTest/kotlin/jvm_based/JvmUiTestBuilder.kt @@ -4,7 +4,10 @@ import startServer import stopServer fun jvmBasedTest(block: (String) -> Unit) { - val serverProcess = startServer(8080,"..") ?: return - block("http://localhost:8811") - stopServer(serverProcess, 8080) + val serverProcess = startServer("..", 8080) ?: return + try { + block("http://localhost:8811") + } finally { + stopServer(serverProcess, 8080) + } } diff --git a/composeApp/src/commonTest/kotlin/jvm_based/TopicContentTest.kt b/composeApp/src/commonTest/kotlin/jvm_based/TopicContentTest.kt index 76b4c15..4c952f8 100644 --- a/composeApp/src/commonTest/kotlin/jvm_based/TopicContentTest.kt +++ b/composeApp/src/commonTest/kotlin/jvm_based/TopicContentTest.kt @@ -12,14 +12,17 @@ class TopicContentTest : UsingContextTest() { @OptIn(ExperimentalTestApi::class) @Test - fun `test app`() = jvmBasedTest { - runComposeUiTest { - setContent { - AppInternal(it, it.replace("http", "ws")) - } + fun testApp() = jvmBasedTest { + onActivity { + runComposeUiTest { + setContent { + AppInternal(it, it.replace("http", "ws")) + } - onNodeWithTag("me").performClick() + onNodeWithTag("me").performClick() + } } + } } diff --git a/composeApp/src/commonTest/kotlin/jvm_based/UsingContextTest.kt b/composeApp/src/commonTest/kotlin/jvm_based/UsingContextTest.kt index 000fab2..8735b45 100644 --- a/composeApp/src/commonTest/kotlin/jvm_based/UsingContextTest.kt +++ b/composeApp/src/commonTest/kotlin/jvm_based/UsingContextTest.kt @@ -1,3 +1,5 @@ package jvm_based -expect abstract class UsingContextTest() +expect abstract class UsingContextTest() { + fun onActivity(block: () -> Unit) +} diff --git a/composeApp/src/desktopMain/kotlin/com/storyteller_f/a/app/utils/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/storyteller_f/a/app/utils/Platform.desktop.kt index e107130..ba120b1 100644 --- a/composeApp/src/desktopMain/kotlin/com/storyteller_f/a/app/utils/Platform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/storyteller_f/a/app/utils/Platform.desktop.kt @@ -2,3 +2,5 @@ package com.storyteller_f.a.app.utils actual val platform: Platform get() = Platform(false) + +actual fun initEnvironment(context: Any) = Unit diff --git a/composeApp/src/desktopTest/kotlin/jvm_based/UsingContextTest.desktop.kt b/composeApp/src/desktopTest/kotlin/jvm_based/UsingContextTest.desktop.kt index e0c428d..38a6743 100644 --- a/composeApp/src/desktopTest/kotlin/jvm_based/UsingContextTest.desktop.kt +++ b/composeApp/src/desktopTest/kotlin/jvm_based/UsingContextTest.desktop.kt @@ -10,8 +10,14 @@ actual abstract class UsingContextTest { @Before fun setup() { Assume.assumeTrue(System.getProperty("os.name").orEmpty().contains("win", true)) - System.load(File(AppConfig.PROJECT_PATH, "src/androidUnitTests/jniLibs/LiteCore.dll").absolutePath) - System.load(File(AppConfig.PROJECT_PATH, "src/androidUnitTests/jniLibs/LiteCoreJNI.dll").absolutePath) + Assume.assumeNoException(kotlin.runCatching { + System.load(File(AppConfig.PROJECT_PATH, "src/androidUnitTests/jniLibs/LiteCore.dll").absolutePath) + System.load(File(AppConfig.PROJECT_PATH, "src/androidUnitTests/jniLibs/LiteCoreJNI.dll").absolutePath) + }.exceptionOrNull()) CouchbaseLite.init() } + + actual fun onActivity(block: () -> Unit) { + block() + } } \ No newline at end of file diff --git a/scripts/build_scripts/build.bat b/scripts/build_scripts/build.bat new file mode 100644 index 0000000..0511ac1 --- /dev/null +++ b/scripts/build_scripts/build.bat @@ -0,0 +1,19 @@ +cmd /c .\gradlew.bat build || exit /b +@REM start cmd /c .\gradlew.bat test-server:run + +echo Waiting for port 8888 to become available... +:wait_for_port + netstat -ano | findstr :8888 >nul + if %errorlevel% neq 0 ( + echo Port 8888 is not available yet. Waiting... + timeout /t 5 >nul + goto wait_for_port + ) + +echo Port 8888 is now available. Continuing execution... + +cmd /c .\gradlew.bat :composeApp:connectedAndroidTest || exit /b +cmd /c .\gradlew.bat :composeApp:desktopTest || exit /b +@REM ./gradlew :composeApp:wasmJsTest +@REM ./gradlew :composeApp:iosSimulatorArm64Test +cmd /c "for /f \"tokens=5\" %i in ('netstat -ano ^| findstr :8888') do taskkill /PID %i /F" diff --git a/scripts/build_scripts/build.sh b/scripts/build_scripts/build.sh index 2c268ca..50c708e 100644 --- a/scripts/build_scripts/build.sh +++ b/scripts/build_scripts/build.sh @@ -4,6 +4,5 @@ ./gradlew :composeApp:desktopTest #./gradlew :composeApp:wasmJsTest #./gradlew :composeApp:iosSimulatorArm64Test -#cmd /c "for /f \"tokens=5\" %i in ('netstat -ano ^| findstr :8888') do taskkill /PID %i /F\n" kill -9 $(lsof -t -i :8888>) #kill -9 $(netstat -tuln | grep :8888 | awk '{print $7}' | cut -d'/' -f1) diff --git a/scripts/tool_scripts/forward-android-devices.bat b/scripts/tool_scripts/forward-android-devices.bat index 736373c..f37f135 100644 --- a/scripts/tool_scripts/forward-android-devices.bat +++ b/scripts/tool_scripts/forward-android-devices.bat @@ -1,10 +1,18 @@ @echo off setlocal +:: 检查是否提供了端口参数 +if "%1"=="" ( + echo Please provide a port number. + exit /b 1 +) + +set PORT=%1 + :: 获取所有已连接的设备并设置端口转发 for /f "tokens=1" %%d in ('adb devices ^| findstr /R /C:"device"') do ( - echo Setting up port forwarding for device %%d - adb -s %%d reverse tcp:8888 tcp:8888 + echo Setting up port forwarding for device %%d on port %PORT% + adb -s %%d reverse tcp:%PORT% tcp:%PORT% ) -echo Port forwarding setup complete! +echo Port forwarding setup complete on port %PORT%! diff --git a/scripts/tool_scripts/forward-android-devices.sh b/scripts/tool_scripts/forward-android-devices.sh index 4c8f977..cd28a64 100644 --- a/scripts/tool_scripts/forward-android-devices.sh +++ b/scripts/tool_scripts/forward-android-devices.sh @@ -1,9 +1,17 @@ #!/bin/bash +# 检查是否提供了端口参数 +if [ -z "$1" ]; then + echo "Please provide a port number." + exit 1 +fi + +PORT=$1 + # 遍历所有已连接的设备并设置端口转发 for device in $(adb devices | grep -w 'device' | cut -f1); do - echo "Setting up port forwarding for device $device" - adb -s $device reverse tcp:8888 tcp:8888 + echo "Setting up port forwarding for device $device on port $PORT" + adb -s $device reverse tcp:$PORT tcp:$PORT done -echo "Port forwarding setup complete!" +echo "Port forwarding setup complete on port $PORT!" diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt b/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt index 798596d..58b56b1 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/Application.kt @@ -2,14 +2,11 @@ package com.storyteller_f.a.server import com.maxmind.geoip2.DatabaseReader import com.perraco.utils.SnowflakeFactory -import com.storyteller_f.DatabaseFactory -import com.storyteller_f.MergedEnv +import com.storyteller_f.* import com.storyteller_f.a.server.auth.UserSession import com.storyteller_f.a.server.auth.configureAuth import com.storyteller_f.a.server.auth.getRateLimitKey -import com.storyteller_f.buildBackendFromEnv import com.storyteller_f.media.loadAvif -import com.storyteller_f.readEnv import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import io.ktor.http.* @@ -41,9 +38,7 @@ fun main(args: Array) { SnowflakeFactory.setMachine(0) val map = readEnv() - processPreSetData(map) - val serverPort = map["SERVER_PORT"].takeIf { it.isNotEmpty() }?.toInt() ?: 80 val extraArgs = arrayOf("-port=$serverPort") @@ -94,16 +89,8 @@ private fun processPreSetData(env: MergedEnv) { @Suppress("unused") fun Application.module() { - val associate = engine.environment.config.toMap().mapNotNull { - (it.value as? String)?.let { v -> - it.key to v - } - }.associate { it } - val map = readEnv(associate) - val backend = buildBackendFromEnv(map) - val reader = DatabaseReader.Builder( - ClassLoader.getSystemClassLoader().getResourceAsStream("GeoLite2-Country.mmdb") - ).build() + val reader = buildDatabaseReader() + val backend = buildBackend() DatabaseFactory.init(backend.config.databaseConnection) install(ContentNegotiation) { @@ -156,6 +143,24 @@ fun Application.module() { configureAuth(backend, reader) } +private fun Application.buildBackend(): Backend { + val associate = engine.environment.config.toMap().mapNotNull { + (it.value as? String)?.let { v -> + it.key to v + } + }.associate { it } + val env = readEnv(associate) + Napier.i { + "start server at ${env["SERVER_PORT"]}" + } + val backend = buildBackendFromEnv(env) + return backend +} + +private fun buildDatabaseReader() = DatabaseReader.Builder( + ClassLoader.getSystemClassLoader().getResourceAsStream("GeoLite2-Country.mmdb") +).build() + private fun buildLog( call: ApplicationCall, reader: DatabaseReader diff --git a/server/src/main/kotlin/com/storyteller_f/a/server/service/MediaService.kt b/server/src/main/kotlin/com/storyteller_f/a/server/service/MediaService.kt index 444f819..d39e198 100644 --- a/server/src/main/kotlin/com/storyteller_f/a/server/service/MediaService.kt +++ b/server/src/main/kotlin/com/storyteller_f/a/server/service/MediaService.kt @@ -64,17 +64,26 @@ suspend fun RoutingContext.uploadMedia( when (part) { is PartData.FileItem -> { val fileName = part.originalFileName as String - val file = File(root, Uuid.random().toString() + fileName) + // total 93 + val extension = fileName.substringAfterLast(".").take(10) + val originName = fileName.substringBeforeLast(".") + val uuid = Uuid.random().toHexString() + val name = originName.take(60 - extension.length) + uuid + val newSavedFileName = if (fileName.length > 60) { + "${originName.take(27 - extension.length)}$uuid.$extension" + } else { + fileName + } + val file = File(root, "$name.$extension") // ktor 自带检查,保险起见再次检查 if (file.canonicalPath == file.absolutePath) { val fileBytes = part.provider().readRemaining().readByteArray() - try { file.writeBytes(fileBytes) val info = uploadFiles( tika, backend, - listOf(Triple(file, "${it.objectId}/$fileName", part.contentType.toString())) + listOf(Triple(file, "${it.objectId}/$newSavedFileName", part.contentType.toString())) ).getOrThrow() result.addAll(info.filterNotNull()) } finally { diff --git a/server/src/test/kotlin/TitleTest.kt b/server/src/test/kotlin/TitleTest.kt index e79a137..b6fb20e 100644 --- a/server/src/test/kotlin/TitleTest.kt +++ b/server/src/test/kotlin/TitleTest.kt @@ -6,6 +6,7 @@ import com.storyteller_f.shared.obj.NewTitle import com.storyteller_f.shared.obj.TitleSearchType import com.storyteller_f.shared.type.ObjectType import com.storyteller_f.shared.type.TitleType +import io.ktor.http.* import kotlin.test.Test class TitleTest { diff --git a/server/src/test/kotlin/TopicTest.kt b/server/src/test/kotlin/TopicTest.kt index 92de09a..dcaca49 100644 --- a/server/src/test/kotlin/TopicTest.kt +++ b/server/src/test/kotlin/TopicTest.kt @@ -121,7 +121,8 @@ class TopicTest { client.joinCommunity(communityId).getOrThrow() val roomInfo = client.joinRoom(publicRoomId).getOrThrow() wsClient.useWebSocket { - sendMessage(roomInfo, "test", emptyList(), null) + sendMessage(roomInfo, "test", emptyList(), null, null) { + } }?.join() withContext(Dispatchers.Default) { delay(1000) } assertListSize(1, client.getRoomTopics(publicRoomId, null, 10)) @@ -129,7 +130,8 @@ class TopicTest { val roomInfo2 = client.getRoomInfo(privateRoomId).getOrThrow() val keys = client.requestRoomKeys(privateRoomId, null, 10).getOrThrow().data wsClient.useWebSocket { - sendMessage(roomInfo2, "hello", keys, null) + sendMessage(roomInfo2, "hello", keys, null, LoadingState.Done) { + } }?.join() withContext(Dispatchers.Default) { delay(1000) } assertResponse(1, client.getRoomTopics(privateRoomId, null, 10)) { diff --git a/test-server/build.gradle.kts b/test-server/build.gradle.kts index d0aed06..cef5989 100644 --- a/test-server/build.gradle.kts +++ b/test-server/build.gradle.kts @@ -9,7 +9,7 @@ plugins { group = "com.storyteller_f.a" version = "1.0.0" application { - mainClass.set("com.storyteller_f.a.server.ApplicationKt") + mainClass.set("com.storyteller_f.a.test_server.TestServerApplicationKt") applicationDefaultJvmArgs = listOf("--add-modules", "jdk.incubator.vector") } @@ -47,6 +47,7 @@ buildConfig { tasks.withType { jvmArgs = listOf("--add-modules", "jdk.incubator.vector") + standardInput = System.`in` } tasks.withType { diff --git a/test-server/simple/src/main/kotlin/LocalGradleServer.kt b/test-server/simple/src/main/kotlin/LocalGradleServer.kt index 4dc6bc3..3c46bdf 100644 --- a/test-server/simple/src/main/kotlin/LocalGradleServer.kt +++ b/test-server/simple/src/main/kotlin/LocalGradleServer.kt @@ -8,12 +8,11 @@ import java.util.concurrent.CountDownLatch fun runGradle(envFilePath: String, port: Int): Process? { val envFile = File(envFilePath, "server/src/test/resources/.env") if (!envFile.exists()) { - println("${envFile.absolutePath} not exists") + println("${envFile.canonicalPath} not exists") return null } val file = File(envFilePath) // 确保这个路径正确,指向包含 gradlew.bat 的父目录 - val isWin = isWin() - val gradleCommand = if (isWin) { + val gradleCommand = if (isWin()) { // Windows File(file, "gradlew.bat").absolutePath } else { @@ -36,9 +35,9 @@ fun runGradle(envFilePath: String, port: Int): Process? { "" } } - builder.environment().putAll(pairs) - builder.environment()["SERVER_PORT"] = port.toString() - + val environment = builder.environment() + environment.putAll(pairs) + environment["SERVER_PORT"] = port.toString() return builder.start() } @@ -54,7 +53,7 @@ fun forceStop(port: Int) { arrayOf( "cmd", "/c", - "for /f \"tokens=5\" %i in ('netstat -ano ^| findstr :$port') do taskkill /PID %i /F\n" + "for /f \"tokens=5\" %i in ('netstat -ano ^| findstr :$port') do taskkill /PID %i /F" ) ) } @@ -66,13 +65,13 @@ fun stopServer(serverProcess: Process, port: Int) { } @OptIn(DelicateCoroutinesApi::class) -fun startServer(port: Int, envFilePath: String): Process? { +fun startServer(envFileBasePath: String, port: Int): Process? { forceStop(port) - val serverProcess = runGradle(envFilePath, port) ?: return null + val serverProcess = runGradle(envFileBasePath, port) ?: return null val latch = CountDownLatch(1) GlobalScope.launch { serverProcess.inputStream.bufferedReader().use { - while (serverProcess.isAlive) { + while (serverProcess.isRunning()) { val line = it.readLine() ?: break println(line) if (line.contains("Application started")) { @@ -83,7 +82,7 @@ fun startServer(port: Int, envFilePath: String): Process? { } GlobalScope.launch { serverProcess.errorStream.bufferedReader().use { - while (serverProcess.isAlive) { + while (serverProcess.isRunning()) { val line = it.readLine() ?: break if (line.contains("Execution failed for task ':server:")) { error(line) @@ -96,3 +95,5 @@ fun startServer(port: Int, envFilePath: String): Process? { latch.await() return serverProcess } + +private fun Process.isRunning() = runCatching { this@isRunning.exitValue() }.getOrNull() == null diff --git a/test-server/src/main/kotlin/com/storyteller_f/a/test_server/TestServerApplication.kt b/test-server/src/main/kotlin/com/storyteller_f/a/test_server/TestServerApplication.kt index 81b4657..674422d 100644 --- a/test-server/src/main/kotlin/com/storyteller_f/a/test_server/TestServerApplication.kt +++ b/test-server/src/main/kotlin/com/storyteller_f/a/test_server/TestServerApplication.kt @@ -18,24 +18,53 @@ import java.io.BufferedReader import java.io.File import java.io.InputStreamReader import java.net.ServerSocket +import java.util.* val ext = if (isWin()) "bat" else "sh" -fun main() { +fun main(args: Array) { Napier.base(DebugAntilog()) - val path = File("scripts/tool_scripts/forward-android-devices.$ext").canonicalPath - val process = ProcessBuilder(path).start() - check(process.waitFor() == 0) - println(process.inputReader().readText()) - previousDevices.addAll(getConnectedDevices()) - forceStop(8888) - EngineMain.main(emptyArray()) + println("current path: ${File("").canonicalPath}") + val isNested = File("").canonicalPath.endsWith("test-server") + if (args.isNotEmpty() && args.any { + it == "auto" + }) { + println("auto mode") + val path = File(if (isNested) ".." else ".", "scripts/tool_scripts/forward-android-devices.$ext").canonicalPath + val process = ProcessBuilder(path, "9000").start() + check(process.waitFor() == 0) + println(process.inputReader().readText()) + previousDevices.addAll(getConnectedDevices()) + val job = startListening(9000) + Runtime.getRuntime().addShutdownHook(object : Thread() { + override fun run() { + super.run() + job.cancel() + println("force shutdown") + } + }) + val scanner = Scanner(System.`in`) + while (true) { + if (!scanner.hasNextLine()) { + println("manual shutdown") + break + } + } + } else { + val path = File(if (isNested) ".." else ".", "scripts/tool_scripts/forward-android-devices.$ext").canonicalPath + val process = ProcessBuilder(path, "8888").start() + check(process.waitFor() == 0) + println(process.inputReader().readText()) + previousDevices.addAll(getConnectedDevices()) + forceStop(8888) + EngineMain.main(emptyArray()) + } } @Suppress("unused") fun Application.module() { val processMap = mutableMapOf() - val job = startListening() + val job = startListening(8888) monitor.subscribe(ApplicationStopped) { application -> application.log.info("Server is stopped") job.cancel() @@ -79,7 +108,7 @@ private suspend fun handleStopRoute( call.respond(HttpStatusCode.NotFound) } } else { - application.log.info("stop $port server not found") + application.log.info("stop port server not found") call.respond(HttpStatusCode.NotFound) } } @@ -94,16 +123,25 @@ private suspend fun handleStartRoute( call.respond(HttpStatusCode.BadRequest) return } + val isNested = File("").canonicalPath.endsWith("test-server") val (name, id) = platformInfo val port = findAvailablePort() - val server = startServer(port, ".") + val server = startServer(if (isNested) ".." else ".", port) if (server != null) { application.log.info("start $port server success") processMap[port] = server if (name.startsWith("Android", true)) { - val path = File("scripts/tool_scripts/forward-special-device.$ext").absolutePath + val path = + File(if (isNested) ".." else ".", "scripts/tool_scripts/forward-special-device.$ext").canonicalPath withContext(Dispatchers.IO) { - check(ProcessBuilder(path, id, port.toString()).start().waitFor() == 0) + val start = ProcessBuilder(path, id, port.toString()).start() + val waitFor = start.waitFor() + if (waitFor != 0) { + println(start.inputReader().readText()) + System.err.println(start.errorReader().readText()) + } else { + println("forward $id device success") + } } } call.respondText { @@ -143,7 +181,7 @@ private val previousDevices = mutableSetOf() // 启动协程监听 ADB 设备连接 @OptIn(DelicateCoroutinesApi::class) -fun startListening(): Job { +fun startListening(port: Int): Job { return GlobalScope.launch { while (isActive) { // 持续监听直到协程被取消 val currentDevices = getConnectedDevices() @@ -151,7 +189,7 @@ fun startListening(): Job { currentDevices.forEach { device -> if (device !in previousDevices) { val code = - ProcessBuilder("adb", "-s", device, "reverse", "tcp:8888", "tcp:8888").start().waitFor() + ProcessBuilder("adb", "-s", device, "reverse", "tcp:$port", "tcp:$port").start().waitFor() println("New device connected: $device exitCode: $code") } }