diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5d80699..21a114a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,15 +1,54 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.4.8): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 23023a5..f862fb2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; }; 8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; }; 8E5D9D9E2C69000100F10002 /* PlatformInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */; }; @@ -54,7 +53,6 @@ 69561E7442E8CC939DB2B482 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = ""; }; 8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformInfoPlugin.swift; sourceTree = ""; }; @@ -86,7 +84,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -104,7 +101,6 @@ DFA822DC4965C5AFAFF6BB41 /* Pods-RunnerTests.release.xcconfig */, 99DBE6263F70650B13C33EAC /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -128,7 +124,6 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -208,15 +203,14 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */, + 99E9790F23C2D0C0B51A6C19 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -250,9 +244,6 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, - ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -345,6 +336,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -360,6 +368,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + 99E9790F23C2D0C0B51A6C19 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -726,20 +751,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb2..445444b 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -70,7 +70,7 @@ Void)? func attach(previewView: RecordingPreviewView) { - self.previewView = previewView - previewView.previewLayer.session = session + let bindPreview = { [weak self, weak previewView] in + guard let self, let previewView else { return } + self.previewView = previewView + previewView.previewLayer.session = self.session + } + if Thread.isMainThread { + bindPreview() + } else { + DispatchQueue.main.async(execute: bindPreview) + } } func detach(previewView: RecordingPreviewView) { - if self.previewView === previewView { - self.previewView?.previewLayer.session = nil - self.previewView = nil + let unbindPreview = { [weak self, weak previewView] in + guard let self, let previewView else { return } + if self.previewView === previewView { + previewView.previewLayer.session = nil + self.previewView = nil + } + } + if Thread.isMainThread { + unbindPreview() + } else { + DispatchQueue.main.async(execute: unbindPreview) } } diff --git a/lib/features/recording/view-model/view_model_recording.dart b/lib/features/recording/view-model/view_model_recording.dart index 2f6f8a9..49fa4ba 100644 --- a/lib/features/recording/view-model/view_model_recording.dart +++ b/lib/features/recording/view-model/view_model_recording.dart @@ -31,6 +31,19 @@ enum ClipboardReadResult { invalid, } +List recordingGalleryPermissionsForHost({ + required bool isIOS, + required bool isAndroid, +}) { + if (isIOS) { + return [Permission.photosAddOnly]; + } + if (isAndroid) { + return [Permission.videos, Permission.storage]; + } + return const []; +} + /// 开始录制所需的相机/麦克风权限检测结果。 class RecordingRequiredPermissions { const RecordingRequiredPermissions({ @@ -221,7 +234,9 @@ class RecordingViewModel extends Notifier { Future restorePreview() async { if (!RecordingPlatform.isSupported) return; - _updateSession((s) => s.copyWith(isPreviewReady: false, errorMessage: null)); + _updateSession( + (s) => s.copyWith(isPreviewReady: false, errorMessage: null), + ); try { final status = await _initializePreviewWithRetry(); _updateSession( @@ -245,13 +260,10 @@ class RecordingViewModel extends Notifier { /// 当前平台所需的相册/视频保存权限列表。 List _galleryPermissions() { - if (Platform.isIOS) { - return [Permission.photosAddOnly, Permission.photos]; - } - if (Platform.isAndroid) { - return [Permission.videos, Permission.storage]; - } - return const []; + return recordingGalleryPermissionsForHost( + isIOS: Platform.isIOS, + isAndroid: Platform.isAndroid, + ); } /// 判断相册相关权限是否至少有一项已授予。 @@ -267,7 +279,8 @@ class RecordingViewModel extends Notifier { } /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 - Future ensureCameraAndMicrophonePermissions() async { + Future + ensureCameraAndMicrophonePermissions() async { final permissions = await PermissionService.requestMissing([ Permission.camera, Permission.microphone, @@ -303,9 +316,7 @@ class RecordingViewModel extends Notifier { return; } if (!session.isPreviewReady) { - _updateSession( - (s) => s.copyWith(errorMessage: '相机预览未就绪,请稍后重试'), - ); + _updateSession((s) => s.copyWith(errorMessage: '相机预览未就绪,请稍后重试')); return; } diff --git a/test/features/recording/view_model_recording_test.dart b/test/features/recording/view_model_recording_test.dart index d73bb5c..43b1f8d 100644 --- a/test/features/recording/view_model_recording_test.dart +++ b/test/features/recording/view_model_recording_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; void main() { @@ -38,6 +39,27 @@ void main() { }); }); + group('recordingGalleryPermissionsForHost', () { + test('requests only add-only photo permission on iOS', () { + final permissions = recordingGalleryPermissionsForHost( + isIOS: true, + isAndroid: false, + ); + + expect(permissions, [Permission.photosAddOnly]); + expect(permissions, isNot(contains(Permission.photos))); + }); + + test('keeps Android gallery permissions unchanged', () { + final permissions = recordingGalleryPermissionsForHost( + isIOS: false, + isAndroid: true, + ); + + expect(permissions, [Permission.videos, Permission.storage]); + }); + }); + group('RecordingViewModel.getClipboardContent', () { test( 'updates state when clipboard contains valid mini program JSON', @@ -56,14 +78,8 @@ void main() { expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛'); expect(model.clipboardRecordingModel.startTimestamp, 1717334400); expect(model.clipboardRecordingModel.endTimestamp, 1717334400); - expect( - model.clipboardRecordingModel.address, - '广州市番禺区·粤港澳大湾区青年人才双创小镇', - ); - expect( - model.clipboardRecordingModel.filename, - '选手名称_选手ID_赛事名称_赛项', - ); + expect(model.clipboardRecordingModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇'); + expect(model.clipboardRecordingModel.filename, '选手名称_选手ID_赛事名称_赛项'); }, ); @@ -93,7 +109,10 @@ void main() { expect(result, ClipboardReadResult.invalid); expect( - container.read(recordingViewModelProvider).clipboardRecordingModel.title, + container + .read(recordingViewModelProvider) + .clipboardRecordingModel + .title, defaultClipboardTitle, ); expect( @@ -113,33 +132,18 @@ void main() { expect(result, ClipboardReadResult.invalid); expect( - container.read(recordingViewModelProvider).clipboardRecordingModel.title, - defaultClipboardTitle, - ); - }); - - test('returns invalid when clipboard JSON misses required address', () async { - await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}'); - final container = ProviderContainer(); - addTearDown(container.dispose); - - 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( - 'updates state when clipboard omits optional timestamps', + 'returns invalid when clipboard JSON misses required address', () async { - await setClipboardText( - '{"title":"郑昌梦 丨黄伟依 空中格斗赛 小学组","address":"广东省汕头市番禺区青蓝街 111 号","filename":"郑昌梦_黄伟依_6月3日测试-1_空中格斗赛"}', - ); + await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}'); final container = ProviderContainer(); addTearDown(container.dispose); @@ -147,18 +151,36 @@ void main() { .read(recordingViewModelProvider.notifier) .getClipboardContent(); - expect(result, ClipboardReadResult.success); - final model = container.read(recordingViewModelProvider); - expect(model.hasValidClipboardInfo, isTrue); - expect(model.clipboardRecordingModel.startTimestamp, isNull); - expect(model.clipboardRecordingModel.endTimestamp, isNull); + expect(result, ClipboardReadResult.invalid); expect( - model.clipboardRecordingModel.filename, - '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛', + container + .read(recordingViewModelProvider) + .clipboardRecordingModel + .title, + defaultClipboardTitle, ); }, ); + test('updates state when clipboard omits optional timestamps', () async { + await setClipboardText( + '{"title":"郑昌梦 丨黄伟依 空中格斗赛 小学组","address":"广东省汕头市番禺区青蓝街 111 号","filename":"郑昌梦_黄伟依_6月3日测试-1_空中格斗赛"}', + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + final result = await container + .read(recordingViewModelProvider.notifier) + .getClipboardContent(); + + expect(result, ClipboardReadResult.success); + final model = container.read(recordingViewModelProvider); + expect(model.hasValidClipboardInfo, isTrue); + expect(model.clipboardRecordingModel.startTimestamp, isNull); + expect(model.clipboardRecordingModel.endTimestamp, isNull); + expect(model.clipboardRecordingModel.filename, '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛'); + }); + test('returns invalid when clipboard JSON has wrong field type', () async { await setClipboardText( '{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}', @@ -172,7 +194,10 @@ void main() { expect(result, ClipboardReadResult.invalid); expect( - container.read(recordingViewModelProvider).clipboardRecordingModel.title, + container + .read(recordingViewModelProvider) + .clipboardRecordingModel + .title, defaultClipboardTitle, ); });