1 line
201 KiB
JSON
1 line
201 KiB
JSON
{"repo": "chiennna/studyassistcamera", "files": {"app/src/main/java/com/example/studyassist/MainActivity.kt": {"415ee80482e2e3c4c375851ca5e254d8d390c411": {"t": 1785745724.9867094, "n": 32526, "h": "288e220e3883b01b", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.ClipData\nimport android.content.ClipboardManager\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.graphics.Rect\nimport android.os.Bundle\nimport android.text.InputType\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.EditText\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.Text\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.io.IOException\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport okhttp3.Call\nimport okhttp3.Callback\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport org.json.JSONArray\nimport org.json.JSONObject\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var apiKeyInput: EditText\n private lateinit var ocrText: TextView\n private lateinit var translationText: TextView\n private lateinit var analysisText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var saveApiKeyButton: Button\n private lateinit var clearApiKeyButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var cancelAiButton: Button\n private lateinit var copyButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val latinTextRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n private val chineseTextRecognizer: TextRecognizer =\n TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build())\n private val httpClient: OkHttpClient = OkHttpClient.Builder()\n .connectTimeout(15, TimeUnit.SECONDS)\n .readTimeout(60, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var currentAiCall: Call? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private var lastAiRequestedText = \"\"\n private var lastAiRequestedAt = 0L\n private var aiRequestVersion = 0\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"摄像头权限已授予。正在启动预览...\")\n startCamera()\n } else {\n updateStatus(\"摄像头权限被拒绝。预览不可用。\")\n Toast.makeText(this, \"需要摄像头权限才能使用。\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"需要摄像头权限才能预览。\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cancelAiRequest(showToast = false)\n cameraProvider?.unbindAll()\n cameraProvider = null\n latinTextRecognizer.close()\n chineseTextRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF3F6FA.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n // Preview takes 44% of screen — large enough to frame shots\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 0.44f,\n )\n }\n\n statusText = sectionText(\"正在准备摄像头...\", textSize = 13f)\n apiKeyInput = EditText(this).apply {\n hint = \"在此粘贴 DeepSeek API 密钥\"\n setText(loadDeepSeekApiKey())\n textSize = 14f\n setSingleLine(true)\n inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD\n setPadding(16, 10, 16, 10)\n setBackgroundColor(0xFFFFFFFF.toInt())\n }\n ocrText = sectionText(\"OCR 识别文字将显示在此处。\")\n translationText = sectionText(\"中文翻译将显示在此处。\")\n analysisText = sectionText(\"DeepSeek 学习分析将显示在此处。\")\n\n // Scrollable content area takes remaining 56% of screen\n val content = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(10, 4, 10, 12)\n }\n val contentScroll = ScrollView(this).apply {\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 0.56f,\n )\n addView(content)\n }\n\n // Buttons in 2-column grid — compact and easy to reach\n toggleRecognitionButton = Button(this).apply { text = \"暂停识别\" }\n saveApiKeyButton = Button(this).apply { text = \"保存密钥\" }\n clearApiKeyButton = Button(this).apply { text = \"清除密钥\" }\n manualScanButton = Button(this).apply { text = \"手动扫描\" }\n cancelAiButton = Button(this).apply { text = \"取消 AI 请求\"; isEnabled = false }\n copyButton = Button(this).apply { text = \"复制结果\" }\n clearButton = Button(this).apply { text = \"清除结果\" }\n retryPermissionButton = Button(this).apply {\n text = \"重新请求权限\"\n isEnabled = false\n }\n\n val buttonGrid = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 4, 0, 0)\n }\n buttonGrid.addView(buttonRow(toggleRecognitionButton, manualScanButton))\n buttonGrid.addView(buttonRow(saveApiKeyButton, clearApiKeyButton))\n buttonGrid.addView(buttonRow(cancelAiButton, copyButton))\n buttonGrid.addView(buttonRow(clearButton, retryPermissionButton))\n\n root.addView(previewView)\n content.addView(statusText)\n content.addView(sectionLabel(\"DeepSeek API 密钥\"))\n content.addView(apiKeyInput)\n content.addView(labeledSection(\"OCR 识别结果\", ocrText, minHeight = 70))\n content.addView(labeledSection(\"中文翻译\", translationText, minHeight = 50))\n content.addView(labeledSection(\"学习分析\", analysisText, minHeight = 90))\n content.addView(buttonGrid)\n root.addView(contentScroll)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"暂停识别\" else \"开始识别\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR 正在运行(限帧分析)。\"\n } else {\n \"OCR 已暂停。摄像头预览正常。\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"等待下一帧摄像头画面进行扫描...\")\n }\n\n saveApiKeyButton.setOnClickListener {\n saveDeepSeekApiKey()\n }\n\n clearApiKeyButton.setOnClickListener {\n clearDeepSeekApiKey()\n }\n\n cancelAiButton.setOnClickListener {\n cancelAiRequest(showToast = true)\n }\n\n copyButton.setOnClickListener {\n copyResultsToClipboard()\n }\n\n clearButton.setOnClickListener {\n cancelAiRequest(showToast = false)\n lastRecognizedText = \"\"\n lastAiRequestedText = \"\"\n ocrText.text = \"OCR 识别文字将显示在此处。\"\n translationText.text = \"中文翻译将显示在此处。\"\n analysisText.text = \"DeepSeek 学习分析将显示在此处。\"\n updateStatus(\"结果已清除。\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"摄像头预览和 OCR 正在运行。\")\n } catch (error: Exception) {\n updateStatus(\"摄像头启动失败:${error.localizedMessage ?: \"未知错误\"}\")\n Toast.makeText(this, \"启动摄像头失败。\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val rotation = imageProxy.imageInfo.rotationDegrees\n val inputImage = InputImage.fromMediaImage(mediaImage, rotation)\n\n // Calculate visible crop area — FILL_CENTER shows only a centre crop\n val visibleRect = visibleRectForPreview(imageProxy, rotation)\n\n recognizeLatinThenChinese(inputImage, imageProxy, visibleRect)\n }\n\n /** Returns the sensor region actually visible in the FILL_CENTER preview. */\n private fun visibleRectForPreview(imageProxy: ImageProxy, rotation: Int): Rect? {\n val viewW = previewView.width\n val viewH = previewView.height\n if (viewW <= 0 || viewH <= 0) return null\n\n val (imgW, imgH) = if (rotation == 90 || rotation == 270) {\n imageProxy.height to imageProxy.width\n } else {\n imageProxy.width to imageProxy.height\n }\n\n val imageRatio = imgW.toFloat() / imgH\n val viewRatio = viewW.toFloat() / viewH\n if (Math.abs(imageRatio - viewRatio) < 0.03f) return null // negligible crop\n\n return if (viewRatio > imageRatio) {\n // View wider than sensor — crop top & bottom\n val cropH = (imgW / viewRatio).toInt().coerceAtMost(imgH)\n Rect(0, (imgH - cropH) / 2, imgW, (imgH + cropH) / 2)\n } else {\n // View taller than sensor — crop left & right\n val cropW = (imgH * viewRatio).toInt().coerceAtMost(imgW)\n Rect((imgW - cropW) / 2, 0, (imgW + cropW) / 2, imgH)\n }\n }\n\n private fun recognizeLatinThenChinese(\n inputImage: InputImage,\n imageProxy: ImageProxy,\n visibleRect: Rect?,\n ) {\n var latinText = \"\"\n var chineseText = \"\"\n var latinError: Exception? = null\n var chineseError: Exception? = null\n var latinDone = false\n var chineseDone = false\n\n fun tryFinish() {\n if (!latinDone || !chineseDone) return\n finishOcrProcessing(\n imageProxy = imageProxy,\n latinText = latinText,\n chineseText = chineseText,\n latinError = latinError,\n chineseError = chineseError,\n )\n }\n\n // Run Latin and Chinese recognizers in parallel — cuts OCR time roughly in half\n latinTextRecognizer.process(inputImage)\n .addOnSuccessListener { result ->\n latinText = visibleTextOnly(result, visibleRect)\n }\n .addOnFailureListener { error -> latinError = error }\n .addOnCompleteListener { latinDone = true; tryFinish() }\n\n chineseTextRecognizer.process(inputImage)\n .addOnSuccessListener { result ->\n chineseText = visibleTextOnly(result, visibleRect)\n }\n .addOnFailureListener { error -> chineseError = error }\n .addOnCompleteListener { chineseDone = true; tryFinish() }\n }\n\n /** Keep only text blocks whose bounding box intersects the visible preview area. */\n private fun visibleTextOnly(result: Text, visibleRect: Rect?): String {\n if (visibleRect == null) return result.text\n val blocks = result.textBlocks\n if (blocks.isEmpty()) return result.text\n val visible = blocks.filter { block ->\n val box = block.boundingBox ?: return@filter true\n Rect.intersects(box, visibleRect)\n }\n if (visible.isEmpty()) return result.text // safety: never return empty if ML Kit found text\n return visible.joinToString(\"\\n\") { it.text }\n }\n\n private fun finishOcrProcessing(\n imageProxy: ImageProxy,\n latinText: String,\n chineseText: String,\n latinError: Exception?,\n chineseError: Exception?,\n ) {\n try {\n val cleanedText = mergeOcrText(latinText, chineseText)\n if (cleanedText.isBlank()) {\n val errorText = listOfNotNull(latinError, chineseError)\n .joinToString(\"; \") { it.localizedMessage ?: \"未知 OCR 错误\" }\n updateOcrResult(\n if (errorText.isBlank()) {\n \"未检测到清晰文字。请调整拍摄角度或距离。\"\n } else {\n \"OCR 识别失败或未找到文字:$errorText\"\n },\n )\n return\n }\n\n if (isSimilarText(cleanedText, lastRecognizedText)) {\n updateStatus(\"OCR 文字与上次结果相似,跳过重复。\")\n return\n }\n\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n handleRecognizedText(cleanedText)\n } finally {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun handleRecognizedText(text: String) {\n when (classifyText(text)) {\n ContentKind.TOO_SHORT -> {\n updateTranslation(\"文字过短或不完整。请调整角度后重新扫描。\")\n updateAnalysis(\"等待更清晰的 OCR 结果后再调用 DeepSeek。\")\n }\n ContentKind.ENGLISH -> {\n updateTranslation(\"检测到英文内容,正在请求中文翻译...\")\n maybeRequestAi(text)\n }\n ContentKind.QUESTION -> {\n updateTranslation(\"检测到题目类内容,正在请求学习分析...\")\n maybeRequestAi(text)\n }\n ContentKind.OTHER -> {\n updateTranslation(\"已检测到文字,但不像英文或完整题目。\")\n updateAnalysis(\"保持页面稳定,或在完整内容可见时使用「手动扫描」。\")\n }\n }\n }\n\n private fun maybeRequestAi(text: String) {\n val apiKey = loadDeepSeekApiKey()\n if (apiKey.isEmpty()) {\n updateAnalysis(\n \"缺少 DeepSeek API 密钥。请在密钥输入框中粘贴密钥,点击「保存密钥」,然后重新扫描。\",\n )\n updateStatus(\"缺少 DeepSeek API 密钥。\")\n return\n }\n\n val now = System.currentTimeMillis()\n if (isSimilarText(text, lastAiRequestedText) && now - lastAiRequestedAt < AI_COOLDOWN_MS) {\n updateStatus(\"DeepSeek 请求因重复冷却被跳过。\")\n return\n }\n\n lastAiRequestedText = text\n lastAiRequestedAt = now\n aiRequestVersion += 1\n val requestVersion = aiRequestVersion\n\n currentAiCall?.cancel()\n cancelAiButton.isEnabled = true\n updateAnalysis(\"正在加载 DeepSeek 学习分析...\")\n updateStatus(\"正在调用 DeepSeek...\")\n\n val requestBody = JSONObject()\n .put(\"model\", DEEPSEEK_MODEL)\n .put(\n \"messages\",\n JSONArray()\n .put(JSONObject().put(\"role\", \"system\").put(\"content\", SYSTEM_PROMPT))\n .put(JSONObject().put(\"role\", \"user\").put(\"content\", buildStudyPrompt(text))),\n )\n .put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n .put(\"thinking\", JSONObject().put(\"type\", \"disabled\"))\n .put(\"temperature\", 0.2)\n .put(\"max_tokens\", 1024)\n .put(\"stream\", false)\n .toString()\n .toRequestBody(JSON_MEDIA_TYPE)\n\n val request = Request.Builder()\n .url(DEEPSEEK_CHAT_URL)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(requestBody)\n .build()\n\n val call = httpClient.newCall(request)\n currentAiCall = call\n call.enqueue(object : Callback {\n override fun onFailure(call: Call, e: IOException) {\n if (call.isCanceled()) return\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n updateStatus(\"网络请求失败。\")\n analysisText.text = \"DeepSeek 请求失败:${e.localizedMessage ?: \"网络错误\"}\"\n }\n }\n\n override fun onResponse(call: Call, response: Response) {\n val responseText = response.use { it.body?.string().orEmpty() }\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n if (!response.isSuccessful) {\n updateStatus(\"DeepSeek 返回 HTTP ${response.code}。\")\n analysisText.text = \"DeepSeek 错误 ${response.code}:$responseText\"\n return@runOnUiThread\n }\n\n val result = parseDeepSeekResult(responseText)\n translationText.text = result.translation\n analysisText.text = result.analysis\n updateStatus(result.status)\n }\n }\n })\n }\n\n private fun cancelAiRequest(showToast: Boolean) {\n aiRequestVersion += 1\n currentAiCall?.cancel()\n currentAiCall = null\n cancelAiButton.isEnabled = false\n updateStatus(\"AI 请求已取消。\")\n if (showToast) {\n Toast.makeText(this, \"AI 请求已取消。\", Toast.LENGTH_SHORT).show()\n }\n }\n\n private fun classifyText(text: String): ContentKind {\n val compact = text.filterNot { it.isWhitespace() }\n if (compact.length < MIN_USEFUL_TEXT_LENGTH) return ContentKind.TOO_SHORT\n\n val letters = compact.count { it in 'A'..'Z' || it in 'a'..'z' }\n val englishRatio = letters.toFloat() / compact.length.coerceAtLeast(1)\n if (letters >= MIN_ENGLISH_LETTERS && englishRatio >= ENGLISH_RATIO_THRESHOLD) {\n return ContentKind.ENGLISH\n }\n\n val lower = text.lowercase()\n val looksLikeQuestion = text.contains(\"?\") ||\n text.contains(\"\\uFF1F\") ||\n text.contains(\"=\") ||\n QUESTION_KEYWORDS.any { lower.contains(it) } ||\n CHINESE_QUESTION_KEYWORDS.any { text.contains(it) } ||\n OPTION_PATTERN.containsMatchIn(text) ||\n MATH_PATTERN.containsMatchIn(text)\n\n return if (looksLikeQuestion) ContentKind.QUESTION else ContentKind.OTHER\n }\n\n private fun buildStudyPrompt(ocrText: String): String {\n return \"\"\"\n Return one valid JSON object only. Do not wrap it in Markdown.\n Required JSON keys:\n {\n \"content_status\": \"complete | incomplete | uncertain\",\n \"chinese_translation\": \"Chinese translation or a short note if translation is not needed\",\n \"study_analysis\": \"Study explanation in Simplified Chinese with numbered sections\",\n \"suspicious_ocr\": \"OCR positions that may be wrong, or empty string\"\n }\n\n You are a study tutoring assistant. The text below was recognized by OCR from learning material or a question image.\n First judge whether the content is complete and whether it looks like a question.\n If the text is incomplete, tell the user to retake the image or adjust the angle.\n If the content is in English, translate it into Chinese first.\n If it is a question, include these sections in study_analysis:\n 1. Question understanding\n 2. Related knowledge points\n 3. Solving idea\n 4. Detailed steps\n 5. Final answer\n 6. Common mistakes\n 7. How to recognize similar question types\n\n Requirements:\n - Do not only give the answer.\n - Explain for students with weak foundations.\n - If OCR may be wrong, point out suspicious positions.\n - If conditions are insufficient, do not invent missing content.\n - Respond in Simplified Chinese inside JSON string values.\n\n OCR text:\n $ocrText\n \"\"\".trimIndent()\n }\n\n private fun parseDeepSeekResult(responseText: String): StudyResult {\n return try {\n val content = JSONObject(responseText)\n .getJSONArray(\"choices\")\n .getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n .trim()\n\n val resultObject = JSONObject(content)\n val status = resultObject.optString(\"content_status\", \"DeepSeek response updated.\")\n val translation = resultObject.optString(\"chinese_translation\", \"未返回翻译。\")\n val analysis = resultObject.optString(\"study_analysis\", content)\n val suspicious = resultObject.optString(\"suspicious_ocr\", \"\")\n StudyResult(\n translation = translation,\n analysis = buildString {\n append(analysis.ifBlank { content })\n if (suspicious.isNotBlank()) {\n append(\"\\n\\nOCR 提示:\\n\")\n append(suspicious)\n }\n },\n status = \"DeepSeek 分析完成:$status\",\n )\n } catch (error: Exception) {\n StudyResult(\n translation = \"DeepSeek 已返回,但 JSON 解析失败。\",\n analysis = \"无法解析 DeepSeek 响应:${error.localizedMessage ?: \"未知错误\"}\\n\\n$responseText\",\n status = \"DeepSeek 响应解析失败。\",\n )\n }\n }\n\n private fun mergeOcrText(latinText: String, chineseText: String): String {\n val latinClean = cleanOcrText(latinText)\n val chineseClean = cleanOcrText(chineseText)\n if (latinClean.isBlank()) return chineseClean\n if (chineseClean.isBlank()) return latinClean\n if (latinClean == chineseClean) return latinClean\n\n val mergedLines = mutableListOf<String>()\n (chineseClean.lines() + latinClean.lines())\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .forEach { line ->\n if (mergedLines.none { existing -> isSimilarText(existing, line) }) {\n mergedLines.add(line)\n }\n }\n\n return mergedLines.joinToString(\"\\n\")\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun isSimilarText(newText: String, oldText: String): Boolean {\n if (newText.isBlank() || oldText.isBlank()) return false\n if (newText == oldText) return true\n val a = normalizeForSimilarity(newText)\n val b = normalizeForSimilarity(oldText)\n if (a == b) return true\n val shorter = minOf(a.length, b.length)\n val longer = maxOf(a.length, b.length)\n if (longer == 0 || shorter.toFloat() / longer < 0.75f) return false\n val common = a.toSet().intersect(b.toSet()).size\n val total = a.toSet().union(b.toSet()).size.coerceAtLeast(1)\n return common.toFloat() / total >= 0.88f\n }\n\n private fun normalizeForSimilarity(text: String): String {\n return text.lowercase()\n .filterNot { it.isWhitespace() }\n .filterNot { it in \",.;:!?()[]{}\" }\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR 已更新。\")\n }\n }\n\n private fun updateTranslation(text: String) {\n runOnUiThread {\n translationText.text = text\n }\n }\n\n private fun updateAnalysis(text: String) {\n runOnUiThread {\n analysisText.text = text\n }\n }\n\n private fun copyResultsToClipboard() {\n val resultText = buildString {\n append(\"OCR 识别结果:\\n\")\n append(ocrText.text)\n append(\"\\n\\n中文翻译:\\n\")\n append(translationText.text)\n append(\"\\n\\n学习分析:\\n\")\n append(analysisText.text)\n }\n val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager\n clipboard.setPrimaryClip(ClipData.newPlainText(\"学习助手结果\", resultText))\n Toast.makeText(this, \"结果已复制。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun saveDeepSeekApiKey() {\n val apiKey = apiKeyInput.text.toString().trim()\n if (apiKey.isEmpty()) {\n clearDeepSeekApiKey()\n return\n }\n\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .putString(PREFERENCE_DEEPSEEK_API_KEY, apiKey)\n .apply()\n apiKeyInput.setText(apiKey)\n updateStatus(\"DeepSeek API 密钥已保存在本设备。\")\n Toast.makeText(this, \"API 密钥已保存。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun clearDeepSeekApiKey() {\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .remove(PREFERENCE_DEEPSEEK_API_KEY)\n .apply()\n apiKeyInput.text?.clear()\n updateStatus(\"DeepSeek API 密钥已清除。\")\n Toast.makeText(this, \"API 密钥已清除。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun loadDeepSeekApiKey(): String {\n return getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .getString(PREFERENCE_DEEPSEEK_API_KEY, \"\")\n .orEmpty()\n .trim()\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun labeledSection(title: String, textView: TextView, minHeight: Int): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 6, 0, 0)\n addView(sectionLabel(title))\n textView.setBackgroundColor(0xFFFFFFFF.toInt())\n textView.minHeight = dp(minHeight)\n val params = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 2, 0, 0)\n }\n addView(textView, params)\n }\n }\n\n private fun sectionLabel(text: String): TextView {\n return TextView(this).apply {\n this.text = text\n textSize = 13f\n setTextColor(0xFF4B5563.toInt())\n setPadding(2, 0, 0, 0)\n }\n }\n\n private fun sectionText(text: String, textSize: Float = 14f): TextView {\n return TextView(this).apply {\n this.text = text\n this.textSize = textSize\n setTextColor(0xFF111827.toInt())\n setPadding(16, 12, 16, 12)\n setLineSpacing(0f, 1.08f)\n }\n }\n\n private fun buttonRow(left: Button, right: Button): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.HORIZONTAL\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 4, 0, 0)\n }\n val half = LinearLayout.LayoutParams(\n 0,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n 1f,\n ).apply {\n setMargins(3, 0, 3, 0)\n }\n addView(left, half)\n addView(right, half)\n }\n }\n\n private fun dp(value: Int): Int {\n return (value * resources.displayMetrics.density).toInt()\n }\n\n private data class StudyResult(\n val translation: String,\n val analysis: String,\n val status: String,\n )\n\n private enum class ContentKind {\n TOO_SHORT,\n ENGLISH,\n QUESTION,\n OTHER,\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_100L\n const val AI_COOLDOWN_MS = 12_000L\n const val MIN_USEFUL_TEXT_LENGTH = 12\n const val MIN_ENGLISH_LETTERS = 8\n const val ENGLISH_RATIO_THRESHOLD = 0.45f\n const val DEEPSEEK_CHAT_URL = \"https://api.deepseek.com/chat/completions\"\n const val DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val PREFERENCES_NAME = \"study_assist_preferences\"\n const val PREFERENCE_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n val JSON_MEDIA_TYPE = \"application/json; charset=utf-8\".toMediaType()\n val OPTION_PATTERN = Regex(\"\"\"(?im)(^|\\n)\\s*[A-D][\\).: ]\"\"\")\n val MATH_PATTERN = Regex(\"\"\"[0-9]\\s*[+\\-*/=^]\\s*[0-9a-zA-Z(]\"\"\")\n val QUESTION_KEYWORDS = listOf(\n \"solve\",\n \"calculate\",\n \"choose\",\n \"find\",\n \"prove\",\n \"answer\",\n \"question\",\n \"what is\",\n \"which\",\n \"why\",\n )\n val CHINESE_QUESTION_KEYWORDS = listOf(\n \"\\u6C42\",\n \"\\u8BA1\\u7B97\",\n \"\\u9009\\u62E9\",\n \"\\u8BC1\\u660E\",\n \"\\u7B54\\u6848\",\n \"\\u89E3\\u6790\",\n \"\\u4E0B\\u5217\",\n \"\\u9898\",\n )\n const val SYSTEM_PROMPT =\n \"You help students learn from OCR text. You do not help with cheating or rule-breaking.\"\n }\n}\n"}, "90b8909ee73a0012b2b04d560981b2955eeed075": {"t": 1785755936.6581, "n": 30408, "h": "6d5bf7169cd11245", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.ClipData\nimport android.content.ClipboardManager\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.text.InputType\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.EditText\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.Text\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.io.IOException\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport okhttp3.Call\nimport okhttp3.Callback\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport org.json.JSONArray\nimport org.json.JSONObject\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var apiKeyInput: EditText\n private lateinit var ocrText: TextView\n private lateinit var translationText: TextView\n private lateinit var analysisText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var saveApiKeyButton: Button\n private lateinit var clearApiKeyButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var cancelAiButton: Button\n private lateinit var copyButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val latinTextRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n private val chineseTextRecognizer: TextRecognizer =\n TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build())\n private val httpClient: OkHttpClient = OkHttpClient.Builder()\n .connectTimeout(15, TimeUnit.SECONDS)\n .readTimeout(60, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var currentAiCall: Call? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private var lastAiRequestedText = \"\"\n private var lastAiRequestedAt = 0L\n private var aiRequestVersion = 0\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"摄像头权限已授予。正在启动预览...\")\n startCamera()\n } else {\n updateStatus(\"摄像头权限被拒绝。预览不可用。\")\n Toast.makeText(this, \"需要摄像头权限才能使用。\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"需要摄像头权限才能预览。\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cancelAiRequest(showToast = false)\n cameraProvider?.unbindAll()\n cameraProvider = null\n latinTextRecognizer.close()\n chineseTextRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF3F6FA.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n // Preview takes 44% of screen — large enough to frame shots\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 0.44f,\n )\n }\n\n statusText = sectionText(\"正在准备摄像头...\", textSize = 13f)\n apiKeyInput = EditText(this).apply {\n hint = \"在此粘贴 DeepSeek API 密钥\"\n setText(loadDeepSeekApiKey())\n textSize = 14f\n setSingleLine(true)\n inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD\n setPadding(16, 10, 16, 10)\n setBackgroundColor(0xFFFFFFFF.toInt())\n }\n ocrText = sectionText(\"OCR 识别文字将显示在此处。\")\n translationText = sectionText(\"中文翻译将显示在此处。\")\n analysisText = sectionText(\"DeepSeek 学习分析将显示在此处。\")\n\n // Scrollable content area takes remaining 56% of screen\n val content = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(10, 4, 10, 12)\n }\n val contentScroll = ScrollView(this).apply {\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 0.56f,\n )\n addView(content)\n }\n\n // Buttons in 2-column grid — compact and easy to reach\n toggleRecognitionButton = Button(this).apply { text = \"暂停识别\" }\n saveApiKeyButton = Button(this).apply { text = \"保存密钥\" }\n clearApiKeyButton = Button(this).apply { text = \"清除密钥\" }\n manualScanButton = Button(this).apply { text = \"手动扫描\" }\n cancelAiButton = Button(this).apply { text = \"取消 AI 请求\"; isEnabled = false }\n copyButton = Button(this).apply { text = \"复制结果\" }\n clearButton = Button(this).apply { text = \"清除结果\" }\n retryPermissionButton = Button(this).apply {\n text = \"重新请求权限\"\n isEnabled = false\n }\n\n val buttonGrid = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 4, 0, 0)\n }\n buttonGrid.addView(buttonRow(toggleRecognitionButton, manualScanButton))\n buttonGrid.addView(buttonRow(saveApiKeyButton, clearApiKeyButton))\n buttonGrid.addView(buttonRow(cancelAiButton, copyButton))\n buttonGrid.addView(buttonRow(clearButton, retryPermissionButton))\n\n root.addView(previewView)\n content.addView(statusText)\n content.addView(sectionLabel(\"DeepSeek API 密钥\"))\n content.addView(apiKeyInput)\n content.addView(labeledSection(\"OCR 识别结果\", ocrText, minHeight = 70))\n content.addView(labeledSection(\"中文翻译\", translationText, minHeight = 50))\n content.addView(labeledSection(\"学习分析\", analysisText, minHeight = 90))\n content.addView(buttonGrid)\n root.addView(contentScroll)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"暂停识别\" else \"开始识别\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR 正在运行(限帧分析)。\"\n } else {\n \"OCR 已暂停。摄像头预览正常。\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"等待下一帧摄像头画面进行扫描...\")\n }\n\n saveApiKeyButton.setOnClickListener {\n saveDeepSeekApiKey()\n }\n\n clearApiKeyButton.setOnClickListener {\n clearDeepSeekApiKey()\n }\n\n cancelAiButton.setOnClickListener {\n cancelAiRequest(showToast = true)\n }\n\n copyButton.setOnClickListener {\n copyResultsToClipboard()\n }\n\n clearButton.setOnClickListener {\n cancelAiRequest(showToast = false)\n lastRecognizedText = \"\"\n lastAiRequestedText = \"\"\n ocrText.text = \"OCR 识别文字将显示在此处。\"\n translationText.text = \"中文翻译将显示在此处。\"\n analysisText.text = \"DeepSeek 学习分析将显示在此处。\"\n updateStatus(\"结果已清除。\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"摄像头预览和 OCR 正在运行。\")\n } catch (error: Exception) {\n updateStatus(\"摄像头启动失败:${error.localizedMessage ?: \"未知错误\"}\")\n Toast.makeText(this, \"启动摄像头失败。\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val inputImage = InputImage.fromMediaImage(\n mediaImage,\n imageProxy.imageInfo.rotationDegrees,\n )\n\n recognizeLatinThenChinese(inputImage, imageProxy)\n }\n\n private fun recognizeLatinThenChinese(inputImage: InputImage, imageProxy: ImageProxy) {\n var latinText = \"\"\n var chineseText = \"\"\n var latinError: Exception? = null\n var chineseError: Exception? = null\n var latinDone = false\n var chineseDone = false\n\n fun tryFinish() {\n if (!latinDone || !chineseDone) return\n finishOcrProcessing(\n imageProxy = imageProxy,\n latinText = latinText,\n chineseText = chineseText,\n latinError = latinError,\n chineseError = chineseError,\n )\n }\n\n // Run Latin and Chinese recognizers in parallel — cuts OCR time roughly in half\n latinTextRecognizer.process(inputImage)\n .addOnSuccessListener { result -> latinText = result.text }\n .addOnFailureListener { error -> latinError = error }\n .addOnCompleteListener { latinDone = true; tryFinish() }\n\n chineseTextRecognizer.process(inputImage)\n .addOnSuccessListener { result -> chineseText = result.text }\n .addOnFailureListener { error -> chineseError = error }\n .addOnCompleteListener { chineseDone = true; tryFinish() }\n }\n\n private fun finishOcrProcessing(\n imageProxy: ImageProxy,\n latinText: String,\n chineseText: String,\n latinError: Exception?,\n chineseError: Exception?,\n ) {\n try {\n val cleanedText = mergeOcrText(latinText, chineseText)\n if (cleanedText.isBlank()) {\n val errorText = listOfNotNull(latinError, chineseError)\n .joinToString(\"; \") { it.localizedMessage ?: \"未知 OCR 错误\" }\n updateOcrResult(\n if (errorText.isBlank()) {\n \"未检测到清晰文字。请调整拍摄角度或距离。\"\n } else {\n \"OCR 识别失败或未找到文字:$errorText\"\n },\n )\n return\n }\n\n if (isSimilarText(cleanedText, lastRecognizedText)) {\n updateStatus(\"OCR 文字与上次结果相似,跳过重复。\")\n return\n }\n\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n handleRecognizedText(cleanedText)\n } finally {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun handleRecognizedText(text: String) {\n when (classifyText(text)) {\n ContentKind.TOO_SHORT -> {\n updateTranslation(\"文字过短或不完整。请调整角度后重新扫描。\")\n updateAnalysis(\"等待更清晰的 OCR 结果后再调用 DeepSeek。\")\n }\n ContentKind.ENGLISH -> {\n updateTranslation(\"检测到英文内容,正在请求中文翻译...\")\n maybeRequestAi(text)\n }\n ContentKind.QUESTION -> {\n updateTranslation(\"检测到题目类内容,正在请求学习分析...\")\n maybeRequestAi(text)\n }\n ContentKind.OTHER -> {\n updateTranslation(\"已检测到文字,但不像英文或完整题目。\")\n updateAnalysis(\"保持页面稳定,或在完整内容可见时使用「手动扫描」。\")\n }\n }\n }\n\n private fun maybeRequestAi(text: String) {\n val apiKey = loadDeepSeekApiKey()\n if (apiKey.isEmpty()) {\n updateAnalysis(\n \"缺少 DeepSeek API 密钥。请在密钥输入框中粘贴密钥,点击「保存密钥」,然后重新扫描。\",\n )\n updateStatus(\"缺少 DeepSeek API 密钥。\")\n return\n }\n\n val now = System.currentTimeMillis()\n if (isSimilarText(text, lastAiRequestedText) && now - lastAiRequestedAt < AI_COOLDOWN_MS) {\n updateStatus(\"DeepSeek 请求因重复冷却被跳过。\")\n return\n }\n\n lastAiRequestedText = text\n lastAiRequestedAt = now\n aiRequestVersion += 1\n val requestVersion = aiRequestVersion\n\n currentAiCall?.cancel()\n cancelAiButton.isEnabled = true\n updateAnalysis(\"正在加载 DeepSeek 学习分析...\")\n updateStatus(\"正在调用 DeepSeek...\")\n\n val requestBody = JSONObject()\n .put(\"model\", DEEPSEEK_MODEL)\n .put(\n \"messages\",\n JSONArray()\n .put(JSONObject().put(\"role\", \"system\").put(\"content\", SYSTEM_PROMPT))\n .put(JSONObject().put(\"role\", \"user\").put(\"content\", buildStudyPrompt(text))),\n )\n .put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n .put(\"thinking\", JSONObject().put(\"type\", \"disabled\"))\n .put(\"temperature\", 0.2)\n .put(\"max_tokens\", 1024)\n .put(\"stream\", false)\n .toString()\n .toRequestBody(JSON_MEDIA_TYPE)\n\n val request = Request.Builder()\n .url(DEEPSEEK_CHAT_URL)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(requestBody)\n .build()\n\n val call = httpClient.newCall(request)\n currentAiCall = call\n call.enqueue(object : Callback {\n override fun onFailure(call: Call, e: IOException) {\n if (call.isCanceled()) return\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n updateStatus(\"网络请求失败。\")\n analysisText.text = \"DeepSeek 请求失败:${e.localizedMessage ?: \"网络错误\"}\"\n }\n }\n\n override fun onResponse(call: Call, response: Response) {\n val responseText = response.use { it.body?.string().orEmpty() }\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n if (!response.isSuccessful) {\n updateStatus(\"DeepSeek 返回 HTTP ${response.code}。\")\n analysisText.text = \"DeepSeek 错误 ${response.code}:$responseText\"\n return@runOnUiThread\n }\n\n val result = parseDeepSeekResult(responseText)\n translationText.text = result.translation\n analysisText.text = result.analysis\n updateStatus(result.status)\n }\n }\n })\n }\n\n private fun cancelAiRequest(showToast: Boolean) {\n aiRequestVersion += 1\n currentAiCall?.cancel()\n currentAiCall = null\n cancelAiButton.isEnabled = false\n updateStatus(\"AI 请求已取消。\")\n if (showToast) {\n Toast.makeText(this, \"AI 请求已取消。\", Toast.LENGTH_SHORT).show()\n }\n }\n\n private fun classifyText(text: String): ContentKind {\n val compact = text.filterNot { it.isWhitespace() }\n if (compact.length < MIN_USEFUL_TEXT_LENGTH) return ContentKind.TOO_SHORT\n\n val letters = compact.count { it in 'A'..'Z' || it in 'a'..'z' }\n val englishRatio = letters.toFloat() / compact.length.coerceAtLeast(1)\n if (letters >= MIN_ENGLISH_LETTERS && englishRatio >= ENGLISH_RATIO_THRESHOLD) {\n return ContentKind.ENGLISH\n }\n\n val lower = text.lowercase()\n val looksLikeQuestion = text.contains(\"?\") ||\n text.contains(\"\\uFF1F\") ||\n text.contains(\"=\") ||\n QUESTION_KEYWORDS.any { lower.contains(it) } ||\n CHINESE_QUESTION_KEYWORDS.any { text.contains(it) } ||\n OPTION_PATTERN.containsMatchIn(text) ||\n MATH_PATTERN.containsMatchIn(text)\n\n return if (looksLikeQuestion) ContentKind.QUESTION else ContentKind.OTHER\n }\n\n private fun buildStudyPrompt(ocrText: String): String {\n return \"\"\"\n Return one valid JSON object only. Do not wrap it in Markdown.\n Required JSON keys:\n {\n \"content_status\": \"complete | incomplete | uncertain\",\n \"chinese_translation\": \"Chinese translation or a short note if translation is not needed\",\n \"study_analysis\": \"Study explanation in Simplified Chinese with numbered sections\",\n \"suspicious_ocr\": \"OCR positions that may be wrong, or empty string\"\n }\n\n You are a study tutoring assistant. The text below was recognized by OCR from learning material or a question image.\n First judge whether the content is complete and whether it looks like a question.\n If the text is incomplete, tell the user to retake the image or adjust the angle.\n If the content is in English, translate it into Chinese first.\n If it is a question, include these sections in study_analysis:\n 1. Question understanding\n 2. Related knowledge points\n 3. Solving idea\n 4. Detailed steps\n 5. Final answer\n 6. Common mistakes\n 7. How to recognize similar question types\n\n Requirements:\n - Do not only give the answer.\n - Explain for students with weak foundations.\n - If OCR may be wrong, point out suspicious positions.\n - If conditions are insufficient, do not invent missing content.\n - Respond in Simplified Chinese inside JSON string values.\n\n OCR text:\n $ocrText\n \"\"\".trimIndent()\n }\n\n private fun parseDeepSeekResult(responseText: String): StudyResult {\n return try {\n val content = JSONObject(responseText)\n .getJSONArray(\"choices\")\n .getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n .trim()\n\n val resultObject = JSONObject(content)\n val status = resultObject.optString(\"content_status\", \"DeepSeek response updated.\")\n val translation = resultObject.optString(\"chinese_translation\", \"未返回翻译。\")\n val analysis = resultObject.optString(\"study_analysis\", content)\n val suspicious = resultObject.optString(\"suspicious_ocr\", \"\")\n StudyResult(\n translation = translation,\n analysis = buildString {\n append(analysis.ifBlank { content })\n if (suspicious.isNotBlank()) {\n append(\"\\n\\nOCR 提示:\\n\")\n append(suspicious)\n }\n },\n status = \"DeepSeek 分析完成:$status\",\n )\n } catch (error: Exception) {\n StudyResult(\n translation = \"DeepSeek 已返回,但 JSON 解析失败。\",\n analysis = \"无法解析 DeepSeek 响应:${error.localizedMessage ?: \"未知错误\"}\\n\\n$responseText\",\n status = \"DeepSeek 响应解析失败。\",\n )\n }\n }\n\n private fun mergeOcrText(latinText: String, chineseText: String): String {\n val latinClean = cleanOcrText(latinText)\n val chineseClean = cleanOcrText(chineseText)\n if (latinClean.isBlank()) return chineseClean\n if (chineseClean.isBlank()) return latinClean\n if (latinClean == chineseClean) return latinClean\n\n val mergedLines = mutableListOf<String>()\n (chineseClean.lines() + latinClean.lines())\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .forEach { line ->\n if (mergedLines.none { existing -> isSimilarText(existing, line) }) {\n mergedLines.add(line)\n }\n }\n\n return mergedLines.joinToString(\"\\n\")\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun isSimilarText(newText: String, oldText: String): Boolean {\n if (newText.isBlank() || oldText.isBlank()) return false\n if (newText == oldText) return true\n val a = normalizeForSimilarity(newText)\n val b = normalizeForSimilarity(oldText)\n if (a == b) return true\n val shorter = minOf(a.length, b.length)\n val longer = maxOf(a.length, b.length)\n if (longer == 0 || shorter.toFloat() / longer < 0.75f) return false\n val common = a.toSet().intersect(b.toSet()).size\n val total = a.toSet().union(b.toSet()).size.coerceAtLeast(1)\n return common.toFloat() / total >= 0.88f\n }\n\n private fun normalizeForSimilarity(text: String): String {\n return text.lowercase()\n .filterNot { it.isWhitespace() }\n .filterNot { it in \",.;:!?()[]{}\" }\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR 已更新。\")\n }\n }\n\n private fun updateTranslation(text: String) {\n runOnUiThread {\n translationText.text = text\n }\n }\n\n private fun updateAnalysis(text: String) {\n runOnUiThread {\n analysisText.text = text\n }\n }\n\n private fun copyResultsToClipboard() {\n val resultText = buildString {\n append(\"OCR 识别结果:\\n\")\n append(ocrText.text)\n append(\"\\n\\n中文翻译:\\n\")\n append(translationText.text)\n append(\"\\n\\n学习分析:\\n\")\n append(analysisText.text)\n }\n val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager\n clipboard.setPrimaryClip(ClipData.newPlainText(\"学习助手结果\", resultText))\n Toast.makeText(this, \"结果已复制。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun saveDeepSeekApiKey() {\n val apiKey = apiKeyInput.text.toString().trim()\n if (apiKey.isEmpty()) {\n clearDeepSeekApiKey()\n return\n }\n\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .putString(PREFERENCE_DEEPSEEK_API_KEY, apiKey)\n .apply()\n apiKeyInput.setText(apiKey)\n updateStatus(\"DeepSeek API 密钥已保存在本设备。\")\n Toast.makeText(this, \"API 密钥已保存。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun clearDeepSeekApiKey() {\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .remove(PREFERENCE_DEEPSEEK_API_KEY)\n .apply()\n apiKeyInput.text?.clear()\n updateStatus(\"DeepSeek API 密钥已清除。\")\n Toast.makeText(this, \"API 密钥已清除。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun loadDeepSeekApiKey(): String {\n return getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .getString(PREFERENCE_DEEPSEEK_API_KEY, \"\")\n .orEmpty()\n .trim()\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun labeledSection(title: String, textView: TextView, minHeight: Int): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 6, 0, 0)\n addView(sectionLabel(title))\n textView.setBackgroundColor(0xFFFFFFFF.toInt())\n textView.minHeight = dp(minHeight)\n val params = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 2, 0, 0)\n }\n addView(textView, params)\n }\n }\n\n private fun sectionLabel(text: String): TextView {\n return TextView(this).apply {\n this.text = text\n textSize = 13f\n setTextColor(0xFF4B5563.toInt())\n setPadding(2, 0, 0, 0)\n }\n }\n\n private fun sectionText(text: String, textSize: Float = 14f): TextView {\n return TextView(this).apply {\n this.text = text\n this.textSize = textSize\n setTextColor(0xFF111827.toInt())\n setPadding(16, 12, 16, 12)\n setLineSpacing(0f, 1.08f)\n }\n }\n\n private fun buttonRow(left: Button, right: Button): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.HORIZONTAL\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 4, 0, 0)\n }\n val half = LinearLayout.LayoutParams(\n 0,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n 1f,\n ).apply {\n setMargins(3, 0, 3, 0)\n }\n addView(left, half)\n addView(right, half)\n }\n }\n\n private fun dp(value: Int): Int {\n return (value * resources.displayMetrics.density).toInt()\n }\n\n private data class StudyResult(\n val translation: String,\n val analysis: String,\n val status: String,\n )\n\n private enum class ContentKind {\n TOO_SHORT,\n ENGLISH,\n QUESTION,\n OTHER,\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_100L\n const val AI_COOLDOWN_MS = 12_000L\n const val MIN_USEFUL_TEXT_LENGTH = 12\n const val MIN_ENGLISH_LETTERS = 8\n const val ENGLISH_RATIO_THRESHOLD = 0.45f\n const val DEEPSEEK_CHAT_URL = \"https://api.deepseek.com/chat/completions\"\n const val DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val PREFERENCES_NAME = \"study_assist_preferences\"\n const val PREFERENCE_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n val JSON_MEDIA_TYPE = \"application/json; charset=utf-8\".toMediaType()\n val OPTION_PATTERN = Regex(\"\"\"(?im)(^|\\n)\\s*[A-D][\\).: ]\"\"\")\n val MATH_PATTERN = Regex(\"\"\"[0-9]\\s*[+\\-*/=^]\\s*[0-9a-zA-Z(]\"\"\")\n val QUESTION_KEYWORDS = listOf(\n \"solve\",\n \"calculate\",\n \"choose\",\n \"find\",\n \"prove\",\n \"answer\",\n \"question\",\n \"what is\",\n \"which\",\n \"why\",\n )\n val CHINESE_QUESTION_KEYWORDS = listOf(\n \"\\u6C42\",\n \"\\u8BA1\\u7B97\",\n \"\\u9009\\u62E9\",\n \"\\u8BC1\\u660E\",\n \"\\u7B54\\u6848\",\n \"\\u89E3\\u6790\",\n \"\\u4E0B\\u5217\",\n \"\\u9898\",\n )\n const val SYSTEM_PROMPT =\n \"You help students learn from OCR text. You do not help with cheating or rule-breaking.\"\n }\n}\n"}, "320586c17d25624fc1f02aad36c801d3fa73b4cf": {"t": 1785755938.5577748, "n": 30641, "h": "f67933e7e4b8aad3", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.ClipData\nimport android.content.ClipboardManager\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.text.InputType\nimport android.view.Gravity\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.EditText\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.Text\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.io.IOException\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport okhttp3.Call\nimport okhttp3.Callback\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport org.json.JSONArray\nimport org.json.JSONObject\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var apiKeyInput: EditText\n private lateinit var ocrText: TextView\n private lateinit var translationText: TextView\n private lateinit var analysisText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var saveApiKeyButton: Button\n private lateinit var clearApiKeyButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var cancelAiButton: Button\n private lateinit var copyButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val latinTextRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n private val chineseTextRecognizer: TextRecognizer =\n TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build())\n private val httpClient: OkHttpClient = OkHttpClient.Builder()\n .connectTimeout(15, TimeUnit.SECONDS)\n .readTimeout(60, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var currentAiCall: Call? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private var lastAiRequestedText = \"\"\n private var lastAiRequestedAt = 0L\n private var aiRequestVersion = 0\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"摄像头权限已授予。正在启动预览...\")\n startCamera()\n } else {\n updateStatus(\"摄像头权限被拒绝。预览不可用。\")\n Toast.makeText(this, \"需要摄像头权限才能使用。\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"需要摄像头权限才能预览。\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cancelAiRequest(showToast = false)\n cameraProvider?.unbindAll()\n cameraProvider = null\n latinTextRecognizer.close()\n chineseTextRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF3F6FA.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n dp(280),\n )\n }\n\n statusText = sectionText(\"正在准备摄像头...\", textSize = 15f)\n apiKeyInput = EditText(this).apply {\n hint = \"在此粘贴 DeepSeek API 密钥\"\n setText(loadDeepSeekApiKey())\n textSize = 14f\n setSingleLine(true)\n inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD\n setPadding(16, 10, 16, 10)\n }\n ocrText = sectionText(\"OCR 识别文字将显示在此处。\")\n translationText = sectionText(\"中文翻译将显示在此处。\")\n analysisText = sectionText(\"DeepSeek 学习分析将显示在此处。\")\n\n val content = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(16, 10, 16, 18)\n }\n val contentScroll = ScrollView(this).apply {\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 1f,\n )\n addView(content)\n }\n\n val controls = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n gravity = Gravity.CENTER\n setPadding(0, 6, 0, 0)\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n )\n }\n\n toggleRecognitionButton = Button(this).apply {\n text = \"暂停识别\"\n layoutParams = controlLayoutParams()\n }\n\n saveApiKeyButton = Button(this).apply {\n text = \"保存密钥\"\n layoutParams = controlLayoutParams()\n }\n\n clearApiKeyButton = Button(this).apply {\n text = \"清除密钥\"\n layoutParams = controlLayoutParams()\n }\n\n manualScanButton = Button(this).apply {\n text = \"手动扫描\"\n layoutParams = controlLayoutParams()\n }\n\n cancelAiButton = Button(this).apply {\n text = \"取消 AI 请求\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n copyButton = Button(this).apply {\n text = \"复制结果\"\n layoutParams = controlLayoutParams()\n }\n\n clearButton = Button(this).apply {\n text = \"清除结果\"\n layoutParams = controlLayoutParams()\n }\n\n retryPermissionButton = Button(this).apply {\n text = \"重新请求权限\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n controls.addView(toggleRecognitionButton)\n controls.addView(saveApiKeyButton)\n controls.addView(clearApiKeyButton)\n controls.addView(manualScanButton)\n controls.addView(cancelAiButton)\n controls.addView(copyButton)\n controls.addView(clearButton)\n controls.addView(retryPermissionButton)\n\n root.addView(previewView)\n content.addView(statusText)\n content.addView(labeledSection(\"DeepSeek API 密钥\", apiKeyInput, minHeight = 58))\n content.addView(labeledSection(\"OCR 识别结果\", ocrText, minHeight = 130))\n content.addView(labeledSection(\"中文翻译\", translationText, minHeight = 110))\n content.addView(labeledSection(\"学习分析\", analysisText, minHeight = 220))\n content.addView(controls)\n root.addView(contentScroll)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"暂停识别\" else \"开始识别\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR 正在运行(限帧分析)。\"\n } else {\n \"OCR 已暂停。摄像头预览正常。\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"等待下一帧摄像头画面进行扫描...\")\n }\n\n saveApiKeyButton.setOnClickListener {\n saveDeepSeekApiKey()\n }\n\n clearApiKeyButton.setOnClickListener {\n clearDeepSeekApiKey()\n }\n\n cancelAiButton.setOnClickListener {\n cancelAiRequest(showToast = true)\n }\n\n copyButton.setOnClickListener {\n copyResultsToClipboard()\n }\n\n clearButton.setOnClickListener {\n cancelAiRequest(showToast = false)\n lastRecognizedText = \"\"\n lastAiRequestedText = \"\"\n ocrText.text = \"OCR 识别文字将显示在此处。\"\n translationText.text = \"中文翻译将显示在此处。\"\n analysisText.text = \"DeepSeek 学习分析将显示在此处。\"\n updateStatus(\"结果已清除。\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"摄像头预览和 OCR 正在运行。\")\n } catch (error: Exception) {\n updateStatus(\"摄像头启动失败:${error.localizedMessage ?: \"未知错误\"}\")\n Toast.makeText(this, \"启动摄像头失败。\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val inputImage = InputImage.fromMediaImage(\n mediaImage,\n imageProxy.imageInfo.rotationDegrees,\n )\n\n recognizeLatinThenChinese(inputImage, imageProxy)\n }\n\n private fun recognizeLatinThenChinese(inputImage: InputImage, imageProxy: ImageProxy) {\n var latinText = \"\"\n var chineseText = \"\"\n var latinError: Exception? = null\n var chineseError: Exception? = null\n\n latinTextRecognizer.process(inputImage)\n .addOnSuccessListener { result: Text ->\n latinText = result.text\n }\n .addOnFailureListener { error ->\n latinError = error\n }\n .addOnCompleteListener {\n chineseTextRecognizer.process(inputImage)\n .addOnSuccessListener { result: Text ->\n chineseText = result.text\n }\n .addOnFailureListener { error ->\n chineseError = error\n }\n .addOnCompleteListener {\n finishOcrProcessing(\n imageProxy = imageProxy,\n latinText = latinText,\n chineseText = chineseText,\n latinError = latinError,\n chineseError = chineseError,\n )\n }\n }\n }\n\n private fun finishOcrProcessing(\n imageProxy: ImageProxy,\n latinText: String,\n chineseText: String,\n latinError: Exception?,\n chineseError: Exception?,\n ) {\n try {\n val cleanedText = mergeOcrText(latinText, chineseText)\n if (cleanedText.isBlank()) {\n val errorText = listOfNotNull(latinError, chineseError)\n .joinToString(\"; \") { it.localizedMessage ?: \"未知 OCR 错误\" }\n updateOcrResult(\n if (errorText.isBlank()) {\n \"未检测到清晰文字。请调整拍摄角度或距离。\"\n } else {\n \"OCR 识别失败或未找到文字:$errorText\"\n },\n )\n return\n }\n\n if (isSimilarText(cleanedText, lastRecognizedText)) {\n updateStatus(\"OCR 文字与上次结果相似,跳过重复。\")\n return\n }\n\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n handleRecognizedText(cleanedText)\n } finally {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun handleRecognizedText(text: String) {\n when (classifyText(text)) {\n ContentKind.TOO_SHORT -> {\n updateTranslation(\"文字过短或不完整。请调整角度后重新扫描。\")\n updateAnalysis(\"等待更清晰的 OCR 结果后再调用 DeepSeek。\")\n }\n ContentKind.ENGLISH -> {\n updateTranslation(\"检测到英文内容,正在请求中文翻译...\")\n maybeRequestAi(text)\n }\n ContentKind.QUESTION -> {\n updateTranslation(\"检测到题目类内容,正在请求学习分析...\")\n maybeRequestAi(text)\n }\n ContentKind.OTHER -> {\n updateTranslation(\"已检测到文字,但不像英文或完整题目。\")\n updateAnalysis(\"保持页面稳定,或在完整内容可见时使用「手动扫描」。\")\n }\n }\n }\n\n private fun maybeRequestAi(text: String) {\n val apiKey = loadDeepSeekApiKey()\n if (apiKey.isEmpty()) {\n updateAnalysis(\n \"缺少 DeepSeek API 密钥。请在密钥输入框中粘贴密钥,点击「保存密钥」,然后重新扫描。\",\n )\n updateStatus(\"缺少 DeepSeek API 密钥。\")\n return\n }\n\n val now = System.currentTimeMillis()\n if (isSimilarText(text, lastAiRequestedText) && now - lastAiRequestedAt < AI_COOLDOWN_MS) {\n updateStatus(\"DeepSeek 请求因重复冷却被跳过。\")\n return\n }\n\n lastAiRequestedText = text\n lastAiRequestedAt = now\n aiRequestVersion += 1\n val requestVersion = aiRequestVersion\n\n currentAiCall?.cancel()\n cancelAiButton.isEnabled = true\n updateAnalysis(\"正在加载 DeepSeek 学习分析...\")\n updateStatus(\"正在调用 DeepSeek...\")\n\n val requestBody = JSONObject()\n .put(\"model\", DEEPSEEK_MODEL)\n .put(\n \"messages\",\n JSONArray()\n .put(JSONObject().put(\"role\", \"system\").put(\"content\", SYSTEM_PROMPT))\n .put(JSONObject().put(\"role\", \"user\").put(\"content\", buildStudyPrompt(text))),\n )\n .put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n .put(\"thinking\", JSONObject().put(\"type\", \"disabled\"))\n .put(\"temperature\", 0.2)\n .put(\"max_tokens\", 1800)\n .put(\"stream\", false)\n .toString()\n .toRequestBody(JSON_MEDIA_TYPE)\n\n val request = Request.Builder()\n .url(DEEPSEEK_CHAT_URL)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(requestBody)\n .build()\n\n val call = httpClient.newCall(request)\n currentAiCall = call\n call.enqueue(object : Callback {\n override fun onFailure(call: Call, e: IOException) {\n if (call.isCanceled()) return\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n updateStatus(\"网络请求失败。\")\n analysisText.text = \"DeepSeek 请求失败:${e.localizedMessage ?: \"网络错误\"}\"\n }\n }\n\n override fun onResponse(call: Call, response: Response) {\n val responseText = response.use { it.body?.string().orEmpty() }\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n if (!response.isSuccessful) {\n updateStatus(\"DeepSeek 返回 HTTP ${response.code}。\")\n analysisText.text = \"DeepSeek 错误 ${response.code}:$responseText\"\n return@runOnUiThread\n }\n\n val result = parseDeepSeekResult(responseText)\n translationText.text = result.translation\n analysisText.text = result.analysis\n updateStatus(result.status)\n }\n }\n })\n }\n\n private fun cancelAiRequest(showToast: Boolean) {\n aiRequestVersion += 1\n currentAiCall?.cancel()\n currentAiCall = null\n cancelAiButton.isEnabled = false\n updateStatus(\"AI 请求已取消。\")\n if (showToast) {\n Toast.makeText(this, \"AI 请求已取消。\", Toast.LENGTH_SHORT).show()\n }\n }\n\n private fun classifyText(text: String): ContentKind {\n val compact = text.filterNot { it.isWhitespace() }\n if (compact.length < MIN_USEFUL_TEXT_LENGTH) return ContentKind.TOO_SHORT\n\n val letters = compact.count { it in 'A'..'Z' || it in 'a'..'z' }\n val englishRatio = letters.toFloat() / compact.length.coerceAtLeast(1)\n if (letters >= MIN_ENGLISH_LETTERS && englishRatio >= ENGLISH_RATIO_THRESHOLD) {\n return ContentKind.ENGLISH\n }\n\n val lower = text.lowercase()\n val looksLikeQuestion = text.contains(\"?\") ||\n text.contains(\"\\uFF1F\") ||\n text.contains(\"=\") ||\n QUESTION_KEYWORDS.any { lower.contains(it) } ||\n CHINESE_QUESTION_KEYWORDS.any { text.contains(it) } ||\n OPTION_PATTERN.containsMatchIn(text) ||\n MATH_PATTERN.containsMatchIn(text)\n\n return if (looksLikeQuestion) ContentKind.QUESTION else ContentKind.OTHER\n }\n\n private fun buildStudyPrompt(ocrText: String): String {\n return \"\"\"\n Return one valid JSON object only. Do not wrap it in Markdown.\n Required JSON keys:\n {\n \"content_status\": \"complete | incomplete | uncertain\",\n \"chinese_translation\": \"Chinese translation or a short note if translation is not needed\",\n \"study_analysis\": \"Study explanation in Simplified Chinese with numbered sections\",\n \"suspicious_ocr\": \"OCR positions that may be wrong, or empty string\"\n }\n\n You are a study tutoring assistant. The text below was recognized by OCR from learning material or a question image.\n First judge whether the content is complete and whether it looks like a question.\n If the text is incomplete, tell the user to retake the image or adjust the angle.\n If the content is in English, translate it into Chinese first.\n If it is a question, include these sections in study_analysis:\n 1. Question understanding\n 2. Related knowledge points\n 3. Solving idea\n 4. Detailed steps\n 5. Final answer\n 6. Common mistakes\n 7. How to recognize similar question types\n\n Requirements:\n - Do not only give the answer.\n - Explain for students with weak foundations.\n - If OCR may be wrong, point out suspicious positions.\n - If conditions are insufficient, do not invent missing content.\n - Respond in Simplified Chinese inside JSON string values.\n\n OCR text:\n $ocrText\n \"\"\".trimIndent()\n }\n\n private fun parseDeepSeekResult(responseText: String): StudyResult {\n return try {\n val content = JSONObject(responseText)\n .getJSONArray(\"choices\")\n .getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n .trim()\n\n val resultObject = JSONObject(content)\n val status = resultObject.optString(\"content_status\", \"DeepSeek response updated.\")\n val translation = resultObject.optString(\"chinese_translation\", \"未返回翻译。\")\n val analysis = resultObject.optString(\"study_analysis\", content)\n val suspicious = resultObject.optString(\"suspicious_ocr\", \"\")\n StudyResult(\n translation = translation,\n analysis = buildString {\n append(analysis.ifBlank { content })\n if (suspicious.isNotBlank()) {\n append(\"\\n\\nOCR 提示:\\n\")\n append(suspicious)\n }\n },\n status = \"DeepSeek 分析完成:$status\",\n )\n } catch (error: Exception) {\n StudyResult(\n translation = \"DeepSeek 已返回,但 JSON 解析失败。\",\n analysis = \"无法解析 DeepSeek 响应:${error.localizedMessage ?: \"未知错误\"}\\n\\n$responseText\",\n status = \"DeepSeek 响应解析失败。\",\n )\n }\n }\n\n private fun mergeOcrText(latinText: String, chineseText: String): String {\n val latinClean = cleanOcrText(latinText)\n val chineseClean = cleanOcrText(chineseText)\n if (latinClean.isBlank()) return chineseClean\n if (chineseClean.isBlank()) return latinClean\n if (latinClean == chineseClean) return latinClean\n\n val mergedLines = mutableListOf<String>()\n (chineseClean.lines() + latinClean.lines())\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .forEach { line ->\n if (mergedLines.none { existing -> isSimilarText(existing, line) }) {\n mergedLines.add(line)\n }\n }\n\n return mergedLines.joinToString(\"\\n\")\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun isSimilarText(newText: String, oldText: String): Boolean {\n if (newText.isBlank() || oldText.isBlank()) return false\n if (newText == oldText) return true\n val a = normalizeForSimilarity(newText)\n val b = normalizeForSimilarity(oldText)\n if (a == b) return true\n val shorter = minOf(a.length, b.length)\n val longer = maxOf(a.length, b.length)\n if (longer == 0 || shorter.toFloat() / longer < 0.75f) return false\n val common = a.toSet().intersect(b.toSet()).size\n val total = a.toSet().union(b.toSet()).size.coerceAtLeast(1)\n return common.toFloat() / total >= 0.88f\n }\n\n private fun normalizeForSimilarity(text: String): String {\n return text.lowercase()\n .filterNot { it.isWhitespace() }\n .filterNot { it in \",.;:!?()[]{}\" }\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR 已更新。\")\n }\n }\n\n private fun updateTranslation(text: String) {\n runOnUiThread {\n translationText.text = text\n }\n }\n\n private fun updateAnalysis(text: String) {\n runOnUiThread {\n analysisText.text = text\n }\n }\n\n private fun copyResultsToClipboard() {\n val resultText = buildString {\n append(\"OCR 识别结果:\\n\")\n append(ocrText.text)\n append(\"\\n\\n中文翻译:\\n\")\n append(translationText.text)\n append(\"\\n\\n学习分析:\\n\")\n append(analysisText.text)\n }\n val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager\n clipboard.setPrimaryClip(ClipData.newPlainText(\"学习助手结果\", resultText))\n Toast.makeText(this, \"结果已复制。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun saveDeepSeekApiKey() {\n val apiKey = apiKeyInput.text.toString().trim()\n if (apiKey.isEmpty()) {\n clearDeepSeekApiKey()\n return\n }\n\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .putString(PREFERENCE_DEEPSEEK_API_KEY, apiKey)\n .apply()\n apiKeyInput.setText(apiKey)\n updateStatus(\"DeepSeek API 密钥已保存在本设备。\")\n Toast.makeText(this, \"API 密钥已保存。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun clearDeepSeekApiKey() {\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .remove(PREFERENCE_DEEPSEEK_API_KEY)\n .apply()\n apiKeyInput.text?.clear()\n updateStatus(\"DeepSeek API 密钥已清除。\")\n Toast.makeText(this, \"API 密钥已清除。\", Toast.LENGTH_SHORT).show()\n }\n\n private fun loadDeepSeekApiKey(): String {\n return getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .getString(PREFERENCE_DEEPSEEK_API_KEY, \"\")\n .orEmpty()\n .trim()\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun labeledSection(title: String, textView: TextView, minHeight: Int): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 8, 0, 0)\n addView(sectionLabel(title))\n addView(\n ScrollView(this@MainActivity).apply {\n setBackgroundColor(0xFFFFFFFF.toInt())\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n dp(minHeight),\n ).apply {\n setMargins(0, 4, 0, 0)\n }\n addView(textView)\n },\n )\n }\n }\n\n private fun sectionLabel(text: String): TextView {\n return TextView(this).apply {\n this.text = text\n textSize = 13f\n setTextColor(0xFF4B5563.toInt())\n setPadding(2, 0, 0, 0)\n }\n }\n\n private fun sectionText(text: String, textSize: Float = 14f): TextView {\n return TextView(this).apply {\n this.text = text\n this.textSize = textSize\n setTextColor(0xFF111827.toInt())\n setPadding(16, 12, 16, 12)\n setLineSpacing(0f, 1.08f)\n }\n }\n\n private fun controlLayoutParams(): LinearLayout.LayoutParams {\n return LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 8, 0, 0)\n }\n }\n\n private fun dp(value: Int): Int {\n return (value * resources.displayMetrics.density).toInt()\n }\n\n private data class StudyResult(\n val translation: String,\n val analysis: String,\n val status: String,\n )\n\n private enum class ContentKind {\n TOO_SHORT,\n ENGLISH,\n QUESTION,\n OTHER,\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_500L\n const val AI_COOLDOWN_MS = 20_000L\n const val MIN_USEFUL_TEXT_LENGTH = 12\n const val MIN_ENGLISH_LETTERS = 8\n const val ENGLISH_RATIO_THRESHOLD = 0.45f\n const val DEEPSEEK_CHAT_URL = \"https://api.deepseek.com/chat/completions\"\n const val DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val PREFERENCES_NAME = \"study_assist_preferences\"\n const val PREFERENCE_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n val JSON_MEDIA_TYPE = \"application/json; charset=utf-8\".toMediaType()\n val OPTION_PATTERN = Regex(\"\"\"(?im)(^|\\n)\\s*[A-D][\\).: ]\"\"\")\n val MATH_PATTERN = Regex(\"\"\"[0-9]\\s*[+\\-*/=^]\\s*[0-9a-zA-Z(]\"\"\")\n val QUESTION_KEYWORDS = listOf(\n \"solve\",\n \"calculate\",\n \"choose\",\n \"find\",\n \"prove\",\n \"answer\",\n \"question\",\n \"what is\",\n \"which\",\n \"why\",\n )\n val CHINESE_QUESTION_KEYWORDS = listOf(\n \"\\u6C42\",\n \"\\u8BA1\\u7B97\",\n \"\\u9009\\u62E9\",\n \"\\u8BC1\\u660E\",\n \"\\u7B54\\u6848\",\n \"\\u89E3\\u6790\",\n \"\\u4E0B\\u5217\",\n \"\\u9898\",\n )\n const val SYSTEM_PROMPT =\n \"You help students learn from OCR text. You do not help with cheating or rule-breaking.\"\n }\n}\n"}, "a1a339cf163f9391985983d76d38f6b5ef71e208": {"t": 1785755940.0813065, "n": 31831, "h": "95e6c415c581b96b", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.ClipData\nimport android.content.ClipboardManager\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.text.InputType\nimport android.view.Gravity\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.EditText\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.Text\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.io.IOException\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport okhttp3.Call\nimport okhttp3.Callback\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport org.json.JSONArray\nimport org.json.JSONObject\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var apiKeyInput: EditText\n private lateinit var ocrText: TextView\n private lateinit var translationText: TextView\n private lateinit var analysisText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var saveApiKeyButton: Button\n private lateinit var clearApiKeyButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var cancelAiButton: Button\n private lateinit var copyButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val latinTextRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n private val chineseTextRecognizer: TextRecognizer =\n TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build())\n private val httpClient: OkHttpClient = OkHttpClient.Builder()\n .connectTimeout(15, TimeUnit.SECONDS)\n .readTimeout(60, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var currentAiCall: Call? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private var lastAiRequestedText = \"\"\n private var lastAiRequestedAt = 0L\n private var aiRequestVersion = 0\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"Camera permission granted. Starting preview...\")\n startCamera()\n } else {\n updateStatus(\"Camera permission denied. Preview is unavailable.\")\n Toast.makeText(this, \"Camera permission is required.\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"Camera permission is required for live preview.\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cancelAiRequest(showToast = false)\n cameraProvider?.unbindAll()\n cameraProvider = null\n latinTextRecognizer.close()\n chineseTextRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF3F6FA.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n dp(280),\n )\n }\n\n statusText = sectionText(\"Preparing camera...\", textSize = 15f)\n apiKeyInput = EditText(this).apply {\n hint = \"Paste DeepSeek API key here\"\n setText(loadDeepSeekApiKey())\n textSize = 14f\n setSingleLine(true)\n inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD\n setPadding(16, 10, 16, 10)\n }\n ocrText = sectionText(\"OCR text will appear here.\")\n translationText = sectionText(\"Chinese translation will appear here.\")\n analysisText = sectionText(\"DeepSeek study analysis will appear here.\")\n\n val content = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(16, 10, 16, 18)\n }\n val contentScroll = ScrollView(this).apply {\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 1f,\n )\n addView(content)\n }\n\n val controls = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n gravity = Gravity.CENTER\n setPadding(0, 6, 0, 0)\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n )\n }\n\n toggleRecognitionButton = Button(this).apply {\n text = \"Pause OCR\"\n layoutParams = controlLayoutParams()\n }\n\n saveApiKeyButton = Button(this).apply {\n text = \"Save API key\"\n layoutParams = controlLayoutParams()\n }\n\n clearApiKeyButton = Button(this).apply {\n text = \"Clear API key\"\n layoutParams = controlLayoutParams()\n }\n\n manualScanButton = Button(this).apply {\n text = \"Scan once\"\n layoutParams = controlLayoutParams()\n }\n\n cancelAiButton = Button(this).apply {\n text = \"Cancel AI request\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n copyButton = Button(this).apply {\n text = \"Copy results\"\n layoutParams = controlLayoutParams()\n }\n\n clearButton = Button(this).apply {\n text = \"Clear results\"\n layoutParams = controlLayoutParams()\n }\n\n retryPermissionButton = Button(this).apply {\n text = \"Request permission again\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n controls.addView(toggleRecognitionButton)\n controls.addView(saveApiKeyButton)\n controls.addView(clearApiKeyButton)\n controls.addView(manualScanButton)\n controls.addView(cancelAiButton)\n controls.addView(copyButton)\n controls.addView(clearButton)\n controls.addView(retryPermissionButton)\n\n root.addView(previewView)\n content.addView(statusText)\n content.addView(labeledSection(\"DeepSeek API Key\", apiKeyInput, minHeight = 58))\n content.addView(labeledSection(\"OCR Text\", ocrText, minHeight = 130))\n content.addView(labeledSection(\"Translation\", translationText, minHeight = 110))\n content.addView(labeledSection(\"Study Analysis\", analysisText, minHeight = 220))\n content.addView(controls)\n root.addView(contentScroll)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"Pause OCR\" else \"Start OCR\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR is running with throttled frame analysis.\"\n } else {\n \"OCR is paused. Camera preview remains active.\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"Waiting for the next camera frame to scan...\")\n }\n\n saveApiKeyButton.setOnClickListener {\n saveDeepSeekApiKey()\n }\n\n clearApiKeyButton.setOnClickListener {\n clearDeepSeekApiKey()\n }\n\n cancelAiButton.setOnClickListener {\n cancelAiRequest(showToast = true)\n }\n\n copyButton.setOnClickListener {\n copyResultsToClipboard()\n }\n\n clearButton.setOnClickListener {\n cancelAiRequest(showToast = false)\n lastRecognizedText = \"\"\n lastAiRequestedText = \"\"\n ocrText.text = \"OCR text will appear here.\"\n translationText.text = \"Chinese translation will appear here.\"\n analysisText.text = \"DeepSeek study analysis will appear here.\"\n updateStatus(\"Results cleared.\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"Camera preview and OCR are running.\")\n } catch (error: Exception) {\n updateStatus(\"Camera failed: ${error.localizedMessage ?: \"unknown error\"}\")\n Toast.makeText(this, \"Failed to start camera.\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val inputImage = InputImage.fromMediaImage(\n mediaImage,\n imageProxy.imageInfo.rotationDegrees,\n )\n\n recognizeLatinThenChinese(inputImage, imageProxy)\n }\n\n private fun recognizeLatinThenChinese(inputImage: InputImage, imageProxy: ImageProxy) {\n var latinText = \"\"\n var chineseText = \"\"\n var latinError: Exception? = null\n var chineseError: Exception? = null\n\n latinTextRecognizer.process(inputImage)\n .addOnSuccessListener { result: Text ->\n latinText = result.text\n }\n .addOnFailureListener { error ->\n latinError = error\n }\n .addOnCompleteListener {\n chineseTextRecognizer.process(inputImage)\n .addOnSuccessListener { result: Text ->\n chineseText = result.text\n }\n .addOnFailureListener { error ->\n chineseError = error\n }\n .addOnCompleteListener {\n finishOcrProcessing(\n imageProxy = imageProxy,\n latinText = latinText,\n chineseText = chineseText,\n latinError = latinError,\n chineseError = chineseError,\n )\n }\n }\n }\n\n private fun finishOcrProcessing(\n imageProxy: ImageProxy,\n latinText: String,\n chineseText: String,\n latinError: Exception?,\n chineseError: Exception?,\n ) {\n try {\n val cleanedText = mergeOcrText(latinText, chineseText)\n if (cleanedText.isBlank()) {\n val errorText = listOfNotNull(latinError, chineseError)\n .joinToString(\"; \") { it.localizedMessage ?: \"unknown OCR error\" }\n updateOcrResult(\n if (errorText.isBlank()) {\n \"No clear text detected. Adjust angle or distance.\"\n } else {\n \"OCR failed or found no text: $errorText\"\n },\n )\n return\n }\n\n if (isSimilarText(cleanedText, lastRecognizedText)) {\n updateStatus(\"OCR text is similar to the previous result. Skipping duplicate.\")\n return\n }\n\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n handleRecognizedText(cleanedText)\n } finally {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun handleRecognizedText(text: String) {\n when (classifyText(text)) {\n ContentKind.TOO_SHORT -> {\n updateTranslation(\"Text is too short or incomplete. Adjust angle and scan again.\")\n updateAnalysis(\"Waiting for a clearer OCR result before calling DeepSeek.\")\n }\n ContentKind.ENGLISH -> {\n updateTranslation(\"English text detected. Requesting Chinese translation...\")\n maybeRequestAi(text)\n }\n ContentKind.QUESTION -> {\n updateTranslation(\"Question-like content detected. Requesting study analysis...\")\n maybeRequestAi(text)\n }\n ContentKind.OTHER -> {\n updateTranslation(\"Text detected, but it does not look like English text or a complete question yet.\")\n updateAnalysis(\"Keep the page steady or use Scan once when the full content is visible.\")\n }\n }\n }\n\n private fun maybeRequestAi(text: String) {\n val apiKey = loadDeepSeekApiKey()\n if (apiKey.isEmpty()) {\n updateAnalysis(\n \"DeepSeek API key is missing. Paste your key in the API key field, tap Save API key, then scan again.\",\n )\n updateStatus(\"DeepSeek API key missing.\")\n return\n }\n\n val now = System.currentTimeMillis()\n if (isSimilarText(text, lastAiRequestedText) && now - lastAiRequestedAt < AI_COOLDOWN_MS) {\n updateStatus(\"DeepSeek request skipped by duplicate cooldown.\")\n return\n }\n\n lastAiRequestedText = text\n lastAiRequestedAt = now\n aiRequestVersion += 1\n val requestVersion = aiRequestVersion\n\n currentAiCall?.cancel()\n cancelAiButton.isEnabled = true\n updateAnalysis(\"Loading DeepSeek study response...\")\n updateStatus(\"Calling DeepSeek...\")\n\n val requestBody = JSONObject()\n .put(\"model\", DEEPSEEK_MODEL)\n .put(\n \"messages\",\n JSONArray()\n .put(JSONObject().put(\"role\", \"system\").put(\"content\", SYSTEM_PROMPT))\n .put(JSONObject().put(\"role\", \"user\").put(\"content\", buildStudyPrompt(text))),\n )\n .put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n .put(\"thinking\", JSONObject().put(\"type\", \"disabled\"))\n .put(\"temperature\", 0.2)\n .put(\"max_tokens\", 1800)\n .put(\"stream\", false)\n .toString()\n .toRequestBody(JSON_MEDIA_TYPE)\n\n val request = Request.Builder()\n .url(DEEPSEEK_CHAT_URL)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(requestBody)\n .build()\n\n val call = httpClient.newCall(request)\n currentAiCall = call\n call.enqueue(object : Callback {\n override fun onFailure(call: Call, e: IOException) {\n if (call.isCanceled()) return\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n updateStatus(\"Network request failed.\")\n analysisText.text = \"DeepSeek request failed: ${e.localizedMessage ?: \"network error\"}\"\n }\n }\n\n override fun onResponse(call: Call, response: Response) {\n val responseText = response.use { it.body?.string().orEmpty() }\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n if (!response.isSuccessful) {\n updateStatus(\"DeepSeek returned HTTP ${response.code}.\")\n analysisText.text = \"DeepSeek error ${response.code}: $responseText\"\n return@runOnUiThread\n }\n\n val result = parseDeepSeekResult(responseText)\n translationText.text = result.translation\n analysisText.text = result.analysis\n updateStatus(result.status)\n }\n }\n })\n }\n\n private fun cancelAiRequest(showToast: Boolean) {\n aiRequestVersion += 1\n currentAiCall?.cancel()\n currentAiCall = null\n cancelAiButton.isEnabled = false\n updateStatus(\"AI request canceled.\")\n if (showToast) {\n Toast.makeText(this, \"AI request canceled.\", Toast.LENGTH_SHORT).show()\n }\n }\n\n private fun classifyText(text: String): ContentKind {\n val compact = text.filterNot { it.isWhitespace() }\n if (compact.length < MIN_USEFUL_TEXT_LENGTH) return ContentKind.TOO_SHORT\n\n val letters = compact.count { it in 'A'..'Z' || it in 'a'..'z' }\n val englishRatio = letters.toFloat() / compact.length.coerceAtLeast(1)\n if (letters >= MIN_ENGLISH_LETTERS && englishRatio >= ENGLISH_RATIO_THRESHOLD) {\n return ContentKind.ENGLISH\n }\n\n val lower = text.lowercase()\n val looksLikeQuestion = text.contains(\"?\") ||\n text.contains(\"\\uFF1F\") ||\n text.contains(\"=\") ||\n QUESTION_KEYWORDS.any { lower.contains(it) } ||\n CHINESE_QUESTION_KEYWORDS.any { text.contains(it) } ||\n OPTION_PATTERN.containsMatchIn(text) ||\n MATH_PATTERN.containsMatchIn(text)\n\n return if (looksLikeQuestion) ContentKind.QUESTION else ContentKind.OTHER\n }\n\n private fun buildStudyPrompt(ocrText: String): String {\n return \"\"\"\n Return one valid JSON object only. Do not wrap it in Markdown.\n Required JSON keys:\n {\n \"content_status\": \"complete | incomplete | uncertain\",\n \"chinese_translation\": \"Chinese translation or a short note if translation is not needed\",\n \"study_analysis\": \"Study explanation in Simplified Chinese with numbered sections\",\n \"suspicious_ocr\": \"OCR positions that may be wrong, or empty string\"\n }\n\n You are a study tutoring assistant. The text below was recognized by OCR from learning material or a question image.\n First judge whether the content is complete and whether it looks like a question.\n If the text is incomplete, tell the user to retake the image or adjust the angle.\n If the content is in English, translate it into Chinese first.\n If it is a question, include these sections in study_analysis:\n 1. Question understanding\n 2. Related knowledge points\n 3. Solving idea\n 4. Detailed steps\n 5. Final answer\n 6. Common mistakes\n 7. How to recognize similar question types\n\n Requirements:\n - Do not only give the answer.\n - Explain for students with weak foundations.\n - If OCR may be wrong, point out suspicious positions.\n - If conditions are insufficient, do not invent missing content.\n - Respond in Simplified Chinese inside JSON string values.\n\n OCR text:\n $ocrText\n \"\"\".trimIndent()\n }\n\n private fun parseDeepSeekResult(responseText: String): StudyResult {\n return try {\n val content = JSONObject(responseText)\n .getJSONArray(\"choices\")\n .getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n .trim()\n\n val resultObject = JSONObject(content)\n val status = resultObject.optString(\"content_status\", \"DeepSeek response updated.\")\n val translation = resultObject.optString(\"chinese_translation\", \"No translation returned.\")\n val analysis = resultObject.optString(\"study_analysis\", content)\n val suspicious = resultObject.optString(\"suspicious_ocr\", \"\")\n StudyResult(\n translation = translation,\n analysis = buildString {\n append(analysis.ifBlank { content })\n if (suspicious.isNotBlank()) {\n append(\"\\n\\nOCR notes:\\n\")\n append(suspicious)\n }\n },\n status = \"DeepSeek response updated: $status\",\n )\n } catch (error: Exception) {\n StudyResult(\n translation = \"DeepSeek response received, but JSON parsing failed.\",\n analysis = \"Failed to parse DeepSeek response: ${error.localizedMessage ?: \"unknown error\"}\\n\\n$responseText\",\n status = \"DeepSeek response parse failed.\",\n )\n }\n }\n\n private fun mergeOcrText(latinText: String, chineseText: String): String {\n val latinClean = cleanOcrText(latinText)\n val chineseClean = cleanOcrText(chineseText)\n if (latinClean.isBlank()) return chineseClean\n if (chineseClean.isBlank()) return latinClean\n if (latinClean == chineseClean) return latinClean\n\n val mergedLines = mutableListOf<String>()\n (chineseClean.lines() + latinClean.lines())\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .forEach { line ->\n if (mergedLines.none { existing -> isSimilarText(existing, line) }) {\n mergedLines.add(line)\n }\n }\n\n return mergedLines.joinToString(\"\\n\")\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun isSimilarText(newText: String, oldText: String): Boolean {\n if (newText.isBlank() || oldText.isBlank()) return false\n if (newText == oldText) return true\n val a = normalizeForSimilarity(newText)\n val b = normalizeForSimilarity(oldText)\n if (a == b) return true\n val shorter = minOf(a.length, b.length)\n val longer = maxOf(a.length, b.length)\n if (longer == 0 || shorter.toFloat() / longer < 0.75f) return false\n val common = a.toSet().intersect(b.toSet()).size\n val total = a.toSet().union(b.toSet()).size.coerceAtLeast(1)\n return common.toFloat() / total >= 0.88f\n }\n\n private fun normalizeForSimilarity(text: String): String {\n return text.lowercase()\n .filterNot { it.isWhitespace() }\n .filterNot { it in \",.;:!?()[]{}\" }\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR updated.\")\n }\n }\n\n private fun updateTranslation(text: String) {\n runOnUiThread {\n translationText.text = text\n }\n }\n\n private fun updateAnalysis(text: String) {\n runOnUiThread {\n analysisText.text = text\n }\n }\n\n private fun copyResultsToClipboard() {\n val resultText = buildString {\n append(\"OCR Text:\\n\")\n append(ocrText.text)\n append(\"\\n\\nTranslation:\\n\")\n append(translationText.text)\n append(\"\\n\\nStudy Analysis:\\n\")\n append(analysisText.text)\n }\n val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager\n clipboard.setPrimaryClip(ClipData.newPlainText(\"Study Assist Result\", resultText))\n Toast.makeText(this, \"Results copied.\", Toast.LENGTH_SHORT).show()\n }\n\n private fun saveDeepSeekApiKey() {\n val apiKey = apiKeyInput.text.toString().trim()\n if (apiKey.isEmpty()) {\n clearDeepSeekApiKey()\n return\n }\n\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .putString(PREFERENCE_DEEPSEEK_API_KEY, apiKey)\n .apply()\n apiKeyInput.setText(apiKey)\n updateStatus(\"DeepSeek API key saved on this device.\")\n Toast.makeText(this, \"API key saved.\", Toast.LENGTH_SHORT).show()\n }\n\n private fun clearDeepSeekApiKey() {\n getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .edit()\n .remove(PREFERENCE_DEEPSEEK_API_KEY)\n .apply()\n apiKeyInput.text?.clear()\n updateStatus(\"DeepSeek API key cleared.\")\n Toast.makeText(this, \"API key cleared.\", Toast.LENGTH_SHORT).show()\n }\n\n private fun loadDeepSeekApiKey(): String {\n return getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)\n .getString(PREFERENCE_DEEPSEEK_API_KEY, \"\")\n .orEmpty()\n .trim()\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun labeledSection(title: String, textView: TextView, minHeight: Int): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 8, 0, 0)\n addView(sectionLabel(title))\n addView(\n ScrollView(this@MainActivity).apply {\n setBackgroundColor(0xFFFFFFFF.toInt())\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n dp(minHeight),\n ).apply {\n setMargins(0, 4, 0, 0)\n }\n addView(textView)\n },\n )\n }\n }\n\n private fun sectionLabel(text: String): TextView {\n return TextView(this).apply {\n this.text = text\n textSize = 13f\n setTextColor(0xFF4B5563.toInt())\n setPadding(2, 0, 0, 0)\n }\n }\n\n private fun sectionText(text: String, textSize: Float = 14f): TextView {\n return TextView(this).apply {\n this.text = text\n this.textSize = textSize\n setTextColor(0xFF111827.toInt())\n setPadding(16, 12, 16, 12)\n setLineSpacing(0f, 1.08f)\n }\n }\n\n private fun controlLayoutParams(): LinearLayout.LayoutParams {\n return LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 8, 0, 0)\n }\n }\n\n private fun dp(value: Int): Int {\n return (value * resources.displayMetrics.density).toInt()\n }\n\n private data class StudyResult(\n val translation: String,\n val analysis: String,\n val status: String,\n )\n\n private enum class ContentKind {\n TOO_SHORT,\n ENGLISH,\n QUESTION,\n OTHER,\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_500L\n const val AI_COOLDOWN_MS = 20_000L\n const val MIN_USEFUL_TEXT_LENGTH = 12\n const val MIN_ENGLISH_LETTERS = 8\n const val ENGLISH_RATIO_THRESHOLD = 0.45f\n const val DEEPSEEK_CHAT_URL = \"https://api.deepseek.com/chat/completions\"\n const val DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n const val PREFERENCES_NAME = \"study_assist_preferences\"\n const val PREFERENCE_DEEPSEEK_API_KEY = \"deepseek_api_key\"\n val JSON_MEDIA_TYPE = \"application/json; charset=utf-8\".toMediaType()\n val OPTION_PATTERN = Regex(\"\"\"(?im)(^|\\n)\\s*[A-D][\\).: ]\"\"\")\n val MATH_PATTERN = Regex(\"\"\"[0-9]\\s*[+\\-*/=^]\\s*[0-9a-zA-Z(]\"\"\")\n val QUESTION_KEYWORDS = listOf(\n \"solve\",\n \"calculate\",\n \"choose\",\n \"find\",\n \"prove\",\n \"answer\",\n \"question\",\n \"what is\",\n \"which\",\n \"why\",\n )\n val CHINESE_QUESTION_KEYWORDS = listOf(\n \"\\u6C42\",\n \"\\u8BA1\\u7B97\",\n \"\\u9009\\u62E9\",\n \"\\u8BC1\\u660E\",\n \"\\u7B54\\u6848\",\n \"\\u89E3\\u6790\",\n \"\\u4E0B\\u5217\",\n \"\\u9898\",\n )\n const val SYSTEM_PROMPT =\n \"You help students learn from OCR text. You do not help with cheating or rule-breaking.\"\n }\n}\n"}, "c4874ca35a100b53bcaf6096f615f16ec87ce1bf": {"t": 1785755941.478788, "n": 29461, "h": "983e0e50a61305f3", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.ClipData\nimport android.content.ClipboardManager\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.view.Gravity\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.Text\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.io.IOException\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport okhttp3.Call\nimport okhttp3.Callback\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport org.json.JSONArray\nimport org.json.JSONObject\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var ocrText: TextView\n private lateinit var translationText: TextView\n private lateinit var analysisText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var cancelAiButton: Button\n private lateinit var copyButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val latinTextRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n private val chineseTextRecognizer: TextRecognizer =\n TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build())\n private val httpClient: OkHttpClient = OkHttpClient.Builder()\n .connectTimeout(15, TimeUnit.SECONDS)\n .readTimeout(60, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var currentAiCall: Call? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private var lastAiRequestedText = \"\"\n private var lastAiRequestedAt = 0L\n private var aiRequestVersion = 0\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"Camera permission granted. Starting preview...\")\n startCamera()\n } else {\n updateStatus(\"Camera permission denied. Preview is unavailable.\")\n Toast.makeText(this, \"Camera permission is required.\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"Camera permission is required for live preview.\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cancelAiRequest(showToast = false)\n cameraProvider?.unbindAll()\n cameraProvider = null\n latinTextRecognizer.close()\n chineseTextRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF3F6FA.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n dp(280),\n )\n }\n\n statusText = sectionText(\"Preparing camera...\", textSize = 15f)\n ocrText = sectionText(\"OCR text will appear here.\")\n translationText = sectionText(\"Chinese translation will appear here.\")\n analysisText = sectionText(\"DeepSeek study analysis will appear here.\")\n\n val content = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(16, 10, 16, 18)\n }\n val contentScroll = ScrollView(this).apply {\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 1f,\n )\n addView(content)\n }\n\n val controls = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n gravity = Gravity.CENTER\n setPadding(0, 6, 0, 0)\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n )\n }\n\n toggleRecognitionButton = Button(this).apply {\n text = \"Pause OCR\"\n layoutParams = controlLayoutParams()\n }\n\n manualScanButton = Button(this).apply {\n text = \"Scan once\"\n layoutParams = controlLayoutParams()\n }\n\n cancelAiButton = Button(this).apply {\n text = \"Cancel AI request\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n copyButton = Button(this).apply {\n text = \"Copy results\"\n layoutParams = controlLayoutParams()\n }\n\n clearButton = Button(this).apply {\n text = \"Clear results\"\n layoutParams = controlLayoutParams()\n }\n\n retryPermissionButton = Button(this).apply {\n text = \"Request permission again\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n controls.addView(toggleRecognitionButton)\n controls.addView(manualScanButton)\n controls.addView(cancelAiButton)\n controls.addView(copyButton)\n controls.addView(clearButton)\n controls.addView(retryPermissionButton)\n\n root.addView(previewView)\n content.addView(statusText)\n content.addView(labeledSection(\"OCR Text\", ocrText, minHeight = 130))\n content.addView(labeledSection(\"Translation\", translationText, minHeight = 110))\n content.addView(labeledSection(\"Study Analysis\", analysisText, minHeight = 220))\n content.addView(controls)\n root.addView(contentScroll)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"Pause OCR\" else \"Start OCR\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR is running with throttled frame analysis.\"\n } else {\n \"OCR is paused. Camera preview remains active.\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"Waiting for the next camera frame to scan...\")\n }\n\n cancelAiButton.setOnClickListener {\n cancelAiRequest(showToast = true)\n }\n\n copyButton.setOnClickListener {\n copyResultsToClipboard()\n }\n\n clearButton.setOnClickListener {\n cancelAiRequest(showToast = false)\n lastRecognizedText = \"\"\n lastAiRequestedText = \"\"\n ocrText.text = \"OCR text will appear here.\"\n translationText.text = \"Chinese translation will appear here.\"\n analysisText.text = \"DeepSeek study analysis will appear here.\"\n updateStatus(\"Results cleared.\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"Camera preview and OCR are running.\")\n } catch (error: Exception) {\n updateStatus(\"Camera failed: ${error.localizedMessage ?: \"unknown error\"}\")\n Toast.makeText(this, \"Failed to start camera.\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val inputImage = InputImage.fromMediaImage(\n mediaImage,\n imageProxy.imageInfo.rotationDegrees,\n )\n\n recognizeLatinThenChinese(inputImage, imageProxy)\n }\n\n private fun recognizeLatinThenChinese(inputImage: InputImage, imageProxy: ImageProxy) {\n var latinText = \"\"\n var chineseText = \"\"\n var latinError: Exception? = null\n var chineseError: Exception? = null\n\n latinTextRecognizer.process(inputImage)\n .addOnSuccessListener { result: Text ->\n latinText = result.text\n }\n .addOnFailureListener { error ->\n latinError = error\n }\n .addOnCompleteListener {\n chineseTextRecognizer.process(inputImage)\n .addOnSuccessListener { result: Text ->\n chineseText = result.text\n }\n .addOnFailureListener { error ->\n chineseError = error\n }\n .addOnCompleteListener {\n finishOcrProcessing(\n imageProxy = imageProxy,\n latinText = latinText,\n chineseText = chineseText,\n latinError = latinError,\n chineseError = chineseError,\n )\n }\n }\n }\n\n private fun finishOcrProcessing(\n imageProxy: ImageProxy,\n latinText: String,\n chineseText: String,\n latinError: Exception?,\n chineseError: Exception?,\n ) {\n try {\n val cleanedText = mergeOcrText(latinText, chineseText)\n if (cleanedText.isBlank()) {\n val errorText = listOfNotNull(latinError, chineseError)\n .joinToString(\"; \") { it.localizedMessage ?: \"unknown OCR error\" }\n updateOcrResult(\n if (errorText.isBlank()) {\n \"No clear text detected. Adjust angle or distance.\"\n } else {\n \"OCR failed or found no text: $errorText\"\n },\n )\n return\n }\n\n if (isSimilarText(cleanedText, lastRecognizedText)) {\n updateStatus(\"OCR text is similar to the previous result. Skipping duplicate.\")\n return\n }\n\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n handleRecognizedText(cleanedText)\n } finally {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun handleRecognizedText(text: String) {\n when (classifyText(text)) {\n ContentKind.TOO_SHORT -> {\n updateTranslation(\"Text is too short or incomplete. Adjust angle and scan again.\")\n updateAnalysis(\"Waiting for a clearer OCR result before calling DeepSeek.\")\n }\n ContentKind.ENGLISH -> {\n updateTranslation(\"English text detected. Requesting Chinese translation...\")\n maybeRequestAi(text)\n }\n ContentKind.QUESTION -> {\n updateTranslation(\"Question-like content detected. Requesting study analysis...\")\n maybeRequestAi(text)\n }\n ContentKind.OTHER -> {\n updateTranslation(\"Text detected, but it does not look like English text or a complete question yet.\")\n updateAnalysis(\"Keep the page steady or use Scan once when the full content is visible.\")\n }\n }\n }\n\n private fun maybeRequestAi(text: String) {\n val apiKey = BuildConfig.DEEPSEEK_API_KEY.trim()\n if (apiKey.isEmpty()) {\n updateAnalysis(\n \"DeepSeek API key is missing. Add DEEPSEEK_API_KEY=your_key to local.properties \" +\n \"or set the DEEPSEEK_API_KEY environment variable, then sync Gradle.\",\n )\n updateStatus(\"DeepSeek API key missing.\")\n return\n }\n\n val now = System.currentTimeMillis()\n if (isSimilarText(text, lastAiRequestedText) && now - lastAiRequestedAt < AI_COOLDOWN_MS) {\n updateStatus(\"DeepSeek request skipped by duplicate cooldown.\")\n return\n }\n\n lastAiRequestedText = text\n lastAiRequestedAt = now\n aiRequestVersion += 1\n val requestVersion = aiRequestVersion\n\n currentAiCall?.cancel()\n cancelAiButton.isEnabled = true\n updateAnalysis(\"Loading DeepSeek study response...\")\n updateStatus(\"Calling DeepSeek...\")\n\n val requestBody = JSONObject()\n .put(\"model\", DEEPSEEK_MODEL)\n .put(\n \"messages\",\n JSONArray()\n .put(JSONObject().put(\"role\", \"system\").put(\"content\", SYSTEM_PROMPT))\n .put(JSONObject().put(\"role\", \"user\").put(\"content\", buildStudyPrompt(text))),\n )\n .put(\"response_format\", JSONObject().put(\"type\", \"json_object\"))\n .put(\"thinking\", JSONObject().put(\"type\", \"disabled\"))\n .put(\"temperature\", 0.2)\n .put(\"max_tokens\", 1800)\n .put(\"stream\", false)\n .toString()\n .toRequestBody(JSON_MEDIA_TYPE)\n\n val request = Request.Builder()\n .url(DEEPSEEK_CHAT_URL)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(requestBody)\n .build()\n\n val call = httpClient.newCall(request)\n currentAiCall = call\n call.enqueue(object : Callback {\n override fun onFailure(call: Call, e: IOException) {\n if (call.isCanceled()) return\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n updateStatus(\"Network request failed.\")\n analysisText.text = \"DeepSeek request failed: ${e.localizedMessage ?: \"network error\"}\"\n }\n }\n\n override fun onResponse(call: Call, response: Response) {\n val responseText = response.use { it.body?.string().orEmpty() }\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n cancelAiButton.isEnabled = false\n if (!response.isSuccessful) {\n updateStatus(\"DeepSeek returned HTTP ${response.code}.\")\n analysisText.text = \"DeepSeek error ${response.code}: $responseText\"\n return@runOnUiThread\n }\n\n val result = parseDeepSeekResult(responseText)\n translationText.text = result.translation\n analysisText.text = result.analysis\n updateStatus(result.status)\n }\n }\n })\n }\n\n private fun cancelAiRequest(showToast: Boolean) {\n aiRequestVersion += 1\n currentAiCall?.cancel()\n currentAiCall = null\n cancelAiButton.isEnabled = false\n updateStatus(\"AI request canceled.\")\n if (showToast) {\n Toast.makeText(this, \"AI request canceled.\", Toast.LENGTH_SHORT).show()\n }\n }\n\n private fun classifyText(text: String): ContentKind {\n val compact = text.filterNot { it.isWhitespace() }\n if (compact.length < MIN_USEFUL_TEXT_LENGTH) return ContentKind.TOO_SHORT\n\n val letters = compact.count { it in 'A'..'Z' || it in 'a'..'z' }\n val englishRatio = letters.toFloat() / compact.length.coerceAtLeast(1)\n if (letters >= MIN_ENGLISH_LETTERS && englishRatio >= ENGLISH_RATIO_THRESHOLD) {\n return ContentKind.ENGLISH\n }\n\n val lower = text.lowercase()\n val looksLikeQuestion = text.contains(\"?\") ||\n text.contains(\"\\uFF1F\") ||\n text.contains(\"=\") ||\n QUESTION_KEYWORDS.any { lower.contains(it) } ||\n CHINESE_QUESTION_KEYWORDS.any { text.contains(it) } ||\n OPTION_PATTERN.containsMatchIn(text) ||\n MATH_PATTERN.containsMatchIn(text)\n\n return if (looksLikeQuestion) ContentKind.QUESTION else ContentKind.OTHER\n }\n\n private fun buildStudyPrompt(ocrText: String): String {\n return \"\"\"\n Return one valid JSON object only. Do not wrap it in Markdown.\n Required JSON keys:\n {\n \"content_status\": \"complete | incomplete | uncertain\",\n \"chinese_translation\": \"Chinese translation or a short note if translation is not needed\",\n \"study_analysis\": \"Study explanation in Simplified Chinese with numbered sections\",\n \"suspicious_ocr\": \"OCR positions that may be wrong, or empty string\"\n }\n\n You are a study tutoring assistant. The text below was recognized by OCR from learning material or a question image.\n First judge whether the content is complete and whether it looks like a question.\n If the text is incomplete, tell the user to retake the image or adjust the angle.\n If the content is in English, translate it into Chinese first.\n If it is a question, include these sections in study_analysis:\n 1. Question understanding\n 2. Related knowledge points\n 3. Solving idea\n 4. Detailed steps\n 5. Final answer\n 6. Common mistakes\n 7. How to recognize similar question types\n\n Requirements:\n - Do not only give the answer.\n - Explain for students with weak foundations.\n - If OCR may be wrong, point out suspicious positions.\n - If conditions are insufficient, do not invent missing content.\n - Respond in Simplified Chinese inside JSON string values.\n\n OCR text:\n $ocrText\n \"\"\".trimIndent()\n }\n\n private fun parseDeepSeekResult(responseText: String): StudyResult {\n return try {\n val content = JSONObject(responseText)\n .getJSONArray(\"choices\")\n .getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n .trim()\n\n val resultObject = JSONObject(content)\n val status = resultObject.optString(\"content_status\", \"DeepSeek response updated.\")\n val translation = resultObject.optString(\"chinese_translation\", \"No translation returned.\")\n val analysis = resultObject.optString(\"study_analysis\", content)\n val suspicious = resultObject.optString(\"suspicious_ocr\", \"\")\n StudyResult(\n translation = translation,\n analysis = buildString {\n append(analysis.ifBlank { content })\n if (suspicious.isNotBlank()) {\n append(\"\\n\\nOCR notes:\\n\")\n append(suspicious)\n }\n },\n status = \"DeepSeek response updated: $status\",\n )\n } catch (error: Exception) {\n StudyResult(\n translation = \"DeepSeek response received, but JSON parsing failed.\",\n analysis = \"Failed to parse DeepSeek response: ${error.localizedMessage ?: \"unknown error\"}\\n\\n$responseText\",\n status = \"DeepSeek response parse failed.\",\n )\n }\n }\n\n private fun mergeOcrText(latinText: String, chineseText: String): String {\n val latinClean = cleanOcrText(latinText)\n val chineseClean = cleanOcrText(chineseText)\n if (latinClean.isBlank()) return chineseClean\n if (chineseClean.isBlank()) return latinClean\n if (latinClean == chineseClean) return latinClean\n\n val mergedLines = mutableListOf<String>()\n (chineseClean.lines() + latinClean.lines())\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .forEach { line ->\n if (mergedLines.none { existing -> isSimilarText(existing, line) }) {\n mergedLines.add(line)\n }\n }\n\n return mergedLines.joinToString(\"\\n\")\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun isSimilarText(newText: String, oldText: String): Boolean {\n if (newText.isBlank() || oldText.isBlank()) return false\n if (newText == oldText) return true\n val a = normalizeForSimilarity(newText)\n val b = normalizeForSimilarity(oldText)\n if (a == b) return true\n val shorter = minOf(a.length, b.length)\n val longer = maxOf(a.length, b.length)\n if (longer == 0 || shorter.toFloat() / longer < 0.75f) return false\n val common = a.toSet().intersect(b.toSet()).size\n val total = a.toSet().union(b.toSet()).size.coerceAtLeast(1)\n return common.toFloat() / total >= 0.88f\n }\n\n private fun normalizeForSimilarity(text: String): String {\n return text.lowercase()\n .filterNot { it.isWhitespace() }\n .filterNot { it in \",.;:!?()[]{}\" }\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR updated.\")\n }\n }\n\n private fun updateTranslation(text: String) {\n runOnUiThread {\n translationText.text = text\n }\n }\n\n private fun updateAnalysis(text: String) {\n runOnUiThread {\n analysisText.text = text\n }\n }\n\n private fun copyResultsToClipboard() {\n val resultText = buildString {\n append(\"OCR Text:\\n\")\n append(ocrText.text)\n append(\"\\n\\nTranslation:\\n\")\n append(translationText.text)\n append(\"\\n\\nStudy Analysis:\\n\")\n append(analysisText.text)\n }\n val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager\n clipboard.setPrimaryClip(ClipData.newPlainText(\"Study Assist Result\", resultText))\n Toast.makeText(this, \"Results copied.\", Toast.LENGTH_SHORT).show()\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun labeledSection(title: String, textView: TextView, minHeight: Int): LinearLayout {\n return LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setPadding(0, 8, 0, 0)\n addView(sectionLabel(title))\n addView(\n ScrollView(this@MainActivity).apply {\n setBackgroundColor(0xFFFFFFFF.toInt())\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n dp(minHeight),\n ).apply {\n setMargins(0, 4, 0, 0)\n }\n addView(textView)\n },\n )\n }\n }\n\n private fun sectionLabel(text: String): TextView {\n return TextView(this).apply {\n this.text = text\n textSize = 13f\n setTextColor(0xFF4B5563.toInt())\n setPadding(2, 0, 0, 0)\n }\n }\n\n private fun sectionText(text: String, textSize: Float = 14f): TextView {\n return TextView(this).apply {\n this.text = text\n this.textSize = textSize\n setTextColor(0xFF111827.toInt())\n setPadding(16, 12, 16, 12)\n setLineSpacing(0f, 1.08f)\n }\n }\n\n private fun controlLayoutParams(): LinearLayout.LayoutParams {\n return LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 8, 0, 0)\n }\n }\n\n private fun dp(value: Int): Int {\n return (value * resources.displayMetrics.density).toInt()\n }\n\n private data class StudyResult(\n val translation: String,\n val analysis: String,\n val status: String,\n )\n\n private enum class ContentKind {\n TOO_SHORT,\n ENGLISH,\n QUESTION,\n OTHER,\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_500L\n const val AI_COOLDOWN_MS = 20_000L\n const val MIN_USEFUL_TEXT_LENGTH = 12\n const val MIN_ENGLISH_LETTERS = 8\n const val ENGLISH_RATIO_THRESHOLD = 0.45f\n const val DEEPSEEK_CHAT_URL = \"https://api.deepseek.com/chat/completions\"\n const val DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n val JSON_MEDIA_TYPE = \"application/json; charset=utf-8\".toMediaType()\n val OPTION_PATTERN = Regex(\"\"\"(?im)(^|\\n)\\s*[A-D][\\).: ]\"\"\")\n val MATH_PATTERN = Regex(\"\"\"[0-9]\\s*[+\\-*/=^]\\s*[0-9a-zA-Z(]\"\"\")\n val QUESTION_KEYWORDS = listOf(\n \"solve\",\n \"calculate\",\n \"choose\",\n \"find\",\n \"prove\",\n \"answer\",\n \"question\",\n \"what is\",\n \"which\",\n \"why\",\n )\n val CHINESE_QUESTION_KEYWORDS = listOf(\n \"\\u6C42\",\n \"\\u8BA1\\u7B97\",\n \"\\u9009\\u62E9\",\n \"\\u8BC1\\u660E\",\n \"\\u7B54\\u6848\",\n \"\\u89E3\\u6790\",\n \"\\u4E0B\\u5217\",\n \"\\u9898\",\n )\n const val SYSTEM_PROMPT =\n \"You help students learn from OCR text. You do not help with cheating or rule-breaking.\"\n }\n}\n"}, "54fb5824ee96808bf48c8868d414c99c52ace4c8": {"t": 1785755942.8683553, "n": 21038, "h": "fa192c1a2b5d48d4", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.view.Gravity\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.io.IOException\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport okhttp3.Call\nimport okhttp3.Callback\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport org.json.JSONArray\nimport org.json.JSONObject\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var ocrText: TextView\n private lateinit var translationText: TextView\n private lateinit var analysisText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val textRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n private val httpClient: OkHttpClient = OkHttpClient.Builder()\n .connectTimeout(15, TimeUnit.SECONDS)\n .readTimeout(60, TimeUnit.SECONDS)\n .writeTimeout(30, TimeUnit.SECONDS)\n .build()\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var currentAiCall: Call? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private var lastAiRequestedText = \"\"\n private var lastAiRequestedAt = 0L\n private var aiRequestVersion = 0\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"Camera permission granted. Starting preview...\")\n startCamera()\n } else {\n updateStatus(\"Camera permission denied. Preview is unavailable.\")\n Toast.makeText(this, \"Camera permission is required.\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"Camera permission is required for live preview.\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n currentAiCall?.cancel()\n cameraProvider?.unbindAll()\n cameraProvider = null\n textRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF6F7F9.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 1f,\n )\n }\n\n statusText = sectionText(\"Preparing camera...\", textSize = 15f)\n ocrText = sectionText(\"OCR text will appear here.\")\n translationText = sectionText(\"Translation or language hint will appear here.\")\n analysisText = sectionText(\"DeepSeek study analysis will appear here.\")\n\n val controls = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n gravity = Gravity.CENTER\n setPadding(20, 8, 20, 24)\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n )\n }\n\n toggleRecognitionButton = Button(this).apply {\n text = \"Pause OCR\"\n layoutParams = controlLayoutParams()\n }\n\n manualScanButton = Button(this).apply {\n text = \"Scan once\"\n layoutParams = controlLayoutParams()\n }\n\n clearButton = Button(this).apply {\n text = \"Clear results\"\n layoutParams = controlLayoutParams()\n }\n\n retryPermissionButton = Button(this).apply {\n text = \"Request permission again\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n controls.addView(toggleRecognitionButton)\n controls.addView(manualScanButton)\n controls.addView(clearButton)\n controls.addView(retryPermissionButton)\n root.addView(previewView)\n root.addView(statusText)\n root.addView(scrollSection(ocrText, height = 190))\n root.addView(scrollSection(translationText, height = 150))\n root.addView(scrollSection(analysisText, height = 240))\n root.addView(controls)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"Pause OCR\" else \"Start OCR\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR is running with throttled frame analysis.\"\n } else {\n \"OCR is paused. Camera preview remains active.\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"Waiting for the next camera frame to scan...\")\n }\n\n clearButton.setOnClickListener {\n currentAiCall?.cancel()\n lastRecognizedText = \"\"\n lastAiRequestedText = \"\"\n ocrText.text = \"OCR text will appear here.\"\n translationText.text = \"Translation or language hint will appear here.\"\n analysisText.text = \"DeepSeek study analysis will appear here.\"\n updateStatus(\"Results cleared.\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"Camera preview and OCR are running.\")\n } catch (error: Exception) {\n updateStatus(\"Camera failed: ${error.localizedMessage ?: \"unknown error\"}\")\n Toast.makeText(this, \"Failed to start camera.\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val inputImage = InputImage.fromMediaImage(\n mediaImage,\n imageProxy.imageInfo.rotationDegrees,\n )\n\n textRecognizer.process(inputImage)\n .addOnSuccessListener { result ->\n val cleanedText = cleanOcrText(result.text)\n if (cleanedText.isBlank()) {\n updateOcrResult(\"No clear text detected. Adjust angle or distance.\")\n return@addOnSuccessListener\n }\n\n if (cleanedText != lastRecognizedText) {\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n handleRecognizedText(cleanedText)\n } else {\n updateStatus(\"OCR text unchanged. Skipping duplicate update.\")\n }\n }\n .addOnFailureListener { error ->\n updateStatus(\"OCR failed: ${error.localizedMessage ?: \"unknown error\"}\")\n }\n .addOnCompleteListener {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun handleRecognizedText(text: String) {\n when (classifyText(text)) {\n ContentKind.TOO_SHORT -> {\n updateTranslation(\"Text is too short or incomplete. Adjust angle and scan again.\")\n updateAnalysis(\"Waiting for a clearer OCR result before calling DeepSeek.\")\n }\n ContentKind.ENGLISH -> {\n updateTranslation(\"English text detected. Requesting Chinese translation...\")\n maybeRequestAi(text)\n }\n ContentKind.QUESTION -> {\n updateTranslation(\"Question-like content detected. Requesting study analysis...\")\n maybeRequestAi(text)\n }\n ContentKind.OTHER -> {\n updateTranslation(\"Text detected, but it does not look like English text or a complete question yet.\")\n updateAnalysis(\"Keep the page steady or use Scan once when the full content is visible.\")\n }\n }\n }\n\n private fun maybeRequestAi(text: String) {\n val apiKey = BuildConfig.DEEPSEEK_API_KEY.trim()\n if (apiKey.isEmpty()) {\n updateAnalysis(\n \"DeepSeek API key is missing. Add DEEPSEEK_API_KEY=your_key to local.properties \" +\n \"or set the DEEPSEEK_API_KEY environment variable, then sync Gradle.\",\n )\n updateStatus(\"DeepSeek API key missing.\")\n return\n }\n\n val now = System.currentTimeMillis()\n if (text == lastAiRequestedText && now - lastAiRequestedAt < AI_COOLDOWN_MS) {\n updateStatus(\"DeepSeek request skipped by duplicate cooldown.\")\n return\n }\n\n lastAiRequestedText = text\n lastAiRequestedAt = now\n aiRequestVersion += 1\n val requestVersion = aiRequestVersion\n\n currentAiCall?.cancel()\n updateAnalysis(\"Loading DeepSeek study response...\")\n updateStatus(\"Calling DeepSeek...\")\n\n val requestBody = JSONObject()\n .put(\"model\", DEEPSEEK_MODEL)\n .put(\n \"messages\",\n JSONArray()\n .put(JSONObject().put(\"role\", \"system\").put(\"content\", SYSTEM_PROMPT))\n .put(JSONObject().put(\"role\", \"user\").put(\"content\", buildStudyPrompt(text))),\n )\n .put(\"stream\", false)\n .toString()\n .toRequestBody(JSON_MEDIA_TYPE)\n\n val request = Request.Builder()\n .url(DEEPSEEK_CHAT_URL)\n .addHeader(\"Authorization\", \"Bearer $apiKey\")\n .addHeader(\"Content-Type\", \"application/json\")\n .post(requestBody)\n .build()\n\n val call = httpClient.newCall(request)\n currentAiCall = call\n call.enqueue(object : Callback {\n override fun onFailure(call: Call, e: IOException) {\n if (call.isCanceled()) return\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n updateStatus(\"Network request failed.\")\n analysisText.text = \"DeepSeek request failed: ${e.localizedMessage ?: \"network error\"}\"\n }\n }\n\n override fun onResponse(call: Call, response: Response) {\n val responseText = response.use { it.body?.string().orEmpty() }\n runOnUiThread {\n if (requestVersion != aiRequestVersion) return@runOnUiThread\n if (!response.isSuccessful) {\n updateStatus(\"DeepSeek returned HTTP ${response.code}.\")\n analysisText.text = \"DeepSeek error ${response.code}: $responseText\"\n return@runOnUiThread\n }\n\n val content = parseDeepSeekContent(responseText)\n analysisText.text = content\n translationText.text = extractTranslationHint(content)\n updateStatus(\"DeepSeek response updated.\")\n }\n }\n })\n }\n\n private fun classifyText(text: String): ContentKind {\n val compact = text.filterNot { it.isWhitespace() }\n if (compact.length < MIN_USEFUL_TEXT_LENGTH) return ContentKind.TOO_SHORT\n\n val letters = compact.count { it in 'A'..'Z' || it in 'a'..'z' }\n val englishRatio = letters.toFloat() / compact.length.coerceAtLeast(1)\n if (letters >= MIN_ENGLISH_LETTERS && englishRatio >= ENGLISH_RATIO_THRESHOLD) {\n return ContentKind.ENGLISH\n }\n\n val lower = text.lowercase()\n val looksLikeQuestion = text.contains(\"?\") ||\n text.contains(\"\\uFF1F\") ||\n text.contains(\"=\") ||\n QUESTION_KEYWORDS.any { lower.contains(it) } ||\n OPTION_PATTERN.containsMatchIn(text) ||\n MATH_PATTERN.containsMatchIn(text)\n\n return if (looksLikeQuestion) ContentKind.QUESTION else ContentKind.OTHER\n }\n\n private fun buildStudyPrompt(ocrText: String): String {\n return \"\"\"\n You are a study tutoring assistant. The following text was recognized by OCR from learning material or a question image.\n First judge whether the content is complete and whether it looks like a question.\n\n If the text is incomplete, tell the user to retake the image or adjust the angle.\n If the content is in English, translate it into Chinese first.\n If it is a question, answer with this structure:\n\n 1. Chinese translation if needed\n 2. Question understanding\n 3. Related knowledge points\n 4. Solving idea\n 5. Detailed steps\n 6. Final answer\n 7. Common mistakes\n 8. How to recognize similar question types\n\n Requirements:\n - Do not only give the answer.\n - Explain for students with weak foundations.\n - If OCR may be wrong, point out suspicious positions.\n - If conditions are insufficient, do not invent missing content.\n - Respond in Simplified Chinese.\n\n OCR text:\n $ocrText\n \"\"\".trimIndent()\n }\n\n private fun parseDeepSeekContent(responseText: String): String {\n return try {\n JSONObject(responseText)\n .getJSONArray(\"choices\")\n .getJSONObject(0)\n .getJSONObject(\"message\")\n .getString(\"content\")\n .trim()\n .ifEmpty { \"DeepSeek returned an empty response.\" }\n } catch (error: Exception) {\n \"Failed to parse DeepSeek response: ${error.localizedMessage ?: \"unknown error\"}\\n\\n$responseText\"\n }\n }\n\n private fun extractTranslationHint(content: String): String {\n val firstLines = content.lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .take(6)\n .joinToString(\"\\n\")\n\n return firstLines.ifBlank { \"DeepSeek response received.\" }\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR updated.\")\n }\n }\n\n private fun updateTranslation(text: String) {\n runOnUiThread {\n translationText.text = text\n }\n }\n\n private fun updateAnalysis(text: String) {\n runOnUiThread {\n analysisText.text = text\n }\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun scrollSection(textView: TextView, height: Int): ScrollView {\n return ScrollView(this).apply {\n setBackgroundColor(0xFFFFFFFF.toInt())\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n height,\n ).apply {\n setMargins(0, 6, 0, 0)\n }\n addView(textView)\n }\n }\n\n private fun sectionText(text: String, textSize: Float = 14f): TextView {\n return TextView(this).apply {\n this.text = text\n this.textSize = textSize\n setTextColor(0xFF111827.toInt())\n setPadding(24, 12, 24, 12)\n }\n }\n\n private fun controlLayoutParams(): LinearLayout.LayoutParams {\n return LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 8, 0, 0)\n }\n }\n\n private enum class ContentKind {\n TOO_SHORT,\n ENGLISH,\n QUESTION,\n OTHER,\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_500L\n const val AI_COOLDOWN_MS = 20_000L\n const val MIN_USEFUL_TEXT_LENGTH = 12\n const val MIN_ENGLISH_LETTERS = 8\n const val ENGLISH_RATIO_THRESHOLD = 0.45f\n const val DEEPSEEK_CHAT_URL = \"https://api.deepseek.com/chat/completions\"\n const val DEEPSEEK_MODEL = \"deepseek-v4-flash\"\n val JSON_MEDIA_TYPE = \"application/json; charset=utf-8\".toMediaType()\n val OPTION_PATTERN = Regex(\"\"\"(?im)(^|\\n)\\s*[A-D][\\).: ]\"\"\")\n val MATH_PATTERN = Regex(\"\"\"[0-9]\\s*[+\\-*/=^]\\s*[0-9a-zA-Z(]\"\"\")\n val QUESTION_KEYWORDS = listOf(\n \"solve\",\n \"calculate\",\n \"choose\",\n \"find\",\n \"prove\",\n \"answer\",\n \"question\",\n \"what is\",\n \"which\",\n \"why\",\n )\n const val SYSTEM_PROMPT =\n \"You help students learn from OCR text. You do not help with cheating or rule-breaking.\"\n }\n}\n"}, "d99798aca3a5b7d60261f025445090590b2ee1c7": {"t": 1785755944.5153651, "n": 11084, "h": "ec33e83fb23b52e8", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.view.Gravity\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.LinearLayout\nimport android.widget.ScrollView\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.ImageAnalysis\nimport androidx.camera.core.ImageProxy\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.text.TextRecognition\nimport com.google.mlkit.vision.text.TextRecognizer\nimport com.google.mlkit.vision.text.latin.TextRecognizerOptions\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.atomic.AtomicBoolean\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var ocrText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var manualScanButton: Button\n private lateinit var clearButton: Button\n\n private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()\n private val textRecognizer: TextRecognizer =\n TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var isRecognitionEnabled = true\n private var lastAnalyzedAt = 0L\n private var lastRecognizedText = \"\"\n private val isOcrRunning = AtomicBoolean(false)\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"Camera permission granted. Starting preview...\")\n startCamera()\n } else {\n updateStatus(\"Camera permission denied. Preview is unavailable.\")\n Toast.makeText(this, \"Camera permission is required.\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"Camera permission is required for live preview.\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cameraProvider?.unbindAll()\n cameraProvider = null\n textRecognizer.close()\n cameraExecutor.shutdown()\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF6F7F9.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 1f,\n )\n }\n\n statusText = TextView(this).apply {\n textSize = 15f\n setTextColor(0xFF1F2937.toInt())\n setPadding(24, 16, 24, 8)\n text = \"Preparing camera...\"\n }\n\n ocrText = TextView(this).apply {\n textSize = 14f\n setTextColor(0xFF111827.toInt())\n setPadding(24, 12, 24, 12)\n text = \"OCR text will appear here.\"\n }\n\n val ocrScroll = ScrollView(this).apply {\n setBackgroundColor(0xFFFFFFFF.toInt())\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 220,\n )\n addView(ocrText)\n }\n\n val controls = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n gravity = Gravity.CENTER\n setPadding(20, 8, 20, 24)\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n )\n }\n\n toggleRecognitionButton = Button(this).apply {\n text = \"Pause OCR\"\n layoutParams = controlLayoutParams()\n }\n\n manualScanButton = Button(this).apply {\n text = \"Scan once\"\n layoutParams = controlLayoutParams()\n }\n\n clearButton = Button(this).apply {\n text = \"Clear text\"\n layoutParams = controlLayoutParams()\n }\n\n retryPermissionButton = Button(this).apply {\n text = \"Request permission again\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n controls.addView(toggleRecognitionButton)\n controls.addView(manualScanButton)\n controls.addView(clearButton)\n controls.addView(retryPermissionButton)\n root.addView(previewView)\n root.addView(statusText)\n root.addView(ocrScroll)\n root.addView(controls)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"Pause OCR\" else \"Start OCR\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"OCR is running with throttled frame analysis.\"\n } else {\n \"OCR is paused. Camera preview remains active.\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n lastAnalyzedAt = 0L\n updateStatus(\"Waiting for the next camera frame to scan...\")\n }\n\n clearButton.setOnClickListener {\n lastRecognizedText = \"\"\n ocrText.text = \"OCR text will appear here.\"\n updateStatus(\"OCR result cleared.\")\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n val analysis = ImageAnalysis.Builder()\n .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)\n .build()\n .also { imageAnalysis ->\n imageAnalysis.setAnalyzer(cameraExecutor, ::analyzeImage)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n analysis,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"Camera preview and OCR are running.\")\n } catch (error: Exception) {\n updateStatus(\"Camera failed: ${error.localizedMessage ?: \"unknown error\"}\")\n Toast.makeText(this, \"Failed to start camera.\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun analyzeImage(imageProxy: ImageProxy) {\n val now = System.currentTimeMillis()\n val shouldSkip = !isRecognitionEnabled ||\n now - lastAnalyzedAt < OCR_INTERVAL_MS ||\n !isOcrRunning.compareAndSet(false, true)\n\n if (shouldSkip) {\n imageProxy.close()\n return\n }\n\n lastAnalyzedAt = now\n val mediaImage = imageProxy.image\n if (mediaImage == null) {\n isOcrRunning.set(false)\n imageProxy.close()\n return\n }\n\n val inputImage = InputImage.fromMediaImage(\n mediaImage,\n imageProxy.imageInfo.rotationDegrees,\n )\n\n textRecognizer.process(inputImage)\n .addOnSuccessListener { result ->\n val cleanedText = cleanOcrText(result.text)\n if (cleanedText.isBlank()) {\n updateOcrResult(\"No clear text detected. Adjust angle or distance.\")\n return@addOnSuccessListener\n }\n\n if (cleanedText != lastRecognizedText) {\n lastRecognizedText = cleanedText\n updateOcrResult(cleanedText)\n } else {\n updateStatus(\"OCR text unchanged. Skipping duplicate update.\")\n }\n }\n .addOnFailureListener { error ->\n updateStatus(\"OCR failed: ${error.localizedMessage ?: \"unknown error\"}\")\n }\n .addOnCompleteListener {\n isOcrRunning.set(false)\n imageProxy.close()\n }\n }\n\n private fun cleanOcrText(rawText: String): String {\n return rawText\n .lines()\n .map { it.trim() }\n .filter { it.isNotEmpty() }\n .joinToString(separator = \"\\n\")\n }\n\n private fun updateOcrResult(text: String) {\n runOnUiThread {\n ocrText.text = text\n updateStatus(\"OCR updated.\")\n }\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n runOnUiThread {\n statusText.text = message\n }\n }\n\n private fun controlLayoutParams(): LinearLayout.LayoutParams {\n return LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 8, 0, 0)\n }\n }\n\n private companion object {\n const val OCR_INTERVAL_MS = 1_500L\n }\n}\n"}, "f0739bc4ee54200c8a2edfe0052496689be7d1a7": {"t": 1785755945.8335526, "n": 6452, "h": "95ea6e55e325d707", "c": "package com.example.studyassist\n\nimport android.Manifest\nimport android.content.pm.PackageManager\nimport android.os.Bundle\nimport android.view.Gravity\nimport android.view.ViewGroup\nimport android.widget.Button\nimport android.widget.LinearLayout\nimport android.widget.TextView\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.camera.core.CameraSelector\nimport androidx.camera.core.Preview\nimport androidx.camera.lifecycle.ProcessCameraProvider\nimport androidx.camera.view.PreviewView\nimport androidx.core.content.ContextCompat\n\nclass MainActivity : ComponentActivity() {\n private lateinit var previewView: PreviewView\n private lateinit var statusText: TextView\n private lateinit var toggleRecognitionButton: Button\n private lateinit var retryPermissionButton: Button\n private lateinit var manualScanButton: Button\n\n private var cameraProvider: ProcessCameraProvider? = null\n private var isRecognitionEnabled = true\n\n private val requestCameraPermission =\n registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->\n if (granted) {\n updateStatus(\"摄像头权限已允许,正在启动预览\")\n startCamera()\n } else {\n updateStatus(\"摄像头权限被拒绝,无法显示实时预览\")\n Toast.makeText(this, \"请允许摄像头权限后再使用识别功能\", Toast.LENGTH_LONG).show()\n retryPermissionButton.isEnabled = true\n }\n }\n\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n buildContentView()\n bindControls()\n\n if (hasCameraPermission()) {\n startCamera()\n } else {\n updateStatus(\"需要摄像头权限才能显示实时预览\")\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n override fun onDestroy() {\n cameraProvider?.unbindAll()\n cameraProvider = null\n super.onDestroy()\n }\n\n private fun buildContentView() {\n val root = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n setBackgroundColor(0xFFF6F7F9.toInt())\n layoutParams = ViewGroup.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.MATCH_PARENT,\n )\n }\n\n previewView = PreviewView(this).apply {\n implementationMode = PreviewView.ImplementationMode.PERFORMANCE\n scaleType = PreviewView.ScaleType.FILL_CENTER\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n 0,\n 1f,\n )\n }\n\n statusText = TextView(this).apply {\n textSize = 15f\n setTextColor(0xFF1F2937.toInt())\n setPadding(24, 18, 24, 12)\n text = \"正在准备摄像头\"\n }\n\n val controls = LinearLayout(this).apply {\n orientation = LinearLayout.VERTICAL\n gravity = Gravity.CENTER\n setPadding(20, 8, 20, 24)\n layoutParams = LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n )\n }\n\n toggleRecognitionButton = Button(this).apply {\n text = \"暂停识别\"\n layoutParams = controlLayoutParams()\n }\n\n manualScanButton = Button(this).apply {\n text = \"手动重新识别\"\n layoutParams = controlLayoutParams()\n }\n\n retryPermissionButton = Button(this).apply {\n text = \"重新申请权限\"\n isEnabled = false\n layoutParams = controlLayoutParams()\n }\n\n controls.addView(toggleRecognitionButton)\n controls.addView(manualScanButton)\n controls.addView(retryPermissionButton)\n root.addView(previewView)\n root.addView(statusText)\n root.addView(controls)\n setContentView(root)\n }\n\n private fun bindControls() {\n toggleRecognitionButton.setOnClickListener {\n isRecognitionEnabled = !isRecognitionEnabled\n toggleRecognitionButton.text = if (isRecognitionEnabled) \"暂停识别\" else \"开始识别\"\n updateStatus(\n if (isRecognitionEnabled) {\n \"摄像头预览运行中,OCR 会在下一阶段接入\"\n } else {\n \"已暂停识别,摄像头预览仍保持运行\"\n },\n )\n }\n\n manualScanButton.setOnClickListener {\n Toast.makeText(this, \"OCR 将在下一阶段接入\", Toast.LENGTH_SHORT).show()\n }\n\n retryPermissionButton.setOnClickListener {\n retryPermissionButton.isEnabled = false\n requestCameraPermission.launch(Manifest.permission.CAMERA)\n }\n }\n\n private fun startCamera() {\n val cameraProviderFuture = ProcessCameraProvider.getInstance(this)\n\n cameraProviderFuture.addListener(\n {\n try {\n val provider = cameraProviderFuture.get()\n val preview = Preview.Builder().build().also {\n it.setSurfaceProvider(previewView.surfaceProvider)\n }\n\n provider.unbindAll()\n provider.bindToLifecycle(\n this,\n CameraSelector.DEFAULT_BACK_CAMERA,\n preview,\n )\n\n cameraProvider = provider\n retryPermissionButton.isEnabled = false\n updateStatus(\"摄像头预览已启动\")\n } catch (error: Exception) {\n updateStatus(\"摄像头启动失败:${error.localizedMessage ?: \"未知错误\"}\")\n Toast.makeText(this, \"摄像头启动失败\", Toast.LENGTH_LONG).show()\n }\n },\n ContextCompat.getMainExecutor(this),\n )\n }\n\n private fun hasCameraPermission(): Boolean {\n return ContextCompat.checkSelfPermission(\n this,\n Manifest.permission.CAMERA,\n ) == PackageManager.PERMISSION_GRANTED\n }\n\n private fun updateStatus(message: String) {\n statusText.text = message\n }\n\n private fun controlLayoutParams(): LinearLayout.LayoutParams {\n return LinearLayout.LayoutParams(\n ViewGroup.LayoutParams.MATCH_PARENT,\n ViewGroup.LayoutParams.WRAP_CONTENT,\n ).apply {\n setMargins(0, 8, 0, 0)\n }\n }\n}\n"}}}} |