From eb11fe8498605d8dba3db633163ed8b5ea34f770 Mon Sep 17 00:00:00 2001 From: Brad Nelson Date: Thu, 15 Oct 2020 19:58:39 -0700 Subject: [PATCH] Use Android intent links in place of bouncing. This fixes behavior of Facebook Messenger when the app is installed (currently it always redirects to the store), and appears to generalize to more SMS clients. It also more closely matches this approach: https://firebase.google.com/docs/dynamic-links/operating-system-integrations NOTE: For iOS ENX, Store URL should be left blank, as no App Link triggers ENX. Custom Apps using the verification server currently can't do the "fancier" clipboard trick described in the link above, see: https://github.com/google/exposure-notifications-verification-server/issues/844 Intents are built according to: https://developer.chrome.com/multidevice/android/intents Which documents the URI scheme used here: https://android.googlesource.com/platform/frameworks/base/+/135936072b24b090fb63940aea41b408d855a4f3/core/java/android/content/Intent.java#6227 --- pkg/controller/redirect/appstore.go | 12 ++- pkg/controller/redirect/index.go | 41 +++++--- pkg/controller/redirect/index_test.go | 135 ++++++++++++++++++++------ 3 files changed, 139 insertions(+), 49 deletions(-) diff --git a/pkg/controller/redirect/appstore.go b/pkg/controller/redirect/appstore.go index 6d0a32643..52e84da02 100644 --- a/pkg/controller/redirect/appstore.go +++ b/pkg/controller/redirect/appstore.go @@ -21,20 +21,23 @@ import ( ) type AppStoreData struct { - AndroidURL string `json:"androidURL"` - IOSURL string `json:"iosURL"` + AndroidURL string `json:"androidURL"` + AndroidAppID string `json:"androidAppID"` + IOSURL string `json:"iosURL"` } // getAppStoreData finds data tied to app store listings. func (c *Controller) getAppStoreData(realmID uint) (*AppStoreData, error) { // Pick first Android app (in the realm) for Play Store redirect. androidURL := "" + androidAppID := "" androidApps, err := c.db.ListActiveAppsByOS(realmID, database.OSTypeAndroid) if err != nil { return nil, fmt.Errorf("failed to get Android Apps: %w", err) } if len(androidApps) > 0 { androidURL = androidApps[0].URL + androidAppID = androidApps[0].AppID } // Pick first iOS app (in the realm) for Store redirect. @@ -48,7 +51,8 @@ func (c *Controller) getAppStoreData(realmID uint) (*AppStoreData, error) { } return &AppStoreData{ - AndroidURL: androidURL, - IOSURL: iosURL, + AndroidURL: androidURL, + AndroidAppID: androidAppID, + IOSURL: iosURL, }, nil } diff --git a/pkg/controller/redirect/index.go b/pkg/controller/redirect/index.go index cef516e7d..4dbbe6c0a 100644 --- a/pkg/controller/redirect/index.go +++ b/pkg/controller/redirect/index.go @@ -102,23 +102,10 @@ func decideRedirect(region, userAgent string, url url.URL, onAndroid := isAndroid(userAgent) onIOS := isIOS(userAgent) - // A subset of SMS clients (e.g. Facebook Messenger) open links - // in inline WebViews without giving https Intents a opportunity - // to trigger App Links. - // Redirect to ourselves once to attempt to trigger the link. - // Bounce to self if we haven't already (on Android only). - // Keep track of state by including an extra bounce=1 url param. - if onAndroid && url.Query().Get("bounce") == "" { - q := url.Query() - q.Set("bounce", "1") - url.RawQuery = q.Encode() - return url.String(), true - } - // On Android redirect to Play Store if App Link doesn't trigger // and an a link is set up. - if onAndroid && appStoreData.AndroidURL != "" { - return appStoreData.AndroidURL, true + if onAndroid && appStoreData.AndroidURL != "" && appStoreData.AndroidAppID != "" { + return buildIntentURL(path, url.Query(), region, appStoreData.AndroidAppID, appStoreData.AndroidURL), true } // On iOS redirect to App Store if App Link doesn't trigger @@ -143,8 +130,30 @@ func buildEnsURL(path string, query url.Values, region string) string { u.RawQuery = query.Encode() q := u.Query() q.Set("r", region) - q.Del("bounce") u.RawQuery = q.Encode() return u.String() } + +// buildIntentURL returns the ens:// URL with fallback +// for the given path, query, and region. +func buildIntentURL(path string, query url.Values, region, appID, fallback string) string { + u := &url.URL{ + Scheme: "intent", + Path: strings.TrimPrefix(path, "/"), + } + u.RawQuery = query.Encode() + q := u.Query() + q.Set("r", region) + u.RawQuery = q.Encode() + + suffix := "#Intent" + suffix += ";scheme=ens" + suffix += ";package=" + appID + suffix += ";action=android.intent.action.VIEW" + suffix += ";category=android.intent.category.BROWSABLE" + suffix += ";S.browser_fallback_url=" + url.QueryEscape(fallback) + suffix += ";end" + + return u.String() + suffix +} diff --git a/pkg/controller/redirect/index_test.go b/pkg/controller/redirect/index_test.go index ef0bd7ce7..9edbcdc55 100644 --- a/pkg/controller/redirect/index_test.go +++ b/pkg/controller/redirect/index_test.go @@ -75,11 +75,11 @@ func TestBuildEnsURL(t *testing.T) { exp: "ens://v?r=US-AA", }, { - name: "drop_bounce", + name: "replace_just_region", path: "v", - query: url.Values{"c": []string{"12345678"}, "bounce": []string{"123"}}, - region: "US-AA", - exp: "ens://v?c=12345678&r=US-AA", + query: url.Values{"c": []string{"12345678"}, "r": []string{"DE"}}, + region: "US-BB", + exp: "ens://v?c=12345678&r=US-BB", }, } @@ -97,6 +97,86 @@ func TestBuildEnsURL(t *testing.T) { } } +func TestBuildIntentURL(t *testing.T) { + t.Parallel() + + expectedSuffix := "#Intent" + + ";scheme=ens" + + ";package=gov.moosylvania.app" + + ";action=android.intent.action.VIEW" + + ";category=android.intent.category.BROWSABLE" + + ";S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dgov.moosylvania.app" + + ";end" + cases := []struct { + name string + path string + query url.Values + region string + exp string + }{ + { + name: "leading_slash_region", + path: "v", + region: "/US-AA", + exp: "intent://v?r=%2FUS-AA" + expectedSuffix, + }, + { + name: "trailing_slash_region", + path: "v", + region: "US-AA/", + exp: "intent://v?r=US-AA%2F" + expectedSuffix, + }, + { + name: "leading_slash_path", + path: "/v", + region: "US-AA", + exp: "intent://v?r=US-AA" + expectedSuffix, + }, + { + name: "trailing_slash_path", + path: "v/", + region: "US-AA", + exp: "intent://v/?r=US-AA" + expectedSuffix, + }, + { + name: "includes_code", + path: "v", + query: url.Values{"c": []string{"1234567890abcdef"}}, + region: "US-AA", + exp: "intent://v?c=1234567890abcdef&r=US-AA" + expectedSuffix, + }, + { + name: "includes_other", + path: "v", + query: url.Values{"foo": []string{"bar"}}, + region: "US-AA", + exp: "intent://v?foo=bar&r=US-AA" + expectedSuffix, + }, + { + name: "replace_region", + path: "v", + query: url.Values{"r": []string{"US-XX"}}, + region: "US-AA", + exp: "intent://v?r=US-AA" + expectedSuffix, + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + appID := "gov.moosylvania.app" + fallback := "https://play.google.com/store/apps/details?id=gov.moosylvania.app" + got, want := buildIntentURL(tc.path, tc.query, tc.region, appID, fallback), tc.exp + if got != want { + t.Errorf("expected %q to be %q", got, want) + } + }) + } +} + func TestAgentDetection(t *testing.T) { t.Parallel() @@ -168,17 +248,28 @@ func TestAgentDetection(t *testing.T) { func TestDecideRedirect(t *testing.T) { t.Parallel() + expectedSuffix := "#Intent" + + ";scheme=ens" + + ";package=gov.moosylvania.app" + + ";action=android.intent.action.VIEW" + + ";category=android.intent.category.BROWSABLE" + + ";S.browser_fallback_url=https%3A%2F%2Fandroid.example.com%2Fstore%2Fmoosylvania" + + ";end" + appLinkBoth := AppStoreData{ - AndroidURL: "https://android.example.com/store/moosylvania", - IOSURL: "https://ios.example.com/store/moosylvania", + AndroidURL: "https://android.example.com/store/moosylvania", + AndroidAppID: "gov.moosylvania.app", + IOSURL: "https://ios.example.com/store/moosylvania", } appLinkOnlyAndroid := AppStoreData{ - AndroidURL: "https://android.example.com/store/moosylvania", - IOSURL: "", + AndroidURL: "https://android.example.com/store/moosylvania", + AndroidAppID: "gov.moosylvania.app", + IOSURL: "", } appLinkNeither := AppStoreData{ - AndroidURL: "", - IOSURL: "", + AndroidURL: "", + AndroidAppID: "", + IOSURL: "", } userAgentAndroid := "Android" @@ -202,38 +293,24 @@ func TestDecideRedirect(t *testing.T) { expected string }{ { - name: "moosylvania_android_pre_bounce", + name: "moosylvania_android_both", url: "https://moosylvania.gov/v?c=1234567890abcdef", userAgent: userAgentAndroid, appStoreData: &appLinkBoth, - expected: "https://moosylvania.gov/v?bounce=1&c=1234567890abcdef", - }, - { - name: "moosylvania_android_post_bounce", - url: "https://moosylvania.gov/v?bounce=1&c=1234567890abcdef", - userAgent: userAgentAndroid, - appStoreData: &appLinkBoth, - expected: "https://android.example.com/store/moosylvania", + expected: "intent://v?c=1234567890abcdef&r=US-MOO" + expectedSuffix, }, { - name: "moosylvania_android_pre_bounce_relative", + name: "moosylvania_android_both_relative", altURL: &relativePinURL, userAgent: userAgentAndroid, appStoreData: &appLinkBoth, - expected: "/v?bounce=1&c=1234567890abcdef", + expected: "intent://v?c=1234567890abcdef&r=US-MOO" + expectedSuffix, }, { - name: "moosylvania_android_no_applink_pre_bounce", + name: "moosylvania_android_no_applink", url: "https://moosylvania.gov/v?c=1234567890abcdef", userAgent: userAgentAndroid, appStoreData: &appLinkNeither, - expected: "https://moosylvania.gov/v?bounce=1&c=1234567890abcdef", - }, - { - name: "moosylvania_android_no_applink_post_bounce", - url: "https://moosylvania.gov/v?c=1234567890abcdef&bounce=1", - userAgent: userAgentAndroid, - appStoreData: &appLinkNeither, expected: "ens://v?c=1234567890abcdef&r=US-MOO", }, {