mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Implement the save action when previewing media. (#3630)
* Implement the save action on the media preview. * Update Compound and use the correct icon. Also fixes an icon that has been renamed. * Update the add to photo library usage description to match the designs. * PR comments.
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"NSFaceIDUsageDescription" = "Face ID is used to access your app.";
|
||||
"NSLocationWhenInUseUsageDescription" = "Grant location access so that Element X can share your location.";
|
||||
"NSMicrophoneUsageDescription" = "To record and send messages with audio, Element X needs to access the microphone.";
|
||||
"NSPhotoLibraryUsageDescription" = "Allows saving photos and videos to your library.";
|
||||
"NSPhotoLibraryUsageDescription" = "This lets you save images and videos to your photo library.";
|
||||
|
||||
@@ -274,6 +274,7 @@
|
||||
"dialog_permission_microphone_description_ios" = "Grant access so you can record and send messages with audio.";
|
||||
"dialog_permission_microphone_title_ios" = "%1$@ needs permission to access your microphone.";
|
||||
"dialog_permission_notification" = "In order to let the application display notifications, please grant the permission in the system settings.";
|
||||
"dialog_permission_photo_library_title_ios" = "%1$@ does not have access to your photo library.";
|
||||
"dialog_title_confirmation" = "Confirmation";
|
||||
"dialog_title_warning" = "Warning";
|
||||
"dialog_unsaved_changes_description_ios" = "Your changes won’t be saved";
|
||||
|
||||
@@ -109,7 +109,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private func presentMediaPreview(for previewContext: TimelineMediaPreviewContext) {
|
||||
let parameters = TimelineMediaPreviewCoordinatorParameters(context: previewContext,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
userIndicatorController: userIndicatorController)
|
||||
userIndicatorController: userIndicatorController,
|
||||
appMediator: appMediator)
|
||||
|
||||
let coordinator = TimelineMediaPreviewCoordinator(parameters: parameters)
|
||||
coordinator.actionsPublisher
|
||||
|
||||
@@ -606,6 +606,10 @@ internal enum L10n {
|
||||
}
|
||||
/// In order to let the application display notifications, please grant the permission in the system settings.
|
||||
internal static var dialogPermissionNotification: String { return L10n.tr("Localizable", "dialog_permission_notification") }
|
||||
/// %1$@ does not have access to your photo library.
|
||||
internal static func dialogPermissionPhotoLibraryTitleIos(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "dialog_permission_photo_library_title_ios", String(describing: p1))
|
||||
}
|
||||
/// Confirmation
|
||||
internal static var dialogTitleConfirmation: String { return L10n.tr("Localizable", "dialog_title_confirmation") }
|
||||
/// Error
|
||||
|
||||
@@ -8,6 +8,7 @@ import Combine
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import MatrixRustSDK
|
||||
import Photos
|
||||
import SwiftUI
|
||||
class AnalyticsClientMock: AnalyticsClientProtocol {
|
||||
var isRunning: Bool {
|
||||
@@ -12327,6 +12328,79 @@ class PHGPostHogMock: PHGPostHogProtocol {
|
||||
screenPropertiesClosure?(screenTitle, properties)
|
||||
}
|
||||
}
|
||||
class PhotoLibraryManagerMock: PhotoLibraryManagerProtocol {
|
||||
|
||||
//MARK: - addResource
|
||||
|
||||
var addResourceAtUnderlyingCallsCount = 0
|
||||
var addResourceAtCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return addResourceAtUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = addResourceAtUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
addResourceAtUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
addResourceAtUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var addResourceAtCalled: Bool {
|
||||
return addResourceAtCallsCount > 0
|
||||
}
|
||||
var addResourceAtReceivedArguments: (type: PHAssetResourceType, url: URL)?
|
||||
var addResourceAtReceivedInvocations: [(type: PHAssetResourceType, url: URL)] = []
|
||||
|
||||
var addResourceAtUnderlyingReturnValue: Result<Void, PhotoLibraryManagerError>!
|
||||
var addResourceAtReturnValue: Result<Void, PhotoLibraryManagerError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return addResourceAtUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Void, PhotoLibraryManagerError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = addResourceAtUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
addResourceAtUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
addResourceAtUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var addResourceAtClosure: ((PHAssetResourceType, URL) async -> Result<Void, PhotoLibraryManagerError>)?
|
||||
|
||||
func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result<Void, PhotoLibraryManagerError> {
|
||||
addResourceAtCallsCount += 1
|
||||
addResourceAtReceivedArguments = (type: type, url: url)
|
||||
DispatchQueue.main.async {
|
||||
self.addResourceAtReceivedInvocations.append((type: type, url: url))
|
||||
}
|
||||
if let addResourceAtClosure = addResourceAtClosure {
|
||||
return await addResourceAtClosure(type, url)
|
||||
} else {
|
||||
return addResourceAtReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
class PollInteractionHandlerMock: PollInteractionHandlerProtocol {
|
||||
|
||||
//MARK: - sendPollResponse
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PhotoLibraryManagerMock {
|
||||
struct Configuration {
|
||||
var authorizationDenied = false
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
convenience init(_ configuration: Configuration) {
|
||||
self.init()
|
||||
|
||||
addResourceAtReturnValue = configuration.authorizationDenied ? .failure(PhotoLibraryManagerError.notAuthorized) : .success(())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Photos
|
||||
|
||||
enum PhotoLibraryManagerError: Error {
|
||||
case notAuthorized
|
||||
case unknown(Error)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol PhotoLibraryManagerProtocol {
|
||||
func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result<Void, PhotoLibraryManagerError>
|
||||
}
|
||||
|
||||
struct PhotoLibraryManager: PhotoLibraryManagerProtocol {
|
||||
func addResource(_ type: PHAssetResourceType, at url: URL) async -> Result<Void, PhotoLibraryManagerError> {
|
||||
do {
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
let request = PHAssetCreationRequest.forAsset()
|
||||
let options = PHAssetResourceCreationOptions()
|
||||
request.addResource(with: type, fileURL: url, options: options)
|
||||
}
|
||||
return .success(())
|
||||
} catch {
|
||||
if (error as NSError).code == PHPhotosError.accessUserDenied.rawValue {
|
||||
return .failure(.notAuthorized)
|
||||
} else {
|
||||
return .failure(.unknown(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ struct TimelineMediaPreviewCoordinatorParameters {
|
||||
let context: TimelineMediaPreviewContext
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let appMediator: AppMediatorProtocol
|
||||
}
|
||||
|
||||
enum TimelineMediaPreviewCoordinatorAction {
|
||||
@@ -50,7 +51,9 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
|
||||
|
||||
viewModel = TimelineMediaPreviewViewModel(context: parameters.context,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
photoLibraryManager: PhotoLibraryManager(),
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
appMediator: parameters.appMediator)
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -69,6 +72,6 @@ final class TimelineMediaPreviewCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(TimelineMediaPreviewView(context: viewModel.context))
|
||||
AnyView(TimelineMediaPreviewScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,18 @@ struct TimelineMediaPreviewViewState: BindableState {
|
||||
}
|
||||
|
||||
struct TimelineMediaPreviewViewStateBindings {
|
||||
/// A binding that will present the Details view for the specified item.
|
||||
var mediaDetailsItem: TimelineMediaPreviewItem?
|
||||
/// A binding that will present a confirmation to redact the specified item.
|
||||
var redactConfirmationItem: TimelineMediaPreviewItem?
|
||||
/// A binding that will present a document picker to export the specified file.
|
||||
var fileToExport: TimelineMediaPreviewFileExportPicker.File?
|
||||
|
||||
var alertInfo: AlertInfo<TimelineMediaPreviewAlertType>?
|
||||
}
|
||||
|
||||
enum TimelineMediaPreviewAlertType {
|
||||
case authorizationRequired
|
||||
}
|
||||
|
||||
/// Wraps a media file and title to be previewed with QuickLook.
|
||||
|
||||
@@ -13,7 +13,9 @@ typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaP
|
||||
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
private let timelineViewModel: TimelineViewModelProtocol
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let photoLibraryManager: PhotoLibraryManagerProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
private let appMediator: AppMediatorProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<TimelineMediaPreviewViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<TimelineMediaPreviewViewModelAction, Never> {
|
||||
@@ -22,12 +24,14 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
|
||||
init(context: TimelineMediaPreviewContext,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
photoLibraryManager: PhotoLibraryManagerProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol,
|
||||
appMediator: AppMediatorProtocol) {
|
||||
timelineViewModel = context.viewModel
|
||||
self.mediaProvider = mediaProvider
|
||||
|
||||
// We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔
|
||||
self.photoLibraryManager = photoLibraryManager
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
|
||||
let currentItem = TimelineMediaPreviewItem(timelineItem: context.item)
|
||||
|
||||
@@ -64,10 +68,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
MXLog.error("Received unexpected action: \(action)")
|
||||
}
|
||||
case .redactConfirmation(let item):
|
||||
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
|
||||
state.bindings.redactConfirmationItem = nil
|
||||
state.bindings.mediaDetailsItem = nil
|
||||
actionsSubject.send(.dismiss)
|
||||
redactItem(item)
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
@@ -108,12 +109,43 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
}
|
||||
|
||||
private func saveCurrentItem() async {
|
||||
guard let url = state.currentItem.fileHandle?.url else {
|
||||
guard let fileURL = state.currentItem.fileHandle?.url else {
|
||||
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
|
||||
return
|
||||
}
|
||||
|
||||
showErrorIndicator()
|
||||
do {
|
||||
switch state.currentItem.timelineItem {
|
||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||
state.bindings.fileToExport = .init(url: fileURL)
|
||||
return // Don't show the indicator.
|
||||
case is ImageRoomTimelineItem:
|
||||
try await photoLibraryManager.addResource(.photo, at: fileURL).get()
|
||||
case is VideoRoomTimelineItem:
|
||||
try await photoLibraryManager.addResource(.video, at: fileURL).get()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
showSavedIndicator()
|
||||
} catch PhotoLibraryManagerError.notAuthorized {
|
||||
MXLog.error("Not authorised to save item to photo library")
|
||||
state.bindings.alertInfo = .init(id: .authorizationRequired,
|
||||
title: L10n.dialogPermissionPhotoLibraryTitleIos(InfoPlistReader.main.bundleDisplayName),
|
||||
primaryButton: .init(title: L10n.commonSettings) { self.appMediator.openAppSettings() },
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
} catch {
|
||||
MXLog.error("Failed saving item: \(error)")
|
||||
showErrorIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
private func redactItem(_ item: TimelineMediaPreviewItem) {
|
||||
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
|
||||
state.bindings.redactConfirmationItem = nil
|
||||
state.bindings.mediaDetailsItem = nil
|
||||
actionsSubject.send(.dismiss)
|
||||
showRedactedIndicator()
|
||||
}
|
||||
|
||||
// MARK: - Indicators
|
||||
@@ -132,22 +164,38 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
userIndicatorController.retractIndicatorWithId(indicatorID)
|
||||
}
|
||||
|
||||
// FIXME: Add the strings and correct indicator types
|
||||
private func showDownloadErrorIndicator() {
|
||||
// FIXME: Add the correct string and indicator type??
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: downloadErrorIndicatorID,
|
||||
type: .modal,
|
||||
title: L10n.errorUnknown,
|
||||
iconName: "exclamationmark.circle.fill"))
|
||||
}
|
||||
|
||||
private func showRedactedIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: "File deleted",
|
||||
iconName: "checkmark"))
|
||||
}
|
||||
|
||||
private func showSavedIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: "File saved",
|
||||
iconName: "checkmark"))
|
||||
}
|
||||
|
||||
private func showErrorIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: errorIndicatorID,
|
||||
type: .modal,
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: L10n.errorUnknown,
|
||||
iconName: "xmark"))
|
||||
}
|
||||
|
||||
private var errorIndicatorID: String { "\(Self.self)-Error" }
|
||||
private var statusIndicatorID: String { "\(Self.self)-Status" }
|
||||
|
||||
// Separate indicator IDs for downloads as these can be triggered in the background when swiping between items
|
||||
private var downloadErrorIndicatorID: String { "\(Self.self)-DownloadError" }
|
||||
private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String {
|
||||
"\(Self.self)-Download-\(itemID.uniqueID.id)"
|
||||
|
||||
+3
-1
@@ -184,6 +184,8 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
|
||||
viewModel: TimelineViewModel.mock(timelineKind: timelineKind),
|
||||
namespace: previewNamespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock())
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineMediaPreviewFileExportPicker: UIViewControllerRepresentable {
|
||||
struct File: Identifiable {
|
||||
let url: URL
|
||||
var id: String { url.absoluteString }
|
||||
}
|
||||
|
||||
let file: File
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
UIDocumentPickerViewController(forExporting: [file.url], asCopy: true)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
|
||||
}
|
||||
+3
-1
@@ -142,6 +142,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
|
||||
viewModel: TimelineViewModel.mock,
|
||||
namespace: previewNamespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock())
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -10,7 +10,7 @@ import Compound
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineMediaPreviewView: View {
|
||||
struct TimelineMediaPreviewScreen: View {
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
|
||||
@@ -33,6 +33,11 @@ struct TimelineMediaPreviewView: View {
|
||||
.sheet(item: $context.mediaDetailsItem) { item in
|
||||
TimelineMediaPreviewDetailsView(item: item, context: context)
|
||||
}
|
||||
.sheet(item: $context.fileToExport) { file in
|
||||
TimelineMediaPreviewFileExportPicker(file: file)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
.preferredColorScheme(.dark)
|
||||
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
|
||||
}
|
||||
@@ -102,7 +107,7 @@ struct TimelineMediaPreviewView: View {
|
||||
Spacer()
|
||||
|
||||
Button { context.send(viewAction: .saveCurrentItem) } label: {
|
||||
CompoundIcon(\.download)
|
||||
CompoundIcon(\.downloadIos)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,13 +184,13 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineMediaPreviewView_Previews: PreviewProvider {
|
||||
struct TimelineMediaPreviewScreen_Previews: PreviewProvider {
|
||||
@Namespace private static var namespace
|
||||
|
||||
static let viewModel = makeViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
QuickLookView(viewModelContext: viewModel.context)
|
||||
TimelineMediaPreviewScreen(context: viewModel.context)
|
||||
}
|
||||
|
||||
static func makeViewModel() -> TimelineMediaPreviewViewModel {
|
||||
@@ -207,6 +212,8 @@ struct TimelineMediaPreviewView_Previews: PreviewProvider {
|
||||
viewModel: TimelineViewModel.mock(timelineKind: .media(.mediaFilesScreen)),
|
||||
namespace: namespace),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: UserIndicatorControllerMock())
|
||||
photoLibraryManager: PhotoLibraryManagerMock(.init()),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock())
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ struct CallInviteRoomTimelineView: View {
|
||||
Label {
|
||||
Text(L10n.screenRoomTimelineLegacyCall)
|
||||
} icon: {
|
||||
CompoundIcon(\.voiceCall, size: .medium, relativeTo: .compound.bodyMD)
|
||||
CompoundIcon(\.voiceCallSolid, size: .medium, relativeTo: .compound.bodyMD)
|
||||
}
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Allows saving photos and videos to your library.</string>
|
||||
<string>This lets you save images and videos to your photo library.</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
|
||||
@@ -87,7 +87,7 @@ targets:
|
||||
]
|
||||
NSCameraUsageDescription: To take pictures or videos and send them as a message $(APP_DISPLAY_NAME) needs access to the camera.
|
||||
NSMicrophoneUsageDescription: To record and send messages with audio, $(APP_DISPLAY_NAME) needs to access the microphone.
|
||||
NSPhotoLibraryAddUsageDescription: Allows saving photos and videos to your library.
|
||||
NSPhotoLibraryAddUsageDescription: This lets you save images and videos to your photo library.
|
||||
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
|
||||
NSFaceIDUsageDescription: Face ID is used to access your app.
|
||||
UIBackgroundModes: [
|
||||
|
||||
Reference in New Issue
Block a user