Skip to main content
  1. Posts/

Gluing Flutter and Live Activities: Part 1 - Android

·14 mins· loading · loading · ·
Technical Flutter Dart
Table of Contents

1. What are Live Activities?
#

First, it’s worth understanding what Live Activities are and how Apple sees the concept.

A live activity displays up-to-date information from your app, allowing people to see at a glance the progress of an activity, event, or task.

In other words, it’s a notification that dynamically changes its content based on events that occur. For example, you can show delivery status updates, track the progress of a task, or even the current score in a sports match. Such notifications are always relevant and informative without the user having to open the app.

There is no doubt that this is incredibly convenient. Unfortunately, however, not every platform offers this functionality by default. Live Activities were originally an Apple exclusive and were implemented through the ActivityKit framework. But this part is about Android, and it doesn’t provide a similar mechanism out of the box.

This is where RemoteViews comes to the rescue - a class in Android that describes a hierarchy of views that can be displayed in another process. This hierarchy is created based on XML markup, and RemoteViews provides basic operations to modify the contents of these views. Simply put, it’s a handy tool for dynamically updating notification content, allowing you to change UI elements such as text, progress bar and images in real time.

But who gives a theory without an example? :D To be clear, let’s break down the process of creating such functionality on mock data. The goal I set for myself will look something like this:

Demo of Android Live Activity

2. Create your own analog of Live Activity
#

Step 1: Create data
#

First, we need to define what data we will use in these notifications. Therefore, it is logical to start by creating a separate class that will be responsible for managing this data.

class LiveActivityModel {
  int stage;
  int minutesToDelivery;
  int stagesCount;

  LiveActivityModel({
    required this.stage,
    required this.minutesToDelivery,
    required this.stagesCount,
  });

  factory LiveActivityModel.fromJson(Map<String, dynamic> json) {
    return LiveActivityModel(
      stage: json['stage'] as int,
      minutesToDelivery: json['minutesToDelivery'] as int,
      stagesCount: json['stagesCount'] as int,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'stage': stage,
      'minutesToDelivery': minutesToDelivery,
      'stagesCount': stagesCount,
    };
  }
}

Step 2: Create links #

To set up the communication mechanism between the Flutter application and the native Android part, you need to create a MethodChannel. This will allow sending commands from Flutter to the native application and receiving responses from Android.

To manage notifications, let’s create the LiveActivityAndroidService class, which will contain all the methods to interact with the native application part. In this class, we will define methods to start, refresh, end and terminate notifications.

Besides, we will convert the data into JSON at once, so that it can be accessed by keys in the Android part.

class LiveActivityAndroidService {
  // It's important to use the same name everywhere!
  final MethodChannel _method =
      const MethodChannel("flutterAndroidLiveActivity");

  Future<void> startNotifications({required LiveActivityModel data}) async {
    try {
      await _method.invokeMethod("startDelivery", data.toJson());
    } on PlatformException catch (e) {
      throw PlatformException(code: e.code);
    }
  }

  Future<void> updateNotifications({required LiveActivityModel data}) async {
    try {
      await _method.invokeMethod("updateDeliveryStatus", data.toJson());
    } on PlatformException catch (e) {
      throw PlatformException(code: e.code);
    }
  }

  Future<void> finishNotifications({required LiveActivityModel data}) async {
    try {
      await _method.invokeMethod("finishDelivery", data.toJson());
    } on PlatformException catch (e) {
      throw PlatformException(code: e.code);
    }
  }

  Future<void> endNotifications({required LiveActivityModel data}) async {
    try {
      await _method.invokeMethod("endNotifications");
    } on PlatformException catch (e) {
      throw PlatformException(code: e.code);
    }
  }
}

To make sure that not only Flutter knows how to handle these requests, we need to notify Android as well. To do this, we will override the configureFlutterEngine method in the Android part of the application (android\app\src...\MainActivity.kt), where we will link Flutter with the corresponding functions in the native part.

class MainActivity : FlutterActivity() {
    // The same channel we specified in our class for methods  
    private val CHANNEL = "flutterAndroidLiveActivity"
    
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            if (call.method == "startDelivery") {
                // ...Processing of “startDelivery” method call
            } else if (call.method == "updateDeliveryStatus") {
                // ...Processing of “updateDeliveryStatus” method call
            } else if (call.method == "finishDelivery") {
                // ...Processing of “finishDelivery” method call
            } else if (call.method == "endNotifications") {
                // ...Processing of “endNotifications” method call
            }
        }
    }
}

Without noticing, we have already made a third to develop our Live Activity. Now the most interesting part :>

Step 3: Smoothly diving into the nativ
#

To lay the framework of our most default notification we need to create it. The file should be in android\app\src\main\res\layout with the extension xml.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">
    <!-- Text with order status -->
    <TextView
        android:id="@+id/order_status"  
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Your order is being processed"  
        android:textSize="16sp"
        android:textColor="#000000"
        android:layout_marginBottom="8dp" />
    <!-- Text with stage status -->
    <TextView
        android:id="@+id/stage_status"  
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Stage status 1/4"  
        android:textSize="16sp"
        android:textStyle="bold"
        android:shadowColor="#AA000000"
        android:textColor="#000000"
        android:layout_marginBottom="8dp"
        android:gravity="start" />
    <!-- Image (delivery stage icon) -->
    <ImageView
        android:id="@+id/image_stage"  
        android:layout_width="wrap_content"
        android:layout_height="100dp" 
        android:layout_marginBottom="8dp"
        android:src="@drawable/stage1"  
    />
</LinearLayout>

As mentioned earlier our entire notification is essentially a dynamically changing XML markup content. So it’s very good if you know it. If not, google chatgpt to the rescue) Here I’ll break down the key points:

android:id (for text) and android:src (for image) are two key attributes that we will be working with almost all the time. They create a connection between RemoteViews and our markup, allowing us to dynamically update notification content and interact with UI elements. While text is simple - we pass a string and it is displayed immediately - images are a bit more complicated. All the images you use need to be placed in android/app/src/main/res/drawable in advance so that you can work with them in notifications. When doing this, it is important to take into account Android’s recommendations:

Android supports bitmap files in the following formats: PNG (preferred), WEBP (preferred, requires API level 17 or higher), JPG (acceptable), GIF (not recommended).

Step 4: The most native:)
#

Like Flutter we will create a class that will be responsible for the whole notification process.

/// For Android version >= 8.0
@RequiresApi(Build.VERSION_CODES.O)
class LiveActivityManager(private val context: Context) {
    
    // Custom notification layout, associated with the package name and XML layout
    private val remoteViews = RemoteViews("com.example.live_acticity_article", R.layout.live_notification)

    // Unique notification ID for displaying and updating it
    private val notificationId = 100

    // High-priority notification channel for important notifications
    private val channelWithHighPriority = "channelWithHighPriority"

    // Default-priority notification channel for less important notifications
    private val channelWithDefaultPriority = "channelWithDefaultPriority"

    // PendingIntent to open MainActivity when interacting with the notification
    private val pendingIntent = PendingIntent.getActivity(
        context, 
        200,
        Intent(context, MainActivity::class.java).apply { 
            flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT 
        },
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    // Service for managing notifications on the device
    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    init {
        createNotificationChannel(channelWithDefaultPriority)
        createNotificationChannel(channelWithHighPriority, true)
    }

    // Function to create notification channels
    private fun createNotificationChannel(channelName: String, importanceHigh: Boolean = false) {
        val importance = if (importanceHigh) NotificationManager.IMPORTANCE_HIGH else NotificationManager.IMPORTANCE_DEFAULT
        val existingChannel = notificationManager.getNotificationChannel(channelName)
        if (existingChannel == null) { 
            val channel = NotificationChannel(channelName, "Delivery Notification", importance).apply {
                setSound(null, null)
                vibrationPattern = longArrayOf(0L)
            }
            notificationManager.createNotificationChannel(channel)
        }
    }

    // Stage 1 - Order has been placed
    private fun onFirstNotification(): Notification {
        return Notification.Builder(context, channelWithHighPriority)
            .setSmallIcon(R.drawable.notification_icon)
            .setContentTitle("Live Notification - Your order has been processed")
            .setContentIntent(pendingIntent)
            .setWhen(3000)
            .setOngoing(true)
            .setCustomBigContentView(remoteViews)
            .build()
    }
    
    // Stage 2 - Order is being assembled
    private fun onGoingNotification(): Notification {
        return Notification.Builder(context, channelWithDefaultPriority)
            .setSmallIcon(R.drawable.notification_icon)
            .setContentTitle("Live Notification - Your order is being collected")
            .setContentIntent(pendingIntent)
            .setOngoing(true)
            .setCustomBigContentView(remoteViews)
            .build()
    }
    
    // Stage 3 - Order is on the way
    private fun onOrderOnTheWayNotification(minutesToDelivery: Int): Notification {
        val minuteString = if (minutesToDelivery > 1) "minutes" else "minute"

        return Notification.Builder(context, channelWithHighPriority)
            .setSmallIcon(R.drawable.notification_icon)
            .setContentIntent(pendingIntent)
            .setOngoing(true)
            .setContentTitle("Live Notification - Your order is on its way and will be delivered in $minutesToDelivery $minuteString")
            .setCustomBigContentView(remoteViews)
            .build()
    }
    
    // Stage 4 - Order delivered
    private fun onFinishNotification(): Notification {
        return Notification.Builder(context, channelWithHighPriority)
            .setSmallIcon(R.drawable.notification_icon)
            .setContentTitle("Live Notification - Your order is delivered")
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
            .setCustomBigContentView(remoteViews) 
            .build()
    }

    // Function to display the first stage notification
    fun showNotification(stage: Int, stagesCount: Int) {
        val notification = onFirstNotification()
        remoteViews.setTextViewText(R.id.order_status, "Your order has been processed and will be collected soon")
        remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")
        notificationManager.notify(notificationId, notification)
    }

    // Function to update and display the second and third stage notifications
    fun updateNotification(minutesToDelivery: Int, stage: Int, stagesCount: Int) {
        val minuteString = if (minutesToDelivery > 1) "minutes" else "minute"
        
        when (stage) {
            2 -> {
                remoteViews.setTextViewText(R.id.order_status, "Your order is being assembled and will be shipped to you soon")
                remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage2)
            }
            3 -> {
                remoteViews.setTextViewText(R.id.order_status, "Your order is on its way and will be delivered in $minutesToDelivery $minuteString")
                remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage3)
            }
        }
        remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")

        val notification: Notification? = when (stage) {
            2 -> {
                onGoingNotification()
            }
            3 -> {
                onOrderOnTheWayNotification(minutesToDelivery)
            }
            else -> null
        }
        
        if (notification != null) {
            notificationManager.notify(notificationId, notification)
        } else {
            println("Error: Notification is null.")
        }

        notificationManager.notify(notificationId, notification)
    }

    // Function to display the fourth stage notification
    fun finishDeliveryNotification(stage: Int, stagesCount: Int) {
        val notification = onFinishNotification()
        remoteViews.setTextViewText(R.id.order_status, "Your order is delivered. Enjoy your purchase!")
        remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage4)
        remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")
        notificationManager.notify(notificationId, notification)
    }

    // Function to remove channels when the notification lifecycle ends (just hides it)
    fun endNotification() {
        val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.deleteNotificationChannel(channelWithHighPriority)
        notificationManager.deleteNotificationChannel(channelWithDefaultPriority)
        remoteViews.setViewVisibility(R.id.order_status, View.GONE)
    }
}

If you were scared, don’t worry - now let’s break down the main points in this, albeit relatively repetitive, but important code.

The key point is the remoteViews variable that has been mentioned all along. This object links the markup that will be displayed in notifications to the current data such as order status, image, and text. In this case, remoteViews is initialized via the package name and markup we created in XML beforehand, and allows us to dynamically update the notification content.

