兼容 IOS
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ pubspec.lock
|
|||||||
*.iws
|
*.iws
|
||||||
.idea/
|
.idea/
|
||||||
.cursor
|
.cursor
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# 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
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
"brand" to Build.BRAND,
|
"brand" to Build.BRAND,
|
||||||
"model" to Build.MODEL,
|
"model" to Build.MODEL,
|
||||||
"systemVersion" to Build.VERSION.RELEASE,
|
"systemVersion" to Build.VERSION.RELEASE,
|
||||||
|
"sdkInt" to Build.VERSION.SDK_INT,
|
||||||
"isPhysicalDevice" to !isEmulator,
|
"isPhysicalDevice" to !isEmulator,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,15 +173,15 @@ class RecordingPlatformHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
|
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 =
|
val payload =
|
||||||
mutableMapOf<String, Any?>(
|
mutableMapOf<String, Any?>(
|
||||||
"outputPath" to path,
|
"outputPath" to path,
|
||||||
"status" to controller.status.toMap(),
|
"status" to controller.status.toMap(),
|
||||||
"gallerySaved" to gallerySaved,
|
"fileSaved" to fileSaved,
|
||||||
)
|
)
|
||||||
if (!gallerySaved) {
|
if (!fileSaved) {
|
||||||
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
|
payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
|
||||||
}
|
}
|
||||||
result.success(payload)
|
result.success(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||||
|
|||||||
29
ios/Podfile
29
ios/Podfile
@@ -45,9 +45,34 @@ post_install do |installer|
|
|||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
'PERMISSION_CAMERA=1',
|
'PERMISSION_CAMERA=1',
|
||||||
'PERMISSION_MICROPHONE=1',
|
'PERMISSION_MICROPHONE=1',
|
||||||
'PERMISSION_PHOTOS=1',
|
|
||||||
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ SPEC CHECKSUMS:
|
|||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
|
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -344,14 +344,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
@@ -380,14 +376,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
|
|||||||
@@ -30,8 +30,10 @@
|
|||||||
<string>需要访问相机以显示预览并录制视频。</string>
|
<string>需要访问相机以显示预览并录制视频。</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<string>需要将录制的视频保存到相册。</string>
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Flutter
|
import Flutter
|
||||||
import Photos
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private enum RecordingState: String {
|
private enum RecordingState: String {
|
||||||
@@ -110,8 +109,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
private var audioInput: AVCaptureDeviceInput?
|
private var audioInput: AVCaptureDeviceInput?
|
||||||
private var configured = false
|
private var configured = false
|
||||||
private var latestOutputPath: String?
|
private var latestOutputPath: String?
|
||||||
private var latestGallerySaved = true
|
private var latestFileSaved = true
|
||||||
private var latestGalleryErrorMessage: String?
|
private var latestFileErrorMessage: String?
|
||||||
private var pendingDisplayName: String?
|
private var pendingDisplayName: String?
|
||||||
private var recordingStartedAt: Date?
|
private var recordingStartedAt: Date?
|
||||||
private var elapsedTimer: Timer?
|
private var elapsedTimer: Timer?
|
||||||
@@ -215,10 +214,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.pendingDisplayName = displayName
|
self.pendingDisplayName = displayName
|
||||||
self.latestGallerySaved = true
|
self.latestFileSaved = true
|
||||||
self.latestGalleryErrorMessage = nil
|
self.latestFileErrorMessage = nil
|
||||||
let outputURL = try self.createOutputURL(displayName: displayName)
|
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||||
self.latestOutputPath = outputURL.lastPathComponent
|
self.latestOutputPath = outputURL.path
|
||||||
self.recordingStartedAt = Date()
|
self.recordingStartedAt = Date()
|
||||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||||
@@ -254,11 +253,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"outputPath": self.latestOutputPath as Any,
|
"outputPath": self.latestOutputPath as Any,
|
||||||
"status": self.currentStatusMap(),
|
"status": self.currentStatusMap(),
|
||||||
"gallerySaved": self.latestGallerySaved,
|
"fileSaved": self.latestFileSaved,
|
||||||
]
|
]
|
||||||
if !self.latestGallerySaved {
|
if !self.latestFileSaved {
|
||||||
payload["galleryErrorMessage"] =
|
payload["fileErrorMessage"] =
|
||||||
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
self.latestFileErrorMessage ?? "保存到文件夹失败"
|
||||||
}
|
}
|
||||||
result(payload)
|
result(payload)
|
||||||
}
|
}
|
||||||
@@ -322,8 +321,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
pendingStopResult = nil
|
pendingStopResult = nil
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
latestGallerySaved = false
|
latestFileSaved = false
|
||||||
latestGalleryErrorMessage = error.localizedDescription
|
latestFileErrorMessage = error.localizedDescription
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||||
@@ -331,29 +330,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
|
latestFileSaved = true
|
||||||
guard let self else { return }
|
latestFileErrorMessage = nil
|
||||||
self.latestGallerySaved = success
|
latestOutputPath = outputFileURL.path
|
||||||
self.latestGalleryErrorMessage = message
|
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
|
||||||
if success {
|
latestFileSaved = false
|
||||||
self.updateStatus(
|
latestFileErrorMessage = "录制文件未生成"
|
||||||
RecordingStatus(
|
updateStatus(
|
||||||
state: .previewing,
|
RecordingStatus(
|
||||||
outputPath: self.latestOutputPath,
|
state: .error,
|
||||||
elapsedMillis: self.elapsedMillis()
|
outputPath: latestOutputPath,
|
||||||
)
|
message: latestFileErrorMessage
|
||||||
)
|
)
|
||||||
} else {
|
)
|
||||||
self.updateStatus(
|
finishStopRecording(stopResult: stopResult)
|
||||||
RecordingStatus(
|
return
|
||||||
state: .error,
|
|
||||||
outputPath: self.latestOutputPath,
|
|
||||||
message: message ?? "保存到相册失败"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self.finishStopRecording(stopResult: stopResult)
|
|
||||||
}
|
}
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
state: .previewing,
|
||||||
|
outputPath: latestOutputPath,
|
||||||
|
elapsedMillis: elapsedMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finishStopRecording(stopResult: stopResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishStopRecording(stopResult: FlutterResult?) {
|
private func finishStopRecording(stopResult: FlutterResult?) {
|
||||||
@@ -363,68 +363,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"outputPath": self.latestOutputPath as Any,
|
"outputPath": self.latestOutputPath as Any,
|
||||||
"status": self.currentStatusMap(),
|
"status": self.currentStatusMap(),
|
||||||
"gallerySaved": self.latestGallerySaved,
|
"fileSaved": self.latestFileSaved,
|
||||||
]
|
]
|
||||||
if !self.latestGallerySaved {
|
if !self.latestFileSaved {
|
||||||
payload["galleryErrorMessage"] =
|
payload["fileErrorMessage"] =
|
||||||
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
|
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
|
||||||
}
|
}
|
||||||
stopResult?(payload)
|
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 {
|
private func configureSession(withAudio: Bool) throws {
|
||||||
if configured {
|
if configured {
|
||||||
try configureAudioInput(enabled: withAudio)
|
try configureAudioInput(enabled: withAudio)
|
||||||
@@ -502,7 +450,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let fileName = Self.resolveFileName(displayName: displayName)
|
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 {
|
private static func resolveFileName(displayName: String?) -> String {
|
||||||
@@ -520,6 +491,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return "REC_\(formatter.string(from: Date())).mov"
|
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) {
|
private func updateStatus(_ next: RecordingStatus) {
|
||||||
status = next
|
status = next
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class RecordingSessionState {
|
|||||||
this.lastSavedDisplayName,
|
this.lastSavedDisplayName,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.permissionWarning,
|
this.permissionWarning,
|
||||||
this.gallerySaveFailed = false,
|
this.fileSaveFailed = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordingStatus status;
|
final RecordingStatus status;
|
||||||
@@ -30,7 +30,7 @@ class RecordingSessionState {
|
|||||||
final String? lastSavedDisplayName;
|
final String? lastSavedDisplayName;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? permissionWarning;
|
final String? permissionWarning;
|
||||||
final bool gallerySaveFailed;
|
final bool fileSaveFailed;
|
||||||
|
|
||||||
bool get isRecording => status.isRecording;
|
bool get isRecording => status.isRecording;
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ class RecordingSessionState {
|
|||||||
String? lastSavedDisplayName,
|
String? lastSavedDisplayName,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? permissionWarning,
|
String? permissionWarning,
|
||||||
bool? gallerySaveFailed,
|
bool? fileSaveFailed,
|
||||||
bool clearPermissionWarning = false,
|
bool clearPermissionWarning = false,
|
||||||
bool clearLastSaved = false,
|
bool clearLastSaved = false,
|
||||||
}) {
|
}) {
|
||||||
@@ -77,7 +77,7 @@ class RecordingSessionState {
|
|||||||
permissionWarning: clearPermissionWarning
|
permissionWarning: clearPermissionWarning
|
||||||
? null
|
? null
|
||||||
: (permissionWarning ?? this.permissionWarning),
|
: (permissionWarning ?? this.permissionWarning),
|
||||||
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
|
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
await ref.read(recordingViewModelProvider.notifier).stopRecording();
|
await ref.read(recordingViewModelProvider.notifier).stopRecording();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final latest = ref.read(recordingViewModelProvider).session;
|
final latest = ref.read(recordingViewModelProvider).session;
|
||||||
if (latest.gallerySaveFailed) {
|
if (latest.fileSaveFailed) {
|
||||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _showRecordingSavedDialogIfNeeded();
|
await _showRecordingSavedDialogIfNeeded();
|
||||||
@@ -190,7 +190,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
||||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||||
final session = recordingInfo.session;
|
final session = recordingInfo.session;
|
||||||
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
|
if (session.lastSavedDisplayName == null || session.fileSaveFailed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,10 +357,7 @@ class _PreviewLoadingLayer extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecordingHudLayer extends ConsumerWidget {
|
class _RecordingHudLayer extends ConsumerWidget {
|
||||||
const _RecordingHudLayer({
|
const _RecordingHudLayer({required this.onStart, required this.onStop});
|
||||||
required this.onStart,
|
|
||||||
required this.onStop,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Future<void> Function() onStart;
|
final Future<void> Function() onStart;
|
||||||
final Future<void> Function() onStop;
|
final Future<void> Function() onStop;
|
||||||
@@ -419,7 +416,10 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
await viewModel.refreshBatteryOptimization();
|
await viewModel.refreshBatteryOptimization();
|
||||||
},
|
},
|
||||||
onToggleTouchLock: () {
|
onToggleTouchLock: () {
|
||||||
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked;
|
final locked = ref
|
||||||
|
.read(recordingViewModelProvider)
|
||||||
|
.session
|
||||||
|
.isTouchLocked;
|
||||||
viewModel.setTouchLocked(!locked);
|
viewModel.setTouchLocked(!locked);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -167,14 +167,14 @@ class RecordingStopResult {
|
|||||||
const RecordingStopResult({
|
const RecordingStopResult({
|
||||||
this.outputPath,
|
this.outputPath,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.gallerySaved = true,
|
this.fileSaved = true,
|
||||||
this.galleryErrorMessage,
|
this.fileErrorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? outputPath;
|
final String? outputPath;
|
||||||
final RecordingStatus status;
|
final RecordingStatus status;
|
||||||
final bool gallerySaved;
|
final bool fileSaved;
|
||||||
final String? galleryErrorMessage;
|
final String? fileErrorMessage;
|
||||||
|
|
||||||
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
|
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
|
||||||
return RecordingStopResult(
|
return RecordingStopResult(
|
||||||
@@ -182,8 +182,8 @@ class RecordingStopResult {
|
|||||||
status: RecordingStatus.fromMap(
|
status: RecordingStatus.fromMap(
|
||||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
||||||
),
|
),
|
||||||
gallerySaved: result?['gallerySaved'] as bool? ?? true,
|
fileSaved: result?['fileSaved'] as bool? ?? true,
|
||||||
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
|
fileErrorMessage: result?['fileErrorMessage'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||||
import 'package:recording_tool/core/permission/permission_service.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_clipboard.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||||
@@ -31,15 +32,19 @@ enum ClipboardReadResult {
|
|||||||
invalid,
|
invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Permission> recordingGalleryPermissionsForHost({
|
List<Permission> recordingFileSavePermissionsForHost({
|
||||||
required bool isIOS,
|
required bool isIOS,
|
||||||
required bool isAndroid,
|
required bool isAndroid,
|
||||||
|
int? androidSdkInt,
|
||||||
}) {
|
}) {
|
||||||
if (isIOS) {
|
if (isIOS) {
|
||||||
return [Permission.photosAddOnly];
|
return const [];
|
||||||
}
|
}
|
||||||
if (isAndroid) {
|
if (isAndroid) {
|
||||||
return [Permission.videos, Permission.storage];
|
if (androidSdkInt != null && androidSdkInt >= 29) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return [Permission.storage];
|
||||||
}
|
}
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
@@ -144,11 +149,12 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final fileSavePermissions = await _fileSavePermissions();
|
||||||
final permissions = await PermissionService.requestMissing([
|
final permissions = await PermissionService.requestMissing([
|
||||||
Permission.camera,
|
Permission.camera,
|
||||||
Permission.microphone,
|
Permission.microphone,
|
||||||
if (Platform.isAndroid) Permission.notification,
|
if (Platform.isAndroid) Permission.notification,
|
||||||
..._galleryPermissions(),
|
...fileSavePermissions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||||
@@ -170,8 +176,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
if (!microphoneGranted) {
|
if (!microphoneGranted) {
|
||||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||||
}
|
}
|
||||||
if (!_isGalleryPermissionGranted(permissions)) {
|
if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) {
|
||||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹');
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||||
@@ -258,24 +264,36 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前平台所需的相册/视频保存权限列表。
|
/// 当前平台所需的视频文件保存权限列表。
|
||||||
List<Permission> _galleryPermissions() {
|
Future<List<Permission>> _fileSavePermissions() async {
|
||||||
return recordingGalleryPermissionsForHost(
|
int? androidSdkInt;
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
try {
|
||||||
|
androidSdkInt = int.tryParse(
|
||||||
|
(await AppPlatformInfo.deviceInfo()).values['sdkInt'] ?? '',
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
androidSdkInt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recordingFileSavePermissionsForHost(
|
||||||
isIOS: Platform.isIOS,
|
isIOS: Platform.isIOS,
|
||||||
isAndroid: Platform.isAndroid,
|
isAndroid: Platform.isAndroid,
|
||||||
|
androidSdkInt: androidSdkInt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断相册相关权限是否至少有一项已授予。
|
/// 判断文件保存相关权限是否至少有一项已授予。
|
||||||
bool _isGalleryPermissionGranted(
|
bool _isFileSavePermissionGranted(
|
||||||
Map<Permission, PermissionStatus> permissions,
|
Map<Permission, PermissionStatus> permissions,
|
||||||
|
List<Permission> fileSavePermissions,
|
||||||
) {
|
) {
|
||||||
for (final permission in _galleryPermissions()) {
|
for (final permission in fileSavePermissions) {
|
||||||
if (permissions[permission]?.isGranted ?? false) {
|
if (permissions[permission]?.isGranted ?? false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _galleryPermissions().isEmpty;
|
return fileSavePermissions.isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
||||||
@@ -338,7 +356,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
lastOutputPath: result.outputPath,
|
lastOutputPath: result.outputPath,
|
||||||
isTouchLocked: true,
|
isTouchLocked: true,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
gallerySaveFailed: false,
|
fileSaveFailed: false,
|
||||||
clearLastSaved: true,
|
clearLastSaved: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -351,13 +369,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止录制、保存到相册,并恢复相机预览。
|
/// 停止录制、保存到文件夹,并恢复相机预览。
|
||||||
Future<void> stopRecording() async {
|
Future<void> stopRecording() async {
|
||||||
if (!state.session.isRecording) return;
|
if (!state.session.isRecording) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await RecordingPlatform.stopRecording();
|
final result = await RecordingPlatform.stopRecording();
|
||||||
final galleryFailed = !result.gallerySaved;
|
final fileFailed = !result.fileSaved;
|
||||||
final savedName = recordingFileNameForPlatform(
|
final savedName = recordingFileNameForPlatform(
|
||||||
state.clipboardRecordingModel.filename,
|
state.clipboardRecordingModel.filename,
|
||||||
);
|
);
|
||||||
@@ -365,11 +383,11 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
(s) => s.copyWith(
|
(s) => s.copyWith(
|
||||||
status: result.status,
|
status: result.status,
|
||||||
lastOutputPath: result.outputPath ?? s.lastOutputPath,
|
lastOutputPath: result.outputPath ?? s.lastOutputPath,
|
||||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
lastSavedDisplayName: fileFailed ? null : savedName,
|
||||||
errorMessage: galleryFailed
|
errorMessage: fileFailed
|
||||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
|
||||||
: null,
|
: null,
|
||||||
gallerySaveFailed: galleryFailed,
|
fileSaveFailed: fileFailed,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
} on PlatformException catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
||||||
|
|
||||||
/// 录制结束并保存到相册后的后续操作弹窗。
|
/// 录制结束并保存到文件夹后的后续操作弹窗。
|
||||||
Future<void> showRecordingSavedDialog(
|
Future<void> showRecordingSavedDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String sessionTitle,
|
required String sessionTitle,
|
||||||
@@ -10,7 +10,7 @@ Future<void> showRecordingSavedDialog(
|
|||||||
}) {
|
}) {
|
||||||
return RecordDialog.showDouble(
|
return RecordDialog.showDouble(
|
||||||
context,
|
context,
|
||||||
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
|
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
|
||||||
leftText: '继续本轮',
|
leftText: '继续本轮',
|
||||||
rightText: '录制新轮',
|
rightText: '录制新轮',
|
||||||
onLeftPressed: onContinueRound,
|
onLeftPressed: onContinueRound,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('iOS permission configuration', () {
|
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();
|
final podfile = File('ios/Podfile').readAsStringSync();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -80,8 +80,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||||
expect(podfile, contains("'PERMISSION_PHOTOS=1'"));
|
expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'")));
|
||||||
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'"));
|
expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'")));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ void main() {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
RecordDialog.showDouble(
|
RecordDialog.showDouble(
|
||||||
context,
|
context,
|
||||||
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
|
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
|
||||||
leftText: '继续本轮',
|
leftText: '继续本轮',
|
||||||
rightText: '录制新轮',
|
rightText: '录制新轮',
|
||||||
onLeftPressed: () => leftTapped = true,
|
onLeftPressed: () => leftTapped = true,
|
||||||
@@ -123,7 +123,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
|
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
|
||||||
expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget);
|
expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget);
|
||||||
expect(find.text('继续本轮'), findsOneWidget);
|
expect(find.text('继续本轮'), findsOneWidget);
|
||||||
expect(find.text('录制新轮'), findsOneWidget);
|
expect(find.text('录制新轮'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, '保存到文件夹失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,24 +39,37 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('recordingGalleryPermissionsForHost', () {
|
group('recordingFileSavePermissionsForHost', () {
|
||||||
test('requests only add-only photo permission on iOS', () {
|
test('does not request photo permission on iOS', () {
|
||||||
final permissions = recordingGalleryPermissionsForHost(
|
final permissions = recordingFileSavePermissionsForHost(
|
||||||
isIOS: true,
|
isIOS: true,
|
||||||
isAndroid: false,
|
isAndroid: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(permissions, <Permission>[Permission.photosAddOnly]);
|
expect(permissions, isEmpty);
|
||||||
|
expect(permissions, isNot(contains(Permission.photosAddOnly)));
|
||||||
expect(permissions, isNot(contains(Permission.photos)));
|
expect(permissions, isNot(contains(Permission.photos)));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps Android gallery permissions unchanged', () {
|
test('requests storage permission on Android 9 and below', () {
|
||||||
final permissions = recordingGalleryPermissionsForHost(
|
final permissions = recordingFileSavePermissionsForHost(
|
||||||
isIOS: false,
|
isIOS: false,
|
||||||
isAndroid: true,
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user