- 实时图传: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>
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
188
app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 fps(66ms 间隔)"
|
||||
StreamFrameRate.FPS_10 -> "10 fps(100ms 间隔)"
|
||||
StreamFrameRate.FPS_5 -> "5 fps(200ms 间隔)"
|
||||
}
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 68 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
4
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||