dotnet / maui

.NET MAUI is the .NET Multi-platform App UI, a framework for building native device applications spanning mobile, tablet, and desktop.
https://dot.net/maui
MIT License
22.27k stars 1.76k forks source link

Support deep linking into .NET MAUI Blazor apps #3788

Closed danroth27 closed 11 months ago

danroth27 commented 2 years ago

Description

Investigate supporting deep linking into pages in a .NET MAUI Blazor app

Public API Changes

TBD

Intended Use-Case

Use a link to navigate to a page in a .NET MAUI Blazor app

PureWeen commented 2 years ago

@Eilon I know Shell navigation was added to .NET MAui Blazor Apps

Does that work provide deep linking into .NET MAUI Blazor Apps if you are using Shell?

PR for reference https://github.com/dotnet/MobileBlazorBindings/pull/152

Eilon commented 2 years ago

@PureWeen we might need to build something similar, but not likely quite the same. I think that feature in Mobile Blazor Bindings (MBB) was for native UI built using Blazor (in MBB we supported true native UI using Xamarin.Forms w/ Blazor syntax, and also using web UI in a BlazorWebView). I don't recall us doing anything for deep-linking into web UI, but it might contain some similarities.

PureWeen commented 2 years ago

@Eilon makes sense!

The routing parts of Shell are all very custom built so also seeing if there's anything here we can reuse/leverage as well But the overlap might be very minimal

ghost commented 2 years ago

We've moved this issue to the Future milestone. This means that it is not going to be worked on for the coming release. We will reassess the issue following the current release and consider this item at that time.

Ghevi commented 2 years ago

Hi, excuse me if this is not the right place to ask. Since deep linking is not yet supported out of the box in Maui, I tried implementing it in the native code, for example with android, in the MainActivity.cs file. It works and I managed to pass parameters and access them with intent.dataString. In order to access this data in the razor components, I save it inMainActivity.cs with the Preferences api and then get it where I needed. This works but it's a bit cumbersome. The messaging center can't be use yet because razor components cannot registers callbacks for these messages at this stage, right? I tried this protected override void OnAppLinkRequestReceived(Uri uri) in App.xaml.cs file but it doesn't seems to be called unless I made some mistake. Any other solution? If you want me I will move this to the discussions section.

Eilon commented 2 years ago

@Ghevi I've never played with deep linking, but in general the strategy would be something like this:

  1. Hook into however each platform does its deep linking and get the requested link
  2. If the link goes to content that is in a BlazorWebView, then do the necessary MAUI actions to make sure that BlazorWebView is visible and active
  3. Once the BlazorWebView is ready and loaded, use Blazor's navigation APIs to go to the exact content that was requested

Unfortunately I don't know anything about part (1), but I would say it's highly likely that it's either exactly the same or almost exactly the same as how to do it in Xamarin.Forms apps, so perhaps there are good docs for Xamarin.Forms that could shed some light on it?

Ghevi commented 2 years ago

@Eilon I managed to do part (1), it was nice to learn how to make it work btw. Unfortunately I'm a bit lost on part (2). In Xamarin I found that there was a method called LoadApplication(myString) that you can call in MainActivity.cs hooks, but there isn't in Maui right? Basically I'm not sure how to pass a piece of data from platform specific code, in this case MainActivity.cs to for example a Razor Component with the @page tag or even to the App.xaml.cs. I mean I managed to do it with the Preferences api but again I feel like it's not the best solution.

Also thank You for the help!

Eilon commented 2 years ago

@Ghevi this is well outside my expertise, but I think for (2) it depends on what your app's UI looks like. For example, if your app is based on the .NET MAUI Shell control, then I think you would tell the Shell control to navigate to the relevant part of the URL, and you can read more about that here: https://docs.microsoft.com/dotnet/maui/fundamentals/shell/navigation. And if that page happens to contain a BlazorWebView, you could wait until it's done loading and send it any additional navigation information.

