Refactoring Simplenote2

Simplenote is an Open Source note taking application published by Automattic. Source for the Android client is available on github.

AboutFragment.java

Here’s the original source file on github.

package com.automattic.simplenote;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.text.Html;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import com.automattic.simplenote.utils.BrowserUtils;
import com.automattic.simplenote.utils.HtmlCompat;
import com.automattic.simplenote.utils.PrefUtils;
import com.automattic.simplenote.utils.ThemeUtils;
import com.automattic.simplenote.widgets.SpinningImageButton;
import com.automattic.simplenote.widgets.SpinningImageButton.SpeedListener;
import java.util.Calendar;
import java.util.Locale;
import java.util.Objects;
public class AboutFragment extends Fragment implements SpeedListener {
private static final String PLAY_STORE_URL = "http://play.google.com/store/apps/details?id=";
private static final String PLAY_STORE_URI = "market://details?id=";
private static final String SIMPLENOTE_BLOG_URL = "https://simplenote.com/blog";
private static final String SIMPLENOTE_HELP_URL = "https://simplenote.com/help";
private static final String SIMPLENOTE_HIRING_HANDLE = "https://automattic.com/work-with-us/";
private static final String SIMPLENOTE_TWITTER_HANDLE = "simplenoteapp";
private static final String TWITTER_PROFILE_URL = "https://twitter.com/#!/";
private static final String TWITTER_APP_URI = "twitter://user?screen_name=";
private static final String URL_CALIFORNIA = "https://automattic.com/privacy/#california-consumer-privacy-act-ccpa";
private static final String URL_CONTRIBUTE = "https://github.com/Automattic/simplenote-android";
private static final String URL_PRIVACY = "https://automattic.com/privacy";
private static final String URL_TERMS = "https://simplenote.com/terms";
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_about, container, false);
TextView version = view.findViewById(R.id.about_version);
View blog = view.findViewById(R.id.about_blog);
View twitter = view.findViewById(R.id.about_twitter);
View help = view.findViewById(R.id.about_help);
View store = view.findViewById(R.id.about_store);
View contribute = view.findViewById(R.id.about_contribute);
View hiring = view.findViewById(R.id.about_careers);
TextView privacy = view.findViewById(R.id.about_privacy);
TextView terms = view.findViewById(R.id.about_terms);
TextView california = view.findViewById(R.id.about_california);
TextView copyright = view.findViewById(R.id.about_copyright);
String colorLink = Integer.toHexString(ContextCompat.getColor(requireContext(), R.color.blue_5) & 0xffffff);
version.setText(PrefUtils.versionInfo());
int thisYear = Calendar.getInstance().get(Calendar.YEAR);
copyright.setText(String.format(Locale.getDefault(), getString(R.string.about_copyright), thisYear));
blog.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), SIMPLENOTE_BLOG_URL);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
twitter.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), TWITTER_APP_URI + SIMPLENOTE_TWITTER_HANDLE);
} catch (Exception e) {
BrowserUtils.launchBrowserOrShowError(requireContext(), TWITTER_PROFILE_URL + SIMPLENOTE_TWITTER_HANDLE);
}
}
});
help.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), SIMPLENOTE_HELP_URL);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
store.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String url = PLAY_STORE_URI + requireActivity().getPackageName();
Intent goToMarket = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
goToMarket.addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY |
Intent.FLAG_ACTIVITY_NEW_DOCUMENT |
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
);
try {
if (BrowserUtils.isBrowserInstalled(requireContext())) {
startActivity(goToMarket);
} else {
BrowserUtils.showDialogErrorBrowser(requireContext(), url);
}
} catch (ActivityNotFoundException e) {
BrowserUtils.launchBrowserOrShowError(requireContext(), url);
}
}
});
contribute.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), URL_CONTRIBUTE);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
hiring.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), SIMPLENOTE_HIRING_HANDLE);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
privacy.setText(Html.fromHtml(String.format(
getResources().getString(R.string.link_privacy),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>"
)));
privacy.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), URL_PRIVACY);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
terms.setText(Html.fromHtml(String.format(
getResources().getString(R.string.link_terms),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>"
)));
terms.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), URL_TERMS);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
california.setText(Html.fromHtml(String.format(
getResources().getString(R.string.link_california),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>"
)));
california.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(URL_CALIFORNIA)));
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
});
((SpinningImageButton) view.findViewById(R.id.about_logo)).setSpeedListener(this);
return view;
}
@Override
public void OnMaximumSpeed() {
String[] items = requireActivity().getResources().getStringArray(R.array.array_about);
long index = System.currentTimeMillis() % items.length;
final AlertDialog dialog = new AlertDialog.Builder(requireActivity())
.setMessage(HtmlCompat.fromHtml(String.format(
items[(int) index],
"<span style=\"color:#",
Integer.toHexString(
ThemeUtils.getColorFromAttribute(requireActivity(), R.attr.colorAccent) & 0xffffff
),
"\">",
"</span>"
)))
.show();
final Handler handler = new Handler();
final Runnable runnable = new Runnable() {
@Override
public void run() {
if (dialog.isShowing()) {
dialog.dismiss();
}
}
};
dialog.setOnDismissListener(
new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
handler.removeCallbacks(runnable);
}
}
);
handler.postDelayed(runnable, items[(int) index].length() * 50);
((TextView) Objects.requireNonNull(dialog.findViewById(android.R.id.message))).setMovementMethod(LinkMovementMethod.getInstance());
}
}