// Stage 1 - Order has been placed
private fun onFirstNotification(): Notification {
    // Create a new notification builder with the high-priority channel
    return Notification.Builder(context, channelWithHighPriority)
        // Set a small icon for the notification
        .setSmallIcon(R.drawable.notification_icon)
        // Set the notification title
        .setContentTitle("Live Notification - Your order has been processed")
        // Set the action when the notification is clicked - opens MainActivity
        .setContentIntent(pendingIntent)
        // Set the flag to keep the notification on the screen
        .setOngoing(true)
        // Use a custom layout for the notification, linked to remoteViews
        .setCustomBigContentView(remoteViews)
        // Build and return the notification
        .build()
}

onFirstNotification() and similar private functions are responsible for creating the notification “skeleton” for the Live Activity, which shows the order status. We customize it using Notification.Builder, adding an icon, title and action when clicked. To keep the notification from disappearing, we use setOngoing(true), and binding via setCustomBigContentView will allow us to change the content of this notification in the future.

// Function to display the first-stage notification
fun showNotification(stage: Int, stagesCount: Int) {
    val notification = onFirstNotification()
    
    remoteViews.setTextViewText(R.id.order_status, "Your order has been processed and will be collected soon")
    /* 
    remoteViews.setImageViewResource(R.id.image_stage, R.drawable.stage2)
    Normally, we would use this method to update the image, but since 
    the default image is already set for stage 1, no changes are needed.
    */
    remoteViews.setTextViewText(R.id.stage_status, "Stage status $stage/$stagesCount")

    notificationManager.notify(notificationId, notification)
}

And just such public functions take this “skeleton” and fill it with actual data. They update the text fields in remoteViews, for example, to display the current order status and stage of fulfillment. Then they pass the ready notification to the notificationManager to make it appear on the screen or update it if it is already active.

The only one with different logic in its body is this:

fun endNotification() {
    // Get the NotificationManager to manage notifications
    val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    // Delete notification channels with high and default priority
    notificationManager.deleteNotificationChannel(channelWithHighPriority)
    notificationManager.deleteNotificationChannel(channelWithDefaultPriority)

    // Hide UI elements displaying the order status and stage status
    remoteViews.setViewVisibility(R.id.order_status, View.GONE)
    remoteViews.setViewVisibility(R.id.stage_status, View.GONE)
}

As it is easy to guess, the endNotification() function is designed to finalize notifications. It removes notification feeds and hides UI elements displaying order status and stage status, kind of like manual completion or cleanup. We’ll call it when the user hides notifications to avoid unnecessary load on the system and free up resources.

The hard part is behind us! All that’s left is to finalize the native part by completing the methods we just created and linking them to the notification processing logic.

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
      super.configureFlutterEngine(flutterEngine)
      MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
        call, result ->
        if (call.method == "startDelivery") {
            val args = call.arguments<Map<String, Any>>()
            val stage = args?.get("stage") as? Int
            val stagesCount = args?.get("stagesCount") as? Int

            
            if (stage != null && stagesCount != null) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    LiveActivityManager(this@MainActivity).showNotification(stage, stagesCount)
                }
            }
            result.success("Notification displayed")
        } else if (call.method == "updateDeliveryStatus") {
            val args = call.arguments<Map<String, Any>>()
            val minutes = args?.get("minutesToDelivery") as? Int
            val stage = args?.get("stage") as? Int
            val stagesCount = args?.get("stagesCount") as? Int

            if (minutes != null && stage != null && stagesCount != null){
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    LiveActivityManager(this@MainActivity).updateNotification(minutes, stage, stagesCount)
                }
            }
            result.success("Notification updated")
        } else if (call.method == "finishDelivery") {
            val args = call.arguments<Map<String, Any>>()
            val stage = args?.get("stage") as? Int
            val stagesCount = args?.get("stagesCount") as? Int

            if (stage != null && stagesCount != null) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    LiveActivityManager(this@MainActivity)
                        .finishDeliveryNotification(stage, stagesCount)
                }
            }
            result.success("Notification delivered")
        } else if (call.method == "endNotifications") { 
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                LiveActivityManager(this@MainActivity)
                    .endNotification()
            }
            result.success("Notification cancelled")
        }
      }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            ActivityCompat.requestPermissions(this, permissions, 200)
        }
    }

    override fun onStop() {
        super.onStop()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            LiveActivityManager(context).endNotification()
        }
    }

