兼容 IOS

This commit is contained in:
2026-06-09 12:29:27 +08:00
parent cf1c2d7d0e
commit de2aacca90
19 changed files with 207 additions and 159 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -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<String, Any?>(
"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)
}

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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

View File

@@ -43,6 +43,6 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
COCOAPODS: 1.16.2

View File

@@ -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";

View File

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

View File

@@ -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()
)
)
} else {
self.updateStatus(
latestFileSaved = true
latestFileErrorMessage = nil
latestOutputPath = outputFileURL.path
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
latestFileSaved = false
latestFileErrorMessage = "录制文件未生成"
updateStatus(
RecordingStatus(
state: .error,
outputPath: self.latestOutputPath,
message: message ?? "保存到相册失败"
outputPath: latestOutputPath,
message: latestFileErrorMessage
)
)
finishStopRecording(stopResult: stopResult)
return
}
self.finishStopRecording(stopResult: stopResult)
}
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
}

View File

@@ -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,
);
}
}

View File

@@ -172,8 +172,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
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<RecordingPage> {
Future<void> _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<void> Function() onStart;
final Future<void> 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);
},
);

View File

@@ -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<String, dynamic>? result) {
return RecordingStopResult(
@@ -182,8 +182,8 @@ class RecordingStopResult {
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.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?,
);
}
}

View File

@@ -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<Permission> recordingGalleryPermissionsForHost({
List<Permission> 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<RecordingModel> {
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<RecordingModel> {
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<RecordingModel> {
}
}
/// 当前平台所需的相册/视频保存权限列表。
List<Permission> _galleryPermissions() {
return recordingGalleryPermissionsForHost(
/// 当前平台所需的视频文件保存权限列表。
Future<List<Permission>> _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<Permission, PermissionStatus> permissions,
List<Permission> 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<RecordingModel> {
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
gallerySaveFailed: false,
fileSaveFailed: false,
clearLastSaved: true,
),
);
@@ -351,13 +369,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
}
}
/// 停止录制、保存到相册,并恢复相机预览。
/// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> 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<RecordingModel> {
(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) {

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:recording_tool/features/dialog/dialog-record.dart';
/// 录制结束并保存到相册后的后续操作弹窗。
/// 录制结束并保存到文件夹后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
@@ -10,7 +10,7 @@ Future<void> showRecordingSavedDialog(
}) {
return RecordDialog.showDouble(
context,
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
leftText: '继续本轮',
rightText: '录制新轮',
onLeftPressed: onContinueRound,

View File

@@ -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'")));
});
});
}

View File

@@ -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);
});

View File

@@ -18,4 +18,20 @@ void main() {
);
});
});
group('RecordingStopResult', () {
test('parses file save result fields from platform payload', () {
final result = RecordingStopResult.fromMap(<String, dynamic>{
'outputPath': '/Documents/recordings/test.mov',
'status': <String, dynamic>{'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, '保存到文件夹失败');
});
});
}

View File

@@ -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>[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>[Permission.videos, Permission.storage]);
expect(permissions, <Permission>[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);
});
});