升级 Gradle → 8.14、AGP → 8.11、Kotlin → 2.2.20 JVM 堆降到 -Xmx4G

This commit is contained in:
2026-06-04 13:35:10 +08:00
parent 250f21a2b8
commit 66435302b3
22 changed files with 755 additions and 291 deletions

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.gdfw.fxjk">
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -8,6 +9,10 @@
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" /> <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-feature <uses-feature
android:name="android.hardware.camera" android:name="android.hardware.camera"

View File

@@ -5,7 +5,6 @@ import android.util.Log
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder import androidx.camera.video.Recorder
@@ -15,10 +14,6 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executor import java.util.concurrent.Executor
class RecordingCameraController( class RecordingCameraController(
@@ -39,6 +34,7 @@ class RecordingCameraController(
private var recordingStartedAt: Long = 0L private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null private var latestOutputPath: String? = null
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview( fun bindPreview(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -118,6 +114,7 @@ class RecordingCameraController(
fun startRecording( fun startRecording(
withAudio: Boolean, withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit, onStarted: (Boolean, String?) -> Unit,
) { ) {
val capture = videoCapture val capture = videoCapture
@@ -131,9 +128,12 @@ class RecordingCameraController(
return return
} }
val outputFile = createOutputFile() val outputOptions =
latestOutputPath = outputFile.absolutePath RecordingOutputFactory.buildMediaStoreOutputOptions(
val outputOptions = FileOutputOptions.Builder(outputFile).build() appContext,
displayName,
)
latestOutputPath = null
val pending = val pending =
capture.output.prepareRecording(appContext, outputOptions).apply { capture.output.prepareRecording(appContext, outputOptions).apply {
@@ -171,6 +171,7 @@ class RecordingCameraController(
), ),
) )
} else { } else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.PREVIEWING, RecordingState.PREVIEWING,
@@ -179,11 +180,14 @@ class RecordingCameraController(
), ),
) )
} }
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
} }
} }
} }
onStarted(true, latestOutputPath) onStarted(true, latestOutputPath ?: "recording")
} }
fun stopRecording(onStopped: (String?) -> Unit) { fun stopRecording(onStopped: (String?) -> Unit) {
@@ -193,6 +197,7 @@ class RecordingCameraController(
return return
} }
pendingStopCallback = onStopped
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
RecordingState.STOPPING, RecordingState.STOPPING,
@@ -202,7 +207,6 @@ class RecordingCameraController(
recording.stop() recording.stop()
activeRecording = null activeRecording = null
onStopped(latestOutputPath)
} }
fun unbind() { fun unbind() {
@@ -221,16 +225,6 @@ class RecordingCameraController(
return System.currentTimeMillis() - recordingStartedAt return System.currentTimeMillis() - recordingStartedAt
} }
private fun createOutputFile(): File {
val moviesDir = File(appContext.getExternalFilesDir(null), "recordings")
if (!moviesDir.exists()) {
moviesDir.mkdirs()
}
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return File(moviesDir, "REC_$timestamp.mp4")
}
private fun updateStatus(next: RecordingStatus) { private fun updateStatus(next: RecordingStatus) {
status = next status = next
statusListener?.invoke(next) statusListener?.invoke(next)

View File

@@ -0,0 +1,51 @@
package com.gdfw.fxjk.recording
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import androidx.camera.video.MediaStoreOutputOptions
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控"
private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions(
context: Context,
displayName: String?,
): MediaStoreOutputOptions {
val fileName = resolveFileName(displayName)
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
}
}
return MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
)
.setContentValues(contentValues)
.build()
}
fun resolveFileName(displayName: String?): String {
val trimmed = displayName?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
trimmed
} else {
"$trimmed.mp4"
}
}
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}

View File

