diff --git a/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw b/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw index b67a746f16be..e2e47a09eea8 100644 --- a/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw +++ b/src/Uno.UI/Resources/Strings/cs-CZ/Resources.resw @@ -126,6 +126,9 @@ iOS Security Scoped + + iOS PHPicker + Tento počítač @@ -135,4 +138,4 @@ JS File Access API - \ No newline at end of file + diff --git a/src/Uno.UI/Resources/Strings/en/Resources.resw b/src/Uno.UI/Resources/Strings/en/Resources.resw index 3aca64c1bf6e..f438ef279457 100644 --- a/src/Uno.UI/Resources/Strings/en/Resources.resw +++ b/src/Uno.UI/Resources/Strings/en/Resources.resw @@ -126,6 +126,9 @@ iOS Security Scoped + + iOS PHPicker + This PC diff --git a/src/Uno.UWP/Devices/Sensors/Helpers/SensorHelpers.iOS.cs b/src/Uno.UWP/Devices/Sensors/Helpers/SensorHelpers.iOS.cs index a4579a75761c..33b1aaffe2b9 100644 --- a/src/Uno.UWP/Devices/Sensors/Helpers/SensorHelpers.iOS.cs +++ b/src/Uno.UWP/Devices/Sensors/Helpers/SensorHelpers.iOS.cs @@ -1,52 +1,14 @@ using Foundation; using System; -namespace Uno.Devices.Sensors.Helpers +namespace Uno.Devices.Sensors.Helpers; + +internal static class SensorHelpers { - internal static class SensorHelpers + public static DateTimeOffset TimestampToDateTimeOffset(double timestamp) { - private static readonly DateTimeOffset NSDateConversionStart = - new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.Zero); - - public static DateTimeOffset TimestampToDateTimeOffset(double timestamp) - { - var bootTime = NSDate.FromTimeIntervalSinceNow(-NSProcessInfo.ProcessInfo.SystemUptime); - var date = (DateTime)bootTime.AddSeconds(timestamp); - return new DateTimeOffset(date); - } - - public static DateTimeOffset NSDateToDateTimeOffset(NSDate nsDate) - { - if (nsDate == NSDate.DistantPast) - { - return DateTimeOffset.MinValue; - } - else if (nsDate == NSDate.DistantFuture) - { - return DateTimeOffset.MaxValue; - } - - return NSDateConversionStart.AddSeconds( - nsDate.SecondsSinceReferenceDate); - } - - public static NSDate DateTimeOffsetToNSDate(DateTimeOffset dateTimeOffset) - { - if (dateTimeOffset == DateTimeOffset.MinValue) - { - return NSDate.DistantPast; - } - else if (dateTimeOffset == DateTimeOffset.MaxValue) - { - return NSDate.DistantFuture; - } - - var dateInSecondsFromStart = dateTimeOffset - .ToUniversalTime() - .Subtract(NSDateConversionStart.UtcDateTime); - - return NSDate.FromTimeIntervalSinceReferenceDate( - dateInSecondsFromStart.TotalSeconds); - } + var bootTime = NSDate.FromTimeIntervalSinceNow(-NSProcessInfo.ProcessInfo.SystemUptime); + var date = (DateTime)bootTime.AddSeconds(timestamp); + return new DateTimeOffset(date); } } diff --git a/src/Uno.UWP/Extensions/NSDateExtensions.cs b/src/Uno.UWP/Extensions/NSDateExtensions.cs new file mode 100644 index 000000000000..f3b093deb2c0 --- /dev/null +++ b/src/Uno.UWP/Extensions/NSDateExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Foundation; + +namespace Windows.Extensions; + +internal static class NSDateExtensions +{ + private static readonly DateTimeOffset NSDateConversionStart = + new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static DateTimeOffset ToDateTimeOffset(this NSDate nsDate) + { + if (nsDate == NSDate.DistantPast) + { + return DateTimeOffset.MinValue; + } + else if (nsDate == NSDate.DistantFuture) + { + return DateTimeOffset.MaxValue; + } + + return NSDateConversionStart.AddSeconds( + nsDate.SecondsSinceReferenceDate); + } + + public static NSDate ToNSDate(this DateTimeOffset dateTimeOffset) + { + if (dateTimeOffset == DateTimeOffset.MinValue) + { + return NSDate.DistantPast; + } + else if (dateTimeOffset == DateTimeOffset.MaxValue) + { + return NSDate.DistantFuture; + } + + var dateInSecondsFromStart = dateTimeOffset + .ToUniversalTime() + .Subtract(NSDateConversionStart.UtcDateTime); + + return NSDate.FromTimeIntervalSinceReferenceDate( + dateInSecondsFromStart.TotalSeconds); + } +} diff --git a/src/Uno.UWP/Storage/Internal/StorageProviders.cs b/src/Uno.UWP/Storage/Internal/StorageProviders.cs index 3c51c1893398..40c30b2affdd 100644 --- a/src/Uno.UWP/Storage/Internal/StorageProviders.cs +++ b/src/Uno.UWP/Storage/Internal/StorageProviders.cs @@ -18,6 +18,8 @@ internal static class StorageProviders #if __IOS__ public static StorageProvider IosSecurityScoped { get; } = new StorageProvider("iossecurityscoped", "StorageProviderIosSecurityScopedDisplayName"); + + public static StorageProvider IosPHPicker { get; } = new("iosphpicker", "StorageProviderIosPHPickerDisplayName"); #endif } } diff --git a/src/Uno.UWP/Storage/Pickers/FileOpenPicker.cs b/src/Uno.UWP/Storage/Pickers/FileOpenPicker.cs index 54bb0e459acf..0a1e4c0527db 100644 --- a/src/Uno.UWP/Storage/Pickers/FileOpenPicker.cs +++ b/src/Uno.UWP/Storage/Pickers/FileOpenPicker.cs @@ -100,21 +100,4 @@ private void ValidateConfiguration() } #endif } - - public static class FileOpenPickerExtensions - { - /// - /// Sets the file limit a user can select when picking multiple files. - /// - /// The maximum number of files that the user can pick. -#if !__IOS__ - [global::Uno.NotImplemented] -#endif - public static void SetMultipleFilesLimit(this FileOpenPicker picker, int limit) - { -#if __IOS__ - picker.SetMultipleFileLimit(limit); -#endif - } - } } diff --git a/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs b/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs index a099da4a6b29..fac719e9d94e 100644 --- a/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs +++ b/src/Uno.UWP/Storage/Pickers/FileOpenPicker.iOS.cs @@ -5,22 +5,22 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Uno.Storage.Pickers.Internal; -using UIKit; using Foundation; -using Windows.ApplicationModel.Core; -using Uno.Helpers.Theming; +using MobileCoreServices; +using Photos; using PhotosUI; +using UIKit; +using Uno.Helpers.Theming; +using Uno.Storage.Pickers.Internal; using Uno.UI.Dispatching; -using MobileCoreServices; -using Windows.Foundation.Metadata; -using Uno.Foundation.Logging; +using Windows.ApplicationModel.Core; namespace Windows.Storage.Pickers { public partial class FileOpenPicker { private int _multipleFileLimit; + private bool _isReadOnly; private Task PickSingleFileTaskAsync(CancellationToken token) { @@ -53,10 +53,9 @@ private Task> PickMultipleFilesTaskAsync(Cancellation return tcs.Task; } - internal void SetMultipleFileLimit(int limit) - { - _multipleFileLimit = limit; - } + internal void SetMultipleFileLimit(int limit) => _multipleFileLimit = limit; + + internal void SetReadOnlyMode(bool readOnly) => _isReadOnly = readOnly; private UIViewController GetViewController(bool multiple, int limit, TaskCompletionSource completionSource) { @@ -73,24 +72,24 @@ private UIViewController GetViewController(bool multiple, int limit, TaskComplet }; case PickerLocationId.PicturesLibrary when multiple is true && iOS14AndAbove is true: - var imageConfiguration = new PHPickerConfiguration + var imageConfiguration = new PHPickerConfiguration(PHPhotoLibrary.SharedPhotoLibrary) { Filter = PHPickerFilter.ImagesFilter, SelectionLimit = limit }; return new PHPickerViewController(imageConfiguration) { - Delegate = new PhotoPickerDelegate(completionSource) + Delegate = new PhotoPickerDelegate(completionSource, _isReadOnly) }; case PickerLocationId.VideosLibrary when multiple is true && iOS14AndAbove is true: - var videoConfiguration = new PHPickerConfiguration + var videoConfiguration = new PHPickerConfiguration(PHPhotoLibrary.SharedPhotoLibrary) { Filter = PHPickerFilter.VideosFilter, SelectionLimit = limit }; return new PHPickerViewController(videoConfiguration) { - Delegate = new PhotoPickerDelegate(completionSource) + Delegate = new PhotoPickerDelegate(completionSource, _isReadOnly) }; default: @@ -167,9 +166,13 @@ public override void FinishedPickingMedia(UIImagePickerController picker, NSDict private class PhotoPickerDelegate : PHPickerViewControllerDelegate { private readonly TaskCompletionSource _taskCompletionSource; + private readonly bool _readOnly; - public PhotoPickerDelegate(TaskCompletionSource taskCompletionSource) => + public PhotoPickerDelegate(TaskCompletionSource taskCompletionSource, bool readOnly) + { _taskCompletionSource = taskCompletionSource; + _readOnly = readOnly; + } public override async void DidFinishPicking(PHPickerViewController picker, PHPickerResult[] results) { @@ -186,56 +189,50 @@ public override async void DidFinishPicking(PHPickerViewController picker, PHPic private async Task> ConvertPickerResults(PHPickerResult[] results) { List storageFiles = new List(); - var providers = results - .Select(res => res.ItemProvider) - .Where(provider => provider != null && provider.RegisteredTypeIdentifiers?.Length > 0) - .ToArray(); - - foreach (NSItemProvider provider in providers) + if (_readOnly) { - var identifier = GetIdentifier(provider.RegisteredTypeIdentifiers ?? []) ?? "public.data"; - var data = await provider.LoadDataRepresentationAsync(identifier); - - if (data is null) + var assetIdentifiers = results + .Select(res => res.AssetIdentifier!) + .Where(id => id != null) + .ToArray(); + + var resultsByIdentifier = results.ToDictionary(res => res.AssetIdentifier!); + var assets = PHAsset.FetchAssetsUsingLocalIdentifiers(assetIdentifiers, null); + foreach (PHAsset asset in assets) { - continue; + var file = StorageFile.GetFromPHPickerResult(resultsByIdentifier[asset.LocalIdentifier], asset, null); + storageFiles.Add(file); } - - var extension = GetExtension(identifier); - - var destinationUrl = NSFileManager.DefaultManager - .GetTemporaryDirectory() - .Append($"{NSProcessInfo.ProcessInfo.GloballyUniqueString}.{extension}", false); - data.Save(destinationUrl, false); - - storageFiles.Add(StorageFile.GetFromSecurityScopedUrl(destinationUrl, null)); } - - return storageFiles; - } - private static string? GetIdentifier(string[] identifiers) - { - if (!(identifiers?.Length > 0)) + else { - return null; - } + var providers = results + .Select(res => res.ItemProvider) + .Where(provider => provider != null && provider.RegisteredTypeIdentifiers?.Length > 0) + .ToArray(); - if (identifiers.Any(i => i.StartsWith(UTType.LivePhoto, StringComparison.InvariantCultureIgnoreCase)) && identifiers.Contains(UTType.JPEG)) - { - return identifiers.FirstOrDefault(i => i == UTType.JPEG); - } + foreach (NSItemProvider provider in providers) + { + var identifier = StorageFile.GetUTIdentifier(provider.RegisteredTypeIdentifiers ?? []) ?? "public.data"; + var data = await provider.LoadDataRepresentationAsync(identifier); - if (identifiers.Contains(UTType.QuickTimeMovie)) - { - return identifiers.FirstOrDefault(i => i == UTType.QuickTimeMovie); - } + if (data is null) + { + continue; + } - return identifiers.FirstOrDefault(); - } + var extension = StorageFile.GetUTFileExtension(identifier); - private string? GetExtension(string identifier) - => UTType.CopyAllTags(identifier, UTType.TagClassFilenameExtension)?.FirstOrDefault(); + var destinationUrl = NSFileManager.DefaultManager + .GetTemporaryDirectory() + .Append($"{NSProcessInfo.ProcessInfo.GloballyUniqueString}.{extension}", false); + data.Save(destinationUrl, false); + storageFiles.Add(StorageFile.GetFromSecurityScopedUrl(destinationUrl, null)); + } + } + return storageFiles; + } } private class FileOpenPickerDelegate : UIDocumentPickerDelegate diff --git a/src/Uno.UWP/Storage/Pickers/FileOpenPickerExtensions.cs b/src/Uno.UWP/Storage/Pickers/FileOpenPickerExtensions.cs new file mode 100644 index 000000000000..dc6152eb7f9c --- /dev/null +++ b/src/Uno.UWP/Storage/Pickers/FileOpenPickerExtensions.cs @@ -0,0 +1,38 @@ +#nullable enable + +namespace Windows.Storage.Pickers; + +/// +/// Contains Uno Platform-specific extesions for the class. +/// +public static class FileOpenPickerExtensions +{ + /// + /// Sets the file limit a user can select when picking multiple files. + /// + /// The maximum number of files that the user can pick. +#if !__IOS__ + [global::Uno.NotImplemented] +#endif + public static void SetMultipleFilesLimit(this FileOpenPicker picker, int limit) + { +#if __IOS__ + picker.SetMultipleFileLimit(limit); +#endif + } + + /// + /// + /// + /// + /// +#if !__IOS__ + [global::Uno.NotImplemented] +#endif + public static void SetReadOnlyMode(this FileOpenPicker picker, bool readOnly) + { +#if __IOS__ + picker.SetReadOnlyMode(readOnly); +#endif + } +} diff --git a/src/Uno.UWP/Storage/StorageFile.iOS.cs b/src/Uno.UWP/Storage/StorageFile.iOS.cs index 6e7cd676e4d1..5417fb4baaba 100644 --- a/src/Uno.UWP/Storage/StorageFile.iOS.cs +++ b/src/Uno.UWP/Storage/StorageFile.iOS.cs @@ -1,18 +1,20 @@ #nullable enable using System; +using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Foundation; +using MobileCoreServices; +using Photos; +using PhotosUI; using UIKit; -using Windows.Storage.FileProperties; -using Windows.Storage.Streams; using Uno.Storage.Internal; using Uno.Storage.Streams.Internal; -using System.IO; -using System.Linq; -using MobileCoreServices; -using SystemPath = System.IO.Path; +using Windows.Extensions; +using Windows.Storage.FileProperties; +using Windows.Storage.Streams; namespace Windows.Storage { @@ -21,6 +23,9 @@ public partial class StorageFile internal static StorageFile GetFromSecurityScopedUrl(NSUrl nsUrl, StorageFolder? parent) => new StorageFile(new SecurityScopedFile(nsUrl, parent)); + internal static StorageFile GetFromPHPickerResult(PHPickerResult result, PHAsset phAsset, StorageFolder? parent) => + new StorageFile(new PHAssetFile(phAsset, result, parent)); + internal class SecurityScopedFile : ImplementationBase { private readonly NSUrl _nsUrl; @@ -95,5 +100,138 @@ protected override bool IsEqual(ImplementationBase implementation) => implementation is SecurityScopedFile file && file._nsUrl.FilePathUrl?.Path == _nsUrl.FilePathUrl?.Path; } + + internal class PHAssetFile : ImplementationBase + { + private readonly PHAsset _phAsset; + private readonly PHPickerResult _pickerResult; + private StorageFolder? _parent; + + private NSUrl? _fileUrl; + + public PHAssetFile(PHAsset phAsset, PHPickerResult pickerResult, StorageFolder? parent) : base(string.Empty) + { + _phAsset = phAsset; + _pickerResult = pickerResult; + _parent = parent; + + var resources = PHAssetResource.GetAssetResources(_phAsset); + Path = ((PHAssetResource)resources[0]).OriginalFilename; + } + + public override StorageProvider Provider => StorageProviders.IosPHPicker; + + public override DateTimeOffset DateCreated => _phAsset.CreationDate.ToDateTimeOffset(); + + public override Task DeleteAsync(CancellationToken ct, StorageDeleteOption options) + { + TaskCompletionSource resultCompletionSource = new TaskCompletionSource(); + PHPhotoLibrary.SharedPhotoLibrary.PerformChanges( + () => + { + PHAssetChangeRequest.DeleteAssets(new PHAsset[] { _phAsset }); + }, + (success, error) => + { + if (success) + { + resultCompletionSource.SetResult(); + } + else + { + resultCompletionSource.SetException(new Exception(error.LocalizedDescription)); + } + } + ); + + return resultCompletionSource.Task; + } + + public override Task GetBasicPropertiesAsync(CancellationToken ct) + { + var resources = PHAssetResource.GetAssetResources(_phAsset); + var imageSizeBytes = (resources.FirstOrDefault()?.ValueForKey(new NSString("fileSize")) as NSNumber) ?? 0; + var dateModified = _phAsset.ModificationDate.ToDateTimeOffset(); + return Task.FromResult(new BasicProperties((ulong)imageSizeBytes, dateModified)); + } + + public override Task GetParentAsync(CancellationToken ct) => Task.FromResult(_parent); + + public override async Task OpenAsync(CancellationToken ct, FileAccessMode accessMode, StorageOpenOptions options) + { + await EnsureLoadedAsync(); + + if (_fileUrl is null) + { + throw new InvalidOperationException("The file could not be loaded."); + } + + return new RandomAccessStreamWithContentType(FileRandomAccessStream.CreateSecurityScoped(_fileUrl, ToFileAccess(accessMode), ToFileShare(options)), ContentType); + } + + public override async Task OpenStreamAsync(CancellationToken ct, FileAccessMode accessMode, StorageOpenOptions options) + { + await EnsureLoadedAsync(); + + if (_fileUrl is null) + { + throw new InvalidOperationException("The file could not be loaded."); + } + + Func streamBuilder = () => File.Open(Path, FileMode.Open, ToFileAccess(accessMode), ToFileShare(options)); + var streamWrapper = new SecurityScopeStreamWrapper(_fileUrl, streamBuilder); + return streamWrapper; + } + + public override Task OpenTransactedWriteAsync(CancellationToken ct, StorageOpenOptions option) => throw new NotSupportedException(); + + protected override bool IsEqual(ImplementationBase implementation) => implementation is PHAssetFile file && file._phAsset.LocalIdentifier == _phAsset.LocalIdentifier; + + private async Task EnsureLoadedAsync() + { + if (_fileUrl is not null) + { + return; + } + + var identifier = GetUTIdentifier(_pickerResult.ItemProvider.RegisteredTypeIdentifiers ?? []) ?? "public.data"; + var extension = GetUTFileExtension(identifier); + var result = await _pickerResult.ItemProvider.LoadFileRepresentationAsync(identifier); + if (result != null) + { + var destinationUrl = NSFileManager.DefaultManager + .GetTemporaryDirectory() + .Append($"{NSProcessInfo.ProcessInfo.GloballyUniqueString}.{extension}", false); + + using Stream source = File.Open(result.Path!, FileMode.Open); + using Stream destination = File.Create(destinationUrl.Path!); + await source.CopyToAsync(destination); + + _fileUrl = destinationUrl; + } + } + } + + internal static string? GetUTIdentifier(string[] identifiers) + { + if (!(identifiers?.Length > 0)) + { + return null; + } + + if (identifiers.Any(i => i.StartsWith(UTType.LivePhoto, StringComparison.InvariantCultureIgnoreCase)) && identifiers.Contains(UTType.JPEG)) + { + return identifiers.FirstOrDefault(i => i == UTType.JPEG); + } + + if (identifiers.Contains(UTType.QuickTimeMovie)) + { + return identifiers.FirstOrDefault(i => i == UTType.QuickTimeMovie); + } + + return identifiers.FirstOrDefault(); + } + + internal static string? GetUTFileExtension(string identifier) => UTType.CopyAllTags(identifier, UTType.TagClassFilenameExtension)?.FirstOrDefault(); } }