The things that stand out to me

  1. The method onCreateView() is really long; obscuring that it mostly initializes some click handlers.
  2. A big block of View and TextView assignments
  3. A long list of OnClickListener attached to the views but disconnected from the View declaration.
  4. Some click listeners with the same click responses.

View reconciliation

The first thing to do is to get the view assignments closer associated with the click listener assignments. That should make it easier to extract methods from onCreateView() later.

I mostly write in Kotlin these days, so there may be a better way to do this in Java. For now though, I have created a static method to get the view assignment and the click handler closer together.

interface ClickHandler {
static void handleClick(@NonNull View view, View.OnClickListener listener) {
view.setOnClickListener(listener);
}
}

Now, the about_blog click handler looks like this:

ClickHandler.handleClick(view.findViewById(R.id.about_blog), v -> {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), SIMPLENOTE_BLOG_URL);
} catch (Exception e) {
Toast.makeText(getActivity(), R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
});

Some of that common click handler behavior can be gathered into a private function.

ClickHandler.handleClick(view.findViewById(R.id.about_blog), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_BLOG_URL));
private void browseOrToastOnError(Context context, String url) {
browseOrToastOnError(context, url, R.string.no_browser_available);
}
private void browseOrToastOnError(Context context, String url, @StringRes int stringRes) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), url);
} catch (Exception e) {
Toast.makeText(context, stringRes, Toast.LENGTH_LONG).show();
}
}

There’s a bit of YAGNI with that default version with the parameterized URL but I’m ok with that for now. Next, use the same approach for the TextView click handlers.

interface ClickHandler {
static void handleClick(@NonNull View view, View.OnClickListener listener) {
view.setOnClickListener(listener);
}
static void handleClick(@NonNull TextView view, CharSequence text, View.OnClickListener listener) {
view.setText(text);
view.setOnClickListener(listener);
}
}
ClickHandler.handleClick(
view.findViewById(R.id.about_privacy),
Html.fromHtml(String.format(
getResources().getString(R.string.link_privacy),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>")),
v -> browseOrToastOnError(view.getContext(), URL_PRIVACY)
);

This change gets the view assignment and click handler closer together. But it doesn’t really get very far in the overall goal of shortening the onCreateView() function.

Here’s the shortened onCreateView() after extracting and grouping functions.

@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_about, container, false);
return setupViews(view);
}
private View setupViews(View view) {
setVersionAndCopyrightText(view);
handleViewClicks(view);
handleTextViewClicks(view);
((SpinningImageButton) view.findViewById(R.id.about_logo)).setSpeedListener(this);
return view;
}
private void setVersionAndCopyrightText(View view) {
TextView version = view.findViewById(R.id.about_version);
version.setText(PrefUtils.versionInfo());
TextView copyright = view.findViewById(R.id.about_copyright);
int thisYear = Calendar.getInstance().get(Calendar.YEAR);
copyright.setText(String.format(Locale.getDefault(), getString(R.string.about_copyright), thisYear));
}

To me, reading this function out seems like an improved explanation of what the function is doing.

View click handling gets setup here.

