Open Sesame

03 Sep 2023

Image: Illustration from "Ali Baba, or the Forty Thieves, by Unknown" https://www.gutenberg.org/files/37679/37679-h/37679-h.htm

Hidden UI

While its easy enough to change settings used in app or inspect configuration values while running debug builds of your app, it can often be useful to do so for release builds also (for QA team members for example). But this then raises the question of how to gate access to such a "debug" or "test" screen, menu or dialog, as we don't really want to confuse our end users with such UI.

For this case there are a number of solutions, some of which are very well known, widely used and which I'll cover here first, before moving on to a possibly less well known or used method which I came across recently.

I dub thee... Tester!

painting of queen knighting a soldier created by DaliE

If your app has the requirement for users to be signed-in, an easy way to gate access is to have a property on user data or a user type that grants access to the hidden UI, so something as simple as:

@override
  Widget build(BuildContext context) {
  return
    if (user.isTester) {
        HiddenTesterWidget()
    } else {
        ...
    }

The magic touch

a toast ui element from Android that reads "you are now a developer

Another technique to gating access to a hidden UI is requiring some sort of not easily accidentally entered user gesture. The most famous of these is likely Android's way of enabling "developer mode" by requiring the user to tap 7 times on the build number UI in quick succession.

Luckily for us, unlike other mobile OS's, Android is open source and we can have a look at the famous Android example:

public class BuildNumberPreferenceController extends BasePreferenceController implements
        LifecycleObserver, OnStart {

    static final int TAPS_TO_BE_A_DEVELOPER = 7;
    ...
    @Override
    public void onStart() {
        mDebuggingFeaturesDisallowedAdmin = RestrictedLockUtilsInternal.checkIfRestrictionEnforced(
                mContext, UserManager.DISALLOW_DEBUGGING_FEATURES, UserHandle.myUserId());
        mDebuggingFeaturesDisallowedBySystem = RestrictedLockUtilsInternal.hasBaseUserRestriction(
                mContext, UserManager.DISALLOW_DEBUGGING_FEATURES, UserHandle.myUserId());
        mDevHitCountdown = DevelopmentSettingsEnabler.isDevelopmentSettingsEnabled(mContext)
                ? -1 : TAPS_TO_BE_A_DEVELOPER;
        mDevHitToast = null;
    }

    ...
    @Override
    public boolean handlePreferenceTreeClick(Preference preference) {
        if (!TextUtils.equals(preference.getKey(), getPreferenceKey())) {
            return false;
        }
        if (isUserAMonkey()) {
            return false;
        }
        ...

        if (mDevHitCountdown > 0) {
            mDevHitCountdown--;
            if (mDevHitCountdown == 0 && !mProcessingLastDevHit) {
                // Add 1 count back, then start password confirmation flow.
                mDevHitCountdown++;

                final String title = mContext
                        .getString(R.string.unlock_set_unlock_launch_picker_title);
                final ChooseLockSettingsHelper.Builder builder =
                        new ChooseLockSettingsHelper.Builder(mActivity, mFragment);
                mProcessingLastDevHit = builder
                        .setRequestCode(REQUEST_CONFIRM_PASSWORD_FOR_DEV_PREF)
                        .setTitle(title)
                        .show();

                if (!mProcessingLastDevHit) {
                    enableDevelopmentSettings();
                }
                mMetricsFeatureProvider.action(
                        mMetricsFeatureProvider.getAttribution(mActivity),
                        MetricsEvent.FIELD_SETTINGS_BUILD_NUMBER_DEVELOPER_MODE_ENABLED,
                        mFragment.getMetricsCategory(),
                        null,
                        mProcessingLastDevHit ? 0 : 1);
            } else if (mDevHitCountdown > 0
                    && mDevHitCountdown < (TAPS_TO_BE_A_DEVELOPER - 2)) {
                if (mDevHitToast != null) {
                    mDevHitToast.cancel();
                }
                mDevHitToast = Toast.makeText(mContext,
                        mContext.getResources().getQuantityString(
                                R.plurals.show_dev_countdown, mDevHitCountdown,
                                mDevHitCountdown),
                        Toast.LENGTH_SHORT);
                mDevHitToast.show();
            }

            mMetricsFeatureProvider.action(
                    mMetricsFeatureProvider.getAttribution(mActivity),
                    MetricsEvent.FIELD_SETTINGS_BUILD_NUMBER_DEVELOPER_MODE_ENABLED,
                    mFragment.getMetricsCategory(),
                    null,
                    0);
        } else if (mDevHitCountdown < 0) {
            if (mDevHitToast != null) {
                mDevHitToast.cancel();
            }
            mDevHitToast = Toast.makeText(mContext, R.string.show_dev_already,
                    Toast.LENGTH_LONG);
            mDevHitToast.show();
            mMetricsFeatureProvider.action(
                    mMetricsFeatureProvider.getAttribution(mActivity),
                    MetricsEvent.FIELD_SETTINGS_BUILD_NUMBER_DEVELOPER_MODE_ENABLED,
                    mFragment.getMetricsCategory(),
                    null,
                    1);
        }
        return true;
    }

source code reference

While the code is quite elaborate, it's not too hard to get the gist of how it works based on the number of clicks (aka taps) on the relevant bit of UI by the user and how you could easily implement this in your Flutter app.

This is the method that I thought of recently when implementing deep linking in a Flutter app. If you are already using or are going to add deep linking support for your app, then gating access to the hidden UI via a deep link becomes a good alternative as once you have deep linking support setup in your app, its relatively easy to add support to get a a hidden UI via a deep link.

If you haven't already enabled deep linking handling that is now built into current stable versions of Flutter, the Flutter documentation covers it well and with a package like auto_route, you will essentially get deep link handling for free once you set up your routing with paths.

And with that we have covered 3 different ways that you can set up gating to "tester" UI in your Flutter app, including one which I think brings a novel approach to doing so.


I hope this has been of help to you and if it has or you have any other handy tips to share, please let me know where you can find me on the Flutter community Mastodon.