Learning Mobile Development

Open Sesame

Set up gating to “tester” UI in your Flutter App

Maksim Lin
Mobile App Development Publication

--

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 it's easy enough to change settings used in the 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 that I came across recently.

I dub thee… Tester!

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

Another technique for 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, 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.

DEEPLINKS: A NEW IDEA

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, it is relatively easy to add support to get 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 this, you will essentially get deep link handling for free once you set up your routing with paths.

With that, we have covered 3 different ways that you can set up gating to “tester” UI in your Flutter app, including one that 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.

Originally published at https://manichord.com.

--

--

Maksim Lin
Mobile App Development Publication

Long time Android Developer now doing Flutter. Co-organiser of Flutter Melbourne.