From de2aacca906888a6d4099dd5b79489cc866ee2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Tue, 9 Jun 2026 12:29:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=BC=E5=AE=B9=20IOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../kotlin/com/dronex/rec/MainActivity.kt | 1 + .../rec/recording/RecordingPlatformHandler.kt | 8 +- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 29 +++- ios/Podfile.lock | 2 +- ios/Runner.xcodeproj/project.pbxproj | 8 - ios/Runner/Info.plist | 6 +- ios/Runner/RecordingPlugin.swift | 158 ++++++++---------- .../model/model_recording_session.dart | 8 +- lib/features/recording/pages/page_record.dart | 16 +- .../platform/recording_platform.dart | 12 +- .../view-model/view_model_recording.dart | 58 ++++--- .../widget_recording_saved_dialog.dart | 4 +- .../permission/permission_service_test.dart | 6 +- test/features/dialog/record_dialog_test.dart | 4 +- .../recording/recording_platform_test.dart | 16 ++ .../recording/view_model_recording_test.dart | 27 ++- 19 files changed, 207 insertions(+), 159 deletions(-) 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 f7dc26d..a0931d5 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 @@ -173,15 +173,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/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 3d4f30f..f161396 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? @@ -215,10 +214,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) @@ -254,11 +253,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) } @@ -322,8 +321,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)) @@ -331,29 +330,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?) { @@ -363,68 +363,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) @@ -502,7 +450,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 { @@ -520,6 +491,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 32423b5..069a65d 100644 --- a/lib/features/recording/model/model_recording_session.dart +++ b/lib/features/recording/model/model_recording_session.dart @@ -15,7 +15,7 @@ class RecordingSessionState { this.lastSavedDisplayName, this.errorMessage, this.permissionWarning, - this.gallerySaveFailed = false, + this.fileSaveFailed = false, }); final RecordingStatus status; @@ -30,7 +30,7 @@ class RecordingSessionState { final String? lastSavedDisplayName; final String? errorMessage; final String? permissionWarning; - final bool gallerySaveFailed; + final bool fileSaveFailed; bool get isRecording => status.isRecording; @@ -55,7 +55,7 @@ class RecordingSessionState { String? lastSavedDisplayName, String? errorMessage, String? permissionWarning, - bool? gallerySaveFailed, + bool? fileSaveFailed, bool clearPermissionWarning = false, bool clearLastSaved = false, }) { @@ -77,7 +77,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 b3a767c..c2b39d0 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; } @@ -357,10 +357,7 @@ class _PreviewLoadingLayer extends ConsumerWidget { } class _RecordingHudLayer extends ConsumerWidget { - const _RecordingHudLayer({ - required this.onStart, - required this.onStop, - }); + const _RecordingHudLayer({required this.onStart, required this.onStop}); final Future Function() onStart; final Future Function() onStop; @@ -419,7 +416,10 @@ class _RecordingHudLayer extends ConsumerWidget { await viewModel.refreshBatteryOptimization(); }, onToggleTouchLock: () { - final locked = ref.read(recordingViewModelProvider).session.isTouchLocked; + final locked = ref + .read(recordingViewModelProvider) + .session + .isTouchLocked; viewModel.setTouchLocked(!locked); }, ); diff --git a/lib/features/recording/platform/recording_platform.dart b/lib/features/recording/platform/recording_platform.dart index c2d2c5d..5865ff4 100644 --- a/lib/features/recording/platform/recording_platform.dart +++ b/lib/features/recording/platform/recording_platform.dart @@ -167,14 +167,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( @@ -182,8 +182,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 49fa4ba..91e20f2 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(); @@ -258,24 +264,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。 @@ -338,7 +356,7 @@ class RecordingViewModel extends Notifier { lastOutputPath: result.outputPath, isTouchLocked: true, errorMessage: null, - gallerySaveFailed: false, + fileSaveFailed: false, clearLastSaved: true, ), ); @@ -351,13 +369,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, ); @@ -365,11 +383,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 43b1f8d..88a1854 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -39,24 +39,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); }); });