Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to gracefully fall back to website when Deep Link can't be handled by app

The situation:

  1. You have an extensive mobile website, m.somewhere.com
  2. On Google Play you have an Android App that duplicates the key features of m.somewhere.com, but not all of them.
  3. Your Client/Employer/Investor has asked you to implement deep-linking for those urls that can be handled by the app.

TL;DR - how do you implement this?

My Approach So Far:

First instinct: match only certain urls and launch for them. Problem: paucity of expression in the AndroidManifest intent-filter prevents this (e.g. http://weiyang.wordpress.ncsu.edu/2013/04/11/a-limitation-in-intent-filter-of-android-application/).

As a subset of the problem, suppose the server at m.somewhere.com knows that any url that ends in a number goes to a certain page on the site, and the marketing guys are constantly futzing with the seo, so e.g.

I want to launch the app for:

http://m.somewhere.com/find/abc-12345
https://m.somewhere.com/shop/xyz-45678928492

But not for

http://m.somewhere.com/find/abc-12345-xyz
https://m.somewhere.com/about-us

no combination of path, pathPrefix, or pathPattern will handle this.

Best practice on stackoverflow (Match URIs with <data> like http://example.com/something in AndroidManifest) seems to be to catch everything, and then handle the situation when you get to onCreate() and realize you shouldn't have handled this particular url:

Android Manifest:

...
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="http"
          android:host="m.somewhere.com"
          android:pathPattern=".*"/>
</intent-filter>
...    

Activity onCreate():

Intent i = getIntent()
String action = i.getAction();
Uri uri = i.getData();
if (Intent.ACTION_VIEW.equals(action) && cantHandleUrl(uri)) {
    // TODO - fallback to browser.
}

I have programmed something similar to the above that is working, but it leads to a very bad end-user experience:

  1. While browsing m.somewhere.com, there is a hiccup on every url click while the app is launched and then falls back.
  2. There is a nasty habit for a Chooser screen to popup for each and every link click on m.somewhere.com, asking the user which they would like to use (and the Android App is listed along with the browsers, but clicking on the Android App just launches the chooser screen again). If I'm not careful I get in an infinite relaunch loop for my app (if the user selects "Always"), and even if I am careful, it appears to the user that their "Always" selection is being ignored.

What can be done?

(EDIT: Displaying the site in a WebView in the app for unhandled pages is NOT an option).

like image 475
jdowdell Avatar asked Jan 22 '15 18:01

jdowdell


Video Answer


2 Answers

Late answer, but for future readers: if you're supporting a minimum of API level 15 then there's a more direct (less hacky) way of falling back to a browser for URLs you realize you don't want to handle, without resorting to disabling/re-enabling URL catching components.

nbarraille's answer is creative and possibly your only option if you need to support APIs lower than 15, but if you don't then you can make use of Intent.makeMainSelectorActivity() to directly launch the user's default browser, allowing you to bypass Android's ResolverActivity app selection dialog.

Don't do this

So instead of re-broadcasting the URL Intent the typical way like this:

// The URL your Activity intercepted
String data = "example.com/someurl"
Intent webIntent = new Intent(Intent.ACTION_VIEW, data);
webIntent.addCategory(Intent.CATEGORY_BROWSABLE);
startActivity(webIntent);

Do this

You would broadcast this Intent instead:

Intent defaultBrowser = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_BROWSER);
defaultBrowser.setData(data);
startActivity(defaultBrowser);

This will tell Android to load the browser app and data URL. This should bypass the chooser dialog even if they have more than one browser app installed. And without the chooser dialog you don't have to worry about the app falling into an infinite loop of intercepting/re-broadcasting the same Intent.

Caveat

You have to be okay with opening the URL (the one you didn't want to handle) in the user's browser. If you wanted to give other non-browser apps a chance to open the link as well, this solution wouldn't work since there is no chooser dialog.

Pitfalls

As far as I can tell, the only quirk from using this solution is that when the user clicks one of your deep links, they'll get to choose to open in your app or their browser, etc. When they choose your app and your internal app logic realizes it's a URL it doesn't want to intercept, the user gets shown the browser right away. So they choose your app but get shown the browser instead.

NOTE: when I say "broadcast" in this answer, I mean the general term, not the actual Android system feature.

like image 76
Tony Chan Avatar answered Oct 25 '22 03:10

Tony Chan


There is a somewhat hacky way of doing this:

  • In the manifest, create an intent-filter for m.somewhere.com, to open a specific deeplink handler activity.
  • In that Activity, figure out if your app supports that URL or not.
  • If it does, just open whatever activity
  • If it doesn't, send a non-resolved ACTION_VIEW intent to be opened by your browser. The problem here, is that your app will also catch this intent, and this will create an infinite loop if your app is selected as the default handler for that URL. The solution is to use PackageManager.setComponentEnabledSetting() to disable your deeplink handler Activity before you send that intent, and re-enable it after.

Some example code:

public class DeepLinkHandlerActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Uri uri = intent.getData();
        Intent intent = makeInternallySupportedIntent(uri);
        if (intent == null) {
            final PackageManager pm = getPackageManager();
            final ComponentName component = new ComponentName(context, DeepLinkHandlerActivity.class);
            pm.setComponentEnabledSetting(component, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);

            Intent webIntent = new Intent(Intent.ACTION_VIEW);
            webIntent.setData(uri);
            context.startActivity(webIntent);

            AsyncTask<Void, Void, Void> task = new AsyncTask<Void, Void, Void>() {

                @Override
                protected Void doInBackground(Void[] params) {
                    SystemClock.sleep(2000);
                    pm.setComponentEnabledSetting(component, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
                    return null;
                    }
                };
             task.execute();
        } else {
            startActivity(intent);
        }
        finish();
    }
}

Hope that helps.

Note: It looks like you need to delay the re-enabling by a couple of seconds for this to work.

Note 2: For a better experience, using a Transparent theme for your activity will make it look like your app didn't even open.

Note 3: If for some reason your app crashes or gets killed before the component re-registers, you're loosing deep link support forever (or until next update/reinstall), so I would also do the component re-enabling in App.onCreate() just in case.

like image 35
nbarraille Avatar answered Oct 25 '22 04:10

nbarraille