diff --git a/.gitignore b/.gitignore index fd315a2..ab62626 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ pubspec.lock *.iws .idea/ .cursor +Podfile.lock # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt b/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt index ccb86a5..52571a0 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/MainActivity.kt @@ -114,6 +114,7 @@ class MainActivity : FlutterActivity() { "brand" to Build.BRAND, "model" to Build.MODEL, "systemVersion" to Build.VERSION.RELEASE, + "sdkInt" to Build.VERSION.SDK_INT, "isPhysicalDevice" to !isEmulator, ) } diff --git a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt index 0118205..8226430 100644 --- a/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt +++ b/android/app/src/main/kotlin/com/dronex/rec/recording/RecordingPlatformHandler.kt @@ -190,15 +190,15 @@ class RecordingPlatformHandler( } private fun deliverStopResult(result: MethodChannel.Result, path: String?) { - val gallerySaved = path != null && controller.status.state != RecordingState.ERROR + val fileSaved = path != null && controller.status.state != RecordingState.ERROR val payload = mutableMapOf( "outputPath" to path, "status" to controller.status.toMap(), - "gallerySaved" to gallerySaved, + "fileSaved" to fileSaved, ) - if (!gallerySaved) { - payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败" + if (!fileSaved) { + payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败" } result.success(payload) } diff --git a/clean.sh b/clean.sh new file mode 100644 index 0000000..f820f0f --- /dev/null +++ b/clean.sh @@ -0,0 +1,7 @@ +flutter clean +flutter pub get +rm -rf ios/Pods +rm -rf ios/Podfile.lock +cd ios +pod install +cd .. diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index ec97fc6..2eb4e73 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1,2 +1,3 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index c4855bf..4307e85 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,3 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" +FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}" diff --git a/ios/Podfile b/ios/Podfile index fe802bc..e1d445b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -45,9 +45,34 @@ post_install do |installer| '$(inherited)', 'PERMISSION_CAMERA=1', 'PERMISSION_MICROPHONE=1', - 'PERMISSION_PHOTOS=1', - 'PERMISSION_PHOTOS_ADD_ONLY=1', ] end end + + pods_runner_dir = File.join( + installer.sandbox.root, + 'Target Support Files', + 'Pods-Runner' + ) + Dir.glob(File.join(pods_runner_dir, 'Pods-Runner.*.xcconfig')).each do |config_path| + config = File.read(config_path) + config.gsub!( + 'FRAMEWORK_SEARCH_PATHS = $(inherited)', + 'FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"' + ) + File.write(config_path, config) + end + + Dir.glob(File.join(pods_runner_dir, 'Pods-Runner-frameworks-*input-files.xcfilelist')).each do |file_list_path| + file_list = File.read(file_list_path) + file_list.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/') + File.write(file_list_path, file_list) + end + + frameworks_script = File.join(pods_runner_dir, 'Pods-Runner-frameworks.sh') + if File.exist?(frameworks_script) + script = File.read(frameworks_script) + script.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/') + File.write(frameworks_script, script) + end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1e7daa2..82ce1df 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -43,6 +43,6 @@ SPEC CHECKSUMS: sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b -PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b +PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 25de910..621b110 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -344,14 +344,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -380,14 +376,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index e691476..4e9b730 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -30,8 +30,10 @@ 需要访问相机以显示预览并录制视频。 NSMicrophoneUsageDescription 需要访问麦克风以录制视频声音;未授权时将静音录制。 - NSPhotoLibraryAddUsageDescription - 需要将录制的视频保存到相册。 + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/Runner/RecordingPlugin.swift b/ios/Runner/RecordingPlugin.swift index 9c9469c..a14b114 100644 --- a/ios/Runner/RecordingPlugin.swift +++ b/ios/Runner/RecordingPlugin.swift @@ -1,6 +1,5 @@ import AVFoundation import Flutter -import Photos import UIKit private enum RecordingState: String { @@ -110,8 +109,8 @@ 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 latestFileSaved = true + private var latestFileErrorMessage: String? private var pendingDisplayName: String? private var recordingStartedAt: Date? private var elapsedTimer: Timer? @@ -216,10 +215,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco } self.pendingDisplayName = displayName - self.latestGallerySaved = true - self.latestGalleryErrorMessage = nil + self.latestFileSaved = true + self.latestFileErrorMessage = nil let outputURL = try self.createOutputURL(displayName: displayName) - self.latestOutputPath = outputURL.lastPathComponent + self.latestOutputPath = outputURL.path self.recordingStartedAt = Date() self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path)) self.movieOutput.startRecording(to: outputURL, recordingDelegate: self) @@ -255,11 +254,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco var payload: [String: Any] = [ "outputPath": self.latestOutputPath as Any, "status": self.currentStatusMap(), - "gallerySaved": self.latestGallerySaved, + "fileSaved": self.latestFileSaved, ] - if !self.latestGallerySaved { - payload["galleryErrorMessage"] = - self.latestGalleryErrorMessage ?? "保存到相册失败" + if !self.latestFileSaved { + payload["fileErrorMessage"] = + self.latestFileErrorMessage ?? "保存到文件夹失败" } result(payload) } @@ -370,8 +369,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco pendingStopResult = nil if let error { - latestGallerySaved = false - latestGalleryErrorMessage = error.localizedDescription + latestFileSaved = false + latestFileErrorMessage = error.localizedDescription updateStatus( RecordingStatus( state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) @@ -379,29 +378,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco 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: self.latestOutputPath, - elapsedMillis: self.elapsedMillis() - ) + latestFileSaved = true + latestFileErrorMessage = nil + latestOutputPath = outputFileURL.path + guard FileManager.default.fileExists(atPath: outputFileURL.path) else { + latestFileSaved = false + latestFileErrorMessage = "录制文件未生成" + updateStatus( + RecordingStatus( + state: .error, + outputPath: latestOutputPath, + message: latestFileErrorMessage ) - } else { - self.updateStatus( - RecordingStatus( - state: .error, - outputPath: self.latestOutputPath, - message: message ?? "保存到相册失败" - ) - ) - } - self.finishStopRecording(stopResult: stopResult) + ) + finishStopRecording(stopResult: stopResult) + return } + updateStatus( + RecordingStatus( + state: .previewing, + outputPath: latestOutputPath, + elapsedMillis: elapsedMillis() + ) + ) + finishStopRecording(stopResult: stopResult) } private func finishStopRecording(stopResult: FlutterResult?) { @@ -411,68 +411,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco var payload: [String: Any] = [ "outputPath": self.latestOutputPath as Any, "status": self.currentStatusMap(), - "gallerySaved": self.latestGallerySaved, + "fileSaved": self.latestFileSaved, ] - if !self.latestGallerySaved { - payload["galleryErrorMessage"] = - self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限" + if !self.latestFileSaved { + payload["fileErrorMessage"] = + self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限" } 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, "未授予相册权限") - } - } - } - private func configureSession(withAudio: Bool) throws { if configured { try configureAudioInput(enabled: withAudio) @@ -584,7 +532,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true) let fileName = Self.resolveFileName(displayName: displayName) - return recordingsURL.appendingPathComponent(fileName) + return uniqueOutputURL(in: recordingsURL, preferredFileName: fileName) + } + + private func uniqueOutputURL(in directoryURL: URL, preferredFileName: String) -> URL { + let preferredURL = directoryURL.appendingPathComponent(preferredFileName) + guard !FileManager.default.fileExists(atPath: preferredURL.path) else { + let fileExtension = preferredURL.pathExtension + let baseName = preferredURL.deletingPathExtension().lastPathComponent + let timestamp = Self.fileNameDateFormatter.string(from: Date()) + + var index = 0 + while true { + let suffix = index == 0 ? timestamp : "\(timestamp)_\(index)" + let nextName = fileExtension.isEmpty + ? "\(baseName)_\(suffix)" + : "\(baseName)_\(suffix).\(fileExtension)" + let nextURL = directoryURL.appendingPathComponent(nextName) + if !FileManager.default.fileExists(atPath: nextURL.path) { + return nextURL + } + index += 1 + } + } + return preferredURL } private static func resolveFileName(displayName: String?) -> String { @@ -602,6 +573,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco return "REC_\(formatter.string(from: Date())).mov" } + private static let fileNameDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd_HHmmss" + return formatter + }() + private func updateStatus(_ next: RecordingStatus) { status = next } diff --git a/lib/features/recording/model/model_recording_session.dart b/lib/features/recording/model/model_recording_session.dart index 69a95f2..9a06e2c 100644 --- a/lib/features/recording/model/model_recording_session.dart +++ b/lib/features/recording/model/model_recording_session.dart @@ -18,7 +18,7 @@ class RecordingSessionState { this.lastSavedDisplayName, this.errorMessage, this.permissionWarning, - this.gallerySaveFailed = false, + this.fileSaveFailed = false, }); final RecordingStatus status; @@ -36,7 +36,7 @@ class RecordingSessionState { final String? lastSavedDisplayName; final String? errorMessage; final String? permissionWarning; - final bool gallerySaveFailed; + final bool fileSaveFailed; bool get isRecording => status.isRecording; @@ -64,7 +64,7 @@ class RecordingSessionState { String? lastSavedDisplayName, String? errorMessage, String? permissionWarning, - bool? gallerySaveFailed, + bool? fileSaveFailed, bool clearPermissionWarning = false, bool clearLastSaved = false, }) { @@ -89,7 +89,7 @@ class RecordingSessionState { permissionWarning: clearPermissionWarning ? null : (permissionWarning ?? this.permissionWarning), - gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed, + fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed, ); } } diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 0fe0696..7841308 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -172,8 +172,8 @@ class _RecordingPageState extends ConsumerState { await ref.read(recordingViewModelProvider.notifier).stopRecording(); if (!mounted) return; final latest = ref.read(recordingViewModelProvider).session; - if (latest.gallerySaveFailed) { - AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); + if (latest.fileSaveFailed) { + AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限'); return; } await _showRecordingSavedDialogIfNeeded(); @@ -190,7 +190,7 @@ class _RecordingPageState extends ConsumerState { Future _showRecordingSavedDialogIfNeeded() async { final recordingInfo = ref.read(recordingViewModelProvider); final session = recordingInfo.session; - if (session.lastSavedDisplayName == null || session.gallerySaveFailed) { + if (session.lastSavedDisplayName == null || session.fileSaveFailed) { return; } diff --git a/lib/features/recording/platform/recording_platform.dart b/lib/features/recording/platform/recording_platform.dart index 10fbe91..6bfa443 100644 --- a/lib/features/recording/platform/recording_platform.dart +++ b/lib/features/recording/platform/recording_platform.dart @@ -205,14 +205,14 @@ class RecordingStopResult { const RecordingStopResult({ this.outputPath, required this.status, - this.gallerySaved = true, - this.galleryErrorMessage, + this.fileSaved = true, + this.fileErrorMessage, }); final String? outputPath; final RecordingStatus status; - final bool gallerySaved; - final String? galleryErrorMessage; + final bool fileSaved; + final String? fileErrorMessage; factory RecordingStopResult.fromMap(Map? result) { return RecordingStopResult( @@ -220,8 +220,8 @@ class RecordingStopResult { status: RecordingStatus.fromMap( Map.from(result?['status'] as Map? ?? const {}), ), - gallerySaved: result?['gallerySaved'] as bool? ?? true, - galleryErrorMessage: result?['galleryErrorMessage'] as String?, + fileSaved: result?['fileSaved'] as bool? ?? true, + fileErrorMessage: result?['fileErrorMessage'] as String?, ); } } diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index d8d9a6c..39276e8 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:recording_tool/core/logging/app_logger.dart'; import 'package:recording_tool/core/permission/permission_service.dart'; +import 'package:recording_tool/core/platform/app_platform_info.dart'; import 'package:recording_tool/features/recording/model/model_clipboard.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart'; @@ -31,15 +32,19 @@ enum ClipboardReadResult { invalid, } -List recordingGalleryPermissionsForHost({ +List recordingFileSavePermissionsForHost({ required bool isIOS, required bool isAndroid, + int? androidSdkInt, }) { if (isIOS) { - return [Permission.photosAddOnly]; + return const []; } if (isAndroid) { - return [Permission.videos, Permission.storage]; + if (androidSdkInt != null && androidSdkInt >= 29) { + return const []; + } + return [Permission.storage]; } return const []; } @@ -144,11 +149,12 @@ class RecordingViewModel extends Notifier { return; } + final fileSavePermissions = await _fileSavePermissions(); final permissions = await PermissionService.requestMissing([ Permission.camera, Permission.microphone, if (Platform.isAndroid) Permission.notification, - ..._galleryPermissions(), + ...fileSavePermissions, ]); final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; @@ -170,8 +176,8 @@ class RecordingViewModel extends Notifier { if (!microphoneGranted) { warnings.add('未授予麦克风权限,当前将以静音模式录制'); } - if (!_isGalleryPermissionGranted(permissions)) { - warnings.add('未授予相册权限,录制结束后可能无法保存到相册'); + if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) { + warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹'); } final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); @@ -260,24 +266,36 @@ class RecordingViewModel extends Notifier { } } - /// 当前平台所需的相册/视频保存权限列表。 - List _galleryPermissions() { - return recordingGalleryPermissionsForHost( + /// 当前平台所需的视频文件保存权限列表。 + Future> _fileSavePermissions() async { + int? androidSdkInt; + if (Platform.isAndroid) { + try { + androidSdkInt = int.tryParse( + (await AppPlatformInfo.deviceInfo()).values['sdkInt'] ?? '', + ); + } on PlatformException { + androidSdkInt = null; + } + } + return recordingFileSavePermissionsForHost( isIOS: Platform.isIOS, isAndroid: Platform.isAndroid, + androidSdkInt: androidSdkInt, ); } - /// 判断相册相关权限是否至少有一项已授予。 - bool _isGalleryPermissionGranted( + /// 判断文件保存相关权限是否至少有一项已授予。 + bool _isFileSavePermissionGranted( Map permissions, + List fileSavePermissions, ) { - for (final permission in _galleryPermissions()) { + for (final permission in fileSavePermissions) { if (permissions[permission]?.isGranted ?? false) { return true; } } - return _galleryPermissions().isEmpty; + return fileSavePermissions.isEmpty; } /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 @@ -381,7 +399,7 @@ class RecordingViewModel extends Notifier { lastOutputPath: result.outputPath, isTouchLocked: true, errorMessage: null, - gallerySaveFailed: false, + fileSaveFailed: false, clearLastSaved: true, ), ); @@ -394,13 +412,13 @@ class RecordingViewModel extends Notifier { } } - /// 停止录制、保存到相册,并恢复相机预览。 + /// 停止录制、保存到文件夹,并恢复相机预览。 Future stopRecording() async { if (!state.session.isRecording) return; try { final result = await RecordingPlatform.stopRecording(); - final galleryFailed = !result.gallerySaved; + final fileFailed = !result.fileSaved; final savedName = recordingFileNameForPlatform( state.clipboardRecordingModel.filename, ); @@ -408,11 +426,11 @@ class RecordingViewModel extends Notifier { (s) => s.copyWith( status: result.status, lastOutputPath: result.outputPath ?? s.lastOutputPath, - lastSavedDisplayName: galleryFailed ? null : savedName, - errorMessage: galleryFailed - ? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限') + lastSavedDisplayName: fileFailed ? null : savedName, + errorMessage: fileFailed + ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限') : null, - gallerySaveFailed: galleryFailed, + fileSaveFailed: fileFailed, ), ); } on PlatformException catch (error) { diff --git a/lib/features/recording/widgets/widget_recording_saved_dialog.dart b/lib/features/recording/widgets/widget_recording_saved_dialog.dart index 4852775..584ed17 100644 --- a/lib/features/recording/widgets/widget_recording_saved_dialog.dart +++ b/lib/features/recording/widgets/widget_recording_saved_dialog.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:recording_tool/features/dialog/dialog-record.dart'; -/// 录制结束并保存到相册后的后续操作弹窗。 +/// 录制结束并保存到文件夹后的后续操作弹窗。 Future showRecordingSavedDialog( BuildContext context, { required String sessionTitle, @@ -10,7 +10,7 @@ Future showRecordingSavedDialog( }) { return RecordDialog.showDouble( context, - title: '本轮比赛视频已保存到相册\n请选择后续录制信息', + title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息', leftText: '继续本轮', rightText: '录制新轮', onLeftPressed: onContinueRound, diff --git a/test/core/permission/permission_service_test.dart b/test/core/permission/permission_service_test.dart index 6c3062b..90197fa 100644 --- a/test/core/permission/permission_service_test.dart +++ b/test/core/permission/permission_service_test.dart @@ -71,7 +71,7 @@ void main() { }); group('iOS permission configuration', () { - test('Podfile enables camera, microphone and photos permission macros', () { + test('Podfile enables camera and microphone permission macros only', () { final podfile = File('ios/Podfile').readAsStringSync(); expect( @@ -80,8 +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'")); + expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'"))); + expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"))); }); }); } diff --git a/test/features/dialog/record_dialog_test.dart b/test/features/dialog/record_dialog_test.dart index 128148f..4283abe 100644 --- a/test/features/dialog/record_dialog_test.dart +++ b/test/features/dialog/record_dialog_test.dart @@ -68,7 +68,7 @@ void main() { onPressed: () { RecordDialog.showDouble( context, - title: '本轮比赛视频已保存到相册\n请选择后续录制信息', + title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息', leftText: '继续本轮', rightText: '录制新轮', onLeftPressed: () => leftTapped = true, @@ -123,7 +123,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); - expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget); + expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget); expect(find.text('继续本轮'), findsOneWidget); expect(find.text('录制新轮'), findsOneWidget); }); diff --git a/test/features/recording/recording_platform_test.dart b/test/features/recording/recording_platform_test.dart index 29bf643..54bd537 100644 --- a/test/features/recording/recording_platform_test.dart +++ b/test/features/recording/recording_platform_test.dart @@ -18,4 +18,20 @@ void main() { ); }); }); + + group('RecordingStopResult', () { + test('parses file save result fields from platform payload', () { + final result = RecordingStopResult.fromMap({ + 'outputPath': '/Documents/recordings/test.mov', + 'status': {'state': 'previewing'}, + 'fileSaved': false, + 'fileErrorMessage': '保存到文件夹失败', + }); + + expect(result.outputPath, '/Documents/recordings/test.mov'); + expect(result.status.state, RecordingState.previewing); + expect(result.fileSaved, isFalse); + expect(result.fileErrorMessage, '保存到文件夹失败'); + }); + }); } diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index f427e3e..644c075 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -125,24 +125,37 @@ void main() { ); }); - group('recordingGalleryPermissionsForHost', () { - test('requests only add-only photo permission on iOS', () { - final permissions = recordingGalleryPermissionsForHost( + group('recordingFileSavePermissionsForHost', () { + test('does not request photo permission on iOS', () { + final permissions = recordingFileSavePermissionsForHost( isIOS: true, isAndroid: false, ); - expect(permissions, [Permission.photosAddOnly]); + expect(permissions, isEmpty); + expect(permissions, isNot(contains(Permission.photosAddOnly))); expect(permissions, isNot(contains(Permission.photos))); }); - test('keeps Android gallery permissions unchanged', () { - final permissions = recordingGalleryPermissionsForHost( + test('requests storage permission on Android 9 and below', () { + final permissions = recordingFileSavePermissionsForHost( isIOS: false, isAndroid: true, + androidSdkInt: 28, ); - expect(permissions, [Permission.videos, Permission.storage]); + expect(permissions, [Permission.storage]); + expect(permissions, isNot(contains(Permission.videos))); + }); + + test('does not request file save permission on Android 10 and above', () { + final permissions = recordingFileSavePermissionsForHost( + isIOS: false, + isAndroid: true, + androidSdkInt: 29, + ); + + expect(permissions, isEmpty); }); });