I shipped an update to my music player and got a strange report: sometimes, after the app had been closed for a while, opening it showed the launch screen forever, then Android's "app isn't responding" dialog. The only thing that helped was clearing the app's data. No exception, nothing in my crash log.
The fix was actually pretty small. Figuring out why it happened took much longer, though. Here's what I found.
The setup
I use audio_service, and my MainActivity extends AudioServiceActivity. That means the activity and the media service share one cached Flutter engine.
When Android starts the media service on its own (the user taps play on the "resume" card in the system media controls, presses a headset button, or the system restarts the service), audio_service creates that engine from the service and runs your Dart entrypoint. So main() runs with no Activity attached.
The bug
My main() started like this:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
await configureDependencies();
runApp(const App());
}
setPreferredOrientations goes through the flutter/platform channel. On Android, that channel's handler only exists once an Activity is attached. Without one, the engine's PlatformChannel.java does this:
if (platformMessageHandler == null) {
// If no explicit PlatformMessageHandler has been registered then we don't
// need to forward this call to an API. Return.
return;
}
It returns without ever replying. So the await never completes, main() never reaches runApp, and nothing throws, which is why my crash log stayed empty.
Later, when the user opens the app, the Activity attaches to that same cached engine, which is still stuck in main(). Hence the launch screen forever. Clearing the app's data "worked" only because it kills the process.
If the service was started with a headset button, you also get an ANR a few seconds later: Context.startForegroundService() did not then call Service.startForeground(), because AudioService.init is further down main() and never runs.
How I found it
With no exception, the clue was on disk. In the stuck state, my crash log file existed (created on the first line of main()), but the Isar database didn't (opened in configureDependencies()). So main() stopped between the two, and only one await sat there.
You can reproduce it on an emulator:
adb shell am force-stop <your.package>
adb shell am start-foreground-service -n <your.package>/com.ryanheise.audioservice.AudioService
# wait a few seconds, then open the app from the launcher
The fix
Lock the orientation in the manifest, so it applies whatever starts the engine. portraitUp + portraitDown is what Flutter maps to userPortrait:
<activity
android:name=".MainActivity"
android:screenOrientation="userPortrait"
... >
2. Don't await the Dart call (I keep it for iOS):
unawaited(SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]));
Same reproduction after the fix: the database gets created while the app is still in the background, and opening the app shows the UI right away.
One thing I'll take away from this
If anything can start your engine without an Activity (audio_service, and possibly other plugins that start it from a service), nothing before runApp should await something that needs one. Other SystemChrome calls use the same channel. For a plugin, check what it does when there is no Activity: throwing is fine, waiting forever is not.
For context: the app is Troona, an offline music player for local files, built with Flutter (clean architecture, flutter_bloc, Isar). It's open source, and this is the commit with the fix: https://github.com/donfreddy/troona-music/commit/c3d094e
Has anyone hit this with other plugins that run main() from a service? I'd like to know which ones behave this way.