升级 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.RECORD_AUDIO" />
<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.POST_NOTIFICATIONS" />
<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
android:name="android.hardware.camera"

View File

@@ -5,7 +5,6 @@ import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
@@ -15,10 +14,6 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
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
class RecordingCameraController(
@@ -39,6 +34,7 @@ class RecordingCameraController(
private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview(
lifecycleOwner: LifecycleOwner,
@@ -118,6 +114,7 @@ class RecordingCameraController(
fun startRecording(
withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
@@ -131,9 +128,12 @@ class RecordingCameraController(
return
}
val outputFile = createOutputFile()
latestOutputPath = outputFile.absolutePath
val outputOptions = FileOutputOptions.Builder(outputFile).build()
val outputOptions =
RecordingOutputFactory.buildMediaStoreOutputOptions(
appContext,
displayName,
)
latestOutputPath = null
val pending =
capture.output.prepareRecording(appContext, outputOptions).apply {
@@ -171,6 +171,7 @@ class RecordingCameraController(
),
)
} else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus(
RecordingStatus(
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) {
@@ -193,6 +197,7 @@ class RecordingCameraController(
return
}
pendingStopCallback = onStopped
updateStatus(
RecordingStatus(
RecordingState.STOPPING,
@@ -202,7 +207,6 @@ class RecordingCameraController(
recording.stop()
activeRecording = null
onStopped(latestOutputPath)
}
fun unbind() {
@@ -221,16 +225,6 @@ class RecordingCameraController(
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) {
status = 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" -> {
val withAudio = call.argument<Boolean>("withAudio") ?: 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)
"disposePreview" -> {
@@ -110,6 +111,7 @@ class RecordingPlatformHandler(
private fun startRecording(
withAudio: Boolean,
enableDnd: Boolean,
displayName: String?,
result: MethodChannel.Result,
) {
val previewView = activity.recordingPreviewView
@@ -125,7 +127,7 @@ class RecordingPlatformHandler(
DoNotDisturbHelper.enable(activity)
}
controller.startRecording(withAudio) { started, message ->
controller.startRecording(withAudio, displayName) { started, message ->
mainHandler.post {
if (started) {
startElapsedTicker()
@@ -170,12 +172,19 @@ class RecordingPlatformHandler(
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
mainHandler.post {
result.success(
mapOf(
val gallerySaved =
path != null &&
controller.status.state != RecordingState.ERROR
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.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
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 {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import AVFoundation
import Flutter
import Photos
import UIKit
private enum RecordingState: String {
@@ -109,6 +110,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var audioInput: AVCaptureDeviceInput?
private var configured = false
private var latestOutputPath: String?
private var latestGallerySaved = true
private var latestGalleryErrorMessage: String?
private var pendingDisplayName: String?
private var recordingStartedAt: Date?
private var elapsedTimer: Timer?
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 {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
@@ -192,8 +196,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return
}
let outputURL = try self.createOutputURL()
self.latestOutputPath = outputURL.path
self.pendingDisplayName = displayName
self.latestGallerySaved = true
self.latestGalleryErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.lastPathComponent
self.recordingStartedAt = Date()
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
@@ -226,10 +233,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard self.movieOutput.isRecording else {
DispatchQueue.main.async {
result([
"outputPath": self.latestOutputPath,
var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(),
])
"gallerySaved": self.latestGallerySaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败"
}
result(payload)
}
return
}
@@ -291,24 +304,104 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil
if let error {
latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
} else {
updateStatus(
finishStopRecording(stopResult: stopResult)
return
}
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: latestOutputPath,
elapsedMillis: elapsedMillis()
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
guard let self else { return }
self.stopElapsedTimer()
stopResult?([
"outputPath": self.latestOutputPath,
var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any,
"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
}
private func createOutputURL() throws -> URL {
private func createOutputURL(displayName: String?) throws -> URL {
let baseURL = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
@@ -380,11 +473,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: 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()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd_HHmmss"
let filename = "REC_\(formatter.string(from: Date())).mov"
return recordingsURL.appendingPathComponent(filename)
return "REC_\(formatter.string(from: Date())).mov"
}
private func updateStatus(_ next: RecordingStatus) {
@@ -450,7 +555,8 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
case "startRecording":
let args = call.arguments as? [String: Any]
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":
controller.stopRecording(result: result)
case "disposePreview":

View File

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

View File

@@ -4,7 +4,13 @@ class RecordingModel {
/// 剪切板内容
final ClipboardRecordingModel clipboardRecordingModel;
RecordingModel({required this.clipboardRecordingModel});
/// 剪切板是否包含有效的小程序录制信息
final bool hasValidClipboardInfo;
RecordingModel({
required this.clipboardRecordingModel,
this.hasValidClipboardInfo = false,
});
factory RecordingModel.fromJson(Map<String, dynamic> json) {
return RecordingModel(
@@ -17,10 +23,15 @@ class RecordingModel {
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
}
RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) {
RecordingModel copyWith({
ClipboardRecordingModel? clipboardRecordingModel,
bool? hasValidClipboardInfo,
}) {
return RecordingModel(
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/services.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_session_controller.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/recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key});
@@ -28,7 +28,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
}
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();
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
@@ -71,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override
Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider);
final recordingInfo = ref.watch(recordingViewModelProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
return PopScope(
canPop: !state.isRecording,
@@ -97,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
),
_RecordingHud(
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
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 {
await controller.openDndSettings();
await controller.refreshDndAccess();
@@ -121,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
class _RecordingHud extends StatelessWidget {
const _RecordingHud({
required this.state,
this.eventTitle,
this.eventAddress,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
@@ -129,42 +149,28 @@ class _RecordingHud extends StatelessWidget {
});
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
static const _overlayTextStyle = TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
);
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
Column(
children: [
const Spacer(),
if (state.isRecording)
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,
),
),
),
],
),
SizedBox(
height: eventTitle != null || state.isRecording ? 56 : 8,
),
const Spacer(),
if (state.errorMessage != null)
@@ -178,7 +184,10 @@ class _RecordingHud extends StatelessWidget {
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
state.permissionWarning!,
style: const TextStyle(
@@ -233,17 +242,77 @@ class _RecordingHud extends StatelessWidget {
],
),
),
if (state.lastOutputPath != null && !state.isRecording)
if (state.lastSavedDisplayName != null &&
!state.isRecording &&
!state.gallerySaveFailed)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'已保存:${state.lastOutputPath}',
'已保存到相册${state.lastSavedDisplayName}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
if (eventTitle != null)
Positioned(
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(
eventAddress!,
style: _overlayTextStyle.copyWith(
fontSize: 13,
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@@ -84,12 +84,14 @@ class RecordingPlatform {
static Future<RecordingStartResult> startRecording({
bool withAudio = true,
bool enableDoNotDisturb = true,
String? displayName,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording',
<String, dynamic>{
'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb,
if (displayName != null) 'displayName': displayName,
},
);
return RecordingStartResult(
@@ -104,12 +106,7 @@ class RecordingPlatform {
final result = await _channel.invokeMapMethod<String, dynamic>(
'stopRecording',
);
return RecordingStopResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
return RecordingStopResult.fromMap(result);
}
static Future<void> disposePreview() =>
@@ -163,8 +160,26 @@ class RecordingStartResult {
}
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 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_riverpod/flutter_riverpod.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/view-model/view_model_recording.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingSessionState {
@@ -17,8 +19,10 @@ class RecordingSessionState {
this.notificationsGranted = true,
this.isMicrophoneGranted = false,
this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage,
this.permissionWarning,
this.gallerySaveFailed = false,
});
final RecordingStatus status;
@@ -29,8 +33,10 @@ class RecordingSessionState {
final bool notificationsGranted;
final bool isMicrophoneGranted;
final String? lastOutputPath;
final String? lastSavedDisplayName;
final String? errorMessage;
final String? permissionWarning;
final bool gallerySaveFailed;
bool get isRecording => status.isRecording;
@@ -50,9 +56,12 @@ class RecordingSessionState {
bool? notificationsGranted,
bool? isMicrophoneGranted,
String? lastOutputPath,
String? lastSavedDisplayName,
String? errorMessage,
String? permissionWarning,
bool? gallerySaveFailed,
bool clearPermissionWarning = false,
bool clearLastSaved = false,
}) {
return RecordingSessionState(
status: status ?? this.status,
@@ -64,10 +73,14 @@ class RecordingSessionState {
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved
? null
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
errorMessage: errorMessage,
permissionWarning: clearPermissionWarning
? null
: (permissionWarning ?? this.permissionWarning),
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
);
}
}
@@ -88,7 +101,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '仅支持 Android 录制');
state = state.copyWith(errorMessage: '当前设备不支持录制');
return;
}
@@ -96,6 +109,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
..._galleryPermissions(),
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -117,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
if (!_isGalleryPermissionGranted(permissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
@@ -167,18 +184,43 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
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 {
if (!state.isPreviewReady || state.isRecording) return;
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
displayName: displayName,
);
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
gallerySaveFailed: false,
clearLastSaved: true,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
@@ -190,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
try {
final result = await RecordingPlatform.stopRecording();
final galleryFailed = !result.gallerySaved;
final savedName = recordingFileNameForPlatform(
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
);
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath ?? state.lastOutputPath,
errorMessage: null,
lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: galleryFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null,
gallerySaveFailed: galleryFailed,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');

View File

@@ -12,6 +12,18 @@ final recordingViewModelProvider =
return RecordingViewModel(ref);
});
/// 剪切板读取结果,供 UI 决定是否提示用户。
enum ClipboardReadResult {
/// 剪切板为空,不提示
empty,
/// 解析成功
success,
/// 有内容但格式不符合小程序录制信息
invalid,
}
class RecordingViewModel extends StateNotifier<RecordingModel> {
RecordingViewModel(this.ref)
: super(
@@ -26,8 +38,15 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
);
final Ref ref;
/// 从剪切板获取内容
Future<void> getClipboardContent() async {
static final _defaultClipboard = ClipboardRecordingModel(
title: '',
startTimestamp: 0,
endTimestamp: 0,
address: '',
);
/// 从剪切板获取小程序复制的录制信息。
Future<ClipboardReadResult> getClipboardContent() async {
try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
@@ -35,22 +54,45 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
if (text == null || text.trim().isEmpty) {
AppLogger.info('剪切板内容为空,跳过录制信息解析');
return;
_resetClipboardInfo();
return ClipboardReadResult.empty;
}
final decoded = jsonDecode(text);
final decoded = jsonDecode(text.trim());
if (decoded is! Map<String, dynamic>) {
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
return;
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
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()}');
return ClipboardReadResult.success;
} on FormatException catch (error) {
AppLogger.warning('剪切板录制信息格式错误:$error');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
} catch (error, 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', () {
test('Podfile enables camera and microphone permission macros', () {
test('Podfile enables camera, microphone and photos permission macros', () {
final podfile = File('ios/Podfile').readAsStringSync();
expect(
@@ -80,6 +80,8 @@ void main() {
);
expect(podfile, contains("'PERMISSION_CAMERA=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.endTimestamp, 1717334400);
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
expect(model.filename, isNull);
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', () {
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 validClipboardText =
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
Future<void> setClipboardText(String? text) async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -33,116 +33,128 @@ void main() {
final container = ProviderContainer();
addTearDown(container.dispose);
await container
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
final clipboardModel = container
.read(recordingViewModelProvider)
.clipboardRecordingModel;
expect(clipboardModel.title, '王东方 丨李想 空中格斗赛');
expect(clipboardModel.startTimestamp, 1717334400);
expect(clipboardModel.endTimestamp, 1717334400);
expect(clipboardModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
expect(result, ClipboardReadResult.success);
final model = container.read(recordingViewModelProvider);
expect(model.hasValidClipboardInfo, isTrue);
expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
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('');
final container = ProviderContainer();
addTearDown(container.dispose);
await container
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle,
);
expect(result, ClipboardReadResult.empty);
final model = container.read(recordingViewModelProvider);
expect(model.hasValidClipboardInfo, isFalse);
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
});
test('keeps default state when clipboard is not JSON', () async {
test('returns invalid when clipboard is not JSON', () async {
await setClipboardText('hello');
final container = ProviderContainer();
addTearDown(container.dispose);
await container
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect(
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
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]');
final container = ProviderContainer();
addTearDown(container.dispose);
await container
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect(
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
defaultClipboardTitle,
);
});
test(
'keeps default state when clipboard JSON misses required fields',
() async {
test('returns invalid when clipboard JSON misses required fields', () async {
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
final container = ProviderContainer();
addTearDown(container.dispose);
await container
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect(
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
defaultClipboardTitle,
);
},
);
});
test(
'keeps default state when clipboard JSON has wrong field type',
() async {
test('returns invalid when clipboard JSON has wrong field type', () async {
await setClipboardText(
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect(
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
container.read(recordingViewModelProvider).clipboardRecordingModel.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,
);
});
});
}