初步完成框架
Some checks failed
Android CI / build (push) Has been cancelled

- 实时图传:WebSocket JPEG 帧发送 + 帧率控制 + PC 浏览器预览
- PDF 上传与处理:上传/处理分离,支持 ocrpdf 和 markdown 两种类型
- MinerU 真实接入:markdown 处理 + images ZIP 打包
- OCRmyPDF 接入:ocrpdf 生成可搜索双层 PDF
- 手机端任务管理面板:轮询状态 + SAF 目录选择下载
- PC 管理面板:/dashboard 文件与任务管理
- 网络层:OkHttp 客户端、WebSocket 图传、局域网发现占位

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MobKBK
2026-06-04 17:03:18 +08:00
parent dd8002009d
commit 1848a88fcf
72 changed files with 6281 additions and 163 deletions

View File

@@ -136,6 +136,7 @@ dependencies {
implementation(libs.reorderable)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
testImplementation(libs.junit)
testImplementation(libs.assertj)

View File

@@ -8,9 +8,10 @@
<uses-permission android:name="android.permission.CAMERA" />
<!-- Required (on Android 9 and lower) to save files -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<!-- REMOVE ACCESS_NETWORK_STATE -->
<!-- cameraX 1.6.1 depends on androidx.media3:media3-common which adds ACCESS_NETWORK_STATE -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<!-- Network permissions for LAN communication -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
@@ -20,9 +21,11 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".FairScanApp"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FairScan"
android:usesCleartextTraffic="true"
tools:targetApi="tiramisu">
<activity
android:name=".MainActivity"

View File

@@ -29,7 +29,14 @@ import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.settings.SettingsRepository
import org.fairscan.app.ui.screens.settings.SettingsViewModel
import okhttp3.OkHttpClient
import org.fairscan.app.network.NetworkInfoProvider
import org.fairscan.app.network.stream.OkHttpStreamClient
import org.fairscan.app.network.stream.StreamClient
import org.fairscan.app.network.tasks.TaskClient
import org.fairscan.app.network.upload.PdfUploadClient
import java.io.File
import java.util.concurrent.TimeUnit
class FairScanApp : Application() {
lateinit var appContainer: AppContainer
@@ -57,6 +64,17 @@ class AppContainer(context: Context) {
val imageLoader = AndroidImageLoader(context.contentResolver)
val settingsRepository = SettingsRepository(context)
// Network modules
val networkInfoProvider = NetworkInfoProvider()
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for streaming
.writeTimeout(0, TimeUnit.SECONDS) // No write timeout
.build()
val streamClient: StreamClient = OkHttpStreamClient(okHttpClient)
val pdfUploadClient = PdfUploadClient(okHttpClient)
val taskClient = TaskClient(okHttpClient)
@Suppress("UNCHECKED_CAST")
inline fun <reified VM : ViewModel> viewModelFactory(
crossinline create: (AppContainer) -> VM

View File

@@ -126,6 +126,7 @@ class MainActivity : ComponentActivity() {
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
val taskPanelState by exportViewModel.taskPanelState.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
CollectCameraEvents(cameraViewModel, viewModel)
CollectExportEvents(context, exportViewModel)
@@ -211,7 +212,14 @@ class MainActivity : ComponentActivity() {
share = { exportViewModel.onShareClicked() },
save = { exportViewModel.onSaveClicked() },
open = { item -> openUri(item.uri, item.format.mimeType, logger) },
uploadToPc = { exportViewModel.uploadPdfToServer() },
uploadAndProcess = { processType -> exportViewModel.uploadAndProcess(processType) },
downloadResult = { task, destDirUri, context ->
exportViewModel.downloadResult(task, destDirUri, context)
},
resetDownloadState = { exportViewModel.resetDownloadState() },
),
taskPanelState = taskPanelState,
onCloseScan = {
exportViewModel.resetFilename()
viewModel.startNewDocument()
@@ -295,6 +303,14 @@ class MainActivity : ComponentActivity() {
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
onServerHostChanged = { host -> settingsViewModel.setServerHost(host) },
onServerPortChanged = { port -> settingsViewModel.setServerPort(port) },
onStreamQualityChanged = { quality -> settingsViewModel.setStreamQuality(quality) },
onPostProcessModeChanged = { mode -> settingsViewModel.setPostProcessMode(mode) },
onAutoDownloadChanged = { enabled -> settingsViewModel.setAutoDownloadProcessedResult(enabled) },
onStreamFrameRateChanged = { rate -> settingsViewModel.setStreamFrameRate(rate) },
onScanNetworkHostsClick = { /* TODO: Implement network discovery */ },
onTestConnectionClick = { /* TODO: Implement connection test */ },
onBack = nav.back,
)
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network
import java.net.Inet4Address
import java.net.NetworkInterface
class NetworkInfoProvider {
fun getLocalIpAddress(): String? {
return try {
NetworkInterface.getNetworkInterfaces().asSequence()
.flatMap { it.inetAddresses.asSequence() }
.filterNot { it.isLoopbackAddress }
.filterIsInstance<Inet4Address>()
.firstOrNull()
?.hostAddress
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network
data class ServerEndpoint(
val host: String,
val port: Int,
val protocol: String = "http",
) {
val url: String get() = "$protocol://$host:$port"
val wsUrl: String get() = "ws://$host:$port"
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.discovery
data class DiscoveredHost(
val serviceName: String,
val displayName: String,
val host: String,
val port: Int,
val features: List<String> = emptyList(),
val version: String? = null,
)

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.discovery
sealed class DiscoveryState {
data object Idle : DiscoveryState()
data object Discovering : DiscoveryState()
data class Success(val hosts: List<DiscoveredHost>) : DiscoveryState()
data object Empty : DiscoveryState()
data class Error(val message: String) : DiscoveryState()
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.discovery
import kotlinx.coroutines.flow.Flow
interface LanServiceDiscovery {
suspend fun startDiscovery(serviceType: String): Flow<DiscoveryState>
suspend fun stopDiscovery()
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.stream
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.ByteArrayOutputStream
class FrameCompressor {
/**
* Compress a bitmap to JPEG bytes with resize and quality settings.
* Returns null if compression fails.
*/
fun compress(
source: Bitmap,
maxDimension: Int,
jpegQuality: Int,
): ByteArray? {
return try {
val resized = resizeIfNeeded(source, maxDimension)
val output = ByteArrayOutputStream()
resized.compress(Bitmap.CompressFormat.JPEG, jpegQuality, output)
output.toByteArray()
} catch (e: Exception) {
null
}
}
private fun resizeIfNeeded(bitmap: Bitmap, maxDimension: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val max = maxOf(width, height)
if (max <= maxDimension) return bitmap
val ratio = maxDimension.toFloat() / max
val newWidth = (width * ratio).toInt()
val newHeight = (height * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.stream
/**
* Controls frame dropping based on minimum interval between sends.
* If a frame arrives before the minimum interval has elapsed, it is dropped.
*/
class FrameDropController {
@Volatile
private var lastSendTimeMs: Long = 0L
private val isSending = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Returns true if this frame should be dropped.
* @param minIntervalMs Minimum interval between frames in ms.
* If <= 0, no time-based dropping (only isSending guard).
*/
fun shouldDrop(minIntervalMs: Long): Boolean {
if (isSending.get()) return true
// Unlimited mode: no time-based dropping
if (minIntervalMs <= 0) return false
val now = System.currentTimeMillis()
if (now - lastSendTimeMs < minIntervalMs) return true
return false
}
fun onFrameSent() {
lastSendTimeMs = System.currentTimeMillis()
}
fun markSending(value: Boolean) {
isSending.set(value)
}
fun reset() {
lastSendTimeMs = 0L
isSending.set(false)
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.stream
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import okio.ByteString.Companion.toByteString
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.fairscan.app.network.ServerEndpoint
import java.util.concurrent.TimeUnit
class OkHttpStreamClient(
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for streaming
.writeTimeout(0, TimeUnit.SECONDS) // No write timeout for streaming
.build(),
) : StreamClient {
private val _state = MutableStateFlow<StreamState>(StreamState.Disconnected)
override val state: StateFlow<StreamState> = _state.asStateFlow()
private var webSocket: WebSocket? = null
override suspend fun connect(endpoint: ServerEndpoint) {
if (_state.value is StreamState.Connected || _state.value is StreamState.Connecting) return
_state.value = StreamState.Connecting
val request = Request.Builder()
.url("ws://${endpoint.host}:${endpoint.port}/stream")
.build()
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_state.value = StreamState.Connected
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_state.value = StreamState.Error(t.message ?: "Connection failed")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
_state.value = StreamState.Disconnected
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, "Client closing")
}
})
}
override fun sendFrame(frameData: ByteArray): Boolean {
val ws = webSocket ?: return false
return ws.send(frameData.toByteString())
}
override suspend fun disconnect() {
webSocket?.close(1000, "Client disconnect")
webSocket = null
_state.value = StreamState.Disconnected
}
}
interface StreamClient {
val state: StateFlow<StreamState>
suspend fun connect(endpoint: ServerEndpoint)
fun sendFrame(frameData: ByteArray): Boolean
suspend fun disconnect()
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.stream
import org.fairscan.app.ui.screens.settings.StreamQuality
data class StreamQualityPreset(
val label: String,
val maxResolution: Int,
val jpegQuality: Int,
val targetFps: Int,
) {
val minIntervalMs: Long get() = (1000L / targetFps).coerceAtLeast(50L)
}
fun StreamQuality.toPreset(): StreamQualityPreset = when (this) {
StreamQuality.LOW -> StreamQualityPreset("Low", 640, 45, 10)
StreamQuality.BALANCED -> StreamQualityPreset("Balanced", 960, 60, 8)
StreamQuality.HIGH -> StreamQualityPreset("High", 1280, 75, 6)
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.stream
sealed class StreamState {
data object Disconnected : StreamState()
data object Connecting : StreamState()
data object Connected : StreamState()
data class Error(val message: String) : StreamState()
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.tasks
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.fairscan.app.network.ServerEndpoint
import org.fairscan.app.network.upload.PdfUploadClient
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Client for task management operations on the FairScan PC server.
*/
class TaskClient(
private val okHttpClient: OkHttpClient,
) {
private val downloadClient = okHttpClient.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build()
/**
* Create a processing task for an uploaded PDF.
*
* @param endpoint Server endpoint.
* @param fileName Name of the uploaded PDF file.
* @param mode Processing mode (e.g., "OCRPdf" or "Markdown").
* @return ProcessTaskResult with the assigned task ID.
*/
fun processPdf(
endpoint: ServerEndpoint,
fileId: String,
processType: String = "ocrpdf",
): ProcessTaskResult {
val url = "${endpoint.url}/tasks/process"
val json = """{"fileId":"$fileId","processType":"$processType"}"""
val requestBody = json.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = okHttpClient.newCall(request).execute()
val body = response.body?.string() ?: throw IOException("Empty response")
if (!response.isSuccessful) {
throw IOException("Failed to create task (${response.code}): $body")
}
val taskId = PdfUploadClient.extractJsonString(body, "taskId") ?: ""
val status = PdfUploadClient.extractJsonString(body, "status") ?: "unknown"
return ProcessTaskResult(taskId, status, "")
}
/**
* Get the current status of a processing task.
*/
fun getTaskStatus(endpoint: ServerEndpoint, taskId: String): TaskStatus {
val url = "${endpoint.url}/tasks/$taskId"
val request = Request.Builder().url(url).get().build()
val response = okHttpClient.newCall(request).execute()
val body = response.body?.string() ?: throw IOException("Empty response")
if (!response.isSuccessful) {
throw IOException("Failed to get task status (${response.code}): $body")
}
return TaskStatus(
taskId = PdfUploadClient.extractJsonString(body, "taskId") ?: taskId,
status = PdfUploadClient.extractJsonString(body, "status") ?: "unknown",
progress = extractJsonInt(body, "progress") ?: 0,
fileName = PdfUploadClient.extractJsonString(body, "fileName") ?: "",
createdAt = PdfUploadClient.extractJsonString(body, "createdAt") ?: "",
message = PdfUploadClient.extractJsonString(body, "message") ?: "",
)
}
/**
* List artifacts (result files) for a completed task.
*/
fun listArtifacts(endpoint: ServerEndpoint, taskId: String): List<ArtifactInfo> {
val url = "${endpoint.url}/tasks/$taskId/artifacts"
val request = Request.Builder().url(url).get().build()
val response = okHttpClient.newCall(request).execute()
val body = response.body?.string() ?: throw IOException("Empty response")
if (!response.isSuccessful) {
throw IOException("Failed to list artifacts (${response.code}): $body")
}
return parseArtifactList(body)
}
/**
* Download an artifact to a destination file.
*
* @return The destination file (same as [destFile]).
*/
fun downloadArtifact(
endpoint: ServerEndpoint,
artifactId: String,
destFile: File,
onProgress: ((Float) -> Unit)? = null,
): File {
val url = "${endpoint.url}/artifacts/$artifactId/download"
val request = Request.Builder().url(url).get().build()
val response = downloadClient.newCall(request).execute()
if (!response.isSuccessful) {
throw IOException("Failed to download artifact (${response.code})")
}
val body = response.body ?: throw IOException("Empty response body")
destFile.parentFile?.mkdirs()
val total = body.contentLength()
var bytesRead = 0L
body.byteStream().use { input ->
FileOutputStream(destFile).use { output ->
val buffer = ByteArray(8192)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
bytesRead += read
if (onProgress != null && total > 0) {
onProgress(bytesRead.toFloat() / total)
}
}
}
}
return destFile
}
private fun parseArtifactList(json: String): List<ArtifactInfo> {
val artifacts = mutableListOf<ArtifactInfo>()
var pos = json.indexOf('[')
if (pos < 0) return artifacts
pos = json.indexOf('{', pos)
while (pos >= 0) {
val end = json.indexOf('}', pos)
if (end < 0) break
val obj = json.substring(pos, end + 1)
val id = PdfUploadClient.extractJsonString(obj, "id")
?: PdfUploadClient.extractJsonString(obj, "artifactId")
val fileName = PdfUploadClient.extractJsonString(obj, "fileName") ?: ""
val fileSize = extractJsonLong(obj, "fileSize") ?: 0L
val fileType = PdfUploadClient.extractJsonString(obj, "fileType") ?: ""
if (id != null) {
artifacts.add(ArtifactInfo(id, fileName, fileSize, fileType))
}
pos = json.indexOf('{', end + 1)
}
return artifacts
}
companion object {
fun extractJsonInt(json: String, key: String): Int? {
val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex()
return pattern.find(json)?.groupValues?.getOrNull(1)?.toIntOrNull()
}
fun extractJsonLong(json: String, key: String): Long? {
val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex()
return pattern.find(json)?.groupValues?.getOrNull(1)?.toLongOrNull()
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.tasks
/**
* Status of a processing task on the PC server.
*/
data class TaskStatus(
val taskId: String,
val status: String, // queued, processing, completed, failed
val progress: Int = 0, // 0-100
val fileName: String = "",
val createdAt: String = "",
val message: String = "",
)
/**
* Information about a processed artifact (result file) on the PC server.
*/
data class ArtifactInfo(
val artifactId: String,
val fileName: String,
val fileSize: Long = 0,
val fileType: String = "", // "pdf", "markdown", "md", etc.
)
/**
* Result of creating a processing task.
*/
data class ProcessTaskResult(
val taskId: String,
val status: String,
val message: String = "",
)

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.network.upload
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import org.fairscan.app.network.ServerEndpoint
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Result of a PDF upload operation.
*/
data class UploadResult(
val fileId: String,
val fileName: String = "",
val sizeBytes: Long = 0,
)
/**
* Client for uploading PDF files to the FairScan PC server.
*/
class PdfUploadClient(
private val okHttpClient: OkHttpClient,
) {
private val uploadTimeoutClient = okHttpClient.newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS) // Large files need time to upload
.build()
/**
* Upload a PDF file to the PC server.
*
* @param endpoint The server endpoint to upload to.
* @param file The PDF file to upload.
* @param onProgress Callback with progress 0.0..1.0 (approximate, based on bytes written).
* @return UploadResult with the task ID assigned by the server.
* @throws IOException on network or server error.
*/
fun uploadPdf(
endpoint: ServerEndpoint,
file: File,
onProgress: ((Float) -> Unit)? = null,
): UploadResult {
val url = "${endpoint.url}/upload/pdf"
val fileBody = object : RequestBody() {
override fun contentType() = "application/pdf".toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: okio.BufferedSink) {
val buffer = ByteArray(8192)
val total = file.length()
var written = 0L
file.inputStream().use { input ->
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
written += bytesRead
if (onProgress != null && total > 0) {
onProgress(written.toFloat() / total)
}
}
}
}
}
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", file.name, fileBody)
.build()
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = uploadTimeoutClient.newCall(request).execute()
val responseBody = response.body?.string() ?: throw IOException("Empty response from server")
if (!response.isSuccessful) {
throw IOException("Upload failed (${response.code}): $responseBody")
}
// Parse JSON response — simple manual parse to avoid adding a JSON library
return parseUploadResponse(responseBody)
}
private fun parseUploadResponse(json: String): UploadResult {
val fileId = extractJsonString(json, "fileId") ?: ""
val fileName = extractJsonString(json, "fileName") ?: ""
val sizeBytes = extractJsonLong(json, "sizeBytes") ?: 0L
return UploadResult(fileId, fileName, sizeBytes)
}
companion object {
fun extractJsonString(json: String, key: String): String? {
val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"".toRegex()
return pattern.find(json)?.groupValues?.getOrNull(1)
}
fun extractJsonLong(json: String, key: String): Long? {
val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex()
return pattern.find(json)?.groupValues?.getOrNull(1)?.toLongOrNull()
}
}
}

View File

@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Highlight
import androidx.compose.material3.Button
@@ -104,6 +105,7 @@ import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.network.stream.StreamState
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.CameraPermissionState
@@ -140,6 +142,10 @@ fun CameraScreen(
val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle()
var torchReapplied by remember { mutableStateOf(false) }
// Streaming state
val streamState by cameraViewModel.streamState.collectAsStateWithLifecycle()
val streamTargetHost by cameraViewModel.streamTargetHost.collectAsStateWithLifecycle()
val captureController = remember { CameraCaptureController() }
DisposableEffect(Unit) {
onDispose {
@@ -245,6 +251,9 @@ fun CameraScreen(
isCameraPermissionGranted = cameraPermission.isGranted,
onRequestCameraPermission = { cameraPermission.request() },
onImportClicked = onImportClicked,
streamState = streamState,
streamTargetHost = streamTargetHost,
onToggleStream = { cameraViewModel.toggleStreaming() },
)
}
@@ -263,6 +272,9 @@ private fun CameraScreenScaffold(
isCameraPermissionGranted: Boolean,
onRequestCameraPermission: () -> Unit,
onImportClicked: () -> Unit,
streamState: StreamState,
streamTargetHost: String?,
onToggleStream: () -> Unit,
) {
var focusPoint by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(focusPoint) {
@@ -322,6 +334,15 @@ private fun CameraScreenScaffold(
val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
CapturedImage(page.asImageBitmap(), thumbnailCoords)
}
// Stream toggle button - top left of the screen
StreamToggleButton(
streamState = streamState,
streamTargetHost = streamTargetHost,
onToggle = onToggleStream,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 48.dp, start = 8.dp),
)
}
}
@@ -405,6 +426,49 @@ private fun CameraPreviewBox(
}
}
@Composable
private fun StreamToggleButton(
streamState: StreamState,
streamTargetHost: String?,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
val (iconTint, statusText) = when (streamState) {
StreamState.Disconnected -> Color.Gray to "图传未连接"
StreamState.Connecting -> Color(0xFFFFA000) to "图传连接中..."
StreamState.Connected -> Color(0xFF4CAF50) to "图传已连接"
is StreamState.Error -> Color(0xFFE53935) to "图传错误"
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.End,
) {
IconButton(onClick = onToggle) {
Icon(
imageVector = Icons.Default.Cast,
contentDescription = statusText,
tint = iconTint,
)
}
if (streamState is StreamState.Connected && streamTargetHost != null) {
Text(
text = streamTargetHost,
color = Color(0xFF4CAF50),
fontSize = 10.sp,
modifier = Modifier.padding(end = 12.dp),
)
}
if (streamState is StreamState.Error) {
Text(
text = streamState.message,
color = Color(0xFFE53935),
fontSize = 10.sp,
modifier = Modifier.padding(end = 12.dp),
)
}
}
}
@Composable
private fun CapturedImage(image: ImageBitmap, thumbnailCoords: MutableState<Offset>) {
Surface(
@@ -718,6 +782,9 @@ private fun ScreenPreview(
isCameraPermissionGranted = isCameraPermissionGranted,
onRequestCameraPermission = {},
onImportClicked = {},
streamState = StreamState.Disconnected,
streamTargetHost = null,
onToggleStream = {},
)
}
}

View File

@@ -33,7 +33,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.network.ServerEndpoint
import org.fairscan.app.network.stream.FrameCompressor
import org.fairscan.app.network.stream.FrameDropController
import org.fairscan.app.network.stream.StreamState
import org.fairscan.app.network.stream.toPreset
import org.fairscan.app.platform.extractDocumentFromBitmap
import org.fairscan.app.ui.screens.settings.StreamFrameRate
import org.fairscan.app.ui.screens.settings.StreamQuality
import org.fairscan.app.ui.screens.settings.intervalMs
import org.fairscan.imageprocessing.CameraIntrinsics
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.OpticalMeasures
@@ -51,6 +59,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val imageLoader = appContainer.imageLoader
private val logger = appContainer.logger
// Streaming components
private val streamClient = appContainer.streamClient
private val frameCompressor = FrameCompressor()
private val frameDropController = FrameDropController()
private val _events = MutableSharedFlow<CameraEvent>()
val events = _events.asSharedFlow()
@@ -68,10 +81,49 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val _isTorchEnabled = MutableStateFlow(false)
val isTorchEnabled: StateFlow<Boolean> = _isTorchEnabled
// Streaming state
private val _streamState = MutableStateFlow<StreamState>(StreamState.Disconnected)
val streamState: StateFlow<StreamState> = _streamState.asStateFlow()
private val _streamTargetHost = MutableStateFlow<String?>(null)
val streamTargetHost: StateFlow<String?> = _streamTargetHost.asStateFlow()
private var cachedStreamQuality = StreamQuality.BALANCED
private var cachedStreamFrameRate = StreamFrameRate.FPS_10
init {
viewModelScope.launch {
imageSegmentationService.initialize()
}
// Observe stream client state
viewModelScope.launch {
streamClient.state.collect { state ->
_streamState.value = state
}
}
// Observe stream quality setting
viewModelScope.launch {
settingsRepository.streamQuality.collect { quality ->
cachedStreamQuality = quality
}
}
// Observe stream frame rate setting
viewModelScope.launch {
settingsRepository.streamFrameRate.collect { rate ->
cachedStreamFrameRate = rate
}
}
// Observe server host/port for display
viewModelScope.launch {
kotlinx.coroutines.flow.combine(
settingsRepository.serverHost,
settingsRepository.serverPort,
) { host, port ->
if (host.isNullOrBlank()) null else "$host:$port"
}.collect { host ->
_streamTargetHost.value = host
}
}
}
fun resetLiveAnalysis() {
@@ -103,8 +155,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
viewModelScope.launch {
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val bitmap = imageProxy.toBitmap()
// Streaming: send frame if connected (fire-and-forget on IO)
val currentHost = _streamTargetHost.value
if (_streamState.value is StreamState.Connected && currentHost != null) {
launch(Dispatchers.IO) {
sendStreamFrame(bitmap)
}
}
val result = withContext(Dispatchers.IO) {
imageSegmentationService.runSegmentationAndReturn(imageProxy.toBitmap())
imageSegmentationService.runSegmentationAndReturn(bitmap)
}
result?.let {
@@ -230,6 +292,58 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
importJob = null
_importState.value = ImportState.Idle
}
// ── Streaming ──
fun toggleStreaming() {
viewModelScope.launch {
when (_streamState.value) {
is StreamState.Disconnected, is StreamState.Error -> startStreaming()
is StreamState.Connected -> stopStreaming()
else -> { /* Connecting — ignore */ }
}
}
}
private suspend fun startStreaming() {
val host = settingsRepository.serverHost.first()
val port = settingsRepository.serverPort.first()
if (host.isNullOrBlank()) {
_streamState.value = StreamState.Error("未配置主机地址")
return
}
frameDropController.reset()
streamClient.connect(ServerEndpoint(host, port))
}
private suspend fun stopStreaming() {
streamClient.disconnect()
frameDropController.reset()
}
private suspend fun sendStreamFrame(bitmap: Bitmap) {
if (_streamState.value !is StreamState.Connected) return
val preset = cachedStreamQuality.toPreset()
// Use explicit frame rate if set, otherwise fall back to quality preset's default
val intervalMs = cachedStreamFrameRate.intervalMs ?: preset.minIntervalMs
if (frameDropController.shouldDrop(intervalMs)) return
frameDropController.markSending(true)
try {
val compressed = withContext(Dispatchers.IO) {
frameCompressor.compress(bitmap, preset.maxResolution, preset.jpegQuality)
}
if (compressed != null) {
frameDropController.onFrameSent()
streamClient.sendFrame(compressed)
}
} catch (_: Exception) {
// Frame send failed — drop silently, don't affect capture
} finally {
frameDropController.markSending(false)
}
}
}
sealed class CaptureState {

View File

@@ -17,8 +17,11 @@ package org.fairscan.app.ui.screens.export
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.text.format.Formatter
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
@@ -32,6 +35,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -45,14 +49,18 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -108,6 +116,7 @@ fun ExportScreenWrapper(
uiState: ExportUiState,
currentDocument: DocumentUiModel,
pdfActions: ExportActions,
taskPanelState: TaskPanelState,
onCloseScan: () -> Unit,
) {
BackHandler { navigation.back() }
@@ -122,24 +131,37 @@ fun ExportScreenWrapper(
pdfActions.setFilename(newName)
}
val isBusy = uiState.isSaving
|| uiState.uploadState is UploadState.Uploading
|| taskPanelState.downloadState is DownloadState.Downloading
ExportScreen(
onFilenameChange = onFilenameChange,
uiState = uiState,
currentDocument = currentDocument,
navigation = navigation,
taskPanelState = taskPanelState,
onShare = {
if (!uiState.isSaving) {
pdfActions.share()
}
if (!isBusy) pdfActions.share()
},
onSave = {
if (!uiState.isSaving) {
pdfActions.save()
}
if (!isBusy) pdfActions.save()
},
onOpen = pdfActions.open,
onUploadToPc = {
if (!isBusy && uiState.uploadState !is UploadState.Uploading) {
pdfActions.uploadToPc()
}
},
onUploadAndProcess = { processType ->
if (!isBusy) {
pdfActions.uploadAndProcess(processType)
}
},
onDownloadResult = pdfActions.downloadResult,
onResetDownloadState = pdfActions.resetDownloadState,
onCloseScan = {
if (!uiState.isSaving) {
if (!isBusy) {
if (uiState.hasSavedOrShared || uiState.isResumedScan)
onCloseScan()
else
@@ -160,9 +182,14 @@ fun ExportScreen(
uiState: ExportUiState,
currentDocument: DocumentUiModel,
navigation: Navigation,
taskPanelState: TaskPanelState = TaskPanelState(),
onShare: () -> Unit,
onSave: () -> Unit,
onOpen: (SavedItem) -> Unit,
onUploadToPc: () -> Unit,
onUploadAndProcess: (processType: String) -> Unit,
onDownloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> },
onResetDownloadState: () -> Unit = {},
onCloseScan: () -> Unit,
) {
Scaffold(
@@ -187,7 +214,7 @@ fun ExportScreen(
) {
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
Spacer(Modifier.weight(1f)) // push buttons down
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
MainActions(onFilenameChange, uiState, onShare, onSave, onUploadToPc, onUploadAndProcess, onCloseScan, taskPanelState, onDownloadResult, onResetDownloadState)
}
} else {
Row(
@@ -202,7 +229,7 @@ fun ExportScreen(
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
}
Column(modifier = Modifier.weight(1f)) {
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
MainActions(onFilenameChange, uiState, onShare, onSave, onUploadToPc, onUploadAndProcess, onCloseScan, taskPanelState, onDownloadResult, onResetDownloadState)
}
}
@@ -363,7 +390,12 @@ private fun MainActions(
uiState: ExportUiState,
onShare: () -> Unit,
onSave: () -> Unit,
onUploadToPc: () -> Unit,
onUploadAndProcess: (processType: String) -> Unit,
onCloseScan: () -> Unit,
taskPanelState: TaskPanelState = TaskPanelState(),
onDownloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> },
onResetDownloadState: () -> Unit = {},
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -394,6 +426,13 @@ private fun MainActions(
)
}
}
// Upload to PC server
UploadToPcSection(uiState, onUploadToPc, onUploadAndProcess)
// Task management panel
TaskPanelSection(taskPanelState, onDownloadResult, onResetDownloadState)
ExportButton(
icon = Icons.Default.Done,
text = stringResource(R.string.scan_button),
@@ -404,6 +443,432 @@ private fun MainActions(
}
}
@Composable
private fun UploadToPcSection(
uiState: ExportUiState,
onUploadToPc: () -> Unit,
onUploadAndProcess: (processType: String) -> Unit,
) {
when (val uploadState = uiState.uploadState) {
is UploadState.Idle -> {
if (uiState.result is ExportResult.Pdf) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ExportButton(
icon = Icons.Default.CloudUpload,
text = "仅传输到电脑",
onClick = onUploadToPc,
modifier = Modifier.fillMaxWidth(),
isPrimary = false,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
ExportButton(
icon = Icons.Default.CloudUpload,
text = "上传并处理 (OCR PDF)",
onClick = { onUploadAndProcess("ocrpdf") },
modifier = Modifier.weight(1f),
isPrimary = false,
)
ExportButton(
icon = Icons.Default.CloudUpload,
text = "上传并处理 (Markdown)",
onClick = { onUploadAndProcess("markdown") },
modifier = Modifier.weight(1f),
isPrimary = false,
)
}
}
}
}
is UploadState.Uploading -> {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"上传到电脑中...",
style = MaterialTheme.typography.bodyMedium
)
val progressPercent = (uploadState.progress * 100).toInt()
LinearProgressIndicator(
progress = { uploadState.progress },
modifier = Modifier.fillMaxWidth()
)
Text(
"$progressPercent%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
is UploadState.Uploaded -> {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Column {
Text("已上传到电脑", style = MaterialTheme.typography.bodyMedium)
val statusText = if (uploadState.taskId != null) {
"处理任务已创建 (${uploadState.taskId.take(8)}...)"
} else {
"仅传输,未处理"
}
Text(
statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
}
}
}
}
}
is UploadState.Error -> {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(8.dp))
Text(
"上传失败",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
Text(
uploadState.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
OutlinedButton(
onClick = onUploadToPc,
modifier = Modifier.align(Alignment.End)
) {
Text("重试")
}
}
}
}
}
}
@Composable
private fun TaskPanelSection(
taskPanelState: TaskPanelState,
onDownloadResult: (RemoteTask, Uri, Context) -> Unit,
onResetDownloadState: () -> Unit,
) {
val context = LocalContext.current
val tasks = taskPanelState.tasks
// Track selected destination directories per task
val selectedDirs = remember { mutableStateOf(mapOf<String, Uri>()) }
// Track which task's directory picker is active
val pickingForTask = remember { mutableStateOf<String?>(null) }
val dirPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree()
) { uri ->
val taskId = pickingForTask.value
if (taskId != null && uri != null) {
// Take persistent permission
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
selectedDirs.value = selectedDirs.value + (taskId to uri)
}
pickingForTask.value = null
}
if (tasks.isEmpty()) return
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"任务管理",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
tasks.forEach { task ->
TaskRow(
task = task,
isDownloading = taskPanelState.downloadState is DownloadState.Downloading
&& (taskPanelState.downloadState as DownloadState.Downloading).taskId == task.taskId,
downloadProgress = if (taskPanelState.downloadState is DownloadState.Downloading
&& (taskPanelState.downloadState as DownloadState.Downloading).taskId == task.taskId
) (taskPanelState.downloadState as DownloadState.Downloading).progress else 0f,
isDownloaded = taskPanelState.downloadState is DownloadState.Downloaded
&& (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId,
downloadedUri = if (taskPanelState.downloadState is DownloadState.Downloaded
&& (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId
) (taskPanelState.downloadState as DownloadState.Downloaded).fileUri else null,
downloadError = if (taskPanelState.downloadState is DownloadState.Error
&& (taskPanelState.downloadState as DownloadState.Error).taskId == task.taskId)
(taskPanelState.downloadState as DownloadState.Error).message else null,
selectedDirUri = selectedDirs.value[task.taskId],
onSelectDir = {
pickingForTask.value = task.taskId
dirPickerLauncher.launch(null)
},
onDownload = { destUri ->
onDownloadResult(task, destUri, context)
},
onOpenDownloaded = {
// Open the downloaded file
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
val uri = if (taskPanelState.downloadState is DownloadState.Downloaded
&& (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId
) (taskPanelState.downloadState as DownloadState.Downloaded).fileUri else return@apply
setDataAndType(uri, when (task.processType) {
"markdown" -> "application/zip"
else -> "application/pdf"
})
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
try {
context.startActivity(intent)
} catch (_: Exception) {
// No app to handle this file type
}
},
onDismissError = onResetDownloadState,
)
}
}
}
}
@Composable
private fun TaskRow(
task: RemoteTask,
isDownloading: Boolean,
downloadProgress: Float,
isDownloaded: Boolean,
downloadedUri: android.net.Uri?,
downloadError: String?,
selectedDirUri: android.net.Uri?,
onSelectDir: () -> Unit,
onDownload: (Uri) -> Unit,
onOpenDownloaded: () -> Unit,
onDismissError: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Row 1: file name + type badge + status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = task.fileName.ifEmpty { task.taskId.take(8) + "..." },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
)
// Process type badge
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
) {
Text(
text = task.processType,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.primary,
)
}
// Status badge
val (statusText, statusColor) = when (task.status) {
"queued" -> "排队中" to Color(0xFFFFA726)
"processing" -> "处理中" to Color(0xFF42A5F5)
"completed" -> "已完成" to Color(0xFF66BB6A)
"failed" -> "失败" to Color(0xFFEF5350)
else -> task.status to Color.Gray
}
Text(
text = statusText,
style = MaterialTheme.typography.labelSmall,
color = statusColor,
)
}
// Row 2: Progress bar (for processing tasks)
if (task.status == "processing") {
LinearProgressIndicator(
progress = { task.progress / 100f },
modifier = Modifier.fillMaxWidth()
)
}
// Row 3: Error message (for failed tasks)
if (task.status == "failed" && task.message.isNotEmpty()) {
Text(
text = task.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
maxLines = 2,
)
}
// Row 4: Download actions for completed tasks
if (task.status == "completed" && !isDownloaded) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
if (selectedDirUri != null) {
Button(
onClick = { onDownload(selectedDirUri) },
enabled = !isDownloading,
contentPadding = PaddingValues(
start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp
),
modifier = Modifier.heightIn(min = 32.dp)
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(4.dp))
Text(
"${(downloadProgress * 100).toInt()}%",
style = MaterialTheme.typography.labelSmall
)
} else {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("下载", style = MaterialTheme.typography.labelSmall)
}
}
}
OutlinedButton(
onClick = onSelectDir,
contentPadding = PaddingValues(
start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp
),
modifier = Modifier.heightIn(min = 32.dp)
) {
Icon(
Icons.Default.Download, // reuse download icon for simplicity
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp))
Text(
if (selectedDirUri != null) "已选目录" else "选择目录",
style = MaterialTheme.typography.labelSmall
)
}
}
}
// Row 5: Downloaded state with open button
if (isDownloaded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp)
)
Text(
"已下载",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
OutlinedButton(
onClick = onOpenDownloaded,
contentPadding = PaddingValues(
start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp
),
modifier = Modifier.heightIn(min = 32.dp)
) {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp))
Text("打开", style = MaterialTheme.typography.labelSmall)
}
}
}
// Row 6: Download error
if (downloadError != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(12.dp)
)
Text(
downloadError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun ActionSurface(
modifier: Modifier = Modifier,
@@ -716,6 +1181,8 @@ fun ExportPreviewToCustomize(uiState: ExportUiState) {
onShare = {},
onSave = {},
onOpen = {},
onUploadToPc = {},
onUploadAndProcess = {},
onCloseScan = {},
)
}

View File

@@ -27,10 +27,55 @@ data class ExportUiState(
val hasShared: Boolean = false,
val error: ExportError? = null,
val isResumedScan: Boolean = false,
// Upload to PC
val uploadState: UploadState = UploadState.Idle,
// Task management panel
val taskPanelState: TaskPanelState = TaskPanelState(),
) {
val hasSavedOrShared get() = savedBundle != null || hasShared
}
/** State of the PDF upload to PC server. */
sealed class UploadState {
/** No upload in progress. */
data object Idle : UploadState()
/** Upload is in progress with [progress] 0.0..1.0. */
data class Uploading(val progress: Float) : UploadState()
/** Upload completed successfully. taskId is set when processing was also requested. */
data class Uploaded(val fileId: String, val taskId: String? = null) : UploadState()
/** Upload failed with an error message. */
data class Error(val message: String) : UploadState()
}
/** A remote processing task displayed in the task management panel. */
data class RemoteTask(
val fileId: String,
val taskId: String,
val processType: String, // "ocrpdf" | "markdown"
val status: String, // "queued" | "processing" | "completed" | "failed"
val progress: Int, // 0..100
val fileName: String = "",
val message: String = "",
)
/** State for the task management panel. */
data class TaskPanelState(
val tasks: List<RemoteTask> = emptyList(),
val downloadState: DownloadState = DownloadState.Idle,
)
/** Download state for a task artifact. */
sealed class DownloadState {
data object Idle : DownloadState()
data class Downloading(val taskId: String, val progress: Float) : DownloadState()
/** Download completed, providing the local file URI for the user to open. */
data class Downloaded(val taskId: String, val fileUri: android.net.Uri) : DownloadState()
data class Error(val taskId: String, val message: String) : DownloadState()
}
data class SavedItem(
val uri: Uri,
val fileName: String,

View File

@@ -31,6 +31,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -48,6 +49,9 @@ import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.pagesToExport
import org.fairscan.app.ui.screens.settings.ExportFormat
import org.fairscan.app.network.ServerEndpoint
import org.fairscan.app.network.tasks.TaskClient
import org.fairscan.app.network.upload.PdfUploadClient
import java.io.File
import java.io.FileInputStream
import java.io.IOException
@@ -69,6 +73,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
private val fileManager = container.fileManager
private val settingsRepository = container.settingsRepository
private val logger = container.logger
private val pdfUploadClient = container.pdfUploadClient
private val taskClient = container.taskClient
private val _events = MutableSharedFlow<ExportEvent>()
val events = _events.asSharedFlow()
@@ -93,6 +99,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
private val _uiState = MutableStateFlow(ExportUiState())
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()
// Task management panel
private val _taskPanelState = MutableStateFlow(TaskPanelState())
val taskPanelState: StateFlow<TaskPanelState> = _taskPanelState.asStateFlow()
private val activePollingJobs = mutableMapOf<String, Job>()
private var resumedScanKeys: List<PageViewKey> = emptyList()
init {
viewModelScope.launch {
@@ -397,6 +409,236 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
fileManager.cleanUpOldFiles(thresholdInMillis)
}
fun uploadPdfToServer() {
val result = _uiState.value.result ?: return
if (result !is ExportResult.Pdf) return
viewModelScope.launch {
_uiState.update { it.copy(uploadState = UploadState.Uploading(0f)) }
try {
val endpoint = resolveServerEndpoint() ?: return@launch
val uploadResult = withContext(Dispatchers.IO) {
pdfUploadClient.uploadPdf(endpoint, result.file) { progress ->
_uiState.update { it.copy(uploadState = UploadState.Uploading(progress)) }
}
}
_uiState.update {
it.copy(uploadState = UploadState.Uploaded(uploadResult.fileId))
}
android.util.Log.i("Upload", "PDF uploaded, fileId=${uploadResult.fileId}")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e("Upload", "Failed to upload PDF", e)
_uiState.update {
it.copy(uploadState = UploadState.Error(e.message ?: "上传失败"))
}
}
}
}
fun uploadAndProcess(processType: String = "ocrpdf") {
val result = _uiState.value.result ?: return
if (result !is ExportResult.Pdf) return
viewModelScope.launch {
_uiState.update { it.copy(uploadState = UploadState.Uploading(0f)) }
try {
val endpoint = resolveServerEndpoint() ?: return@launch
// Step 1: Upload
val uploadResult = withContext(Dispatchers.IO) {
pdfUploadClient.uploadPdf(endpoint, result.file) { progress ->
_uiState.update { it.copy(uploadState = UploadState.Uploading(progress)) }
}
}
// Step 2: Create processing task
val taskResult = withContext(Dispatchers.IO) {
taskClient.processPdf(endpoint, uploadResult.fileId, processType)
}
// Step 3: Add task to panel
val remoteTask = RemoteTask(
fileId = uploadResult.fileId,
taskId = taskResult.taskId,
processType = processType,
status = "queued",
progress = 0,
fileName = result.file.name,
)
_taskPanelState.update { state ->
state.copy(tasks = state.tasks + remoteTask)
}
// Step 4: Start background polling
startPolling(remoteTask, endpoint)
_uiState.update {
it.copy(uploadState = UploadState.Uploaded(uploadResult.fileId, taskResult.taskId))
}
android.util.Log.i("Upload", "PDF uploaded + task created: file=${uploadResult.fileId}, task=${taskResult.taskId}")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e("Upload", "Failed to upload & process PDF", e)
_uiState.update {
it.copy(uploadState = UploadState.Error(e.message ?: "上传处理失败"))
}
}
}
}
private fun startPolling(task: RemoteTask, endpoint: ServerEndpoint) {
val job = viewModelScope.launch {
try {
while (true) {
delay(2000)
val taskStatus = withContext(Dispatchers.IO) {
taskClient.getTaskStatus(endpoint, task.taskId)
}
_taskPanelState.update { state ->
val updated = state.tasks.map { t ->
if (t.taskId == task.taskId) {
t.copy(
status = taskStatus.status,
progress = taskStatus.progress,
message = taskStatus.message,
)
} else t
}
state.copy(tasks = updated)
}
if (taskStatus.status == "completed" || taskStatus.status == "failed") {
activePollingJobs.remove(task.taskId)
return@launch
}
}
} catch (e: CancellationException) {
// Polling cancelled
} catch (e: Exception) {
_taskPanelState.update { state ->
val updated = state.tasks.map { t ->
if (t.taskId == task.taskId) {
t.copy(status = "failed", message = e.message ?: "轮询失败")
} else t
}
state.copy(tasks = updated)
}
activePollingJobs.remove(task.taskId)
}
}
activePollingJobs[task.taskId] = job
}
fun downloadResult(task: RemoteTask, destDirUri: Uri, context: Context) {
viewModelScope.launch {
_taskPanelState.update { it.copy(downloadState = DownloadState.Downloading(task.taskId, 0f)) }
try {
val endpoint = resolveServerEndpointForTask(task.taskId) ?: return@launch
// List artifacts to find the preferred one
val artifacts = withContext(Dispatchers.IO) {
taskClient.listArtifacts(endpoint, task.taskId)
}
// Prefer ZIP for markdown, PDF for ocrpdf
val preferredArtifact = if (task.processType == "markdown") {
artifacts.find { it.fileType == "zip" } ?: artifacts.firstOrNull()
} else {
artifacts.find { it.fileType == "pdf" } ?: artifacts.firstOrNull()
}
if (preferredArtifact == null) {
_taskPanelState.update { it.copy(downloadState = DownloadState.Error(task.taskId, "没有可下载的产物")) }
return@launch
}
// Download to a temp file first, then copy to SAF
val tempFile = File(preparationDir, preferredArtifact.fileName)
withContext(Dispatchers.IO) {
taskClient.downloadArtifact(endpoint, preferredArtifact.artifactId, tempFile) { progress ->
_taskPanelState.update {
it.copy(downloadState = DownloadState.Downloading(task.taskId, progress))
}
}
}
// Copy to SAF directory
val safFile = withContext(Dispatchers.IO) {
val tree = DocumentFile.fromTreeUri(context, destDirUri)
?: throw IllegalStateException("Invalid SAF directory")
val target = tree.createFile(
preferredArtifact.fileType.let {
when (it) {
"zip" -> "application/zip"
"pdf" -> "application/pdf"
else -> "text/markdown"
}
},
preferredArtifact.fileName
) ?: throw IllegalStateException("Unable to create file in SAF directory")
context.contentResolver.openOutputStream(target.uri)?.use { output ->
tempFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IllegalStateException("Failed to open SAF output stream")
target
}
// Clean up temp file
tempFile.delete()
_taskPanelState.update {
it.copy(downloadState = DownloadState.Downloaded(task.taskId, safFile.uri))
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e("Download", "Failed to download artifact", e)
_taskPanelState.update {
it.copy(downloadState = DownloadState.Error(task.taskId, e.message ?: "下载失败"))
}
}
}
}
fun resetDownloadState() {
_taskPanelState.update { it.copy(downloadState = DownloadState.Idle) }
}
private suspend fun resolveServerEndpointForTask(taskId: String): ServerEndpoint? {
val host = settingsRepository.serverHost.first()
val port = settingsRepository.serverPort.first()
if (host.isNullOrBlank()) {
_taskPanelState.update {
it.copy(downloadState = DownloadState.Error(taskId, "未配置服务器地址"))
}
return null
}
return ServerEndpoint(host, port)
}
private suspend fun resolveServerEndpoint(): ServerEndpoint? {
val host = settingsRepository.serverHost.first()
val port = settingsRepository.serverPort.first()
if (host.isNullOrBlank()) {
_uiState.update {
it.copy(uploadState = UploadState.Error("未配置服务器地址"))
}
return null
}
return ServerEndpoint(host, port)
}
fun resetUploadState() {
_uiState.update { it.copy(uploadState = UploadState.Idle) }
}
private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? {
return if (exportDirUri == null) {
null
@@ -443,6 +685,10 @@ data class ExportActions(
val share: () -> Unit,
val save: () -> Unit,
val open: (SavedItem) -> Unit,
val uploadToPc: () -> Unit,
val uploadAndProcess: (processType: String) -> Unit,
val downloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> },
val resetDownloadState: () -> Unit = {},
)
class MissingExportDirPermissionException(

View File

@@ -35,6 +35,16 @@ class SettingsRepository(private val context: Context) {
private val EXPORT_FORMAT = stringPreferencesKey("export_format")
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
// Network collaboration settings
private val SERVER_HOST = stringPreferencesKey("server_host")
private val SERVER_PORT = stringPreferencesKey("server_port")
private val SERVER_DISPLAY_NAME = stringPreferencesKey("server_display_name")
private val LAST_SELECTED_SERVICE_ID = stringPreferencesKey("last_selected_service_id")
private val STREAM_QUALITY = stringPreferencesKey("stream_quality")
private val POST_PROCESS_MODE = stringPreferencesKey("post_process_mode")
private val AUTO_DOWNLOAD_PROCESSED_RESULT = stringPreferencesKey("auto_download_processed_result")
private val STREAM_FRAME_RATE = stringPreferencesKey("stream_frame_rate")
val defaultColorMode: Flow<DefaultColorMode> =
context.dataStore.data.map { prefs ->
when (prefs[DEFAULT_COLOR_MODE]) {
@@ -73,6 +83,61 @@ class SettingsRepository(private val context: Context) {
}
}
val serverHost: Flow<String?> =
context.dataStore.data.map { prefs ->
prefs[SERVER_HOST]
}
val serverPort: Flow<Int> =
context.dataStore.data.map { prefs ->
prefs[SERVER_PORT]?.toIntOrNull() ?: 2026
}
val serverDisplayName: Flow<String?> =
context.dataStore.data.map { prefs ->
prefs[SERVER_DISPLAY_NAME]
}
val lastSelectedServiceId: Flow<String?> =
context.dataStore.data.map { prefs ->
prefs[LAST_SELECTED_SERVICE_ID]
}
val streamQuality: Flow<StreamQuality> =
context.dataStore.data.map { prefs ->
when (prefs[STREAM_QUALITY]) {
"LOW" -> StreamQuality.LOW
"HIGH" -> StreamQuality.HIGH
"BALANCED", null -> StreamQuality.BALANCED
else -> StreamQuality.BALANCED
}
}
val postProcessMode: Flow<PostProcessMode> =
context.dataStore.data.map { prefs ->
when (prefs[POST_PROCESS_MODE]) {
"MARKDOWN" -> PostProcessMode.MARKDOWN
"OCRPDF", null -> PostProcessMode.OCRPDF
else -> PostProcessMode.OCRPDF
}
}
val autoDownloadProcessedResult: Flow<Boolean> =
context.dataStore.data.map { prefs ->
prefs[AUTO_DOWNLOAD_PROCESSED_RESULT]?.toBoolean() ?: false
}
val streamFrameRate: Flow<StreamFrameRate> =
context.dataStore.data.map { prefs ->
when (prefs[STREAM_FRAME_RATE]) {
"UNLIMITED" -> StreamFrameRate.UNLIMITED
"FPS_15" -> StreamFrameRate.FPS_15
"FPS_5" -> StreamFrameRate.FPS_5
"FPS_10", null -> StreamFrameRate.FPS_10
else -> StreamFrameRate.FPS_10
}
}
suspend fun setDefaultColorMode(mode: DefaultColorMode) {
context.dataStore.edit { prefs ->
prefs[DEFAULT_COLOR_MODE] = mode.name
@@ -100,6 +165,66 @@ class SettingsRepository(private val context: Context) {
prefs[EXPORT_QUALITY] = quality.name
}
}
suspend fun setServerHost(host: String?) {
context.dataStore.edit { prefs ->
if (host == null) {
prefs.remove(SERVER_HOST)
} else {
prefs[SERVER_HOST] = host
}
}
}
suspend fun setServerPort(port: Int) {
context.dataStore.edit { prefs ->
prefs[SERVER_PORT] = port.toString()
}
}
suspend fun setServerDisplayName(name: String?) {
context.dataStore.edit { prefs ->
if (name == null) {
prefs.remove(SERVER_DISPLAY_NAME)
} else {
prefs[SERVER_DISPLAY_NAME] = name
}
}
}
suspend fun setLastSelectedServiceId(id: String?) {
context.dataStore.edit { prefs ->
if (id == null) {
prefs.remove(LAST_SELECTED_SERVICE_ID)
} else {
prefs[LAST_SELECTED_SERVICE_ID] = id
}
}
}
suspend fun setStreamQuality(quality: StreamQuality) {
context.dataStore.edit { prefs ->
prefs[STREAM_QUALITY] = quality.name
}
}
suspend fun setPostProcessMode(mode: PostProcessMode) {
context.dataStore.edit { prefs ->
prefs[POST_PROCESS_MODE] = mode.name
}
}
suspend fun setAutoDownloadProcessedResult(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[AUTO_DOWNLOAD_PROCESSED_RESULT] = enabled.toString()
}
}
suspend fun setStreamFrameRate(rate: StreamFrameRate) {
context.dataStore.edit { prefs ->
prefs[STREAM_FRAME_RATE] = rate.name
}
}
}
enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) {
@@ -112,3 +237,28 @@ enum class ExportFormat(val mimeType: String) {
PDF("application/pdf"),
JPEG("image/jpeg"),
}
enum class StreamQuality {
LOW,
BALANCED,
HIGH,
}
enum class PostProcessMode {
MARKDOWN,
OCRPDF,
}
enum class StreamFrameRate(val labelRes: Int, val uiLabel: String) {
UNLIMITED(0, "无限制"),
FPS_15(0, "15 fps"),
FPS_10(0, "10 fps"),
FPS_5(0, "5 fps"),
}
val StreamFrameRate.intervalMs: Long? get() = when (this) {
StreamFrameRate.UNLIMITED -> null
StreamFrameRate.FPS_15 -> 66L
StreamFrameRate.FPS_10 -> 100L
StreamFrameRate.FPS_5 -> 200L
}

View File

@@ -30,21 +30,29 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fairscan.app.R
@@ -61,6 +69,14 @@ fun SettingsScreen(
onResetExportDirClick: () -> Unit,
onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
onServerHostChanged: (String?) -> Unit,
onServerPortChanged: (Int) -> Unit,
onStreamQualityChanged: (StreamQuality) -> Unit,
onPostProcessModeChanged: (PostProcessMode) -> Unit,
onAutoDownloadChanged: (Boolean) -> Unit,
onStreamFrameRateChanged: (StreamFrameRate) -> Unit,
onScanNetworkHostsClick: () -> Unit,
onTestConnectionClick: () -> Unit,
onBack: () -> Unit,
) {
BackHandler { onBack() }
@@ -79,6 +95,14 @@ fun SettingsScreen(
onResetExportDirClick,
onExportFormatChanged,
onExportQualityChanged,
onServerHostChanged,
onServerPortChanged,
onStreamQualityChanged,
onPostProcessModeChanged,
onAutoDownloadChanged,
onStreamFrameRateChanged,
onScanNetworkHostsClick,
onTestConnectionClick,
modifier = Modifier.padding(paddingValues))
}
}
@@ -91,6 +115,14 @@ private fun SettingsContent(
onResetExportDirClick: () -> Unit,
onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
onServerHostChanged: (String?) -> Unit,
onServerPortChanged: (Int) -> Unit,
onStreamQualityChanged: (StreamQuality) -> Unit,
onPostProcessModeChanged: (PostProcessMode) -> Unit,
onAutoDownloadChanged: (Boolean) -> Unit,
onStreamFrameRateChanged: (StreamFrameRate) -> Unit,
onScanNetworkHostsClick: () -> Unit,
onTestConnectionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val (folderLabel, folderLabelColor) = when {
@@ -170,6 +202,157 @@ private fun SettingsContent(
label = { t -> t.name},
selectedValue = uiState.exportFormat
)
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
Text(stringResource(R.string.settings_section_network), style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
// Server configuration
Column {
Text("PC 服务器设置", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
var hostInput by remember { mutableStateOf(uiState.serverHost ?: "") }
LaunchedEffect(uiState.serverHost) {
hostInput = uiState.serverHost ?: ""
}
OutlinedTextField(
value = hostInput,
onValueChange = {
hostInput = it
onServerHostChanged(it.ifEmpty { null })
},
label = { Text("主机地址") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
singleLine = true
)
var portInput by remember { mutableStateOf(uiState.serverPort.toString()) }
LaunchedEffect(uiState.serverPort) {
portInput = uiState.serverPort.toString()
}
OutlinedTextField(
value = portInput,
onValueChange = { newValue ->
portInput = newValue
newValue.toIntOrNull()?.let { onServerPortChanged(it) }
},
label = { Text("端口") },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
singleLine = true
)
if (uiState.serverDisplayName != null) {
Text(
"已连接: ${uiState.serverDisplayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onScanNetworkHostsClick,
modifier = Modifier.weight(1f)
) {
Text("扫描主机")
}
OutlinedButton(
onClick = onTestConnectionClick,
modifier = Modifier.weight(1f)
) {
Text("测试连接")
}
}
}
Spacer(Modifier.height(16.dp))
// Stream quality
RadioButtonGroup(
R.string.stream_quality,
StreamQuality.entries,
onClick = onStreamQualityChanged,
label = { t -> when (t) {
StreamQuality.LOW -> "低 (640p, 45%, 8-12fps)"
StreamQuality.BALANCED -> "均衡 (960p, 60%, 6-10fps)"
StreamQuality.HIGH -> "高 (1280p, 75%, 5-8fps)"
} },
selectedValue = uiState.streamQuality
)
Spacer(Modifier.height(16.dp))
// Post process mode
RadioButtonGroup(
R.string.post_process_mode,
PostProcessMode.entries,
onClick = onPostProcessModeChanged,
label = { t -> when (t) {
PostProcessMode.MARKDOWN -> "Markdown (MinerU)"
PostProcessMode.OCRPDF -> "OCR PDF (OCRmyPDF)"
} },
selectedValue = uiState.postProcessMode
)
Spacer(Modifier.height(16.dp))
// Stream frame rate control
Text("图传帧率", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
StreamFrameRate.entries.forEach { rate ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onStreamFrameRateChanged(rate) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = uiState.streamFrameRate == rate,
onClick = null,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp)
)
val desc = when (rate) {
StreamFrameRate.UNLIMITED -> "无限制(每帧都发)"
StreamFrameRate.FPS_15 -> "15 fps66ms 间隔)"
StreamFrameRate.FPS_10 -> "10 fps100ms 间隔)"
StreamFrameRate.FPS_5 -> "5 fps200ms 间隔)"
}
Text(desc, style = MaterialTheme.typography.bodyMedium)
}
}
Spacer(Modifier.height(16.dp))
// Auto download
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onAutoDownloadChanged(!uiState.autoDownloadProcessedResult) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("自动下载处理结果")
Checkbox(
checked = uiState.autoDownloadProcessedResult,
onCheckedChange = { onAutoDownloadChanged(it) }
)
}
}
}
@@ -267,6 +450,14 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
onResetExportDirClick = {},
onExportFormatChanged = {},
onExportQualityChanged = {},
onServerHostChanged = {},
onServerPortChanged = {},
onStreamQualityChanged = {},
onPostProcessModeChanged = {},
onAutoDownloadChanged = {},
onStreamFrameRateChanged = {},
onScanNetworkHostsClick = {},
onTestConnectionClick = {},
onBack = {}
)
}

View File

@@ -32,6 +32,15 @@ data class SettingsUiState(
val exportDirName: String? = null,
val exportFormat: ExportFormat = ExportFormat.PDF,
val exportQuality: ExportQuality = ExportQuality.BALANCED,
// Network collaboration settings
val serverHost: String? = null,
val serverPort: Int = 2026,
val serverDisplayName: String? = null,
val lastSelectedServiceId: String? = null,
val streamQuality: StreamQuality = StreamQuality.BALANCED,
val postProcessMode: PostProcessMode = PostProcessMode.OCRPDF,
val autoDownloadProcessedResult: Boolean = false,
val streamFrameRate: StreamFrameRate = StreamFrameRate.FPS_10,
)
class SettingsViewModel(container: AppContainer) : ViewModel() {
@@ -47,13 +56,29 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
dirName,
repo.exportFormat,
repo.exportQuality,
) { colorMode, uri, name, format, quality ->
repo.serverHost,
repo.serverPort,
repo.serverDisplayName,
repo.lastSelectedServiceId,
repo.streamQuality,
repo.postProcessMode,
repo.autoDownloadProcessedResult,
repo.streamFrameRate,
) { values: Array<Any?> ->
SettingsUiState(
defaultColorMode = colorMode,
exportDirUri = uri,
exportDirName = name,
exportFormat = format,
exportQuality = quality,
defaultColorMode = values[0] as DefaultColorMode,
exportDirUri = values[1] as String?,
exportDirName = values[2] as String?,
exportFormat = values[3] as ExportFormat,
exportQuality = values[4] as ExportQuality,
serverHost = values[5] as String?,
serverPort = values[6] as Int,
serverDisplayName = values[7] as String?,
lastSelectedServiceId = values[8] as String?,
streamQuality = values[9] as StreamQuality,
postProcessMode = values[10] as PostProcessMode,
autoDownloadProcessedResult = values[11] as Boolean,
streamFrameRate = values[12] as StreamFrameRate,
)
}.stateIn(
viewModelScope,
@@ -92,4 +117,52 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
_dirName.value = uri?.let { repo.resolveExportDirName(it) }
}
}
fun setServerHost(host: String?) {
viewModelScope.launch {
repo.setServerHost(host)
}
}
fun setServerPort(port: Int) {
viewModelScope.launch {
repo.setServerPort(port)
}
}
fun setServerDisplayName(name: String?) {
viewModelScope.launch {
repo.setServerDisplayName(name)
}
}
fun setLastSelectedServiceId(id: String?) {
viewModelScope.launch {
repo.setLastSelectedServiceId(id)
}
}
fun setStreamQuality(quality: StreamQuality) {
viewModelScope.launch {
repo.setStreamQuality(quality)
}
}
fun setPostProcessMode(mode: PostProcessMode) {
viewModelScope.launch {
repo.setPostProcessMode(mode)
}
}
fun setAutoDownloadProcessedResult(enabled: Boolean) {
viewModelScope.launch {
repo.setAutoDownloadProcessedResult(enabled)
}
}
fun setStreamFrameRate(rate: StreamFrameRate) {
viewModelScope.launch {
repo.setStreamFrameRate(rate)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
<foreground android:drawable="@drawable/icon"/>
<monochrome android:drawable="@drawable/icon" />
</adaptive-icon>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
<foreground android:drawable="@drawable/icon"/>
<monochrome android:drawable="@drawable/icon" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0CAD55</color>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -63,6 +63,9 @@
<string name="settings">Settings</string>
<string name="settings_section_scan">Scan</string>
<string name="settings_section_export">Export</string>
<string name="settings_section_network">Network Collaboration</string>
<string name="stream_quality">Stream Quality</string>
<string name="post_process_mode">Post Process Mode</string>
<string name="share">Share</string>
<string name="share_document">Share document</string>
<string name="storage_permission_denied">Cannot save file: permission was denied</string>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>