diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..1da2744 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle/ +.idea/ +local.properties +.DS_Store +build/ +/captures +.externalNativeBuild +.cxx diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..1e0a9fd --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinSerialization) apply false +} diff --git a/android/composeApp/build.gradle.kts b/android/composeApp/build.gradle.kts new file mode 100644 index 0000000..c41c707 --- /dev/null +++ b/android/composeApp/build.gradle.kts @@ -0,0 +1,63 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.coroutines.core) + } + + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + } + } +} + +android { + namespace = "com.computerization.mediafilter" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.computerization.mediafilter" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/android/composeApp/src/androidMain/AndroidManifest.xml b/android/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..8bc4544 --- /dev/null +++ b/android/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/App.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/App.kt new file mode 100644 index 0000000..4f40266 --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/App.kt @@ -0,0 +1,35 @@ +package com.computerization.mediafilter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.computerization.mediafilter.data.AnalyzeResponse +import com.computerization.mediafilter.ui.MainScreen +import com.computerization.mediafilter.ui.ResultScreen +import kotlinx.coroutines.flow.SharedFlow + +@Composable +fun App( + initialSharedText: String?, + sharedTextFlow: SharedFlow +) { + val viewModel = remember { SharedViewModel() } + var currentResult by remember { mutableStateOf(null) } + + if (currentResult != null) { + ResultScreen( + result = currentResult!!, + viewModel = viewModel, + onBack = { currentResult = null } + ) + } else { + MainScreen( + viewModel = viewModel, + initialSharedText = initialSharedText, + sharedTextFlow = sharedTextFlow, + onNavigateToResult = { result -> currentResult = result } + ) + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/MainActivity.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/MainActivity.kt new file mode 100644 index 0000000..2df0383 --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/MainActivity.kt @@ -0,0 +1,46 @@ +package com.computerization.mediafilter + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.computerization.mediafilter.theme.MediaFilterTheme +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class MainActivity : ComponentActivity() { + + private val _sharedTextFlow = MutableSharedFlow(extraBufferCapacity = 1) + val sharedTextFlow = _sharedTextFlow.asSharedFlow() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val initialSharedText = handleShareIntent(intent) + + setContent { + MediaFilterTheme { + App( + initialSharedText = initialSharedText, + sharedTextFlow = sharedTextFlow + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleShareIntent(intent)?.let { text -> + _sharedTextFlow.tryEmit(text) + } + } + + private fun handleShareIntent(intent: Intent): String? { + if (intent.action == Intent.ACTION_SEND && intent.type == "text/plain") { + return intent.getStringExtra(Intent.EXTRA_TEXT) + } + return null + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/theme/Color.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/theme/Color.kt new file mode 100644 index 0000000..f51356c --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/theme/Color.kt @@ -0,0 +1,27 @@ +package com.computerization.mediafilter.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +object AppColors { + val primaryBlue = Color(0xFF4A8FD9) + val accentGreen = Color(0xFF33C759) + val warningOrange = Color(0xFFFF9900) + val dangerRed = Color(0xFFE64033) + + @Composable + fun cardBackground(): Color { + return if (isSystemInDarkTheme()) Color(0xFF262626) else Color.White + } + + @Composable + fun secondaryBackground(): Color { + return if (isSystemInDarkTheme()) Color(0xFF1A1A1A) else Color(0xFFF7F7F7) + } + + @Composable + fun inputBackground(): Color { + return if (isSystemInDarkTheme()) Color(0xFF1F1F1F) else Color.White + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/theme/Theme.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/theme/Theme.kt new file mode 100644 index 0000000..a36a6a1 --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/theme/Theme.kt @@ -0,0 +1,55 @@ +package com.computerization.mediafilter.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = AppColors.primaryBlue, + secondary = AppColors.accentGreen, + background = Color(0xFF121212), + surface = Color(0xFF262626), + onPrimary = Color.White, + onBackground = Color.White, + onSurface = Color.White, +) + +private val LightColorScheme = lightColorScheme( + primary = AppColors.primaryBlue, + secondary = AppColors.accentGreen, + background = Color.White, + surface = Color.White, + onPrimary = Color.White, + onBackground = Color.Black, + onSurface = Color.Black, +) + +@Composable +fun MediaFilterTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = AppColors.primaryBlue.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + } + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/MainScreen.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/MainScreen.kt new file mode 100644 index 0000000..859617d --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/MainScreen.kt @@ -0,0 +1,285 @@ +package com.computerization.mediafilter.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.computerization.mediafilter.SharedViewModel +import com.computerization.mediafilter.data.AnalyzeResponse +import com.computerization.mediafilter.theme.AppColors +import com.computerization.mediafilter.ui.components.AnimatedGradientBackground +import com.computerization.mediafilter.ui.components.ConnectionHint +import com.computerization.mediafilter.ui.components.FooterView +import com.computerization.mediafilter.ui.components.InstructionCard +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + viewModel: SharedViewModel, + initialSharedText: String?, + sharedTextFlow: SharedFlow, + onNavigateToResult: (AnalyzeResponse) -> Unit +) { + val isDark = isSystemInDarkTheme() + var inputText by remember { mutableStateOf(initialSharedText ?: "") } + var isLoading by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current + val focusManager = LocalFocusManager.current + + val buttonScale by animateFloatAsState( + targetValue = if (isLoading) 0.98f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "buttonScale" + ) + + // Auto-analyze if text was shared + var hasAutoAnalyzed by remember { mutableStateOf(false) } + LaunchedEffect(initialSharedText) { + if (!initialSharedText.isNullOrEmpty() && !hasAutoAnalyzed) { + hasAutoAnalyzed = true + isLoading = true + viewModel.analyzeContent(inputText) { response, error -> + isLoading = false + if (error != null) { + scope.launch { snackbarHostState.showSnackbar(error) } + } else if (response != null) { + onNavigateToResult(response) + } + } + } + } + + // Listen for new shared text + LaunchedEffect(Unit) { + sharedTextFlow.collect { text -> + inputText = text + isLoading = true + viewModel.analyzeContent(text) { response, error -> + isLoading = false + if (error != null) { + scope.launch { snackbarHostState.showSnackbar(error) } + } else if (response != null) { + onNavigateToResult(response) + } + } + } + } + + fun analyze() { + if (inputText.isBlank()) return + focusManager.clearFocus() + isLoading = true + viewModel.analyzeContent(inputText) { response, error -> + isLoading = false + if (error != null) { + scope.launch { snackbarHostState.showSnackbar(error) } + } else if (response != null) { + onNavigateToResult(response) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("慧眼", fontWeight = FontWeight.SemiBold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = AppColors.primaryBlue, + titleContentColor = Color.White + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + AnimatedGradientBackground(modifier = Modifier.padding(padding)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Instruction card + InstructionCard() + + // Input card + val cardBg = if (isDark) Color(0xFF262626) else Color.White + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(10.dp, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .background(cardBg) + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("📄", fontSize = 16.sp) + Spacer(modifier = Modifier.width(8.dp)) + Text("文章链接或文字内容", fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + } + Spacer(modifier = Modifier.height(12.dp)) + + val inputBg = if (isDark) Color(0xFF1F1F1F) else Color.White + TextField( + value = inputText, + onValueChange = { inputText = it }, + placeholder = { + Text( + "请粘贴微信文章链接或输入需要鉴别的文字...", + color = Color.Gray.copy(alpha = 0.6f) + ) + }, + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + .border( + 1.dp, + Color.Gray.copy(alpha = 0.2f), + RoundedCornerShape(12.dp) + ), + colors = TextFieldDefaults.colors( + focusedContainerColor = inputBg, + unfocusedContainerColor = inputBg, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + shape = RoundedCornerShape(12.dp) + ) + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + clipboardManager.getText()?.text?.let { inputText = it } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors(containerColor = AppColors.primaryBlue), + shape = RoundedCornerShape(12.dp) + ) { + Text("📋 粘贴", modifier = Modifier.padding(vertical = 6.dp)) + } + + Button( + onClick = { inputText = "" }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = if (isDark) Color(0xFF404040) else Color(0xFFE6E6E6) + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + "🗑️ 清空", + color = if (isDark) Color.White else Color.Black, + modifier = Modifier.padding(vertical = 6.dp) + ) + } + } + + // Analyze button + Button( + onClick = { analyze() }, + modifier = Modifier + .fillMaxWidth() + .scale(buttonScale), + enabled = !isLoading && inputText.isNotBlank(), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + disabledContainerColor = Color.Transparent + ), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.horizontalGradient( + if (isLoading) listOf(Color.Gray, Color.Gray) + else listOf(AppColors.accentGreen, AppColors.accentGreen.copy(alpha = 0.8f)) + ), + RoundedCornerShape(16.dp) + ) + .padding(vertical = 14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(10.dp)) + Text("正在分析...", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } else { + Text("🔍", fontSize = 18.sp) + Spacer(modifier = Modifier.width(10.dp)) + Text("开始分析", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } + } + } + + // Connection hint + ConnectionHint() + + // Footer + FooterView() + } + } + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/ResultScreen.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/ResultScreen.kt new file mode 100644 index 0000000..892d6ad --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/ResultScreen.kt @@ -0,0 +1,312 @@ +package com.computerization.mediafilter.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.computerization.mediafilter.SharedViewModel +import com.computerization.mediafilter.data.AnalyzeResponse +import com.computerization.mediafilter.data.ChatMessage +import com.computerization.mediafilter.data.ChatRequest +import com.computerization.mediafilter.theme.AppColors +import com.computerization.mediafilter.ui.components.ChatBubbleData +import com.computerization.mediafilter.ui.components.ChatBubbleView +import com.computerization.mediafilter.ui.components.InfoCard +import com.computerization.mediafilter.ui.components.VerdictCard +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResultScreen( + result: AnalyzeResponse, + viewModel: SharedViewModel, + onBack: () -> Unit +) { + val isDark = isSystemInDarkTheme() + val chatMessages = remember { mutableStateListOf() } + var chatInput by remember { mutableStateOf("") } + var isChatLoading by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + + val verdictText = when (result.verdict) { + "reliable" -> "信息可信" + "misleading" -> "不可信/谣言" + else -> "需要谨慎" + } + + val verdictColor = when (result.verdict) { + "reliable" -> AppColors.accentGreen + "misleading" -> AppColors.dangerRed + else -> AppColors.warningOrange + } + + val inputBg = if (isDark) Color(0xFF1F1F1F) else Color.White + val cardBg = if (isDark) Color(0xFF262626) else Color.White + + fun sendChatMessage() { + val text = chatInput.trim() + if (text.isEmpty()) return + focusManager.clearFocus() + + // Add user message + chatMessages.add(ChatBubbleData(role = "user", content = text)) + chatInput = "" + + // Add empty assistant message for streaming + val assistantId = java.util.UUID.randomUUID().toString() + chatMessages.add(ChatBubbleData(id = assistantId, role = "assistant", content = "", reasoning = "")) + isChatLoading = true + + // Build request + val kmpMessages = chatMessages.dropLast(1).map { ChatMessage(role = it.role, content = it.content) } + val request = ChatRequest( + messages = kmpMessages, + title = result.title, + originalText = result.originalText, + analysisSummary = result.summary, + analysisDetails = result.details + ) + + viewModel.chatStream( + request = request, + onChunk = { event, chunk -> + val index = chatMessages.indexOfFirst { it.id == assistantId } + if (index >= 0) { + val msg = chatMessages[index] + if (event == "reasoning") { + chatMessages[index] = msg.copy(reasoning = (msg.reasoning ?: "") + chunk) + } else if (event == "content") { + chatMessages[index] = msg.copy(content = msg.content + chunk) + } + } + scope.launch { + listState.animateScrollToItem(chatMessages.size - 1) + } + }, + onComplete = { + isChatLoading = false + }, + onError = { error -> + isChatLoading = false + val index = chatMessages.indexOfFirst { it.id == assistantId } + if (index >= 0) { + val msg = chatMessages[index] + chatMessages[index] = msg.copy(content = "抱歉,出错了:$error") + } + } + ) + + scope.launch { + listState.animateScrollToItem(chatMessages.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("详细报告", fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回", + tint = Color.White + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = AppColors.primaryBlue, + titleContentColor = Color.White + ) + ) + }, + bottomBar = { + // Chat input bar + Column { + Divider() + Row( + modifier = Modifier + .fillMaxWidth() + .background(cardBg) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = chatInput, + onValueChange = { chatInput = it }, + placeholder = { Text("问问助手...", color = Color.Gray) }, + modifier = Modifier + .weight(1f) + .border(1.dp, Color.Gray.copy(alpha = 0.2f), RoundedCornerShape(20.dp)), + colors = TextFieldDefaults.colors( + focusedContainerColor = inputBg, + unfocusedContainerColor = inputBg, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + shape = RoundedCornerShape(20.dp), + singleLine = true + ) + Spacer(modifier = Modifier.width(12.dp)) + IconButton( + onClick = { sendChatMessage() }, + enabled = chatInput.isNotEmpty() && !isChatLoading, + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background( + if (chatInput.isEmpty()) Color.Gray + else AppColors.primaryBlue + ) + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "发送", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + ) { padding -> + val bgColor = if (isDark) Color(0xFF1A1A1A) else Color(0xFFF7F7F7) + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .background(bgColor) + .padding(padding) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + item { Spacer(modifier = Modifier.height(4.dp)) } + + // Verdict card + item { + VerdictCard( + verdictEmoji = result.verdictEmoji, + verdictText = verdictText, + verdictColor = verdictColor + ) + } + + // Summary card + item { + InfoCard(icon = "📝", title = "简要说明", content = result.summary) + } + + // Details card + item { + InfoCard(icon = "🔍", title = "详细分析", content = result.details) + } + + // Title card + item { + InfoCard(icon = "📰", title = "原文标题", content = result.title) + } + + // Chat section divider + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Divider(modifier = Modifier.weight(1f), color = Color.Gray.copy(alpha = 0.2f)) + Text( + "向助手提问", + modifier = Modifier.padding(horizontal = 8.dp), + fontSize = 12.sp, + color = Color.Gray + ) + Divider(modifier = Modifier.weight(1f), color = Color.Gray.copy(alpha = 0.2f)) + } + } + + // Welcome message + item { + ChatBubbleView( + ChatBubbleData( + role = "assistant", + content = "您好!我是您的助手。关于这篇文章,我已经为您做好了分析。如果有任何不明白的地方,请随时问我!" + ) + ) + } + + // Chat messages + items(chatMessages, key = { it.id }) { msg -> + ChatBubbleView(msg) + } + + // Loading indicator + if (isChatLoading) { + item { + Row(modifier = Modifier.padding(start = 10.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .background(cardBg) + .padding(14.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + } + } + } + + item { Spacer(modifier = Modifier.height(8.dp)) } + } + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/AnimatedGradient.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/AnimatedGradient.kt new file mode 100644 index 0000000..007defc --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/AnimatedGradient.kt @@ -0,0 +1,55 @@ +package com.computerization.mediafilter.ui.components + +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.computerization.mediafilter.theme.AppColors + +@Composable +fun AnimatedGradientBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val isDark = isSystemInDarkTheme() + val infiniteTransition = rememberInfiniteTransition(label = "gradient") + val animatedOffset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(3000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "gradientOffset" + ) + + val startColor = AppColors.primaryBlue.copy(alpha = if (isDark) 0.3f else 0.1f) + val endColor = if (isDark) Color.Black else Color.White + + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(startColor, endColor), + start = Offset(animatedOffset * 1000f, 0f), + end = Offset((1f - animatedOffset) * 1000f, 1000f) + ) + ) + ) + content() + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/Cards.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/Cards.kt new file mode 100644 index 0000000..3e3946f --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/Cards.kt @@ -0,0 +1,207 @@ +package com.computerization.mediafilter.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.computerization.mediafilter.theme.AppColors + +@Composable +fun InstructionCard() { + val isDark = isSystemInDarkTheme() + val cardBg = if (isDark) Color(0xFF262626) else Color.White + + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(10.dp, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .background(cardBg) + .border(1.dp, AppColors.primaryBlue.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("ℹ️", fontSize = 20.sp) + Spacer(modifier = Modifier.width(8.dp)) + Text("使用说明", fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + } + + Spacer(modifier = Modifier.height(12.dp)) + + InstructionRow("1", "在微信中打开可疑文章") + InstructionRow("2", "点击右上角 ··· → 分享") + InstructionRow("3", "选择「慧眼」即可自动分析") + } +} + +@Composable +private fun InstructionRow(number: String, text: String) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(AppColors.primaryBlue), + contentAlignment = Alignment.Center + ) { + Text( + text = number, + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text(text = text, fontSize = 14.sp) + } +} + +@Composable +fun VerdictCard( + verdictEmoji: String, + verdictText: String, + verdictColor: Color +) { + val isDark = isSystemInDarkTheme() + val cardBg = if (isDark) Color(0xFF262626) else Color.White + + Row( + modifier = Modifier + .fillMaxWidth() + .shadow(10.dp, RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .background( + Brush.linearGradient( + colors = listOf( + verdictColor.copy(alpha = 0.15f), + verdictColor.copy(alpha = 0.05f) + ) + ) + ) + .background(cardBg.copy(alpha = 0.5f)) + .border(2.dp, verdictColor.copy(alpha = 0.3f), RoundedCornerShape(20.dp)) + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = verdictEmoji, fontSize = 56.sp) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = "判定结果", + fontSize = 12.sp, + color = Color.Gray + ) + Text( + text = verdictText, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = verdictColor + ) + } + } +} + +@Composable +fun InfoCard( + icon: String, + title: String, + content: String +) { + val isDark = isSystemInDarkTheme() + val cardBg = if (isDark) Color(0xFF262626) else Color.White + + Column( + modifier = Modifier + .fillMaxWidth() + .shadow(10.dp, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .background(cardBg) + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(icon, fontSize = 16.sp) + Spacer(modifier = Modifier.width(8.dp)) + Text(title, fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = content, + fontSize = 14.sp, + lineHeight = 22.sp + ) + } +} + +@Composable +fun ConnectionHint() { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFFFF9900).copy(alpha = 0.1f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("⚠️", fontSize = 12.sp) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "如遇连接问题,请更新至最新版本", + fontSize = 12.sp, + color = Color.Gray + ) + } +} + +@Composable +fun FooterView() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text("🏢", fontSize = 10.sp) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Computerization", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = Color.Gray + ) + } + Text( + text = "帮助长辈识别网络虚假信息", + fontSize = 10.sp, + color = Color.Gray.copy(alpha = 0.8f) + ) + } +} diff --git a/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/ChatBubble.kt b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/ChatBubble.kt new file mode 100644 index 0000000..38cc05d --- /dev/null +++ b/android/composeApp/src/androidMain/kotlin/com/computerization/mediafilter/ui/components/ChatBubble.kt @@ -0,0 +1,125 @@ +package com.computerization.mediafilter.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.computerization.mediafilter.theme.AppColors + +data class ChatBubbleData( + val id: String = java.util.UUID.randomUUID().toString(), + val role: String, + var content: String, + var reasoning: String? = null +) + +@Composable +fun ChatBubbleView(msg: ChatBubbleData) { + val isUser = msg.role == "user" + var isReasoningExpanded by remember { mutableStateOf(true) } + val isDark = isSystemInDarkTheme() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start + ) { + if (isUser) Spacer(modifier = Modifier.width(50.dp)) + + Column( + horizontalAlignment = if (isUser) Alignment.End else Alignment.Start + ) { + // Reasoning section + val reasoning = msg.reasoning + if (!reasoning.isNullOrEmpty()) { + Column(modifier = Modifier.padding(bottom = 4.dp)) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(AppColors.primaryBlue.copy(alpha = 0.1f)) + .clickable { isReasoningExpanded = !isReasoningExpanded } + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("✨", fontSize = 10.sp) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (isReasoningExpanded) "收起思考过程" else "查看深度思考", + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = AppColors.primaryBlue + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (isReasoningExpanded) "▲" else "▼", + fontSize = 8.sp, + color = AppColors.primaryBlue + ) + } + + AnimatedVisibility( + visible = isReasoningExpanded, + enter = expandVertically() + fadeIn() + ) { + Text( + text = reasoning, + fontSize = 12.sp, + color = Color.Gray, + modifier = Modifier + .padding(top = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray.copy(alpha = 0.05f)) + .padding(10.dp) + .heightIn(max = 150.dp) + .verticalScroll(rememberScrollState()) + ) + } + } + } + + // Main content + if (msg.content.isNotEmpty()) { + Text( + text = msg.content, + color = if (isUser) Color.White else if (isDark) Color.White else Color.Black, + modifier = Modifier + .shadow(3.dp, RoundedCornerShape(18.dp)) + .clip(RoundedCornerShape(18.dp)) + .background( + if (isUser) AppColors.primaryBlue + else if (isDark) Color(0xFF262626) else Color.White + ) + .padding(14.dp) + ) + } + } + + if (!isUser) Spacer(modifier = Modifier.width(50.dp)) + } +} diff --git a/android/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/android/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..f789a08 --- /dev/null +++ b/android/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml b/android/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..6d5ff4b --- /dev/null +++ b/android/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/android/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/composeApp/src/androidMain/res/values/strings.xml b/android/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..4da1f23 --- /dev/null +++ b/android/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 慧眼 + diff --git a/android/composeApp/src/androidMain/res/values/themes.xml b/android/composeApp/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000..09dbc31 --- /dev/null +++ b/android/composeApp/src/androidMain/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/SharedViewModel.kt b/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/SharedViewModel.kt new file mode 100644 index 0000000..86b88e2 --- /dev/null +++ b/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/SharedViewModel.kt @@ -0,0 +1,51 @@ +package com.computerization.mediafilter + +import com.computerization.mediafilter.data.AnalyzeResponse +import com.computerization.mediafilter.data.ChatRequest +import com.computerization.mediafilter.network.MediaFilterApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SharedViewModel { + private val api = MediaFilterApi() + private val scope = MainScope() + + // Callback interface for Swift + // On success: response is not null, error is null + // On failure: response is null, error is not null + fun analyzeContent(text: String, callback: (AnalyzeResponse?, String?) -> Unit) { + scope.launch { + try { + // Switch to IO thread for network call + val result = withContext(Dispatchers.IO) { + api.analyze(text) + } + // Call callback on Main thread + callback(result, null) + } catch (e: Exception) { + callback(null, e.message ?: "Unknown error") + } + } + } + + fun chatStream( + request: ChatRequest, + onChunk: (String, String) -> Unit, // (event, text) + onComplete: () -> Unit, + onError: (String) -> Unit + ) { + scope.launch { + try { + api.chatStream(request).collect { pair -> + onChunk(pair.first, pair.second) + } + onComplete() + } catch (e: Exception) { + onError(e.message ?: "Unknown streaming error") + } + } + } +} diff --git a/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/data/Models.kt b/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/data/Models.kt new file mode 100644 index 0000000..69b91ad --- /dev/null +++ b/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/data/Models.kt @@ -0,0 +1,54 @@ +package com.computerization.mediafilter.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AnalyzeRequest( + val url: String? = null, + val text: String? = null +) + +@Serializable +data class AnalyzeResponse( + val title: String, + val verdict: String, // "reliable", "caution", "misleading" + @SerialName("verdict_emoji") val verdictEmoji: String, + val summary: String, + val details: String, + @SerialName("original_text") val originalText: String? = null +) + +@Serializable +data class ErrorResponse( + val detail: String +) + +// --- Chat Models --- + +@Serializable +data class ChatMessage( + val role: String, // "user" or "assistant" + val content: String +) + +@Serializable +data class ChatRequest( + val messages: List, + val title: String? = null, + @SerialName("original_text") val originalText: String? = null, + @SerialName("analysis_summary") val analysisSummary: String? = null, + @SerialName("analysis_details") val analysisDetails: String? = null +) + +@Serializable +data class ChatResponse( + val response: String, + val reasoning: String? = null +) + +@Serializable +data class ChatStreamChunk( + val text: String? = null, + val error: String? = null +) \ No newline at end of file diff --git a/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/network/MediaFilterApi.kt b/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/network/MediaFilterApi.kt new file mode 100644 index 0000000..51d7332 --- /dev/null +++ b/android/composeApp/src/commonMain/kotlin/com/computerization/mediafilter/network/MediaFilterApi.kt @@ -0,0 +1,353 @@ +package com.computerization.mediafilter.network + +import com.computerization.mediafilter.data.AnalyzeResponse +import com.computerization.mediafilter.data.ChatRequest +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.preparePost +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +// DeepSeek API Models +@Serializable +data class DeepSeekMessage( + val role: String, + val content: String +) + +@Serializable +data class DeepSeekRequest( + val model: String, + val messages: List, + @SerialName("max_tokens") val maxTokens: Int = 2000, + val stream: Boolean = false +) + +@Serializable +data class DeepSeekChoice( + val message: DeepSeekMessage? = null, + val delta: DeepSeekDelta? = null +) + +@Serializable +data class DeepSeekDelta( + val content: String? = null, + @SerialName("reasoning_content") val reasoningContent: String? = null +) + +@Serializable +data class DeepSeekResponse( + val choices: List +) + +class MediaFilterApi { + // ⚠️ API Key embedded in app - can be extracted! Use with caution. + private val apiKey = "sk-56a2c73e18d04371a7d4c872bfc48931" + private val deepSeekUrl = "https://api.deepseek.com/chat/completions" + + private val client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + }) + } + install(HttpTimeout) { + requestTimeoutMillis = 180_000 // 3 minutes for LLM response + connectTimeoutMillis = 30_000 + socketTimeoutMillis = 180_000 + } + } + + private val jsonParser = Json { ignoreUnknownKeys = true } + + /** + * Extract article content from WeChat URL + */ + private suspend fun extractWeChatArticle(url: String): Triple { + val response = client.get(url) { + header("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15") + } + + if (!response.status.isSuccess()) { + throw Exception("无法访问文章链接") + } + + val html = response.bodyAsText() + + // Extract title + val titleRegex = """]*class="[^"]*rich_media_title[^"]*"[^>]*>([\s\S]*?)""".toRegex() + val titleMatch = titleRegex.find(html) + val title = titleMatch?.groupValues?.get(1)?.trim() + ?.replace(Regex("<[^>]+>"), "") + ?.replace(" ", " ") + ?.trim() + ?: run { + // Fallback: try og:title + val ogTitleRegex = """]*property="og:title"[^>]*content="([^"]*)"[^>]*>""".toRegex() + ogTitleRegex.find(html)?.groupValues?.get(1) ?: "未知标题" + } + + // Extract content + val contentRegex = """]*class="[^"]*rich_media_content[^"]*"[^>]*id="js_content"[^>]*>([\s\S]*?)\s*]*>([\s\S]*?)""".toRegex() + fallbackRegex.find(html)?.groupValues?.get(1) ?: "" + } + + // Clean HTML tags and decode entities + content = content + .replace(Regex(""), "\n") + .replace(Regex("]*>"), "\n") + .replace(Regex("

"), "\n") + .replace(Regex("<[^>]+>"), "") + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace(Regex("\\s+"), " ") + .trim() + + if (content.isEmpty()) { + throw Exception("无法提取文章内容,请检查链接是否正确") + } + + // Extract author + val authorRegex = """]*class="[^"]*weui-wa-hotarea[^"]*"[^>]*>([\s\S]*?)""".toRegex() + val author = authorRegex.find(html)?.groupValues?.get(1) + ?.replace(Regex("<[^>]+>"), "") + ?.trim() + ?: "未知来源" + + return Triple(title, content.take(8000), author) + } + + /** + * Analyze content using DeepSeek LLM + */ + private suspend fun analyzeWithLLM(title: String, content: String, author: String): AnalyzeResponse { + val prompt = """你是一位帮助老年人识别网络虚假信息的助手。请分析以下微信公众号文章,判断其可信度。 + +文章标题:$title +来源账号:$author + +文章内容: +${content.take(6000)} + +请从以下几个方面分析: +1. 是否包含虚假健康信息或伪科学 +2. 是否是广告软文或推销产品 +3. 是否使用夸张、恐吓性语言 +4. 信息来源是否可靠 +5. 是否有明显的逻辑错误 + +请用简单易懂的语言回复,适合老年人阅读。直接、坚决地给出回复。内容要口语化,生动,适老化。 + +回复格式: +判定:[可信/需谨慎/不可信] +一句话总结:[用一句简单的话概括这篇文章的可信度] +详细分析:[分点说明你的判断依据,每点用简单的话解释]""" + + val request = DeepSeekRequest( + model = "deepseek-chat", + messages = listOf(DeepSeekMessage(role = "user", content = prompt)), + maxTokens = 2000, + stream = false + ) + + val response = client.preparePost(deepSeekUrl) { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $apiKey") + setBody(request) + }.execute { httpResponse -> + if (!httpResponse.status.isSuccess()) { + val error = httpResponse.bodyAsText() + throw Exception("AI 分析失败: $error") + } + httpResponse.bodyAsText() + } + + val deepSeekResponse = jsonParser.decodeFromString(response) + val llmContent = deepSeekResponse.choices.firstOrNull()?.message?.content + ?: throw Exception("AI 未返回分析结果") + + return parseLLMResponse(llmContent, content) + } + + /** + * Parse LLM response into AnalyzeResponse + */ + private fun parseLLMResponse(llmContent: String, originalContent: String): AnalyzeResponse { + val lines = llmContent.lines() + var verdict = "caution" + var verdictEmoji = "⚠️" + var summary = "" + var details = "" + var inDetails = false + + for (line in lines) { + val trimmedLine = line.trim() + when { + trimmedLine.startsWith("判定:") || trimmedLine.startsWith("判定:") -> { + val verdictText = trimmedLine.substringAfter(":").substringAfter(":").trim() + when { + verdictText.contains("可信") && !verdictText.contains("不可信") -> { + verdict = "reliable" + verdictEmoji = "✅" + } + verdictText.contains("不可信") || verdictText.contains("谣言") -> { + verdict = "misleading" + verdictEmoji = "❌" + } + else -> { + verdict = "caution" + verdictEmoji = "⚠️" + } + } + } + trimmedLine.startsWith("一句话总结:") || trimmedLine.startsWith("一句话总结:") -> { + summary = trimmedLine.substringAfter(":").substringAfter(":").trim() + inDetails = false + } + trimmedLine.startsWith("详细分析:") || trimmedLine.startsWith("详细分析:") -> { + details = trimmedLine.substringAfter(":").substringAfter(":").trim() + inDetails = true + } + inDetails && trimmedLine.isNotEmpty() -> { + details += "\n$trimmedLine" + } + } + } + + // Fallback if parsing failed + if (summary.isEmpty() && details.isEmpty()) { + summary = "AI 分析完成" + details = llmContent + } + + val title = originalContent.take(50).let { + if (it.length == 50) "$it..." else it + } + + return AnalyzeResponse( + title = title, + verdict = verdict, + verdictEmoji = verdictEmoji, + summary = summary, + details = details.trim(), + originalText = originalContent.take(500) + if (originalContent.length > 500) "..." else "" + ) + } + + /** + * Main analyze function - handles both URL and direct text + */ + @Throws(Exception::class) + suspend fun analyze(text: String): AnalyzeResponse { + val isUrl = text.startsWith("http://") || text.startsWith("https://") + + return if (isUrl) { + if (text.contains("mp.weixin.qq.com")) { + val (title, content, author) = extractWeChatArticle(text) + val response = analyzeWithLLM(title, content, author) + response.copy(title = title) + } else { + throw Exception("目前仅支持微信公众号文章链接") + } + } else { + // Direct text analysis + analyzeWithLLM("用户提供的内容", text, "用户输入") + } + } + + /** + * Stream chat response directly from DeepSeek API + */ + @Throws(Exception::class) + fun chatStream(request: ChatRequest): Flow> = flow { + val systemPrompt = """ +你是一位帮助老年人识别网络虚假信息的贴心助手"慧眼"。 +你之前已经分析过这篇文章: +标题:${request.title ?: "未知"} +原文摘要:${request.originalText?.take(500) ?: "无"} + +之前的分析结果: +${request.analysisSummary ?: "无"} +${request.analysisDetails ?: "无"} + +现在的任务是回答用户关于这篇文章的后续提问。 +请保持语气亲切、耐心,像一位靠谱的晚辈在给长辈解释。 +回答要通俗易懂,不要用复杂的术语。 +如果是谣言,要温和但坚定地提醒长辈不要相信。 + +重要提示: +1. 直接回复用户的内容,不要在开头加"(温和地)"或"(认真地)"之类的语气描述。 +2. 不要重复"你好"或自我介绍,直接针对问题回答。 +""" + + val messages = mutableListOf(DeepSeekMessage(role = "system", content = systemPrompt)) + for (msg in request.messages) { + messages.add(DeepSeekMessage(role = msg.role, content = msg.content)) + } + + val deepSeekRequest = DeepSeekRequest( + model = "deepseek-reasoner", + messages = messages, + maxTokens = 2000, + stream = true + ) + + client.preparePost(deepSeekUrl) { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $apiKey") + setBody(deepSeekRequest) + }.execute { response -> + if (!response.status.isSuccess()) { + throw Exception("请求失败: ${response.status}") + } + + val channel = response.bodyAsChannel() + + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: break + + if (line.startsWith("data:")) { + val data = line.removePrefix("data:").trim() + if (data == "[DONE]") break + + try { + val chunk = jsonParser.decodeFromString(data) + val delta = chunk.choices.firstOrNull()?.delta + + delta?.reasoningContent?.let { text -> + if (text.isNotEmpty()) emit(Pair("reasoning", text)) + } + delta?.content?.let { text -> + if (text.isNotEmpty()) emit(Pair("content", text)) + } + } catch (e: Exception) { + // Skip malformed chunks + } + } + } + } + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..52a8810 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +kotlin.code.style=official +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +android.nonTransitiveRClass=true +android.overridePathCheck=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..5f83022 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,50 @@ +[versions] +agp = "8.7.3" +android-compileSdk = "35" +android-minSdk = "24" +android-targetSdk = "35" +androidx-activityCompose = "1.9.3" +androidx-appcompat = "1.7.0" +androidx-constraintlayout = "2.2.0" +androidx-core-ktx = "1.15.0" +androidx-espresso-core = "3.6.1" +androidx-material = "1.12.0" +androidx-test-junit = "1.2.1" +compose-plugin = "1.7.1" +junit = "4.13.2" +kotlin = "2.1.0" +ktor = "3.0.1" +coroutines = "1.9.0" +navigation-compose = "2.8.4" + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } +androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-junit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-material = { group = "com.google.android.material", name = "material", version.ref = "androidx-material" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } + +# Networking +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +# Coroutines +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..7f94d3d --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c3abe6d --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,17 @@ +rootProject.name = "media-filter-android" +include(":composeApp") + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +}