Adapt your Flutter app to Android 13

Adapt your Flutter app to Android 13

Android 13 is the latest major Android version released this year and it comes with some behavioral changes that will force us to make some adaptations to our app to ensure it works properly.

We already have at our disposal this informative document about these changes and links of interest to understand in more detail what they entail. However, in this article I want to take a more pragmatic approach through a simple example.

What I am going to present to you is an application running on Android 12. We are going to see what it does and what happens when we run it on Android 13. Once we have seen what happens, we will make the appropriate adjustments so that it works correctly on the latest version of Android.

📽 Video version available on YouTube and Odysee

Note: In this article I am going to focus basically on the changes related to reading files on disk and the new notifications permission, since they are functionalities that the vast majority of applications have. You can see the rest of the changes in the official documentation.

The example app

Our app will consist of the following:

  • A floating button that when pressed will ask us for permission to read files
  • Once the permission is granted, we can choose a photo and it will be painted on the screen
  • Additionally, a notification will be displayed indicating that the photo has been uploaded correctly

app_preview.gif

We will use file_picker to be able to select files and permission_handler to request the system permissions. To show notifications we will use flutter_local_notifications. The pubspec.yaml file looks like this:

name: adapt_android_13
description: Adapt Android 13
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=2.18.2 <3.0.0'

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  file_picker: ^5.2.0+1
  permission_handler: ^10.2.0
  flutter_local_notifications: ^12.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0

flutter:
  uses-material-design: true

Let's quickly see the code that compose this application:

FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
    FlutterLocalNotificationsPlugin();
const AndroidInitializationSettings initializationSettingsAndroid =
    AndroidInitializationSettings('ic_notification');

const InitializationSettings initializationSettings = InitializationSettings(
  android: initializationSettingsAndroid,
);

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await flutterLocalNotificationsPlugin.initialize(initializationSettings,
      onDidReceiveNotificationResponse: (NotificationResponse response) {});

  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Adapt Android 13',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MainScreen(),
    );
  }
}

We first initialize the notifications using the flutter_local_notifications plugin. We then launch our app encapsulated in the App widget.

class _MainScreenState extends State<MainScreen> {
  String? _filepath;

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Adapt Android 13'),
      ),
      body: _filepath != null
          ? Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SizedBox(
                    width: 250.0,
                    height: 250.0,
                    child: Image.file(File(_filepath!)),
                  ),
                ],
              ),
            )
          : Container(),
      floatingActionButton: FloatingActionButton(
        onPressed: _pickFile,
        tooltip: 'Pick image',
        child: const Icon(Icons.add),
      ),
    );
  }

  Future<void> _pickFile() async {
    final result = await Permission.storage.request();

    if (result == PermissionStatus.granted) {
      final FilePickerResult? result =
          await FilePicker.platform.pickFiles(type: FileType.image);

      final path = result?.files.single.path;

      if (path != null) {
        _postNotification();
        setState(() {
          _filepath = path;
        });
      }
    }
  }

  Future<void> _postNotification() async {
    const AndroidNotificationDetails androidNotificationDetails =
        AndroidNotificationDetails(
      'default_notification_channel_id',
      'Default',
      importance: Importance.max,
      priority: Priority.max,
    );
    const NotificationDetails notificationDetails =
        NotificationDetails(android: androidNotificationDetails);
    await flutterLocalNotificationsPlugin.show(
        0, 'Image successfully loaded', '', notificationDetails);
  }
}

In our main screen we have a state variable _filepath, which will store the path to the chosen file. When the user clicks the button, the _pickFile() method will be called, which will take care of requesting permission to read files and will allow us to choose a file of type image.

Once selected, the status is updated and the picture is displayed on the screen, and the _postNotification() method is called to publish the informative notification.

This code snippet works correctly in versions before Android 13, let's see what happens if we run it in the new version of Android:

Android 13 changes

If we run this app on a device with Android 13 we will see that it works more or less the same as before, except that now, once the image is selected, it asks us for permission to show notifications.

notifications_permission.png

Although the app continues to work, we have the problem that it is the system that is choosing the moment to ask for permission to show notifications, when ideally we should be the ones who decide when it is requested and if any explanatory text must be shown before.

The problem is aggravated if we target Android 13:

defaultConfig {
    applicationId "com.example.adaptandroid13"
    minSdkVersion 22
    targetSdkVersion 33 //Target Android 13
    versionCode flutterVersionCode.toInteger()
    versionName flutterVersionName
    multiDexEnabled true
}

Now when pressing the floating button we are not even asked for permission to read files. Why is this happening?

Android 13 incorporates a granular permission system when it comes to reading files. While before it was enough to have the READ_EXTERNAL_STORAGE permission granted to read any file from external storage, now we will have to specifically define what type of files we intend to read.

In this app we want to read images, so we will ask for the READ_MEDIA_IMAGES permission. If we wanted to read videos we would ask for READ_MEDIA_VIDEO and if we wanted audio files we would have READ_MEDIA_AUDIO.

Let's make the following adjustments to the AndroidManifest file to resolve this issue:

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

Through this change we are indicating to the system that for Android 12 or lower we continue to depend on READ_EXTERNAL_STORAGE, while from Android 13 we will use the new granular permissions system.

Other change that Android 13 brings is the need to have explicit permission from the user to display notifications, as has been the case in iOS for a long time.

Let's declare that permission in AndroidManifest:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

Now we are going to modify _pickFile() so that in versions before Android 13 it asks for the storage permission and in later versions the new notification and image permissions, to do this, first install device_info_plus to be able to know what version of Android is running on the device:

flutter pub add device_info_plus

Now modify _pickFile() as follows:

Future<void> _pickFile() async {
  final androidInfo = await DeviceInfoPlugin().androidInfo;
  late final Map<Permission, PermissionStatus> statusess;

  if (androidInfo.version.sdkInt <= 32) {
    statusess = await [
      Permission.storage,
    ].request();
  } else {
    statusess = await [Permission.photos, Permission.notification].request();
  }

  var allAccepted = true;
  statusess.forEach((permission, status) {
    if (status != PermissionStatus.granted) {
      allAccepted = false;
    }
  });

  if (allAccepted) {
    final FilePickerResult? result =
        await FilePicker.platform.pickFiles(type: FileType.image);

    final path = result?.files.single.path;

    if (path != null) {
      _postNotification();
      setState(() {
        _filepath = path;
      });
    }
  }
}

And with this last change we can now choose an image, it is displayed on the screen and the informative notification is also shown.

Conclusion

In this article I have made a simple review of two of the behavioral changes that Android 13 incorporates: the new granular permission system for reading files and the new permission to be able to show notifications.

In addition to these two, I recommend that you read the official documentation to see these changes in more detail as well as to see the rest of the changes that this version of Android incorporates since they may affect the operation of your application.

You can check the source code of the projecte used in this article here.

Thanks for reading this far, happy coding!

Did you find this article valuable?

Support David Serrano by becoming a sponsor. Any amount is appreciated!