@@ -52,7 +52,8 @@ class RecordingPlatformHandler(
"startRecording" -> { "startRecording" -> {
val withAudio = call.argument<Boolean>("withAudio") ?: true val withAudio = call.argument<Boolean>("withAudio") ?: true
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: true val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: true
startRecording(withAudio, enableDnd, result) val displayName = call.argument<String>("displayName")
startRecording(withAudio, enableDnd, displayName, result)
} }
"stopRecording" -> stopRecording(result) "stopRecording" -> stopRecording(result)
"disposePreview" -> { "disposePreview" -> {
@@ -110,6 +111,7 @@ class RecordingPlatformHandler(
private fun startRecording( private fun startRecording(
withAudio: Boolean, withAudio: Boolean,
enableDnd: Boolean, enableDnd: Boolean,
displayName: String?,
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
val previewView = activity.recordingPreviewView val previewView = activity.recordingPreviewView
@@ -125,7 +127,7 @@ class RecordingPlatformHandler(
DoNotDisturbHelper.enable(activity) DoNotDisturbHelper.enable(activity)
} }
controller.startRecording(withAudio) { started, message -> controller.startRecording(withAudio, displayName) { started, message ->
mainHandler.post { mainHandler.post {
if (started) { if (started) {
startElapsedTicker() startElapsedTicker()
@@ -170,12 +172,19 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity) RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity) DoNotDisturbHelper.disable(activity)
mainHandler.post { mainHandler.post {
result.success( val gallerySaved =
mapOf( path != null &&
"outputPath" to path, controller.status.state != RecordingState.ERROR
"status" to controller.status.toMap(), val payload = mutableMapOf<String, Any?>(
), "outputPath" to path,
"status" to controller.status.toMap(),
"gallerySaved" to gallerySaved,
) )
if (!gallerySaved) {
payload["galleryErrorMessage"] =
controller.status.message ?: "保存到相册失败"
}
result.success(payload)
} }
} }
} }

View File

@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator # This builtInKotlin flag was added automatically by Flutter migrator

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
include(":app") include(":app")

View File

@@ -45,6 +45,8 @@ post_install do |installer|
'$(inherited)', '$(inherited)',
'PERMISSION_CAMERA=1', 'PERMISSION_CAMERA=1',
'PERMISSION_MICROPHONE=1', 'PERMISSION_MICROPHONE=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS_ADD_ONLY=1',
] ]
end end
end end

View File

@@ -30,6 +30,8 @@
<string>需要访问相机以显示预览并录制视频。</string> <string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string> <string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要将录制的视频保存到相册。</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -1,5 +1,6 @@
import AVFoundation import AVFoundation
import Flutter import Flutter
import Photos
import UIKit import UIKit
private enum RecordingState: String { private enum RecordingState: String {
@@ -109,6 +110,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var audioInput: AVCaptureDeviceInput? private var audioInput: AVCaptureDeviceInput?
private var configured = false private var configured = false
private var latestOutputPath: String? private var latestOutputPath: String?
private var latestGallerySaved = true
private var latestGalleryErrorMessage: String?
private var pendingDisplayName: String?
private var recordingStartedAt: Date? private var recordingStartedAt: Date?
private var elapsedTimer: Timer? private var elapsedTimer: Timer?
private var pendingStopResult: FlutterResult? private var pendingStopResult: FlutterResult?
@@ -170,7 +174,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
} }
func startRecording(withAudio: Bool, result: @escaping FlutterResult) { func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
guard previewView != nil else { guard previewView != nil else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil)) result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return return
@@ -192,8 +196,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
let outputURL = try self.createOutputURL() self.pendingDisplayName = displayName
self.latestOutputPath = outputURL.path self.latestGallerySaved = true
self.latestGalleryErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.lastPathComponent
self.recordingStartedAt = Date() self.recordingStartedAt = Date()
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path)) self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self) self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
@@ -226,10 +233,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard self.movieOutput.isRecording else { guard self.movieOutput.isRecording else {
DispatchQueue.main.async { DispatchQueue.main.async {
result([ var payload: [String: Any] = [
"outputPath": self.latestOutputPath, "outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(), "status": self.currentStatusMap(),
]) "gallerySaved": self.latestGallerySaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败"
}
result(payload)
} }
return return
} }
@@ -291,24 +304,104 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil pendingStopResult = nil
if let error { if let error {
latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
} else { finishStopRecording(stopResult: stopResult)
updateStatus( return
RecordingStatus(
state: .previewing,
outputPath: latestOutputPath,
elapsedMillis: elapsedMillis()
)
)
} }
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
guard let self else { return }
self.latestGallerySaved = success
self.latestGalleryErrorMessage = message
if success {
self.updateStatus(
RecordingStatus(
state: .previewing,
outputPath: self.latestOutputPath,
elapsedMillis: self.elapsedMillis()
)
)
} else {
self.updateStatus(
RecordingStatus(
state: .error,
outputPath: self.latestOutputPath,
message: message ?? "保存到相册失败"
)
)
}
self.finishStopRecording(stopResult: stopResult)
}
}
private func finishStopRecording(stopResult: FlutterResult?) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.stopElapsedTimer() self.stopElapsedTimer()
stopResult?([ var payload: [String: Any] = [
"outputPath": self.latestOutputPath, "outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(), "status": self.currentStatusMap(),
]) "gallerySaved": self.latestGallerySaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
}
stopResult?(payload)
}
}
private func saveVideoToPhotoLibrary(
fileURL: URL,
completion: @escaping (Bool, String?) -> Void
) {
let performSave = {
PHPhotoLibrary.shared().performChanges({
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
}) { success, error in
if success {
try? FileManager.default.removeItem(at: fileURL)
completion(true, nil)
} else {
completion(false, error?.localizedDescription ?? "保存到相册失败")
}
}
}
if #available(iOS 14, *) {
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
switch status {
case .authorized, .limited:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
if newStatus == .authorized || newStatus == .limited {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
} else {
let status = PHPhotoLibrary.authorizationStatus()
switch status {
case .authorized:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization { newStatus in
if newStatus == .authorized {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
} }
} }
@@ -370,7 +463,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
} }
private func createOutputURL() throws -> URL { private func createOutputURL(displayName: String?) throws -> URL {
let baseURL = try FileManager.default.url( let baseURL = try FileManager.default.url(
for: .documentDirectory, for: .documentDirectory,
in: .userDomainMask, in: .userDomainMask,
@@ -380,11 +473,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true) let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true)
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
let fileName = Self.resolveFileName(displayName: displayName)
return recordingsURL.appendingPathComponent(fileName)
}
private static func resolveFileName(displayName: String?) -> String {
let trimmed = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
let lower = trimmed.lowercased()
if lower.hasSuffix(".mov") || lower.hasSuffix(".mp4") {
return trimmed
}
return "\(trimmed).mov"
}
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd_HHmmss" formatter.dateFormat = "yyyyMMdd_HHmmss"
let filename = "REC_\(formatter.string(from: Date())).mov" return "REC_\(formatter.string(from: Date())).mov"
return recordingsURL.appendingPathComponent(filename)
} }
private func updateStatus(_ next: RecordingStatus) { private func updateStatus(_ next: RecordingStatus) {
@@ -450,7 +555,8 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
case "startRecording": case "startRecording":
let args = call.arguments as? [String: Any] let args = call.arguments as? [String: Any]
let withAudio = args?["withAudio"] as? Bool ?? true let withAudio = args?["withAudio"] as? Bool ?? true
controller.startRecording(withAudio: withAudio, result: result) let displayName = args?["displayName"] as? String
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
case "stopRecording": case "stopRecording":
controller.stopRecording(result: result) controller.stopRecording(result: result)
case "disposePreview": case "disposePreview":

View File

@@ -1,15 +1,19 @@
/// 剪切板内容数据模型 /// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel { class ClipboardRecordingModel {
final String title; final String title;
final int startTimestamp; final int startTimestamp;
final int endTimestamp; final int endTimestamp;
final String address; final String address;
/// 录制文件名模板如「选手名称_选手ID_赛事名称_赛项」。
final String? filename;
ClipboardRecordingModel({ ClipboardRecordingModel({
required this.title, required this.title,
required this.startTimestamp, required this.startTimestamp,
required this.endTimestamp, required this.endTimestamp,
required this.address, required this.address,
this.filename,
}); });
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) { factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
@@ -18,6 +22,7 @@ class ClipboardRecordingModel {
startTimestamp: _readInt(json, 'startTimestamp'), startTimestamp: _readInt(json, 'startTimestamp'),
endTimestamp: _readInt(json, 'endTimestamp'), endTimestamp: _readInt(json, 'endTimestamp'),
address: _readString(json, 'address'), address: _readString(json, 'address'),
filename: _readOptionalString(json, 'filename'),
); );
} }
@@ -27,9 +32,20 @@ class ClipboardRecordingModel {
'startTimestamp': startTimestamp, 'startTimestamp': startTimestamp,
'endTimestamp': endTimestamp, 'endTimestamp': endTimestamp,
'address': address, 'address': address,
if (filename != null) 'filename': filename,
}; };
} }
static String? _readOptionalString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) return null;
if (value is String && value.isNotEmpty) return value;
if (value is! String) {
throw FormatException('Clipboard field "$key" must be a String.');
}
return null;
}
static String _readString(Map<String, dynamic> json, String key) { static String _readString(Map<String, dynamic> json, String key) {
final value = json[key]; final value = json[key];
if (value is String) return value; if (value is String) return value;

View File

@@ -4,7 +4,13 @@ class RecordingModel {
/// 剪切板内容 /// 剪切板内容
final ClipboardRecordingModel clipboardRecordingModel; final ClipboardRecordingModel clipboardRecordingModel;
RecordingModel({required this.clipboardRecordingModel}); /// 剪切板是否包含有效的小程序录制信息
final bool hasValidClipboardInfo;
RecordingModel({
required this.clipboardRecordingModel,
this.hasValidClipboardInfo = false,
});
factory RecordingModel.fromJson(Map<String, dynamic> json) { factory RecordingModel.fromJson(Map<String, dynamic> json) {
return RecordingModel( return RecordingModel(
@@ -17,10 +23,15 @@ class RecordingModel {
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()}; return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
} }
RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) { RecordingModel copyWith({
ClipboardRecordingModel? clipboardRecordingModel,
bool? hasValidClipboardInfo,
}) {
return RecordingModel( return RecordingModel(
clipboardRecordingModel: clipboardRecordingModel:
clipboardRecordingModel ?? this.clipboardRecordingModel, clipboardRecordingModel ?? this.clipboardRecordingModel,
hasValidClipboardInfo:
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
); );
} }
} }

View File

@@ -0,0 +1,53 @@
import 'dart:io';
/// 非法文件名字符(路径分隔符等)。
final _invalidNameChars = RegExp(r'[/\\:*?"<>|]');
const _maxBaseNameLength = 120;
/// 清洗小程序复制的文件名基底(不含扩展名)。
String? sanitizeRecordingBaseName(String raw) {
var name = raw.replaceAll(_invalidNameChars, '_').trim();
if (name.isEmpty) return null;
if (name.length > _maxBaseNameLength) {
name = name.substring(0, _maxBaseNameLength);
}
return name;
}
/// 解析录制展示名:优先剪切板 filename否则 REC_时间戳。
String resolveRecordingDisplayName(String? clipboardFilename) {
final sanitized = clipboardFilename == null
? null
: sanitizeRecordingBaseName(clipboardFilename);
if (sanitized != null) return sanitized;
final now = DateTime.now();
final stamp =
'${now.year}'
'${now.month.toString().padLeft(2, '0')}'
'${now.day.toString().padLeft(2, '0')}_'
'${now.hour.toString().padLeft(2, '0')}'
'${now.minute.toString().padLeft(2, '0')}'
'${now.second.toString().padLeft(2, '0')}';
return 'REC_$stamp';
}
/// 为展示名补全视频扩展名(已有 .mp4/.mov 则保留)。
String withVideoExtension(String baseName, {bool? isIOS}) {
final ios = isIOS ?? Platform.isIOS;
final ext = ios ? '.mov' : '.mp4';
final lower = baseName.toLowerCase();
if (lower.endsWith('.mp4') || lower.endsWith('.mov')) {
return baseName;
}
return '$baseName$ext';
}
/// 传给原生的完整文件名(含扩展名)。
String recordingFileNameForPlatform(
String? clipboardFilename, {
bool? isIOS,
}) {
final base = resolveRecordingDisplayName(clipboardFilename);
return withVideoExtension(base, isIOS: isIOS);
}

View File

@@ -3,13 +3,13 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/recording_platform.dart'; import 'package:recording_tool/features/recording/recording_platform.dart';
import 'package:recording_tool/features/recording/recording_session_controller.dart'; import 'package:recording_tool/features/recording/recording_session_controller.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart'; import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart';
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart'; import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart'; import 'package:recording_tool/shared/widgets/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingPage extends ConsumerStatefulWidget { class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key}); const RecordingPage({super.key});
@@ -28,7 +28,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
} }
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
await ref.read(recordingViewModelProvider.notifier).getClipboardContent(); final clipboardResult = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (clipboardResult == ClipboardReadResult.invalid) {
AppToast.show('无选手信息');
}
await _enterRecordingMode(); await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview. // Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400)); await Future<void>.delayed(const Duration(milliseconds: 400));
@@ -71,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider); final state = ref.watch(recordingSessionControllerProvider);
final recordingInfo = ref.watch(recordingViewModelProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier); final controller = ref.read(recordingSessionControllerProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
return PopScope( return PopScope(
canPop: !state.isRecording, canPop: !state.isRecording,
@@ -97,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
), ),
_RecordingHud( _RecordingHud(
state: state, state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
onStart: () => controller.startRecording(), onStart: () => controller.startRecording(),
onStop: () => controller.stopRecording(), onStop: () async {
await controller.stopRecording();
if (!context.mounted) return;
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
}
},
onOpenDnd: () async { onOpenDnd: () async {
await controller.openDndSettings(); await controller.openDndSettings();
await controller.refreshDndAccess(); await controller.refreshDndAccess();
@@ -121,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
class _RecordingHud extends StatelessWidget { class _RecordingHud extends StatelessWidget {
const _RecordingHud({ const _RecordingHud({
required this.state, required this.state,
this.eventTitle,
this.eventAddress,
required this.onStart, required this.onStart,
required this.onStop, required this.onStop,
required this.onOpenDnd, required this.onOpenDnd,
@@ -129,117 +149,166 @@ class _RecordingHud extends StatelessWidget {
}); });
final RecordingSessionState state; final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final VoidCallback onStart; final VoidCallback onStart;
final VoidCallback onStop; final VoidCallback onStop;
final VoidCallback onOpenDnd; final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery; final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock; final VoidCallback onToggleTouchLock;
static const _overlayTextStyle = TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
child: Column( child: Stack(
children: [ children: [
Padding( Column(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), children: [
child: Row( SizedBox(
children: [ height: eventTitle != null || state.isRecording ? 56 : 8,
const Spacer(), ),
if (state.isRecording) const Spacer(),
Container( if (state.errorMessage != null)
padding: const EdgeInsets.symmetric( Padding(
horizontal: 12, padding: const EdgeInsets.all(12),
vertical: 6, child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
state.permissionWarning!,
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 12,
), ),
decoration: BoxDecoration( textAlign: TextAlign.center,
color: Colors.red, ),
borderRadius: BorderRadius.circular(20), ),
), _SetupHints(
child: Text( hasDndAccess: state.hasDndAccess,
'REC ${state.elapsedLabel}', isBatteryIgnored: state.isBatteryOptimizedIgnored,
style: const TextStyle( notificationsGranted: state.notificationsGranted,
color: Colors.white, onOpenDnd: onOpenDnd,
fontWeight: FontWeight.bold, onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording
? Icons.stop
: Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
), ),
), ),
), const SizedBox(width: 48),
], ],
),
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
state.permissionWarning!,
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 12,
), ),
textAlign: TextAlign.center,
), ),
), if (state.lastSavedDisplayName != null &&
_SetupHints( !state.isRecording &&
hasDndAccess: state.hasDndAccess, !state.gallerySaveFailed)
isBatteryIgnored: state.isBatteryOptimizedIgnored, Padding(
notificationsGranted: state.notificationsGranted, padding: const EdgeInsets.only(bottom: 16),
onOpenDnd: onOpenDnd, child: Text(
onOpenBattery: onOpenBattery, '已保存到相册:${state.lastSavedDisplayName}',
onOpenNotificationSettings: openAppSettings, style: const TextStyle(color: Colors.white70, fontSize: 12),
), textAlign: TextAlign.center,
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording
? Icons.stop
: Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
), ),
), ),
const SizedBox(width: 48), ],
],
),
), ),
if (state.lastOutputPath != null && !state.isRecording) if (eventTitle != null)
Padding( Positioned(
padding: const EdgeInsets.only(bottom: 16), top: 8,
left: 12,
right: 12,
child: Padding(
padding: EdgeInsets.only(right: state.isRecording ? 96 : 0),
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
if (state.isRecording)
Positioned(
top: 8,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
if (eventAddress != null && eventAddress!.isNotEmpty)
Positioned(
left: 16,
bottom: 108,
right: 120,
child: Text( child: Text(
'已保存:${state.lastOutputPath}', eventAddress!,
style: const TextStyle(color: Colors.white70, fontSize: 12), style: _overlayTextStyle.copyWith(
textAlign: TextAlign.center, fontSize: 13,
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],

View File

@@ -84,12 +84,14 @@ class RecordingPlatform {
static Future<RecordingStartResult> startRecording({ static Future<RecordingStartResult> startRecording({
bool withAudio = true, bool withAudio = true,
bool enableDoNotDisturb = true, bool enableDoNotDisturb = true,
String? displayName,
}) async { }) async {
final result = await _channel.invokeMapMethod<String, dynamic>( final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording', 'startRecording',
<String, dynamic>{ <String, dynamic>{
'withAudio': withAudio, 'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb, 'enableDoNotDisturb': enableDoNotDisturb,
if (displayName != null) 'displayName': displayName,
}, },
); );
return RecordingStartResult( return RecordingStartResult(
@@ -104,12 +106,7 @@ class RecordingPlatform {
final result = await _channel.invokeMapMethod<String, dynamic>( final result = await _channel.invokeMapMethod<String, dynamic>(
'stopRecording', 'stopRecording',
); );
return RecordingStopResult( return RecordingStopResult.fromMap(result);
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
} }
static Future<void> disposePreview() => static Future<void> disposePreview() =>
@@ -163,8 +160,26 @@ class RecordingStartResult {
} }
class RecordingStopResult { class RecordingStopResult {
const RecordingStopResult({this.outputPath, required this.status}); const RecordingStopResult({
this.outputPath,
required this.status,
this.gallerySaved = true,
this.galleryErrorMessage,
});
final String? outputPath; final String? outputPath;
final RecordingStatus status; final RecordingStatus status;
final bool gallerySaved;
final String? galleryErrorMessage;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
gallerySaved: result?['gallerySaved'] as bool? ?? true,
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
);
}
} }

View File

@@ -4,7 +4,9 @@ import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/core/permission/permission_service.dart'; import 'package:recording_tool/core/permission/permission_service.dart';
import 'package:recording_tool/features/recording/recording_display_name.dart';
import 'package:recording_tool/features/recording/recording_platform.dart'; import 'package:recording_tool/features/recording/recording_platform.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
class RecordingSessionState { class RecordingSessionState {
@@ -17,8 +19,10 @@ class RecordingSessionState {
this.notificationsGranted = true, this.notificationsGranted = true,
this.isMicrophoneGranted = false, this.isMicrophoneGranted = false,
this.lastOutputPath, this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.gallerySaveFailed = false,
}); });
final RecordingStatus status; final RecordingStatus status;
@@ -29,8 +33,10 @@ class RecordingSessionState {
final bool notificationsGranted; final bool notificationsGranted;
final bool isMicrophoneGranted; final bool isMicrophoneGranted;
final String? lastOutputPath; final String? lastOutputPath;
final String? lastSavedDisplayName;
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool gallerySaveFailed;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -50,9 +56,12 @@ class RecordingSessionState {
bool? notificationsGranted, bool? notificationsGranted,
bool? isMicrophoneGranted, bool? isMicrophoneGranted,
String? lastOutputPath, String? lastOutputPath,
String? lastSavedDisplayName,
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? gallerySaveFailed,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false,
}) { }) {
return RecordingSessionState( return RecordingSessionState(
status: status ?? this.status, status: status ?? this.status,
@@ -64,10 +73,14 @@ class RecordingSessionState {
notificationsGranted: notificationsGranted ?? this.notificationsGranted, notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
lastOutputPath: lastOutputPath ?? this.lastOutputPath, lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved
? null
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
errorMessage: errorMessage, errorMessage: errorMessage,
permissionWarning: clearPermissionWarning permissionWarning: clearPermissionWarning
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
); );
} }
} }
@@ -88,7 +101,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Future<void> prepareSession() async { Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) { if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '仅支持 Android 录制'); state = state.copyWith(errorMessage: '当前设备不支持录制');
return; return;
} }
@@ -96,6 +109,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Permission.camera, Permission.camera,
Permission.microphone, Permission.microphone,
if (Platform.isAndroid) Permission.notification, if (Platform.isAndroid) Permission.notification,
..._galleryPermissions(),
]); ]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -117,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
if (!microphoneGranted) { if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制'); warnings.add('未授予麦克风权限,当前将以静音模式录制');
} }
if (!_isGalleryPermissionGranted(permissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored = final batteryIgnored =
@@ -167,18 +184,43 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
throw StateError('initializePreview retry exhausted'); throw StateError('initializePreview retry exhausted');
} }
List<Permission> _galleryPermissions() {
if (Platform.isIOS) {
return [Permission.photosAddOnly, Permission.photos];
}
if (Platform.isAndroid) {
return [Permission.videos, Permission.storage];
}
return const [];
}
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> permissions) {
for (final permission in _galleryPermissions()) {
if (permissions[permission]?.isGranted ?? false) {
return true;
}
}
return _galleryPermissions().isEmpty;
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async { Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return; if (!state.isPreviewReady || state.isRecording) return;
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
try { try {
final result = await RecordingPlatform.startRecording( final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess, enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
displayName: displayName,
); );
state = state.copyWith( state = state.copyWith(
status: result.status, status: result.status,
lastOutputPath: result.outputPath, lastOutputPath: result.outputPath,
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
gallerySaveFailed: false,
clearLastSaved: true,
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败'); state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
@@ -190,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
try { try {
final result = await RecordingPlatform.stopRecording(); final result = await RecordingPlatform.stopRecording();
final galleryFailed = !result.gallerySaved;
final savedName = recordingFileNameForPlatform(
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
);
state = state.copyWith( state = state.copyWith(
status: result.status, status: result.status,
lastOutputPath: result.outputPath ?? state.lastOutputPath, lastOutputPath: result.outputPath ?? state.lastOutputPath,
errorMessage: null, lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: galleryFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null,
gallerySaveFailed: galleryFailed,
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败'); state = state.copyWith(errorMessage: error.message ?? '停止录制失败');

View File

@@ -12,6 +12,18 @@ final recordingViewModelProvider =
return RecordingViewModel(ref); return RecordingViewModel(ref);
}); });
/// 剪切板读取结果,供 UI 决定是否提示用户。
enum ClipboardReadResult {
/// 剪切板为空,不提示
empty,
/// 解析成功
success,
/// 有内容但格式不符合小程序录制信息
invalid,
}
class RecordingViewModel extends StateNotifier<RecordingModel> { class RecordingViewModel extends StateNotifier<RecordingModel> {
RecordingViewModel(this.ref) RecordingViewModel(this.ref)
: super( : super(
@@ -26,8 +38,15 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
); );
final Ref ref; final Ref ref;
/// 从剪切板获取内容 static final _defaultClipboard = ClipboardRecordingModel(
Future<void> getClipboardContent() async { title: '',
startTimestamp: 0,
endTimestamp: 0,
address: '',
);
/// 从剪切板获取小程序复制的录制信息。
Future<ClipboardReadResult> getClipboardContent() async {
try { try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text; final text = clipboardData?.text;
@@ -35,22 +54,45 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
if (text == null || text.trim().isEmpty) { if (text == null || text.trim().isEmpty) {
AppLogger.info('剪切板内容为空,跳过录制信息解析'); AppLogger.info('剪切板内容为空,跳过录制信息解析');
return; _resetClipboardInfo();
return ClipboardReadResult.empty;
} }
final decoded = jsonDecode(text); final decoded = jsonDecode(text.trim());
if (decoded is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) {
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析'); AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
return; _resetClipboardInfo();
return ClipboardReadResult.invalid;
} }
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded); final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
state = state.copyWith(clipboardRecordingModel: clipboardRecordingModel); if (clipboardRecordingModel.title.trim().isEmpty) {
AppLogger.warning('剪切板录制信息缺少有效 title');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
state = state.copyWith(
clipboardRecordingModel: clipboardRecordingModel,
hasValidClipboardInfo: true,
);
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}'); AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
return ClipboardReadResult.success;
} on FormatException catch (error) { } on FormatException catch (error) {
AppLogger.warning('剪切板录制信息格式错误:$error'); AppLogger.warning('剪切板录制信息格式错误:$error');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
} catch (error, stackTrace) { } catch (error, stackTrace) {
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace); AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
_resetClipboardInfo();
return ClipboardReadResult.invalid;
} }
} }
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,
hasValidClipboardInfo: false,
);
}
} }