For example, let's say the URL is /myApp/users/info/12345. The myApp/users page is a Shell page, so you would take that part of the URL and tell Shell to navigate there. But what about the info/12345? Let's say that page within Shell uses a BlazorWebView, so once that view is done loading, you would pass that info/12345 to the Blazor navigation system to make sure the right user info page is shown.

Ghevi commented 2 years ago

@Eilon I've read about the Shell and how to use it. But in working on a MAUI Blazor project, so there is no Shell unless I'm not aware of a way to use it in such case. Will do some more research, thank you for the help!

Eilon commented 2 years ago

@Ghevi the Shell was just an example, so it depends on how your app is designed. The point in (2) is that if the deep link goes to a page in your app that is built with BlazorWebView, but that page isn't visible, you'll need to first make that page visible, and then do whatever Blazor navigation is needed.

Ghevi commented 2 years ago

@Eilon yes, thanks i've understood what you ment. I improved my solution by using a service instead of the Preferences api, so i have more control over it. Before navigating to the index component, i check if there is any deep link data in my service and in that case use _navigationManager.NavigateTo("/deepLinkPage");. At this point the BlazorWebView is visible so it works. Thank you for the suggestions :)

MikeAndrews90 commented 2 years ago

Ok after a bit more debugging I got it working in a similar way, but from a separate custom Activity instead of in MainActivity.

I have a custom Activity which is invoked when a particular URL is opened with the app. e.g https://myapp/go-here

I created a static NavigationService type class, where I can set a string which is the path taken from the incoming URL from the activity. In NavigationService I also added an event handler that I fire when the page is set, so it can be subscribed to somewhere in Blazor (I did mine in Main.razor) and then you can use the NavigationManager to navigate to the page.

From the Activity I just set NavigationService.Path = "go-here";

That looks like this:

protected override void OnNewIntent(Intent intent)
        {
            base.OnNewIntent(intent);

            var data = intent.DataString;

            if (intent.Action != Intent.ActionView) return;
            if (string.IsNullOrWhiteSpace(data)) return;

            var path = data.Replace(@"https://myapp", "");
            NavigationService.SetPage(path);
        }

In Main.razor I added a code block which just checks the NavigationService to see if there's a path set, and if so, it uses the standard NavigationManager to navigate to the path just set from the Activity.

That looks like this:

@code
{
    [Inject]
    public NavigationManager NavigationManager { get; set; }

    private static bool _subscribedToNavigationPageSetEvent;
    protected override void OnInitialized()
    {
        if (!_subscribedToNavigationPageSetEvent)
        {
            NavigationService.PageSet += NavigationServiceOnPageSet;
            _subscribedToNavigationPageSetEvent = true;
        }
    }

    private void NavigationServiceOnPageSet(object sender, object e)
    {
        if (!string.IsNullOrWhiteSpace(NavigationService.Page))
        {
            Debug.WriteLine(NavigationService.Page);
            NavigationManager.NavigateTo(NavigationService.Page, forceLoad:true);
            NavigationService.SetPage("");
        }
    }
}

And for convenience, here's my NavigationService code:

public static class NavigationService 
    {
        public delegate void PageSetEventHandler(object sender, object e);

        public static event PageSetEventHandler PageSet;

        public static string Page { get; private set; }

        public static void SetPage(string url)
        {
            Page = url;
            if (!string.IsNullOrEmpty(url))
            {
                PageSet?.Invoke(null, url);
            }
        }
    }

Not ideal, but it works!

GolfJimB commented 2 years ago

@MikeAndrews90 could you share your NavigationService code please. Also, how to do deep linking for ios?

MikeAndrews90 commented 2 years ago

@GolfJimB It doesn't do much, just sets the current page path

    public interface INavService
    {
        public string Page { get; }
        void SetPage(string url);
    }

    public static class NavigationService 
    {
        public static string Page { get; private set; }

        public static void SetPage(string url)
        {
            Page = url;
        }
    }

I'm not targeting iOS so I don't know how to do deep linking on iOS.

Ghevi commented 2 years ago

