Skip to content
This repository has been archived by the owner on May 15, 2024. It is now read-only.

Commit

Permalink
GH-130 & GH-129: Android support for safe shareable file URI’s (#416)
Browse files Browse the repository at this point in the history
* Android: Support for safe shareable file URI’s

On later versions of Android, you have to wrap streams of data you want to share outside your app (between apps) in a stream through a content provider.  Android Support providers a general use FileProvider we can use for this.  This commit basically adds support for getting all the right AndroidManifest declarations for the custom file provider based on the android support provider, so that we expose an internal method which gets a URI safe for sharing outside of the app.

* Fix absolute type naming

* Add a user interaction test for File Provider

* Fix vibration code

the ifdef meant an empty `else { }` statement with no `if { }` for platforms < 26.  This fixes that.

* Reorder using statements

* Fix test attribute

* Get provider authority properly

* Added external storage permission

* Change file provider path

This is md5(“xamarin_essentials”)

* Copy file into temp folder instead of file

We keep the filename the same this way but use a GUID for a temp sub-folder to ensure a unique path.

* Resgen

* Permissions may need to be checked to control functionality

* The Android FileProvider now can detect permissions
 - internal / external storage can be controlled
 - KitKat+ does not require the permissions
 - corrected the FileProvider resource xml

* Added support for email attachments
 - support for a string path and native file types

* Added attachments to the sample app

* Updated the docs with the new types

* Some fixes for iOS

* Fix the mdoc target

* regen docs

* remove the obsolete armeabi ABI

* Reworked the file logic to try and use public folders first
 - if the file is already exposed, then just use it directly
 - if the file is private, copy to an exposed location first
 - exposing the internal and external caches and the public/external files

* Be more specific with the external storage permission name

* Added some more depth to the comments here

* Unnecessary else

* Added base file info class

* EmailAttachment now derives from FileBase

* Added File Sharing

* Keep track of IStorageFile internally

* Prefer internal IStorageFile in UWP

* Use attachment name properly in UWP

* Add ctor to create from existing FileBase

This will let us use UWP to create a new instance of something derived from FileBase with another instance of something else derived from FileBase, all while keeping track of the same `IStorageFile` instance.

So we can conceivably do something like:
```csharp
var mediaFile = await MediaPicker.PickPhotoAsync();
var attachment = new EmailAttachment(mediaFile);
```

* Add ctors for FileBase

* Add ctors for ShareFileRequest

* We can't use N on pre-N platforms

* Updated the docs

* Update some docs.

* Bump

* Gate Email/Share files with feature flags

* Add sample for ShareFileRequest

* Added test for share method in netstandard
  • Loading branch information
Redth authored Mar 13, 2019
1 parent 3272c4a commit f338a81
Show file tree
Hide file tree
Showing 59 changed files with 3,881 additions and 1,958 deletions.
5 changes: 4 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@

# Force bash scripts to always use lf line endings so that if a repo is accessed
# in Unix via a file share from Windows, the scripts will work.
*.sh text eol=lf
*.sh text eol=lf

# Force the docs to always use lf line endings
docs/**/*.xml text eol=lf
7 changes: 5 additions & 2 deletions DeviceTests/DeviceTests.Android/DeviceTests.Android.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<WarningLevel>4</WarningLevel>
<AndroidManagedSymbols>true</AndroidManagedSymbols>
<AndroidUseSharedRuntime>false</AndroidUseSharedRuntime>
<AndroidSupportedAbis>armeabi;armeabi-v7a;x86;x86_64;arm64-v8a</AndroidSupportedAbis>
<AndroidSupportedAbis>armeabi-v7a;x86;x86_64;arm64-v8a</AndroidSupportedAbis>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<JavaMaximumHeapSize>1G</JavaMaximumHeapSize>
<AndroidLinkSkip />
Expand All @@ -52,7 +52,7 @@
<WarningLevel>4</WarningLevel>
<AndroidManagedSymbols>true</AndroidManagedSymbols>
<AndroidUseSharedRuntime>false</AndroidUseSharedRuntime>
<AndroidSupportedAbis>armeabi;armeabi-v7a;x86;x86_64;arm64-v8a</AndroidSupportedAbis>
<AndroidSupportedAbis>armeabi-v7a;x86;x86_64;arm64-v8a</AndroidSupportedAbis>
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
<JavaMaximumHeapSize>1G</JavaMaximumHeapSize>
<AndroidLinkSkip />
Expand Down Expand Up @@ -103,6 +103,9 @@
<Compile Include="Resources\Resource.Designer.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<Compile Include="Tests\**\*.cs" />
</ItemGroup>
<ItemGroup>
<None Include="Properties\AndroidManifest.xml" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
<uses-permission android:name="android.permission.FLASHLIGHT" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:theme="@style/MainTheme"></application>
</manifest>
3,910 changes: 2,014 additions & 1,896 deletions DeviceTests/DeviceTests.Android/Resources/Resource.designer.cs

Large diffs are not rendered by default.

238 changes: 238 additions & 0 deletions DeviceTests/DeviceTests.Android/Tests/FileProvider_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
using System;
using System.IO;
using System.Linq;
using Xamarin.Essentials;
using Xunit;
using AndroidEnvironment = Android.OS.Environment;

namespace DeviceTests.Shared
{
public class Android_FileProvider_Tests
{
[Fact]
[Trait(Traits.InteractionType, Traits.InteractionTypes.Human)]
public void Share_Simple_Text_File_Test()
{
// Save a local cache data directory file
var file = CreateFile(FileSystem.AppDataDirectory, "share-test.txt");

// Make sure it is where we expect it to be
Assert.False(FileProvider.IsFileInPublicLocation(file));

// Actually get a safe shareable file uri
var shareableUri = Platform.GetShareableFileUri(file);

// Launch an intent to let tye user pick where to open this content
var intent = new Android.Content.Intent(Android.Content.Intent.ActionSend);
intent.SetType("text/plain");
intent.PutExtra(Android.Content.Intent.ExtraStream, shareableUri);
intent.PutExtra(Android.Content.Intent.ExtraTitle, "Title Here");
intent.SetFlags(Android.Content.ActivityFlags.GrantReadUriPermission);

var intentChooser = Android.Content.Intent.CreateChooser(intent, "Pick something");

Platform.AppContext.StartActivity(intentChooser);
}

[Theory]
[InlineData(true, FileProviderLocation.Internal)]
[InlineData(true, FileProviderLocation.PreferExternal)]
[InlineData(false, FileProviderLocation.Internal)]
[InlineData(false, FileProviderLocation.PreferExternal)]
public void Get_Shareable_Uri(bool failAccess, FileProviderLocation location)
{
// Always fail to simulate unmounted media
FileProvider.AlwaysFailExternalMediaAccess = failAccess;

try
{
// Save a local cache data directory file
var file = CreateFile(FileSystem.AppDataDirectory);

// Make sure it is where we expect it to be
Assert.False(FileProvider.IsFileInPublicLocation(file));

// Actually get a safe shareable file uri
var shareableUri = GetShareableUri(file, location);

// Determine where the file should be found
var isInternal = (failAccess || location == FileProviderLocation.Internal);
var expectedCache = isInternal ? "internal_cache" : "external_cache";
var expectedCacheDir = isInternal
? Platform.AppContext.CacheDir.AbsolutePath
: Platform.AppContext.ExternalCacheDir.AbsolutePath;

// Make sure the uri is what we expected
Assert.NotNull(shareableUri);
Assert.Equal("content", shareableUri.Scheme);
Assert.Equal("com.xamarin.essentials.devicetests.fileProvider", shareableUri.Authority);
Assert.Equal(4, shareableUri.PathSegments.Count);
Assert.Equal(expectedCache, shareableUri.PathSegments[0]);
Assert.Equal("2203693cc04e0be7f4f024d5f9499e13", shareableUri.PathSegments[1]);
Assert.True(Guid.TryParseExact(shareableUri.PathSegments[2], "N", out var guid));
Assert.Equal(Path.GetFileName(file), shareableUri.PathSegments[3]);

// Make sure the underlying file exists
var realPath = Path.Combine(shareableUri.PathSegments.ToArray())
.Replace(expectedCache, expectedCacheDir);
Assert.True(File.Exists(realPath));
}
finally
{
FileProvider.AlwaysFailExternalMediaAccess = false;
}
}

[Fact]
public void No_Media_Fails_Get_External_Cache_Shareable_Uri()
{
// Always fail to simulate unmounted media
FileProvider.AlwaysFailExternalMediaAccess = true;

try
{
// Save a local cache data directory file
var file = CreateFile(FileSystem.AppDataDirectory);

// Make sure it is where we expect it to be
Assert.False(FileProvider.IsFileInPublicLocation(file));

// try get a uri, but fail as there is no external storage
Assert.Throws<InvalidOperationException>(() => GetShareableUri(file, FileProviderLocation.External));
}
finally
{
FileProvider.AlwaysFailExternalMediaAccess = false;
}
}

[Fact]
public void Get_External_Cache_Shareable_Uri()
{
// Save a local cache data directory file
var file = CreateFile(FileSystem.AppDataDirectory);

// Make sure it is where we expect it to be
Assert.False(FileProvider.IsFileInPublicLocation(file));

// Actually get a safe shareable file uri
var shareableUri = GetShareableUri(file, FileProviderLocation.External);

// Make sure the uri is what we expected
Assert.NotNull(shareableUri);
Assert.Equal("content", shareableUri.Scheme);
Assert.Equal("com.xamarin.essentials.devicetests.fileProvider", shareableUri.Authority);
Assert.Equal(4, shareableUri.PathSegments.Count);
Assert.Equal("external_cache", shareableUri.PathSegments[0]);
Assert.Equal("2203693cc04e0be7f4f024d5f9499e13", shareableUri.PathSegments[1]);
Assert.True(Guid.TryParseExact(shareableUri.PathSegments[2], "N", out var guid));
Assert.Equal(Path.GetFileName(file), shareableUri.PathSegments[3]);

// Make sure the underlying file exists
var realPath = Path.Combine(shareableUri.PathSegments.ToArray())
.Replace("external_cache", Platform.AppContext.ExternalCacheDir.AbsolutePath);
Assert.True(File.Exists(realPath));
}

[Theory]
[InlineData(FileProviderLocation.External)]
[InlineData(FileProviderLocation.Internal)]
[InlineData(FileProviderLocation.PreferExternal)]
public void Get_Existing_Internal_Cache_Shareable_Uri(FileProviderLocation location)
{
// Save a local cache directory file
var file = CreateFile(Platform.AppContext.CacheDir.AbsolutePath);

// Make sure it is where we expect it to be
Assert.True(FileProvider.IsFileInPublicLocation(file));

// Actually get a safe shareable file uri
var shareableUri = GetShareableUri(file, location);

// Make sure the uri is what we expected
Assert.NotNull(shareableUri);
Assert.Equal("content", shareableUri.Scheme);
Assert.Equal("com.xamarin.essentials.devicetests.fileProvider", shareableUri.Authority);
Assert.Equal(new[] { "internal_cache", Path.GetFileName(file) }, shareableUri.PathSegments);
}

[Theory]
[InlineData(FileProviderLocation.External)]
[InlineData(FileProviderLocation.Internal)]
[InlineData(FileProviderLocation.PreferExternal)]
public void Get_Existing_External_Cache_Shareable_Uri(FileProviderLocation location)
{
// Save an external cache directory file
var file = CreateFile(Platform.AppContext.ExternalCacheDir.AbsolutePath);

// Make sure it is where we expect it to be
Assert.True(FileProvider.IsFileInPublicLocation(file));

// Actually get a safe shareable file uri
var shareableUri = GetShareableUri(file, location);

// Make sure the uri is what we expected
Assert.NotNull(shareableUri);
Assert.Equal("content", shareableUri.Scheme);
Assert.Equal("com.xamarin.essentials.devicetests.fileProvider", shareableUri.Authority);
Assert.Equal(new[] { "external_cache", Path.GetFileName(file) }, shareableUri.PathSegments);
}

[Theory]
[InlineData(FileProviderLocation.External)]
[InlineData(FileProviderLocation.Internal)]
[InlineData(FileProviderLocation.PreferExternal)]
public void Get_Existing_External_Shareable_Uri(FileProviderLocation location)
{
// Save an external directory file
var externalRoot = AndroidEnvironment.ExternalStorageDirectory.AbsolutePath;
var root = Platform.AppContext.GetExternalFilesDir(null).AbsolutePath;
var file = CreateFile(root);

// Make sure it is where we expect it to be
Assert.True(FileProvider.IsFileInPublicLocation(file));

// Actually get a safe shareable file uri
var shareableUri = GetShareableUri(file, location);

// Make sure the uri is what we expected
Assert.NotNull(shareableUri);
Assert.Equal("content", shareableUri.Scheme);
Assert.Equal("com.xamarin.essentials.devicetests.fileProvider", shareableUri.Authority);

// replace the real root with the providers "root"
var segements = Path.Combine(root.Replace(externalRoot, "external_files"), Path.GetFileName(file));

Assert.Equal(segements.Split(Path.DirectorySeparatorChar), shareableUri.PathSegments);
}

static string CreateFile(string root, string name = "the-file.txt")
{
var file = Path.Combine(root, name);

if (File.Exists(file))
File.Delete(file);

File.WriteAllText(file, "The file contents.");

return file;
}

static Android.Net.Uri GetShareableUri(string file, FileProviderLocation location)
{
try
{
// use the specific location
FileProvider.TemporaryLocation = location;

// get the uri
return Platform.GetShareableFileUri(file);
}
finally
{
// reset the location
FileProvider.TemporaryLocation = FileProviderLocation.PreferExternal;
}
}
}
}
1 change: 1 addition & 0 deletions DeviceTests/DeviceTests.Shared/DeviceTests.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<PackageReference Include="Xamarin.Android.Support.v7.CardView" Version="28.0.0.1" />
<PackageReference Include="Xamarin.Android.Support.v7.MediaRouter" Version="28.0.0.1" />
<PackageReference Include="Xamarin.Android.Support.CustomTabs" Version="28.0.0.1" />
<PackageReference Include="Xamarin.Android.Support.Compat" Version="28.0.0.1" />
<PackageReference Include="Xamarin.Android.Support.Core.Utils" Version="28.0.0.1" />
<Reference Include="System.Numerics" />
<Reference Include="System.Numerics.Vectors" />
Expand Down
28 changes: 27 additions & 1 deletion DeviceTests/DeviceTests.Shared/Email_Tests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System;
using System.IO;
using System.Threading.Tasks;
using Xamarin.Essentials;
using Xunit;

Expand Down Expand Up @@ -31,5 +33,29 @@ public Task Compose_With_Message_Shows_New_Window()
return Email.ComposeAsync(email);
});
}

[Fact]
[Trait(Traits.InteractionType, Traits.InteractionTypes.Human)]
public Task Email_Attachments_are_Sent()
{
// Save a local cache data directory file
var file = Path.Combine(FileSystem.AppDataDirectory, "EmailTest.txt");
File.WriteAllText(file, "Attachment contents goes here...");

return Utils.OnMainThread(() =>
{
var email = new EmailMessage
{
Subject = "Hello World!",
Body =
"This is a greeting email." + Environment.NewLine +
"There should be an attachment attached.",
To = { "everyone@example.org" },
Attachments = { new EmailAttachment(file) }
};

return Email.ComposeAsync(email);
});
}
}
}
2 changes: 1 addition & 1 deletion DeviceTests/DeviceTests.iOS/Entitlements.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<dict>
<key>keychain-access-groups</key>
<array>
<string>com.xamarin.essentials.devicetests</string>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>
</dict>
</plist>
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,4 @@ Here are some frequently asked questions about Xamarin.Essentials, but be sure t
## License

Please see the [License](LICENSE).

Loading

0 comments on commit f338a81

Please sign in to comment.