diff --git a/Directory.Packages.props b/Directory.Packages.props index 81af77f22b58..d3c069caa7e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs index a01eaa3ee536..4f7ef9d4ae40 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs @@ -42,12 +42,15 @@ public override async Task ExecuteAsync(object? parameter = null) return; var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)); + var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path)); var password = string.Empty; + Encoding? encoding = null; DecompressArchiveDialog decompressArchiveDialog = new(); DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) { IsArchiveEncrypted = isArchiveEncrypted, + IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined, ShowPathSelection = true }; decompressArchiveDialog.ViewModel = decompressArchiveViewModel; @@ -62,6 +65,8 @@ public override async Task ExecuteAsync(object? parameter = null) if (isArchiveEncrypted && decompressArchiveViewModel.Password is not null) password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); + encoding = decompressArchiveViewModel.SelectedEncoding.Encoding; + // Check if archive still exists if (!StorageHelpers.Exists(archive.Path)) return; @@ -77,7 +82,7 @@ public override async Task ExecuteAsync(object? parameter = null) // Operate decompress var result = await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(archive?.Path ?? string.Empty, destinationFolder?.Path ?? string.Empty, password)); + StorageArchiveService.DecompressAsync(archive?.Path ?? string.Empty, destinationFolder?.Path ?? string.Empty, password, encoding)); if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion) await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory); diff --git a/src/Files.App/Data/Contracts/IStorageArchiveService.cs b/src/Files.App/Data/Contracts/IStorageArchiveService.cs index 1052125ce44f..27487eb1763c 100644 --- a/src/Files.App/Data/Contracts/IStorageArchiveService.cs +++ b/src/Files.App/Data/Contracts/IStorageArchiveService.cs @@ -1,6 +1,7 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using System.Text; using SevenZip; namespace Files.App.Data.Contracts @@ -37,8 +38,9 @@ public interface IStorageArchiveService /// The archive file path to decompress. /// The destination folder path which the archive file will be decompressed to. /// The password to decrypt the archive file if applicable. + /// The file name encoding to decrypt the archive file. If set to null, system default encoding will be used. /// True if the decompression has done successfully; otherwise, false. - Task DecompressAsync(string archiveFilePath, string destinationFolderPath, string password = ""); + Task DecompressAsync(string archiveFilePath, string destinationFolderPath, string password = "", Encoding? encoding = null); /// /// Generates the archive file name from item names. @@ -54,6 +56,13 @@ public interface IStorageArchiveService /// True if the archive file is encrypted; otherwise, false. Task IsEncryptedAsync(string archiveFilePath); + /// + /// Gets the value that indicates whether the archive file's encoding is undetermined. + /// + /// The archive file path to check if the item is encrypted. + /// True if the archive file's encoding is undetermined; otherwise, false. + Task IsEncodingUndeterminedAsync(string archiveFilePath); + /// /// Gets the instance from the archive file path. /// diff --git a/src/Files.App/Data/Items/EncodingItem.cs b/src/Files.App/Data/Items/EncodingItem.cs new file mode 100644 index 000000000000..c342c64fc9c8 --- /dev/null +++ b/src/Files.App/Data/Items/EncodingItem.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2024 Files Community +// Licensed under the MIT License. See the LICENSE. + +using System.Text; + +namespace Files.App.Data.Items +{ + /// + /// Represents a text encoding in the application. + /// + public sealed class EncodingItem + { + + public Encoding? Encoding { get; set; } + + /// + /// Gets the encoding name. e.g. English (United States) + /// + public string Name { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The code of the language. + public EncodingItem(string code) + { + if (string.IsNullOrEmpty(code)) + { + Encoding = null; + Name = Strings.Default.GetLocalizedResource(); + } + else + { + Encoding = Encoding.GetEncoding(code); + Name = Encoding.EncodingName; + } + } + + public override string ToString() => Name; + } +} diff --git a/src/Files.App/Dialogs/DecompressArchiveDialog.xaml b/src/Files.App/Dialogs/DecompressArchiveDialog.xaml index 84363e781489..732f04e7072d 100644 --- a/src/Files.App/Dialogs/DecompressArchiveDialog.xaml +++ b/src/Files.App/Dialogs/DecompressArchiveDialog.xaml @@ -5,7 +5,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:helpers="using:Files.App.Helpers" + xmlns:items="using:Files.App.Data.Items" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:uc="using:Files.App.UserControls" Title="{helpers:ResourceString Name=ExtractArchive}" CornerRadius="{StaticResource OverlayCornerRadius}" DefaultButton="Primary" @@ -80,6 +82,34 @@ + + + + + + + + + + + + + + + + diff --git a/src/Files.App/Program.cs b/src/Files.App/Program.cs index 79f87e1ed322..6d7807095797 100644 --- a/src/Files.App/Program.cs +++ b/src/Files.App/Program.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; using System.IO; +using System.Text; using Windows.ApplicationModel.Activation; using Windows.Storage; using static Files.App.Helpers.Win32PInvoke; @@ -55,6 +56,7 @@ static Program() [STAThread] private static void Main() { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); WinRT.ComWrappersSupport.InitializeComWrappers(); // We are about to do the first WinRT server call, in case the WinRT server is hanging diff --git a/src/Files.App/Services/Storage/StorageArchiveService.cs b/src/Files.App/Services/Storage/StorageArchiveService.cs index f3aab26e34f7..1b30e1adaddf 100644 --- a/src/Files.App/Services/Storage/StorageArchiveService.cs +++ b/src/Files.App/Services/Storage/StorageArchiveService.cs @@ -2,8 +2,12 @@ // Licensed under the MIT License. using Files.Shared.Helpers; +using ICSharpCode.SharpZipLib.Core; +using ICSharpCode.SharpZipLib.Zip; using SevenZip; using System.IO; +using System.Linq; +using System.Text; using Windows.Storage; using Windows.Win32; @@ -84,7 +88,17 @@ public async Task CompressAsync(ICompressArchiveModel compressionModel) } /// - public async Task DecompressAsync(string archiveFilePath, string destinationFolderPath, string password = "") + public Task DecompressAsync(string archiveFilePath, string destinationFolderPath, string password = "", Encoding? encoding = null) + { + if(encoding == null){ + return DecompressAsyncWithSevenZip(archiveFilePath, destinationFolderPath, password); + } + else + { + return DecompressAsyncWithSharpZipLib(archiveFilePath, destinationFolderPath, password, encoding); + } + } + async Task DecompressAsyncWithSevenZip(string archiveFilePath, string destinationFolderPath, string password = "") { if (string.IsNullOrEmpty(archiveFilePath) || string.IsNullOrEmpty(destinationFolderPath)) @@ -180,10 +194,134 @@ public async Task DecompressAsync(string archiveFilePath, string destinati fsProgress.Report(); } }; + return isSuccess; + } + + async Task DecompressAsyncWithSharpZipLib(string archiveFilePath, string destinationFolderPath, string password, Encoding encoding) + { + if (string.IsNullOrEmpty(archiveFilePath) || + string.IsNullOrEmpty(destinationFolderPath)) + return false; + using var zipFile = new ZipFile(archiveFilePath, StringCodec.FromEncoding(encoding)); + if(zipFile is null) + return false; + + if(!string.IsNullOrEmpty(password)) + zipFile.Password = password; + + // Initialize a new in-progress status card + var statusCard = StatusCenterHelper.AddCard_Decompress( + archiveFilePath.CreateEnumerable(), + destinationFolderPath.CreateEnumerable(), + ReturnResult.InProgress); + + // Check if the decompress operation canceled + if (statusCard.CancellationToken.IsCancellationRequested) + return false; + + StatusCenterItemProgressModel fsProgress = new( + statusCard.ProgressEventSource, + enumerationCompleted: true, + FileSystemStatusCode.InProgress, + zipFile.Cast().Count(x => !x.IsDirectory)); + fsProgress.TotalSize = zipFile.Cast().Select(x => (long)x.Size).Sum(); + fsProgress.Report(); + + bool isSuccess = false; + + try + { + long processedBytes = 0; + int processedFiles = 0; + + foreach (ZipEntry zipEntry in zipFile) + { + if (statusCard.CancellationToken.IsCancellationRequested) + { + isSuccess = false; + break; + } + + if (!zipEntry.IsFile) + { + continue; // Ignore directories + } + + string entryFileName = zipEntry.Name; + string fullZipToPath = Path.Combine(destinationFolderPath, entryFileName); + string directoryName = Path.GetDirectoryName(fullZipToPath); + + if (!Directory.Exists(directoryName)) + { + Directory.CreateDirectory(directoryName); + } + + byte[] buffer = new byte[4096]; // 4K is a good default + using (Stream zipStream = zipFile.GetInputStream(zipEntry)) + using (FileStream streamWriter = File.Create(fullZipToPath)) + { + await ThreadingService.ExecuteOnUiThreadAsync(() => + { + fsProgress.FileName = entryFileName; + fsProgress.Report(); + }); + + StreamUtils.Copy(zipStream, streamWriter, buffer); + } + processedBytes += zipEntry.Size; + if (fsProgress.TotalSize > 0) + { + fsProgress.Report(processedBytes / (double)fsProgress.TotalSize * 100); + } + processedFiles++; + fsProgress.AddProcessedItemsCount(1); + fsProgress.Report(); + } + + if (!statusCard.CancellationToken.IsCancellationRequested) + { + isSuccess = true; + } + } + catch (Exception ex) + { + isSuccess = false; + Console.WriteLine($"Error during decompression: {ex.Message}"); + } + finally + { + // Remove the in-progress status card + StatusCenterViewModel.RemoveItem(statusCard); + + if (isSuccess) + { + // Successful + StatusCenterHelper.AddCard_Decompress( + archiveFilePath.CreateEnumerable(), + destinationFolderPath.CreateEnumerable(), + ReturnResult.Success); + } + else + { + // Error + StatusCenterHelper.AddCard_Decompress( + archiveFilePath.CreateEnumerable(), + destinationFolderPath.CreateEnumerable(), + statusCard.CancellationToken.IsCancellationRequested + ? ReturnResult.Cancelled + : ReturnResult.Failed); + } + if (zipFile != null) + { + zipFile.IsStreamOwner = true; // Makes close also close the underlying stream + zipFile.Close(); + } + } return isSuccess; } + /// public string GenerateArchiveNameFromItems(IReadOnlyList items) { @@ -208,6 +346,25 @@ public async Task IsEncryptedAsync(string archiveFilePath) return zipFile.ArchiveFileData.Any(file => file.Encrypted || file.Method.Contains("Crypto") || file.Method.Contains("AES")); } + /// + public async Task IsEncodingUndeterminedAsync(string archiveFilePath) + { + if (archiveFilePath is null) return false; + if (Path.GetExtension(archiveFilePath) != ".zip") return false; + try + { + using (ZipFile zipFile = new ZipFile(archiveFilePath)) + { + return !zipFile.Cast().All(entry=>entry.IsUnicodeText); + } + } + catch (Exception ex) + { + Console.WriteLine($"SharpZipLib error: {ex.Message}"); + return true; + } + } + /// public async Task GetSevenZipExtractorAsync(string archiveFilePath, string password = "") { diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index a464faec9838..6abdb2f79735 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -2096,6 +2096,9 @@ Archive password + + Encoding + Path diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs index 6f3151f323de..b1ff52bc2b35 100644 --- a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.IO; +using System.Text; using System.Windows.Input; using Windows.Storage; @@ -36,6 +37,13 @@ public bool IsArchiveEncrypted set => SetProperty(ref isArchiveEncrypted, value); } + private bool isArchiveEncodingUndetermined; + public bool IsArchiveEncodingUndetermined + { + get => isArchiveEncodingUndetermined; + set => SetProperty(ref isArchiveEncodingUndetermined, value); + } + private bool showPathSelection; public bool ShowPathSelection { @@ -45,6 +53,20 @@ public bool ShowPathSelection public DisposableArray? Password { get; private set; } + public EncodingItem[] EncodingOptions { get; set; } = new string?[] { + null,//System Default + "UTF-8", + "shift_jis", + "gb2312", + "big5", + "ks_c_5601-1987", + "Windows-1252", + "macintosh", + } + .Select(x=>new EncodingItem(x)) + .ToArray(); + public EncodingItem SelectedEncoding { get; set; } + public IRelayCommand PrimaryButtonClickCommand { get; private set; } public ICommand SelectDestinationCommand { get; private set; } @@ -53,6 +75,7 @@ public DecompressArchiveDialogViewModel(IStorageFile archive) { this.archive = archive; destinationFolderPath = DefaultDestinationFolderPath(); + SelectedEncoding = EncodingOptions[0]; // Create commands SelectDestinationCommand = new AsyncRelayCommand(SelectDestinationAsync);