For iOS what worked for me was adding this method to the AppDelegate.cs class:

[Export("application:openURL:options:")]
    public override Boolean OpenUrl(UIApplication app, NSUrl url, NSDictionary options)
    {
        var deepLinkService = DependencyService.Get<DeepLinkService>();
        deepLinkService.DeepLinkDto = null;
        if (!String.IsNullOrEmpty(url.AbsoluteString) && url.AbsoluteString.Contains("myapp"))
        {
            if (url.AbsoluteString.Contains("/resources-page"))
            {
                var resourceId= NSUrlComponents
                    .FromString(url.Query)?.QueryItems?
                    .Single(x => x.Name == "resourceId").Value;
                deepLinkService.DeepLinkDto = new DeepLinkDto(resourceId);
            }
        }

        return true;
  }

then add in the Entitlements.plist the options Associated Domains with a string applinks:www.myappurl.com and also in the Identifiers section of https://developer.apple.com/account/resources/identifiers/list the AssociatedDomains to the capabilities. Keep in mind that if you want the app to open immediately without user prompt you have to go host an assetlinks.json file in one of your websites for android

GolfJimB commented 1 year ago

@Ghevi what's the DeepLinkService and DeepLinkDto?

Ghevi commented 1 year ago

@GolfJimB two classes i've made. The service contains the dto and has some other methods.

Eilon commented 1 year ago

I started an investigation on deep linking with .NET MAUI + Blazor Hybrid and I'm fairly sure that there are enough features to implement it in an app.

Here's what I've found so far:

  1. You should be able to follow various Xamarin-based tutorials such as: https://www.xamarinhelp.com/android-app-links/
    • The same tutorials should apply almost exactly in .NET MAUI as well, because it's based on the same fundamental system
  2. Depending on the deep linking you want to do, you need to have a file on your web site in the .well-known/assetlinks.json location that has some metadata in it indicating which URLs can deep link into your app

To handle deep linking in your app:

  1. Use that platform's system for accepting deep links.
    • This likely involves changing your app's web site to indicate which apps can accept the links
    • This likely involves changing your app's manifest to indicate the accepted links
    • This likely involves overriding/implementing a .NET API in your app's platform-specific code to detect that it was launched via a deep link (for example, in Android you'd change Platforms\Android\MainActivity.cs and override OnCreate)
  2. In a case where your app was launched via a deep link, store the relevant parts of the deep link into a static field, such as public static string _blazorDeepLinkStartPath;
  3. In the part of your app that uses a BlazorWebView, check if a deep link was used, and if so, set it:
    if (_blazorDeepLinkStartPath != null)
    {
        blazorWebView.StartPath = _blazorDeepLinkStartPath; // This is a new feature added in .NET MAUI 8
        _blazorDeepLinkStartPath = null;
    }

I feel that for right now I've spent enough time researching this that I'm confident it can work, but testing it end-to-end involves a lot more work that I'm unable to perform right now. Plus, it seems like at least some people have demonstrated doing this successfully, so I'm even more confident it's possible.

I think the eventual goal here is to have better and more complete documentation on how to put together the different pieces, as opposed to adding any features to .NET MAUI / Blazor Hybrid itself.

SamVanhoutte commented 1 year ago

@Eilon , I am trying something like that, but I cannot find out which event handler I should override to navigation to the right location (using that static variable that I have saved earlier). Could you elaborate on that?

What I am currently doing (in order to open a scheme-based link), is the following (for iOS) logic in the AppDelegate class:

public override bool OpenUrl(UIApplication application, NSUrl url, NSDictionary options)
{
    AuthenticationContinuationHelper.SetAuthenticationContinuationEventArgs(url);
    if (url?.Scheme?.Equals(myScheme, StringComparison.InvariantCultureIgnoreCase) ?? false)
    {
        var pageUrl = url.ToString().Replace($"{myScheme}://", "");
        PageSettings.RequestedUri = pageUrl; // This is the static class/var I want to leverage in BlazorWebView
        return base.OpenUrl(application, new NSUrl( pageUrl), options);
    }
    return base.OpenUrl(application, url, options);
}
AndriyGlodan commented 1 year ago

