Flutter, Android, and Process Death
Detecting process death in Flutter & Android
Back in December 2018, Square launched a shiny new iOS and Android SDK (now 100% Kotlin) called In-App Payments which allows developers to take PCI compliant payments. Since we partnered with Flutter, we also launched with a Flutter plugin on the same day. This sparked joy in my heart because we launched on Android, iOS, and Flutter all at the same time. What made me even more happy was finding out we had an obscure bug. A few days after the launch, we were writing updates to our In-App Payments Flutter plugin when we found a strange issue while testing on Android.
Before explaining the bug, you first need to understand how the In-App Payments SDK works. The In-App Payments SDK works by launching an isolated Activity (card entry activity), processing the information, and providing different ways to handle the result via callbacks. These callbacks will receive a card nonce (a single use token) that can be used to perform a payment or save the credit card nonce for a customer profile. Developers can use these options to process a payment right after we get a nonce, before or after the activity finishes, and tell the app user if the payment went through or the card was saved.
While working on the In-App Payments SDK Flutter example app, my co-worker forced their android emulator to kill their app to simulate process death. The issue occurred when the In-App Payments SDK’s card entry activity was restored on top of the activity stack and process death occurred.
What (roughly) happens after process death
When the activity was restored, they went through the flow, saw a spinner, then a checkmark, and finally the activity closed.
What’s supposed to happen vs What happens after process death
Nothing crashed, but our example app is supposed to show a “success” dialog after the card entry activity finishes. Ahh! it’s just a pop up, so it’s a minor thing right? No way. We’re somehow losing any callbacks any Flutter developer creates.
Say a Flutter developer uses our SDK and creates an order ahead app for their brand new pet store. The sign up flow is where the developer asks a customer to fill out credit card information to save their card on file. Say the process death bug occurs for a customer while they are entering their credit card and the user checks out to process a payment, then the customer will run into trouble trying to check out. This could cause many other issues, but all of them result in a frustrated developer (and customer).
Why does this happen?
First we need to figure out how Flutter interacts with native Android. According to the docs, Android acts as the host while Flutter acts as the client. This would mean that the host and the client must establish a link to each other. We can see how Flutter sets this up by creating a default app or looking at the example app on Github.
The example app’s MainActivity.java
On line 11, we have a GeneratedPluginRegistrant.registerWith(this);
. This must be the line that establishes the connection to any Flutter plugin. If we comment out this line in our Flutter example, the app launches, but is stuck in a loading spinner. We also see the error logs talking about an unrecognized plugin.
MissingPluginException log
To establish the connection, the PluginRegistry must be set up to access the plugin’s implementation. If GeneratedPluginRegistrant.registerWith(this)
isn’t called, then the Flutter library isn’t ever connected and communication is completely shut off. So what happens when process death occurs and this MainActivity
was not on the foreground? Whelp. Flutter and Flutter’s plugin registry is never set up, so Android will think everything is fine, but in reality many things could go wrong.
This happened 100% of the time with our SDK because our SDK launches its own Activity (not an instance of FlutterActivity
) and the process can die when our Activity is at the top of the Activity stack. Process death is easily reproduced by going into adb shell and running kill -9 <pid>
where the <pid>
is the Process ID of the Android app.
Finding a solution
In Android, any app is running on its own process and the OS can kill the app, without warning, when it is in a paused or stopped state.
Typically, we wouldn’t worry about this because the default implementation of Activity.onSavedInstanceState()
will save UI information before process death. This means that after process death, the Activity#onCreate()
’s savedInstanceState
will not be null; which cannot be the only indicator used to detect process death because this will hold true on configuration change. With only a check for savedInstanceState != null
, a simple phone rotation will finish our card entry activity.
Leveraging this information, we need to create something in memory that is always associated with the class until process death. We will need to create an object that will persist on rotation, but will be flushed out on process death.
This sounds like an excellent use-case for a lastCustomNonConfigurationInstance()
:
- Retrieve the non-configuration instance data that was previously returned by
onRetainNonConfigurationInstance()
.
We can use this to retain something about our own state during rotation and since it’s part of the activity, it will reset to null when the activity dies. This means we can override onRetainCustomNonConfigurationInstance()
to return an empty object that we will use as part of our process death detection.
For the purpose of checking if this process was killed on the first onCreate()
after process death, this is a great solution. All we would need to do is to add this code snippet into our Activity:
@Override
protected void onCreate(Bundle savedInstanceState) {
if (savedInstanceState != null && getLastNonConfigurationInstance() == null) {
// Finish Activity
}
}
@Override
protected void onRetainCustomNonConfigurationInstance() {
return new Object();
}
The Object that we return can be anything. Using just an empty Object is to show that we can put anything in there and help us aid for process death. You could already be using this as a way to retain your Dagger component or something as important. By combining these two checks together, then we find an excellent solution for our problem: we will finish out activity after process death to have the Flutter app’s MainActivity
set up anything that it needs. Now our Flutter developers won’t have to worry about missing out on a credit card nonce because of process death.
References: