Skip to content

Commit 00ef614

Browse files
committed
Added support for opening files in external apps on MAUI
1 parent f3aea65 commit 00ef614

12 files changed

Lines changed: 102 additions & 64 deletions

File tree

src/Platforms/SecureFolderFS.Maui/Extensions/IocExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ public static IServiceCollection WithMauiServices(this IServiceCollection servic
1818
{
1919
return serviceCollection
2020
.Foundation<ISettingsService, SettingsService>(AddService.AddSingleton, _ => new(new MauiAppSettings(settingsFolder), new UserSettings(settingsFolder)))
21-
.Foundation<IShareService, MauiShareService>(AddService.AddSingleton)
2221
.Foundation<IOverlayService, MauiOverlayService>(AddService.AddSingleton)
2322
.Foundation<IAccountService, MauiAccountService>(AddService.AddSingleton)
2423
.Foundation<IClipboardService, MauiClipboardService>(AddService.AddSingleton)

src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidShareService.cs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Android.Content;
2-
using Android.OS;
32
using OwlCore.Storage;
43
using SecureFolderFS.Sdk.Services;
54
using SecureFolderFS.Shared.Helpers;
@@ -29,12 +28,8 @@ public async Task ShareTextAsync(string text, string title)
2928
/// <inheritdoc/>
3029
public async Task ShareFileAsync(IFile file)
3130
{
32-
// Register the file with the ShareContentProvider
33-
var fileId = ShareContentProvider.RegisterFile(file);
34-
3531
// Build the content URI for this file
36-
var authority = $"{Application.Context.PackageName}.shareProvider";
37-
var contentUri = Uri.Parse($"content://{authority}/{fileId}/{file.Name}");
32+
var contentUri = ShareContentProvider.RegisterFileAndBuildUri(Application.Context, file);
3833

3934
// Determine MIME type
4035
var mimeType = FileTypeHelper.GetMimeType(file.Name);
@@ -51,6 +46,26 @@ public async Task ShareFileAsync(IFile file)
5146
Application.Context.StartActivity(chooserIntent);
5247
await Task.CompletedTask;
5348
}
49+
50+
/// <inheritdoc/>
51+
public Task OpenFileWithAsync(IFile file)
52+
{
53+
// Build the content URI for this file
54+
var contentUri = ShareContentProvider.RegisterFileAndBuildUri(Application.Context, file);
55+
56+
// Determine MIME type
57+
var mimeType = FileTypeHelper.GetMimeType(file.Name);
58+
59+
// Create view intent
60+
var intent = new Intent(Intent.ActionView);
61+
intent.SetDataAndType(contentUri, mimeType);
62+
intent.AddFlags(ActivityFlags.GrantReadUriPermission);
63+
64+
var chooserIntent = Intent.CreateChooser(intent, file.Name);
65+
chooserIntent?.AddFlags(ActivityFlags.NewTask);
66+
67+
Application.Context.StartActivity(chooserIntent);
68+
return Task.CompletedTask;
69+
}
5470
}
5571
}
56-

src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/ShareContentProvider.cs

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,40 @@ namespace SecureFolderFS.Maui.Platforms.Android.ServiceImplementation
2323
[Preserve(AllMembers = true)]
2424
public class ShareContentProvider : ContentProvider
2525
{
26+
/// <summary>
27+
/// How long a registered file is kept alive after the intent is launched,
28+
/// to accommodate apps that open multiple streams (e.g. type sniffing + actual read).
29+
/// </summary>
30+
private static readonly TimeSpan RegistrationTtl = TimeSpan.FromSeconds(20);
31+
2632
private static readonly Dictionary<string, IFile> _registeredFiles = new();
2733
private static readonly Lock _lock = new();
2834

2935
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ShareContentProvider))]
3036
public ShareContentProvider()
3137
{
3238
}
33-
39+
3440
/// <summary>
35-
/// Registers a file for sharing and returns a unique identifier.
41+
/// Registers a file for sharing and returns a content URI suitable for use in an Intent.
42+
/// The registration is automatically cleaned up after <see cref="RegistrationTtl"/>.
3643
/// </summary>
44+
/// <param name="context">The application context, used to resolve the authority.</param>
3745
/// <param name="file">The file to register.</param>
38-
/// <returns>A unique identifier for the file.</returns>
39-
public static string RegisterFile(IFile file)
46+
/// <returns>A content URI pointing to the registered file.</returns>
47+
public static Uri? RegisterFileAndBuildUri(Context context, IFile file)
4048
{
4149
var fileId = Guid.NewGuid().ToString("N");
4250
lock (_lock)
43-
{
4451
_registeredFiles[fileId] = file;
45-
}
46-
47-
return fileId;
52+
53+
// Schedule deferred cleanup - the call site must NOT call UnregisterFile manually,
54+
// because some apps open the stream more than once (type sniffing + actual read).
55+
_ = Task.Delay(RegistrationTtl)
56+
.ContinueWith(_ => UnregisterFile(fileId));
57+
58+
var authority = $"{context.PackageName}.shareProvider";
59+
return Uri.Parse($"content://{authority}/{fileId}/{Uri.Encode(file.Name)}");
4860
}
4961

5062
/// <summary>
@@ -54,9 +66,7 @@ public static string RegisterFile(IFile file)
5466
public static void UnregisterFile(string fileId)
5567
{
5668
lock (_lock)
57-
{
5869
_registeredFiles.Remove(fileId);
59-
}
6070
}
6171

6272
/// <inheritdoc/>
@@ -80,8 +90,8 @@ public override bool OnCreate()
8090
return null;
8191
}
8292

83-
// Create a pipe and stream the file content through it
84-
var pipe = ParcelFileDescriptor.CreatePipe();
93+
// Create a reliable pipe and stream the file content through it
94+
var pipe = ParcelFileDescriptor.CreateReliablePipe();
8595
if (pipe is null || pipe.Length < 2)
8696
return null;
8797

@@ -101,14 +111,17 @@ public override bool OnCreate()
101111
while ((bytesRead = await fileStream.ReadAsync(buffer)) > 0)
102112
await outputStream.WriteAsync(buffer, 0, bytesRead);
103113
}
104-
catch
114+
catch (Java.IO.IOException ex) when (IsBrokenPipe(ex))
105115
{
106-
// Silently handle errors during streaming
116+
// Read side closed before we finished writing — normal for apps that
117+
// sniff the stream type before opening it for real. Exit cleanly.
118+
SafetyHelpers.NoFailure(writeSide.Close);
107119
}
108-
finally
120+
catch (Exception ex)
109121
{
110-
// Clean up the registration after streaming
111-
UnregisterFile(fileId);
122+
// Signal the read side so the receiving app sees a real error
123+
// instead of an unexpected EOF
124+
SafetyHelpers.NoFailure(() => writeSide.CloseWithError(ex.Message));
112125
}
113126
});
114127

@@ -129,7 +142,7 @@ public override bool OnCreate()
129142
return null;
130143
}
131144

132-
// Get the filename from the URI path (second segment)
145+
// Get the display name from the URI path (second segment)
133146
var fileName = uri.PathSegments?.ElementAtOrDefault(1) ?? file.Name;
134147

135148
var columns = projection ?? [ IOpenableColumns.DisplayName, IOpenableColumns.Size ];
@@ -182,6 +195,14 @@ public override bool OnCreate()
182195

183196
/// <inheritdoc/>
184197
public override int Update(Uri uri, ContentValues? values, string? selection, string[]? selectionArgs) => 0;
198+
199+
private static bool IsBrokenPipe(Java.IO.IOException ex)
200+
{
201+
var msg = ex.Message;
202+
return msg is not null &&
203+
(msg.Contains("EPIPE", StringComparison.OrdinalIgnoreCase) ||
204+
msg.Contains("Broken pipe", StringComparison.OrdinalIgnoreCase));
205+
}
185206
}
186207
}
187208

src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSShareService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ public async Task ShareFileAsync(IFile file)
3232
await PresentActivityControllerAsync(activityController);
3333
}
3434

35+
/// <inheritdoc/>
36+
public Task OpenFileWithAsync(IFile file)
37+
{
38+
return Task.FromException(new NotSupportedException("Opening files with associated applications is not supported on iOS. Use ShareFileAsync to share the file instead."));
39+
}
40+
3541
private static Task PresentActivityControllerAsync(UIActivityViewController activityController)
3642
{
3743
var tcs = new TaskCompletionSource();

src/Platforms/SecureFolderFS.Maui/Resources/Styles/Converters.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<vc:NullToBoolConverter x:Key="NullToBoolConverter" />
1010
<vc:BoolOpacityConverter x:Key="BoolOpacityConverter" />
1111
<vc:CountToBoolConverter x:Key="CountToBoolConverter" />
12+
<vc:TypeNameBoolConverter x:Key="TypeNameBoolConverter" />
1213
<vc:BoolToStringConverter x:Key="BoolToStringConverter" />
1314
<vc:CountToStringConverter x:Key="CountToStringConverter" />
1415
<vc:NullToOpacityConverter x:Key="NullToOpacityConverter" />

src/Platforms/SecureFolderFS.Maui/ServiceImplementation/MauiShareService.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/Platforms/SecureFolderFS.Maui/UserControls/Browser/BrowserControl.xaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
BindingContextChanged="ItemContainer_BindingContextChanged"
2626
Loaded="ItemContainer_Loaded">
2727
<apesui:ContextMenuContainer.MenuItems>
28+
<apesui:ContextMenuItem
29+
Command="{Binding OpenInExternalAppCommand}"
30+
IsEnabled="{Binding Mode=OneWay, Converter={StaticResource TypeNameBoolConverter}, ConverterParameter='FileViewModel'}"
31+
Text="{l:ResourceString Rid=OpenInExternalApp}" />
2832
<apesui:ContextMenuItem Command="{Binding OpenPropertiesCommand}" Text="{l:ResourceString Rid=GetInfo}" />
2933
<apesui:ContextMenuItem
3034
Command="{Binding RenameCommand}"
@@ -120,6 +124,10 @@
120124
BindingContextChanged="ItemContainer_BindingContextChanged"
121125
Loaded="ItemContainer_Loaded">
122126
<apesui:ContextMenuContainer.MenuItems>
127+
<apesui:ContextMenuItem
128+
Command="{Binding OpenInExternalAppCommand}"
129+
IsEnabled="{Binding Mode=OneWay, Converter={StaticResource TypeNameBoolConverter}, ConverterParameter='FileViewModel'}"
130+
Text="{l:ResourceString Rid=OpenInExternalApp}" />
123131
<apesui:ContextMenuItem Command="{Binding OpenPropertiesCommand}" Text="{l:ResourceString Rid=GetInfo}" />
124132
<apesui:ContextMenuItem
125133
Command="{Binding RenameCommand}"
@@ -218,6 +226,10 @@
218226
BindingContextChanged="ItemContainer_BindingContextChanged"
219227
Loaded="ItemContainer_Loaded">
220228
<apesui:ContextMenuContainer.MenuItems>
229+
<apesui:ContextMenuItem
230+
Command="{Binding OpenInExternalAppCommand}"
231+
IsEnabled="{Binding Mode=OneWay, Converter={StaticResource TypeNameBoolConverter}, ConverterParameter='FileViewModel'}"
232+
Text="{l:ResourceString Rid=OpenInExternalApp}" />
221233
<apesui:ContextMenuItem Command="{Binding OpenPropertiesCommand}" Text="{l:ResourceString Rid=GetInfo}" />
222234
<apesui:ContextMenuItem
223235
Command="{Binding RenameCommand}"

src/Platforms/SecureFolderFS.Maui/Views/Vault/LoginPage.xaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,9 @@
99
xmlns:mi_material="clr-namespace:MauiIcons.Material;assembly=MauiIcons.Material"
1010
xmlns:uc="clr-namespace:SecureFolderFS.Maui.UserControls"
1111
xmlns:uco="clr-namespace:SecureFolderFS.Maui.UserControls.Options"
12-
xmlns:vc="clr-namespace:SecureFolderFS.Maui.ValueConverters"
1312
Title="{Binding ViewModel.VaultViewModel.Title, Mode=OneWay}"
1413
x:DataType="local:LoginPage">
1514

16-
<ContentPage.Resources>
17-
<vc:TypeNameBoolConverter x:Key="TypeNameBoolConverter" />
18-
</ContentPage.Resources>
19-
2015
<Grid>
2116
<ScrollView>
2217
<Grid Padding="20">

src/Platforms/SecureFolderFS.UI/Strings/en-US/Resources.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,4 +1454,7 @@
14541454
<data name="DiscardSavedCredentials" xml:space="preserve">
14551455
<value>Discard saved credentials</value>
14561456
</data>
1457+
<data name="OpenInExternalApp" xml:space="preserve">
1458+
<value>Open in external app</value>
1459+
</data>
14571460
</root>

src/Platforms/SecureFolderFS.UI/ValueConverters/BaseTypeNameBoolConverter.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23

34
namespace SecureFolderFS.UI.ValueConverters
45
{
@@ -11,7 +12,7 @@ public abstract class BaseTypeNameBoolConverter : BaseConverter
1112
return false;
1213

1314
var split = strParam.Split('|');
14-
var invert = split[1].Contains("invert", StringComparison.OrdinalIgnoreCase);
15+
var invert = split.ElementAtOrDefault(1)?.Contains("invert", StringComparison.OrdinalIgnoreCase) ?? false;
1516
var result = false;
1617

1718
foreach (var item in split[0].Split(','))

0 commit comments

Comments
 (0)