I faced the same issue on iOS. I mean that I can open my app using a Universal link, but the method "OpenUrl" in AppDelegate.cs isn't triggering. Also, the method OnAppLinkRequestReceived in App.xaml.cs isn't triggering as well.

gerneio commented 1 year ago

+1 definitely would like to see some official documentation on how to enable deep linking for each platform. While there is plenty of documentation on the Android/iOS specific setups (manifest, etc), outside of this thread, there is very little code to show how to handle the deep linking once in the MAUI app. Additional docs on how to transfer deep linking down into the BlazorWebView would be appreciated too. In general, it looks like we have all the pieces to get this working for each platform, just scattered in different threads and implementations.

mkArtakMSFT commented 1 year ago

Moving this to a later milestone in .NET 8 to cover what @Eilon has written above in documentation.

mkArtakMSFT commented 1 year ago

@guardrex can you give this a try and see if you can document this and validate yourself, as you're documenting it?

guardrex commented 1 year ago

@mkArtakMSFT ... I've looked this over. To surface @Eilon's remarks temporarily, I cross-linked this issue into the Blazor Hybrid Routing and Navigation article. Unfortunately, I just learned that we haven't activated 8.0 preview content yet, so it can't be seen at this time. I versioned it for 8.0 per @Eilon's remark that blazorWebView.StartPath is an 8.0 feature.

WRT ...

... document this and validate yourself

Well ... 🤔 ... maybe ... at great cost to the budget perhaps. I'm a web dev with limited desktop experience. If it would take @Eilon (emphasis added) "a lot more work that I'm unable to perform right now," I assume that it would take me considerably longer than that 💰😅.

Do you want me to try? If so, what's the priority on this relative to the Blazor Security node work? I'll need perhaps a month to update all of the Blazor security docs. Can this wait until I get past the security work?

AndriyGlodan commented 1 year ago

I was able to resolve this issue on iOS and Android.

iOS workaround: Add these two methods in the AppDelegate.cs file:

