I am trying to set up deep linking/universal linking for my .NET MAUI app. Everything appears on the surface to be set up according to the MS documentation, however when accessing links it simply uses the browser. This happens in both Android and Google.
The desired behaviour is so that any traffic to https://app.rackemapp.com (which will typically be through a QR code) will trigger the app to open.
In Android I tested it with a custom scheme of rackemapp:// which worked, so I know the base logic works, I just can't get the devices to understand that app.rackemapp.com traffic should open the app.
The curl testing to reach my AASA file works ok (no redirects or 40x errors) and the diagnostics tool on the device detects it as a valid universal link.
How do I make it open my app?
Update - Opening iOS required a new version number, that made it work. Android still a problem
MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseMauiCommunityToolkit()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("bootstrap-icons.ttf", "Bootstrap");
})
.ConfigureLifecycleEvents(lifecycle =>
{
#if IOS
lifecycle.AddiOS(ios =>
{
// Universal link delivered to FinishedLaunching after app launch.
ios.FinishedLaunching((app, data) => HandleAppLink(app.UserActivity));
// Universal link delivered to ContinueUserActivity when the app is running or suspended.
ios.ContinueUserActivity((app, userActivity, handler) => HandleAppLink(userActivity));
// Only required if using Scenes for multi-window support.
if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsMacCatalystVersionAtLeast(13))
{
// Universal link delivered to SceneWillConnect after app launch
ios.SceneWillConnect((scene, sceneSession, sceneConnectionOptions)
=> HandleAppLink(sceneConnectionOptions.UserActivities.ToArray()
.FirstOrDefault(a => a.ActivityType == Foundation.NSUserActivityType.BrowsingWeb)));
// Universal link delivered to SceneContinueUserActivity when the app is running or suspended
ios.SceneContinueUserActivity((scene, userActivity) => HandleAppLink(userActivity));
}
});
#endif
#if ANDROID
lifecycle.AddAndroid(android =>
{
android.OnCreate((activity, bundle) =>
{
var action = activity.Intent?.Action;
var data = activity.Intent?.Data?.ToString();
if (action == Android.Content.Intent.ActionView && data is not null)
{
Task.Run(() => HandleAppLink(data));
}
});
});
#endif
});
//Unnecessary code omitted
var app = builder.Build();
ServiceHelper.Initialize(app.Services);
return app;
}
#if IOS
static bool HandleAppLink(Foundation.NSUserActivity? userActivity)
{
if (userActivity is not null && userActivity.ActivityType == Foundation.NSUserActivityType.BrowsingWeb && userActivity.WebPageUrl is not null)
{
HandleAppLink(userActivity.WebPageUrl.ToString());
return true;
}
return false;
}
#endif
static void HandleAppLink(string url)
{
if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
App.Current?.SendOnAppLinkRequestReceived(uri);
}
App.xaml.cs
public App()
{
InitializeComponent();
MainPage = new MainPage();
}
protected override async void OnAppLinkRequestReceived(Uri uri)
{
base.OnAppLinkRequestReceived(uri);
var theurl = uri.ToString();
// Show an alert to test that the app link was received.
await Dispatcher.DispatchAsync(async () =>
{
//await Windows[0].Page!.DisplayAlert("App link received", uri.ToString(), "OK");
var encodedString = theurl.Split("/")[4];
byte[] data = Convert.FromBase64String(encodedString);
string decodedString = System.Text.Encoding.UTF8.GetString(data);
var api = "https://mobileapi.rackemapp.com";
#if DEBUG
api = "https://g7c7crrc-44357.uks1.devtunnels.ms";
#endif
var client = new RestClient(api);
var request = new RestRequest("/mobile/getmatchfromtable/" + decodedString, Method.Get);
var execute = client.Execute(request);
string rawResponse = execute.Content;
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
var response = new TableMatchResponse();
if (rawResponse == null || rawResponse == "")
{
response.MatchCode = "OFFLINE";
}
else
{
response = System.Text.Json.JsonSerializer.Deserialize<TableMatchResponse>(rawResponse, options);
}
if (response.MatchCode == null || response.MatchCode == "")
{
// error condition
}
else
{
App.Current.MainPage.Navigation.PushModalAsync(new Scoreboard(response.MatchCode));
}
});
}
curl to AASA
curl -v https://app.rackemapp.com/.well-known/apple-app-site-association
* Host app.rackemapp.com:443 was resolved.
* IPv6: (none)
* IPv4: 104.21.50.101, 172.67.204.183
* Trying 104.21.50.101:443...
* Connected to app.rackemapp.com (104.21.50.101) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* CAfile: /etc/ssl/cert.pem
* CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-AES256-GCM-SHA384 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
* subject: CN=rackemapp.com
* start date: Dec 27 10:10:42 2024 GMT
* expire date: Mar 27 11:08:16 2025 GMT
* subjectAltName: host "app.rackemapp.com" matched cert's "*.rackemapp.com"
* issuer: C=US; O=Google Trust Services; CN=WE1
* SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://app.rackemapp.com/.well-known/apple-app-site-association
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: app.rackemapp.com]
* [HTTP/2] [1] [:path: /.well-known/apple-app-site-association]
* [HTTP/2] [1] [user-agent: curl/8.7.1]
* [HTTP/2] [1] [accept: */*]
> GET /.well-known/apple-app-site-association HTTP/2
> Host: app.rackemapp.com
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/2 200
< date: Sat, 28 Dec 2024 21:57:37 GMT
< content-type: application/json
< content-length: 315
< last-modified: Tue, 24 Dec 2024 22:39:41 GMT
< accept-ranges: bytes
< etag: "589c11b95456db1:0"
< x-powered-by: ASP.NET
< cf-cache-status: DYNAMIC
< report-to: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=oe3dB4x1fALVNQ6f%2FDzDNWMBUj772yNTdc7RsseLGWDPbOWJ4hgLRn3%2FitA%2BY4vBR4KN5juDa4x9wxu9z0CGbK4jXP5m0n6f0VTyKamvPIGlvEJl6VobT6dMJOdHMuiCqkMtMA%3D%3D"}],"group":"cf-nel","max_age":604800}
< nel: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
< strict-transport-security: max-age=0; includeSubDomains; preload
< x-content-type-options: nosniff
< x-cloudflare-enabled: true
< server: cloudflare
< cf-ray: 8f94ce9b09d271f2-LHR
< alt-svc: h3=":443"; ma=86400
< server-timing: cfL4;desc="?proto=TCP&rtt=41458&min_rtt=36685&rtt_var=18592&sent=5&recv=9&lost=0&retrans=0&sent_bytes=2911&recv_bytes=585&delivery_rate=50756&cwnd=227&unsent_bytes=0&cid=15096d52c33a80ae&ts=100&x=0"
<
{
"activitycontinuation": {
"apps": [ "[Teamid].com.rackemapp.mobileapp" ]
},
"applinks": {
"apps": [],
"details": [
{
"appID": "[Teamid].com.rackemapp.mobileapp",
"paths": [ "*", "/*" ]
}
]
}
* Connection #0 to host app.rackemapp.com left intact
MainActivity.cs
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
[IntentFilter(new[] { Android.Content.Intent.ActionView },
DataScheme = "https",
DataHost = "app.rackemapp.com",
DataPathPrefix = "/",
AutoVerify = true,
Categories = new[] { Intent.ActionView, Intent.CategoryDefault, Intent.CategoryBrowsable })]
public class MainActivity : MauiAppCompatActivity
{
public MainActivity()
{
}
}
assetlinks.json
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "RackEmAppMobile",
"package_name": "com.rackemapp.mobileapp",
"sha256_cert_fingerprints": [
"C5:F1:29:4A:9D:95:41:F3:5A:0B:B9:DD:76:7A:F3:14:ED:D5:C0:17:D5:C8:F0:DB:B8:EC:70:E6:56:20:33:EC"
]
}
}
]