View File

@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>复制赛事录制码</title>
</head>
<body>
<button onclick="copyEventInfo()">复制赛事录制码</button>
<textarea id="copyText" style="position: fixed; left: -9999px;"></textarea>
<script>
function copyEventInfo() {
const data = {
title: '王东方 丨李想 空中格斗赛',
startTimestamp: 1717334400,
endTimestamp: 1717334400,
address: '广州市番禺区·粤港澳大湾区青年人才双创小镇',
}
const jsonStr = JSON.stringify(data)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(jsonStr)
.then(() => {
alert('赛事录制码已复制')
})
.catch(() => {
fallbackCopy(jsonStr)
})
} else {
fallbackCopy(jsonStr)
}
}
function fallbackCopy(text) {
const textarea = document.getElementById('copyText')
textarea.value = text
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
try {
document.execCommand('copy')
alert('赛事录制码已复制')
} catch (err) {
console.error('复制失败:', err)
alert('复制失败,请手动复制')
}
}
</script>
</body>
</html>

View File

@@ -71,7 +71,7 @@ void main() {
}); });
group('iOS permission configuration', () { group('iOS permission configuration', () {
test('Podfile enables camera and microphone permission macros', () { test('Podfile enables camera, microphone and photos permission macros', () {
final podfile = File('ios/Podfile').readAsStringSync(); final podfile = File('ios/Podfile').readAsStringSync();
expect( expect(
@@ -80,6 +80,8 @@ void main() {
); );
expect(podfile, contains("'PERMISSION_CAMERA=1'")); expect(podfile, contains("'PERMISSION_CAMERA=1'"));
expect(podfile, contains("'PERMISSION_MICROPHONE=1'")); expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
expect(podfile, contains("'PERMISSION_PHOTOS=1'"));
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"));
}); });
}); });
} }