public override bool ContinueUserActivity(UIApplication application, NSUserActivity userActivity,
        UIApplicationRestorationHandler completionHandler)
    {
        CheckForAppLink(userActivity);
        return base.ContinueUserActivity(application, userActivity, completionHandler);
    }

    /// <summary>
    /// A method to check if an application has been opened using a Universal link.
    /// iOS implementation.
    /// </summary>
    /// <param name="userActivity"></param>
    private void CheckForAppLink(NSUserActivity userActivity)
    {
        var strLink = string.Empty;

        switch (userActivity.ActivityType)
        {
            case "NSUserActivityTypeBrowsingWeb":
                strLink = userActivity.WebPageUrl.AbsoluteString;
                break;
            case "com.apple.corespotlightitem":
                if (userActivity.UserInfo.ContainsKey(CSSearchableItem.ActivityIdentifier))
                    strLink = userActivity.UserInfo.ObjectForKey(CSSearchableItem.ActivityIdentifier).ToString();
                break;
            default:
                if (userActivity.UserInfo.ContainsKey(new NSString("link")))
                    strLink = userActivity.UserInfo[new NSString("link")].ToString();
                break;
        }

        if (!string.IsNullOrEmpty(strLink))
            App.Current.SendOnAppLinkRequestReceived(new Uri(strLink));
    }`

Android workaround: Add these two methods in the MainActivity.cs file

    protected override void OnNewIntent(Intent intent)
    {
        base.OnNewIntent(intent);
        CheckForAppLink(intent);
    }

    /// <summary>
    /// A method to check if an application has been opened using a Universal link.
    /// Android implementation.
    /// </summary>
    /// <param name="intent"></param>
    private void CheckForAppLink(Intent intent)
    {
        var action = intent.Action;
        var strLink = intent.DataString;
        if (Intent.ActionView != action || string.IsNullOrWhiteSpace(strLink))
            return;

        var link = new Uri(strLink);
        App.Current?.SendOnAppLinkRequestReceived(link);
    }

These implementations were taken from Xamarin.Forms source code. iOS: https://github.com/xamarin/Xamarin.Forms/blob/caab66bcf9614aca0c0805d560a34e176d196e17/Xamarin.Forms.Platform.iOS/FormsApplicationDelegate.cs#L155 Android: https://github.com/xamarin/Xamarin.Forms/blob/9df691e85d8c24486d71b9a502726f9835aad0f7/Xamarin.Forms.Platform.Android/AppCompat/FormsAppCompatActivity.cs#L508

mkArtakMSFT commented 1 year ago

Thanks @guardrex. Leave the validation to us. that's ok.

MikeAndrews90 commented 1 year ago

For anyone coming to this later, my comment above with my solution still works, however I've just edited it with something important. Previously, I was running StartActivity(typeof(MainActivity)) - However I have just discovered today that if you keep doing that, the app gets extremely laggy, I suspect its actually running the whole app/Blazor on top of the existing one every time its invoked, which obviously will be using a load of resources. I found a workaround, and that's by using an event handler, rather than executing StartActivity.

tpmccrary commented 1 year ago

@MikeAndrews90 Can you share a code snippet of using an event handler instead of StartActivity? Thanks in advance!

MikeAndrews90 commented 1 year ago

@tpmccrary I updated my comment above with it all in https://github.com/dotnet/maui/issues/3788#issuecomment-1297529268

tpmccrary commented 1 year ago

@MikeAndrews90 Thanks for updating it!

One other question. I noticed when the app is opened, deep linking works perfect with your approach. However, if the app is fully closed, deep linking does not work. It just sends me to the initial app page. Have you experienced this?

MikeAndrews90 commented 1 year ago

Don't think I have... Have you done an all the assetlinks.json stuff?

tpmccrary commented 1 year ago

@MikeAndrews90 I have not. What do I have to do to get that setup? Thanks for all the help by the way!

MikeAndrews90 commented 1 year ago

You need an assetlinks.json file at the domain you're using for deep linking, e.g https://myapp.com/.well-known/assetlinks.json

A quick Google found an example in this answer on stack overflow: https://stackoverflow.com/questions/48056006/how-to-create-assetlinks-json-when-website-is-handled-by-multiple-apps

To get your sha256 fingerprint for the json file follow Microsoft's docs https://learn.microsoft.com/en-us/xamarin/android/deploy-test/signing/keystore-signature?tabs=windows

tpmccrary commented 1 year ago

@MikeAndrews90 Would you be willing to share the contents of the Activity and IntentFilter attributes for both your MainActivity.cs and the other Activity.cs you created?

MikeAndrews90 commented 1 year ago

@tpmccrary Here's my UrlActivity for the deeplinking: (The casing is a bit weird because I was in a rush to get it working, and havne't got round to sorting it, and it has to match in the manifest XML)

namespace myapp.app
{
    [IntentFilter(new[] { Intent.ActionView },
            Categories = new[]
            {
                Intent.CategoryBrowsable
            },
            DataScheme = "https",
            AutoVerify = true,
            DataHost = "myapp.com", 
            DataMimeType = "com.yourcompany.yourapp"            
        )
    ]
    [Activity(Name = "myapp.app.urlactivity", 
        MainLauncher = false, 
        Theme = "@style/Maui.SplashTheme", 
        ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
    public class urlactivity : Activity
    {
        protected override void OnCreate(Bundle savedInstanceState)
        {
            SetTheme(Resource.Style.AppTheme);
            base.OnCreate(savedInstanceState);
            OnNewIntent(Intent);
        }

        protected override void OnNewIntent(Intent intent)
        {
            base.OnNewIntent(intent);

            var data = intent.DataString;

            if (intent.Action != Intent.ActionView) return;
            if (string.IsNullOrWhiteSpace(data)) return;

            var path = data.Replace(@"https://myapp.com", "");
            NavigationService.SetPage(path);
        }
    }

And MainActivity:

namespace MyApp.App
{
    [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
    [IntentFilter(new[] { NfcAdapter.ActionNdefDiscovered }, Categories = new[] { Intent.CategoryDefault }, DataMimeType = "application/com.yourcompany.yourapp")]
    public class MainActivity : MauiAppCompatActivity
    {    
        protected override void OnCreate(Bundle savedInstanceState)
        {
            base.OnCreate(savedInstanceState);
        }

        protected override void OnResume()
        {         
            base.OnResume();
        }

        protected override void OnNewIntent(Intent intent)
        {
            base.OnNewIntent(intent);      
        }

        // app minimise
        protected override void OnPause()
        {
            ConnectivityStatus.Stop();
            base.OnPause();
        }

        // app close
        protected override void OnDestroy()
        {
            base.OnDestroy();
        }
    }
}

And don't forget to add it into your AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
    <application android:allowBackup="true" tools:replace="android:allowBackup,android:label" android:icon="@mipmap/icon_background" android:roundIcon="@mipmap/icon_background" android:supportsRtl="true">
        <activity android:name="myapp.app.urlactivity" android:exported="true">
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="myapp.com" />
            </intent-filter>
        </activity>
    </application>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.NFC" />
    <uses-feature android:name="android.hardware.nfc" android:required="false" />
    <queries>
        <intent>
            <action android:name="android.support.customtabs.action.CustomTabsService" />
        </intent>
    </queries>
</manifest>
MarcoErlwein commented 1 year ago

Could you please share the project, to see the complete code? Thanks !!!

SamVanhoutte commented 1 year ago

All of this just does not seem to work for me.
In Android, I can manage to trigger the CheckForAppLink method, as described above, and I can see the NavigationManager.NavigateTo() method being called , but it does not move the Blazor page to that uri.

It would be really very helpful to see a project, that works end-to-end with the above approaches.

gerneio commented 1 year ago

Just to share my experiences, for Android specifically (still need to get around to iOS), few things that helped me, in addition to all the code shared from above:

1) Default MAUI template does not specify android:launchMode for the MainActivity, therefore the default will be set to standard (see here). Therefore, if an intent is called into your app, even if your app/activity has already been created, it will, by default, always creates a new instance of the activity. So this explains why OnNewIntent was never called directly. The quick fix (as shown above) was to just have OnCreate always call OnNewIntent, but a better fix IMO is to set the launch mode to something like singleTop. This would also prevent your activity from being re-created (perf benefit?), which, for me at least, was preventing the app from visually re-navigating (might have been related to my use of the NavigationPage control as my MainPage) and now calling OnNewIntent directly. It's still a good idea to have the OnCreate call OnNewIntent, so we can handle Intent's that also triggered the app to be started. Overall, my MainActivity looks something like this:

// Will both automatically be written to the generated `AndroidManifest.xml` file on build (check `..\obj\Debug\net7.0-android\AndroidManifest.xml`)
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, 
    AutoVerify = true, DataScheme = "https", DataHost = "mysite.com")]
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, 
    ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density,
    // Prevents MainActivitiy from being re-created on launching an intent (also makes it to where `OnNewIntent` will be called directly, if the app has already been loaded)
    LaunchMode = LaunchMode.SingleTop 
)]
public class MainActivity : MauiAppCompatActivity
{
    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        // In case the app was opened (on first load) with an `ActionView` intent
        OnNewIntent(this.Intent); 
    }

    protected override void OnNewIntent(Intent intent)
    {
        base.OnNewIntent(intent);

        var data = intent.DataString;

        if (intent.Action != Intent.ActionView) return;
        if (string.IsNullOrWhiteSpace(data)) return;

        // TODO: Call some sort of propogation service to pass down `data`, for example:
        var appServices = ServiceHelper.Current.GetRequiredService<AppServices>(); // MAUI cross-platform service resolver: https://stackoverflow.com/a/73521158/10388359
        appServices.OnAppLinkReceived(data);
    }
}

public class AppLinkReceivedEventArgs : EventArgs
{
    public required string Data { get; set; }
}

// Don't forget to register this in DI as a singleton
public class AppServices
{
    public event EventHandler<AppLinkReceivedEventArgs>? AppLinkReceived;
    public string? LastAppLink { get; private set; }

    public void OnAppLinkReceived(string data)
    {
        LastAppLink = data;

        AppLinkReceived?.Invoke(this, new() { Data = data });
    }
}
  1. Realizing that we don't really need to update AndroidManifest.xml directly for this since the IntentFilterAttribute will basically be written directly to it at build time. You can evaluate the generated file by looking here: ..\obj\Debug\net7.0-android\AndroidManifest.xml

  2. Don't forget to setup the assetlinks.json file at the root of your domain (i.e. https://mysite.com/.well-known/assetlinks.json). I cheated a little and got the sha256_cert_fingerprints from what was already built against my MAUI app and delivered to the android emulator. From VS, start Tools > Android > Android ADB Command Prompt. Make sure the emulator is already started and that you deployed a version of the app with the IntentFilter filled out. Run the cmd adb shell pm get-app-links com.companyname.myapp and copy the value shown in Signatures to your assetlinks.json file. Re-deploy the app and run the get-app-links command again after a little bit to see if the domain was verified. Not sure if this will hold through deploying to the official app store, so guess I'll find out later once I get there.

Some other useful ADB commands for debugging android deep links (see also):

# Resets verified app link states for your app
adb shell pm set-app-links --package com.companyname.myapp 0 all

# Manually trigger verification for your app (will be auto triggered with `android:autoVerify="true"`)
adb shell pm verify-app-links --re-verify com.companyname.myapp

# Manually trigger an `action.VIEW` intent
adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://mysite.com/some/path?q=1"
  1. Now depending on how you decide to store and propagate this intent event, on the Blazor side (and probably same for a MAUI page), you'll need to listen for those events. In my case, I opted for registering an AppServices class in DI as a singleton. My Blazor component that is responsible for handling these app links must then subscribe to the AppLinkReceived event and then act on it accordingly. Biggest pain here is when the Intent is also what starts up your app in the first place, therefore there is no guarantee that your BlazorWebView has finished initializing, and same for your Blazor component of course. To solve this, I basically just store the last intent data string in AppServices.LastAppLink. So my Blazor component will now also check to see if LastAppLink is filled in, and if so then go ahead and process it accordingly. Ideally, this would also mean that you should put this logic in a component that is Initialized only once. Something like this:
@implements IDisposable
@inject AppServices appServices

@code {
    protected override void OnInitialized()
    {
        appServices.AppLinkReceived += AppServices_AppLinkReceived;

        if (!string.IsNullOrEmpty(appServices.LastAppLink))
        {
            AppServices_AppLinkReceived(null, new() { Data = appServices.LastAppLink });
        }
    }

    private void AppServices_AppLinkReceived(object? sender, AppLinkReceivedEventArgs e)
    {
        // TODO: Parse `e.Data` and determine how to route it
    }

    void IDisposable.Dispose()
    {
        appServices.AppLinkReceived -= AppServices_AppLinkReceived;
    }
}

Note that in my use case I didn't need to actually Navigate to any page, so I'm not currently using the NavigationManager, so your mileage may vary.

If anyone ever gets to a point of putting together a full end-to-end example, just an idea, but I think it might be possible to use Github pages to host the assetlinks.json (ref here) so that we can have a fully working example that anyone can just run and quickly experiment with, w/o additional setup on their part. If I have the same sort of trouble setting up iOS (once I get to it), then I might take the initiative (no promises!).

SamVanhoutte commented 1 year ago

@gerneio , thanks for your sample. the key missing part for me, is where I can and should intercept the event (AppLinkReceived) in the BlazorSetup , in order to navigate to the right location. That is (probably), the only real missing part for now, for me.

gerneio commented 1 year ago

@SamVanhoutte I would put this in your top most common layer that's shared across all your pages. So maybe MainLayout.razor or Main.razor. The razor code I shared above is pretty much exactly what u need, with a few modifications to the event handler AppServices_AppLinkReceived. You should then use the built in NavigationManager.NavigateTo to navigate to the specific route you're needing. Something like this perhaps:

MainLayout.razor

@inherits LayoutComponentBase
@implements IDisposable
@inject AppServices appServices
@inject NavigationManager nav

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

@code {
    protected override void OnInitialized()
    {
        appServices.AppLinkReceived += AppServices_AppLinkReceived;

        if (!string.IsNullOrEmpty(appServices.LastAppLink))
        {
            AppServices_AppLinkReceived(null, new() { Data = appServices.LastAppLink });
        }
    }

    private void AppServices_AppLinkReceived(object? sender, AppLinkReceivedEventArgs e)
    {
        // Assuming e.Data represents a URL value
        // Examples: `https://myhost.com/counter?initCount=12345` or `myapp://mobile/counter?initCount=12345`
        if (Uri.TryCreate(e.Data, UriKind.Absolute, out var uri))
        {
            var path = uri.GetComponents(UriComponents.Path, UriFormat.Unescaped);

            // TODO: Do some additional checking to make sure URL is in expected format
            if (path.Equals("counter", StringComparison.OrdinalIgnoreCase))
            {
                var qp = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);

                // TODO: Do some checks to make sure param is of expected type/format?
                if (qp.TryGetValue("initCount", out var cnt))
                {
                    // Navigate to `counter` page and pass in `initCount` param
                    nav.NavigateTo($"counter?initCount={cnt}");
                }
            }
        }
    }

    void IDisposable.Dispose()
    {
        appServices.AppLinkReceived -= AppServices_AppLinkReceived;
    }
}