The main thing worth noting is how we have linked Flutter and native Android code through MethodChannel. This channel can be used to pass data from Flutter to Android and back. For example, when we call startDelivery, updateDeliveryStatus, or finishDelivery, we get data such as minutes or total number of delivery stages, and based on that we trigger or update notifications via LiveActivityManager.

SDK version checks are just as important as anything else. For example, before using notification features that are only available in certain versions of Android, you need to make sure that your device supports them. So, if the SDK version is Android 8.0 (Oreo) or higher (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O), you can safely create notification channels. In addition, starting with Android 13 (Tiramisu), there are additional restrictions and changes in notification behavior that are also worth considering during development.

It is also worth paying attention to val args = call.arguments<Map<String, Any»(). This is a key element that allows you to accept data passed from Flutter in JSON format. In our case, this is information about the delivery stage, number of minutes and other parameters needed to send notifications.

Step 5: Rejoice and look at the result

Now we need to finish our work in Flutter and see what we have done.

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  LiveActivityAndroidService liveActivityService = LiveActivityAndroidService();

  LiveActivityModel liveActivityModel =
      LiveActivityModel(stage: 1, minutesToDelivery: 10, stagesCount: 4);

  Timer? timer;

  @override
  void dispose() {
    endNotifications();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        title: const Text("Live activity in Android"),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Center(
            child: ElevatedButton(
              onPressed: () async {
                setState(() {
                  liveActivityModel = LiveActivityModel(
                      stage: 1, minutesToDelivery: 10, stagesCount: 4);
                });
                await liveActivityService
                    .startNotifications(data: liveActivityModel)
                    .then((value) {
                  startNotifications();
                });
              },
              child: const Text("Start Notifications"),
            ),
          ),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () {
              endNotifications();
            },
            child: const Text("End Notifications"),
          )
        ],
      ),
    );
  }

  void startNotifications() {
    timer?.cancel();

    timer = Timer.periodic(const Duration(seconds: 10), (value) async {
      liveActivityModel.stage += 1;
      liveActivityModel.minutesToDelivery -= 3;

      if (liveActivityModel.stage == 2 || liveActivityModel.stage == 3) {
        await liveActivityService.updateNotifications(data: liveActivityModel);
      }

      if (liveActivityModel.stage == 4) {
        await liveActivityService
            .finishNotifications(data: liveActivityModel)
            .then((value) {
          timer?.cancel();
        });
      }
    });
  }

  void endNotifications() {
    timer?.cancel();
    setState(() {
      liveActivityModel =
          LiveActivityModel(stage: 1, minutesToDelivery: 10, stagesCount: 4);
    });
    liveActivityService.endNotifications(data: liveActivityModel);
  }
}

Let’s write a UI that contains two buttons: one is responsible for starting the process of sending notifications, the other - for stopping them and resetting the data. At startup, an instance of LiveActivityModel is created, which stores information about the current delivery stage, the remaining time and the total number of stages. Once the start button is pressed, a timer is created that updates the notification every 10 seconds, changing the delivery stage and time to completion information. When the process reaches the final stage, the notifications automatically end. When the process stops, all data is reset to the initial state. Overall, this is enough to emulate the data and display our “Live Activity”)

3. Summary
#

Although there is no exact analog of Live Activity in Android, what it provides is already good enough and can cover the tasks it was created for. If you have any questions or suggestions for improvement - write in dm! I would also like to say my subscribers in telegram are always aware of all my shenanigans and learn everything first! Subscribe, soon will be another equally interesting article! ;D

kmdshi/flutter-live-activites-sample

…will be added soon

C++
0
0
Author
Bogdan Lipatov
Middle Flutter Developer & Knowledge enjoyer