View File

@@ -17,9 +17,21 @@ void main() {
expect(model.startTimestamp, 1717334400); expect(model.startTimestamp, 1717334400);
expect(model.endTimestamp, 1717334400); expect(model.endTimestamp, 1717334400);
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇'); expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
expect(model.filename, isNull);
expect(model.toJson(), clipboardJson); expect(model.toJson(), clipboardJson);
}); });
test('parses optional filename from mini program JSON', () {
final json = {
...clipboardJson,
'filename': '选手名称_选手ID_赛事名称_赛项',
};
final model = ClipboardRecordingModel.fromJson(json);
expect(model.filename, '选手名称_选手ID_赛事名称_赛项');
expect(model.toJson(), json);
});
test('throws FormatException when required field is missing', () { test('throws FormatException when required field is missing', () {
final json = Map<String, dynamic>.from(clipboardJson)..remove('title'); final json = Map<String, dynamic>.from(clipboardJson)..remove('title');

View File

@@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/recording_display_name.dart';
void main() {
group('sanitizeRecordingBaseName', () {
test('removes invalid path characters', () {
expect(
sanitizeRecordingBaseName(r'a/b:c*d?e"f<g>h|i'),
'a_b_c_d_e_f_g_h_i',
);
});
test('returns null for blank input', () {
expect(sanitizeRecordingBaseName(' '), isNull);
});
test('truncates overly long names', () {
final long = 'a' * 200;
expect(sanitizeRecordingBaseName(long)!.length, 120);
});
});
group('resolveRecordingDisplayName', () {
test('uses sanitized clipboard filename when present', () {
expect(
resolveRecordingDisplayName('选手名称_选手ID_赛事名称_赛项'),
'选手名称_选手ID_赛事名称_赛项',
);
});
test('falls back to REC_ prefix when clipboard filename is empty', () {
expect(resolveRecordingDisplayName(null), startsWith('REC_'));
expect(resolveRecordingDisplayName(''), startsWith('REC_'));
});
});
group('withVideoExtension', () {
test('appends mp4 on Android', () {
expect(
withVideoExtension('match', isIOS: false),
'match.mp4',
);
});
test('appends mov on iOS', () {
expect(
withVideoExtension('match', isIOS: true),
'match.mov',
);
});
test('keeps existing extension', () {
expect(withVideoExtension('a.mp4', isIOS: false), 'a.mp4');
expect(withVideoExtension('a.MOV', isIOS: true), 'a.MOV');
});
});
group('recordingFileNameForPlatform', () {
test('combines clipboard name with platform extension', () {
expect(
recordingFileNameForPlatform(
'选手名称_选手ID_赛事名称_赛项',
isIOS: false,
),
'选手名称_选手ID_赛事名称_赛项.mp4',
);
});
});
}

View File

@@ -8,7 +8,7 @@ void main() {
const defaultClipboardTitle = ''; const defaultClipboardTitle = '';
const validClipboardText = const validClipboardText =
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}'; '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
Future<void> setClipboardText(String? text) async { Future<void> setClipboardText(String? text) async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -33,116 +33,128 @@ void main() {
final container = ProviderContainer(); final container = ProviderContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container final result = await container
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.getClipboardContent(); .getClipboardContent();
final clipboardModel = container expect(result, ClipboardReadResult.success);
.read(recordingViewModelProvider) final model = container.read(recordingViewModelProvider);
.clipboardRecordingModel; expect(model.hasValidClipboardInfo, isTrue);
expect(clipboardModel.title, '王东方 丨李想 空中格斗赛'); expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
expect(clipboardModel.startTimestamp, 1717334400); expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
expect(clipboardModel.endTimestamp, 1717334400); expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
expect(clipboardModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇'); expect(
model.clipboardRecordingModel.address,
'广州市番禺区·粤港澳大湾区青年人才双创小镇',
);
expect(
model.clipboardRecordingModel.filename,
'选手名称_选手ID_赛事名称_赛项',
);
}, },
); );
test('keeps default state when clipboard is empty', () async { test('returns empty when clipboard is empty', () async {
await setClipboardText(''); await setClipboardText('');
final container = ProviderContainer(); final container = ProviderContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container final result = await container
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.getClipboardContent(); .getClipboardContent();
expect( expect(result, ClipboardReadResult.empty);
container final model = container.read(recordingViewModelProvider);
.read(recordingViewModelProvider) expect(model.hasValidClipboardInfo, isFalse);
.clipboardRecordingModel expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
.title,
defaultClipboardTitle,
);
}); });
test('keeps default state when clipboard is not JSON', () async { test('returns invalid when clipboard is not JSON', () async {
await setClipboardText('hello'); await setClipboardText('hello');
final container = ProviderContainer(); final container = ProviderContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container final result = await container
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.getClipboardContent(); .getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect( expect(
container container.read(recordingViewModelProvider).clipboardRecordingModel.title,
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle, defaultClipboardTitle,
); );
expect(
container.read(recordingViewModelProvider).hasValidClipboardInfo,
isFalse,
);
}); });
test('keeps default state when clipboard JSON is not an object', () async { test('returns invalid when clipboard JSON is not an object', () async {
await setClipboardText('[1,2,3]'); await setClipboardText('[1,2,3]');
final container = ProviderContainer(); final container = ProviderContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
await container final result = await container
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.getClipboardContent(); .getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect( expect(
container container.read(recordingViewModelProvider).clipboardRecordingModel.title,
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle, defaultClipboardTitle,
); );
}); });
test( test('returns invalid when clipboard JSON misses required fields', () async {
'keeps default state when clipboard JSON misses required fields', await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
() async { final container = ProviderContainer();
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}'); addTearDown(container.dispose);
final container = ProviderContainer();
addTearDown(container.dispose);
await container final result = await container
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.getClipboardContent(); .getClipboardContent();
expect( expect(result, ClipboardReadResult.invalid);
container expect(
.read(recordingViewModelProvider) container.read(recordingViewModelProvider).clipboardRecordingModel.title,
.clipboardRecordingModel defaultClipboardTitle,
.title, );
defaultClipboardTitle, });
);
},
);
test( test('returns invalid when clipboard JSON has wrong field type', () async {
'keeps default state when clipboard JSON has wrong field type', await setClipboardText(
() async { '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
await setClipboardText( );
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}', final container = ProviderContainer();
); addTearDown(container.dispose);
final container = ProviderContainer();
addTearDown(container.dispose);
await container final result = await container
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
.getClipboardContent(); .getClipboardContent();
expect( expect(result, ClipboardReadResult.invalid);
container expect(
.read(recordingViewModelProvider) container.read(recordingViewModelProvider).clipboardRecordingModel.title,
.clipboardRecordingModel defaultClipboardTitle,
.title, );
defaultClipboardTitle, });
);
}, test('returns invalid when title is blank', () async {
); await setClipboardText(
'{"title":" ","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市"}',
);
final container = ProviderContainer();
addTearDown(container.dispose);
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect(
container.read(recordingViewModelProvider).hasValidClipboardInfo,
isFalse,
);
});
}); });
} }