private void handleViewClicks(View view) {
ClickHandler.handleClick(view.findViewById(R.id.about_blog), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_BLOG_URL));
ClickHandler.handleClick(view.findViewById(R.id.about_help), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_HELP_URL));
ClickHandler.handleClick(view.findViewById(R.id.about_contribute), v ->
browseOrToastOnError(view.getContext(), URL_CONTRIBUTE));
ClickHandler.handleClick(view.findViewById(R.id.about_careers), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_HIRING_HANDLE));
ClickHandler.handleClick(view.findViewById(R.id.about_twitter), v ->
browseOrBrowseOnException(
TWITTER_APP_URI + SIMPLENOTE_TWITTER_HANDLE,
TWITTER_PROFILE_URL + SIMPLENOTE_TWITTER_HANDLE
));
handleAboutStoreClick(view);
}

Here’s the final, refactored result.

package com.automattic.simplenote;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.text.Html;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import com.automattic.simplenote.utils.BrowserUtils;
import com.automattic.simplenote.utils.HtmlCompat;
import com.automattic.simplenote.utils.PrefUtils;
import com.automattic.simplenote.utils.ThemeUtils;
import com.automattic.simplenote.widgets.SpinningImageButton;
import com.automattic.simplenote.widgets.SpinningImageButton.SpeedListener;
import java.util.Calendar;
import java.util.Locale;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
public class AboutFragment extends Fragment implements SpeedListener {
private static final String PLAY_STORE_URL = "http://play.google.com/store/apps/details?id=";
private static final String PLAY_STORE_URI = "market://details?id=";
private static final String SIMPLENOTE_BLOG_URL = "https://simplenote.com/blog";
private static final String SIMPLENOTE_HELP_URL = "https://simplenote.com/help";
private static final String SIMPLENOTE_HIRING_HANDLE = "https://automattic.com/work-with-us/";
private static final String SIMPLENOTE_TWITTER_HANDLE = "simplenoteapp";
private static final String TWITTER_PROFILE_URL = "https://twitter.com/#!/";
private static final String TWITTER_APP_URI = "twitter://user?screen_name=";
private static final String URL_CALIFORNIA = "https://automattic.com/privacy/#california-consumer-privacy-act-ccpa";
private static final String URL_CONTRIBUTE = "https://github.com/Automattic/simplenote-android";
private static final String URL_PRIVACY = "https://automattic.com/privacy";
private static final String URL_TERMS = "https://simplenote.com/terms";
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_about, container, false);
return setupViews(view);
}
private View setupViews(View view) {
setVersionAndCopyrightText(view);
handleViewClicks(view);
handleTextViewClicks(view);
((SpinningImageButton) view.findViewById(R.id.about_logo)).setSpeedListener(this);
return view;
}
private void setVersionAndCopyrightText(View view) {
TextView version = view.findViewById(R.id.about_version);
version.setText(PrefUtils.versionInfo());
TextView copyright = view.findViewById(R.id.about_copyright);
int thisYear = Calendar.getInstance().get(Calendar.YEAR);
copyright.setText(String.format(Locale.getDefault(), getString(R.string.about_copyright), thisYear));
}
private void handleViewClicks(View view) {
ClickHandler.handleClick(view.findViewById(R.id.about_blog), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_BLOG_URL));
ClickHandler.handleClick(view.findViewById(R.id.about_help), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_HELP_URL));
ClickHandler.handleClick(view.findViewById(R.id.about_contribute), v ->
browseOrToastOnError(view.getContext(), URL_CONTRIBUTE));
ClickHandler.handleClick(view.findViewById(R.id.about_careers), v ->
browseOrToastOnError(view.getContext(), SIMPLENOTE_HIRING_HANDLE));
ClickHandler.handleClick(view.findViewById(R.id.about_twitter), v ->
browseOrBrowseOnException(
TWITTER_APP_URI + SIMPLENOTE_TWITTER_HANDLE,
TWITTER_PROFILE_URL + SIMPLENOTE_TWITTER_HANDLE
));
handleAboutStoreClick(view);
}
private void browseOrBrowseOnException(String url, String exceptionUrl) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), url);
} catch (Exception e) {
BrowserUtils.launchBrowserOrShowError(requireContext(), exceptionUrl);
}
}
private void browseOrToastOnError(Context context, String url) {
browseOrToastOnError(context, url, R.string.no_browser_available);
}
private void browseOrToastOnError(Context context, String url, @StringRes int stringRes) {
try {
BrowserUtils.launchBrowserOrShowError(requireContext(), url);
} catch (Exception e) {
Toast.makeText(context, stringRes, Toast.LENGTH_LONG).show();
}
}
private void handleAboutStoreClick(View view) {
ClickHandler.handleClick(view.findViewById(R.id.about_store), v -> {
String marketUrl = PLAY_STORE_URI + requireActivity().getPackageName();
Intent goToMarket = createMarketIntent(marketUrl);
try {
launchOrDialogBrowserError(goToMarket, marketUrl);
} catch (ActivityNotFoundException e) {
BrowserUtils.launchBrowserOrShowError(requireContext(), marketUrl);
}
});
}
private void launchOrDialogBrowserError(Intent intent, String url) {
if (BrowserUtils.isBrowserInstalled(requireContext())) {
startActivity(intent);
} else {
BrowserUtils.showDialogErrorBrowser(requireContext(), url);
}
}
@NonNull
private Intent createMarketIntent(String url) {
Intent goToMarket = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
goToMarket.addFlags(
Intent.FLAG_ACTIVITY_NO_HISTORY |
Intent.FLAG_ACTIVITY_NEW_DOCUMENT |
Intent.FLAG_ACTIVITY_MULTIPLE_TASK
);
return goToMarket;
}
private void handleTextViewClicks(View view) {
String colorLink = Integer.toHexString(ContextCompat.getColor(requireContext(), R.color.blue_5) & 0xffffff);
ClickHandler.handleClick(
view.findViewById(R.id.about_privacy),
Html.fromHtml(String.format(
getResources().getString(R.string.link_privacy),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>")),
v -> browseOrToastOnError(view.getContext(), URL_PRIVACY)
);
ClickHandler.handleClick(
view.findViewById(R.id.about_terms),
Html.fromHtml(String.format(
getResources().getString(R.string.link_terms),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>")),
v -> browseOrToastOnError(view.getContext(), URL_TERMS)
);
ClickHandler.handleClick(
view.findViewById(R.id.about_california),
Html.fromHtml(String.format(
getResources().getString(R.string.link_california),
"<u><span style=\"color:#",
colorLink,
"\">",
"</span></u>"
)),
v -> viewUriOrToastOnError(view.getContext(), new Intent(Intent.ACTION_VIEW, Uri.parse(URL_CALIFORNIA)))
);
}
private void viewUriOrToastOnError(Context context, Intent intent) {
try {
startActivity(intent);
} catch (Exception e) {
Toast.makeText(context, R.string.no_browser_available, Toast.LENGTH_LONG).show();
}
}
@Override
public void OnMaximumSpeed() {
String[] items = requireActivity().getResources().getStringArray(R.array.array_about);
long index = System.currentTimeMillis() % items.length;
final AlertDialog dialog = new AlertDialog.Builder(requireActivity())
.setMessage(HtmlCompat.fromHtml(String.format(
items[(int) index],
"<span style=\"color:#",
Integer.toHexString(
ThemeUtils.getColorFromAttribute(requireActivity(), R.attr.colorAccent) & 0xffffff
),
"\">",
"</span>"
)))
.show();
final Handler handler = new Handler();
final Runnable runnable = () -> {
if (dialog.isShowing()) {
dialog.dismiss();
}
};
dialog.setOnDismissListener(dialog1 -> handler.removeCallbacks(runnable));
handler.postDelayed(runnable, items[(int) index].length() * 50L);
((TextView) Objects.requireNonNull(dialog.findViewById(android.R.id.message))).setMovementMethod(LinkMovementMethod.getInstance());
}
}
interface ClickHandler {
static void handleClick(@NonNull View view, View.OnClickListener listener) {
view.setOnClickListener(listener);
}
static void handleClick(@NonNull TextView view, CharSequence text, View.OnClickListener listener) {
view.setText(text);
view.setOnClickListener(listener);
}
}

Summary and takeaways

These refactors only reduce the file’s LOC by 10 lines. Even though there isn’t a lot of functionality in this fragment, I think it was worth the effort to make it more readable. It makes it a lot less likely that the next developer will jam some unrelated code into the function named handleViewClicks(). I couldn’t confidently say that about the original onCreateView() method.