Counter.razor

@page "/counter"

<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" onclick="@IncrementCount">Click me</button>

@functions {
    [Parameter]
    [SupplyParameterFromQuery(Name = "initCount")]
    public int currentCount { get; set; } = 0;

    void IncrementCount()
    {
        currentCount++;
    }
}

Note that the above code is completely untested, but I suspect it will generally work, maybe with a few tweaks. You can look at the docs to learn more about Blazor navigation. Also, while you could probably just parse out the url path and query string and have the built-in Blazor router handle it through the NavigateTo method (i.e. nav.NavigateTo(uri.PathAndQuery)), this would probably not be wise as it gives outside attackers a lot of power and flexibility with how they interact with your app (i.e. endpoints for deleting records), therefore it is better to parse the data coming from the outside and only interact with the minimal bits that you're expecting to be present.

Hope this helps!

SamVanhoutte commented 1 year ago

Thank you so much! Finally, I got this working! Right in time. Very much appreciated.

prabhavmehra commented 1 year ago

@gerneio Did you face any issue when testing app link when the app is closed. I am unable to get that to work.

I have a similar setup with the exception:

I get an exception

 android.runtime.JavaProxyThrowable: System.Reflection.TargetInvocationException: Arg_TargetInvocationException
                                                                                                     ---> System.InvalidOperationException: MauiContext should have been set on parent.

This is only when I try to open my app when its closed from the applink. When I have the app open and in background it works as expected and no crash.

Mostly because of some timing issue? Not too sure.

I did try something similar to yours, i.e. using a custom event handler, still the same error. I am trying it on a MAUI app and not Blazor

Update: For anyone facing a similar issue, I solved the issue by moving to .net 8 preview. https://github.com/dotnet/maui/issues/9658 had a similar crash log and a fix assigned ( which is not back ported to .net 7), I thought I'll give it a try and that worked.

Eilon commented 11 months ago

Hi everyone, our docs writers have published a doc on Deep Linking that you can read here: https://learn.microsoft.com/aspnet/core/blazor/hybrid/routing?view=aspnetcore-8.0&pivots=maui#deep-linking