G'day,
Disclaimer: I'm not an Android dev, I'm QAing an Android app with the issue I'm describing. The technical terms I use to describe this issue might be wrong.
I'm testing an Android app that describes in its manifest that it can handle web intents with the address of type https://www.example.com/app/(.*)
. The way it should handle these URLs is that it gets the first match group $1
and sends a request to https://api.example.com/$1
and if the response is a HTTP200, it renders the response within the app. If not, it should open the URL in any browser app the user has installed on their device (by sending an intent to open the URL). If the user has no browser apps installed on their device, we show an error prompt saying they don't have a browser installed which can handle this URL.
Now, this works fine except when the user marks this app as the default to handle URLs like https://www.example.com/app/(.*)
when it first tries to open a URL like https://www.example.com/app/(.*)
. Then, even if the user has browser apps installed on their system, when they open a link that needs to be opened in a browser, the only option seems to be the our original app and we have to show the error message (as it seems like there are no other browser apps installed on the system which can handle this URL).
One way to tackle this is to show a message asking the user to clear the defaults for this app when we encounter a URL that needs to be opened in a browser app but the only option is our own app — but this is terrible UX. Is there another work-around for this issue?
Sample code to understand the issue: https://gist.github.com/GVRV/5879fcf0b1838b495e3a2151449e0da3
Edit 1: Added sample code link
To solve this problem and keep the systems default handling of intents you need 2 additional activities and 1 <activity-alias>
:
Create a new invisible empty Activity. I called it IntentFilterDelegationActivity. This activity is responsible to receive URL intents from the activity-alias
(defined in the next step). Manifest:
<activity
android:name=".intent_filter.IntentFilterDelegationActivity"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleInstance"
android:noHistory="true"
android:taskAffinity=""
android:theme="@style/Theme.Transparent"/>
Code:
public class IntentFilterDelegationActivity extends Activity
{
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
findAndStartMatchingActivity(getIntent());
finish();
}
}
Create an <activity-alias>
. The activity alias is responsible to delegate your URL intents to your IntentFilterDelegationActivity
. Only a Manifest entry is needed:
<activity-alias
android:name="${packageName}.IntentFilterDelegation"
android:enabled="true"
android:exported="true"
android:targetActivity=".intent_filter.IntentFilterDelegationActivity">
<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="www.example.com"/>
</intent-filter>
</activity-alias>
Now you are able to do the trick: You can deactivate the activity-alias
before you launch your own URL intent and activate the alias after the launch. This causes android that your app won't be listed as app which can handle the URL intent. To implement the activation and deactivation you need an additional Activity. I called it ForceOpenInBrowserActivity
.
Manifest:
<activity
android:name=".activity.ForceOpenInBrowserActivity"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:noHistory="true"
android:taskAffinity=""
android:theme="@style/Theme.Transparent"/>
Code:
public class ForceOpenInBrowserActivity extends Activity
{
public static final String URI = IntentUtils.getIntentExtraString(ForceOpenInBrowserActivity.class, "URI");
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
Uri uri = fetchUriFromIntent();
if (uri != null)
{
startForcedBrowserActivity(uri);
}
else
{
finish();
}
}
@Nullable
private Uri fetchUriFromIntent()
{
Uri uri = null;
Intent intent = getIntent();
if (intent != null)
{
uri = intent.getParcelableExtra(URI);
}
return uri;
}
private void startForcedBrowserActivity(Uri uri)
{
disableActivityAlias(this);
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// After starting another activity, this activity will be destroyed
// android:noHistory="true" android:excludeFromRecents="true"
startActivity(intent);
}
/**
* Re-enable intent filters when returning to app.
* Note: The intent filters will also be enabled when starting the app.
*/
@Override
protected void onStop()
{
super.onStop();
enableActivityAlias();
}
public void disableActivityAlias()
{
String packageName = getPackageName();
ComponentName componentName = new ComponentName(packageName, packageName + ".IntentFilterDelegation"); // Activity alias
getPackageManager().setComponentEnabledSetting(componentName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
}
public void enableActivityAlias()
{
String packageName = getPackageName();
ComponentName componentName = new ComponentName(packageName, packageName + ".IntentFilterDelegation");
getPackageManager().setComponentEnabledSetting(componentName, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP);
}
}
Now you can send any URL intent which must be opened in an external browser to the ForceOpenInBrowserActivity
:
@NonNull
public static Intent createForceBrowserIntent(Context context, @NonNull Uri uri)
{
Intent intent = new Intent(context, ForceOpenInBrowserActivity.class);
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(ForceOpenInBrowserActivity.URI, uri);
return intent;
}
if website https://www.example.com/ is under your supervision, you could change the logic and use an unique schema like example://app/(.) to handle your case. The website could then use redirection to for its navigation. In this way when you broadcast https://www.example.com/ for action view only browser apps could handle this and your app would be only listening to your custom schema example://app/(.) and wont launch.
Else you could check for default activity and clear it instead of showing an alert.
PackageManager pm = context.getPackageManager();
final ResolveInfo res = pm.resolveActivity(your_intent, 0);
if (res.activityInfo != null && getPackageName()
.equals(res.activityInfo.packageName)) {
pm.clearPackagePreferredActivities("you_package_name");
broadcast your intent
}
Sadly, there is no official solution for this problem (see this SO question).
A workaround is the following:
Use PackageManager.queryIntentActivities()
, modify the result to not include your app and show it in a custom chooser dialog.
If you don't want your users to choose a browser every time, you can manage a custom default inside your app.
If you control the domain, there is a cleaner workaround:
Lets say your url is http://www.example.com
. Your Android IntentFilter
should listen for that schema. Now you create a second schema, e.g. http://web.example.com
, which displays the same content as the normal url. If you want to redirect to the web from your app, use the second schema. Everywhere else, use the first one.
Note that you should not use a custom schema like example://
, because this will cause problems if your app is not present.