How to add Flutter modules to native Android project and test it on Codemagic

Nov 11, 2019

Written by Souvik Biswas

In this article, I will be showing how to add Flutter to your new or existing Native Android project and how to test it on Codemagic CI/CD using codemagic.yaml file.

The YAML feature is currently in beta and has the following limitations:

  • Only Android and web app configuration can be exported. The commands for building and code signing iOS apps are currently not generated and you cannot configure iOS publishing in YAML yet.
  • The exported configuration is not identical to the settings in UI and lacks the configuration for some features, such as Stop build if tests fail.

  • YAML configuration cannot be used with apps from custom sources.

I will be starting from the scratch to give you some tips and tricks in the way. So, let us first create a simple Native Android app for this tutorial.

App Overview

App screens
App screens

We will be creating a simple app called Court Counter, which will be a score keeper for a basketball match between two teams.

It will contain two screens:

  1. Splash screen (using Flutter Module)
  2. Counter screen (using Native Android)

Creating a native Android app

  1. Start Android Studio on your system.
  2. Click on Start a new Android Studio project. Android Studio
  3. Choose Empty Activity and click on Next. Choose your project
  4. Now enter the details for the project.

    • Name: CourtCounter
    • Package name: com.example.courtcounter (auto-filled)
    • Save location: ~/Desktop/CourtCounterFlutter/CourtCounter
Project details
Project details

I have added an extra folder to store the native Android project because this will make it easier to add the Flutter module later.

  1. Click on Finish and wait for the initial Gradle sync to complete.

So, the empty native Android project is ready.

Creating Flutter module

The Flutter module should be added as a sibling to the native Android project. Assuming that you have your Android project at path/to/project/nativeApp, where nativeApp is the name of your Android project folder, then you have to create the Flutter module from path/to/project folder.

The steps for creating the Flutter module are as follows:

  1. Go to the Terminal inside your Android Studio IDE.
  2. You will be inside your Android project folder, but we have to add the Flutter module as a sibling to the native app. So, you have to go to the previous folder and use the following command to create a new Flutter module.
$ cd ..

$ flutter create -t module name_of_module
Android Studio
Android Studio

After running this command you will have a flutter module generated in the desired folder.

Android Studio
Android Studio

Adding Flutter module to native Android

First of all, you have to add two compile options sourceCompatibility & targetCompatibility to the app level build.gradle of the native Android app.

Add the following commands:

// In build.gradle(app)

android {

  //...
  compileOptions {
    sourceCompatibility 1.8
    targetCompatibility 1.8
  }
}

There are two ways for making the host app depend on the Flutter module:

  1. Depend on the Android Archive (AAR): This packages your Flutter library as a generic local Maven repository comprised of AARs and POMs artifacts. This allows your team to build the host app without needing to install the Flutter SDK.

  2. Depend on the module’s source code: This enables a one-step build for both your Android project and Flutter project. This is convenient when working on both parts simultaneously, but your team must install the Flutter SDK to build the host app.

In this tutorial, I will be using the second method to demonstrate the procedure of adding Flutter to native Android app.

Follow these steps to add Flutter to the native Android app:

  1. Include Flutter module as a sub-project to the host app’s settings.gradle file by adding the following commands:
include ':app'

setBinding(new Binding([gradle:this]))

evaluate(new File(
    settingsDir.parentFile,
    'name_of_module/.android/include_flutter.groovy'
))
Updated settings.gradle
Updated settings.gradle
  1. Then sync the Gradle by clicking on Sync Now button. This will add some additional code and files to the host app for making it flutter dependent.
After Gradle Sync
After Gradle Sync
  1. Add a dependency to the app level build.gradle of the host app.
// In build.gradle(app)

dependencies {

  //...
  implementation project(':flutter')
}
Gradle dependencies
Gradle dependencies

With this, we have successfully added Flutter to the native Android app.

Building the Android app

Now, let’s start building the UI for the app. I will be using Java for the native part.

In the MainActivity.java file, I will first start with the Splash Screen for the Court Counter app which will be using the Flutter module.

Flutter can be added to the native Android app in two ways:

  1. Using Views (Flutter.createView)
  2. Using Fragments (Flutter.createFragment)

Here, I have used a View to display the Flutter screen within the host app.

View flutterView = Flutter.createView(
                MainActivity.this,
                getLifecycle(),
                "splashRoute"
        );

FrameLayout.LayoutParams frameLayout =
        new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
        );

addContentView(flutterView, frameLayout);

The createView() method takes three arguments:

  • Current Activity name
  • Lifecycle
  • Route name

This Route name will be defined in the Flutter module to display the UI using Flutter.

Then, I have used a Frame Layout which takes the height and width of the whole screen, and displays the Flutter View.

The whole Java code for the splash screen is given below.

public class MainActivity extends AppCompatActivity {

    // After completion of 4000 ms, the next activity will get started.
    private static final int SPLASH_SCREEN_TIME_OUT = 4000;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // This method is used so that splash activity
        // can cover the entire screen.
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);

        // This will bind your MainActivity.class file with activity_main
        setContentView(R.layout.activity_main);

        // Defining a view to display flutter screen
        View flutterView = Flutter.createView(
                MainActivity.this,
                getLifecycle(),
                "splashRoute"
        );

        // Using FrameLayout for the Flutter screen
        FrameLayout.LayoutParams frameLayout =
                new FrameLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.MATCH_PARENT
                );

        addContentView(flutterView, frameLayout);


        new Handler().postDelayed(() -> {

            // This intent will be used to switch to the CounterActivity
            Intent i = new Intent(MainActivity.this,
                    CounterActivity.class);

            // Invoke the CounterActivity
            startActivity(i);

            // The current activity will get finished
            finish();

        }, SPLASH_SCREEN_TIME_OUT);
    }
}

The activity_main.xml will only contain a LinearLayout, code for it is given below.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

</LinearLayout>

The Splash Screen will display for 4000ms and then it will navigate to the CounterActivity class.

The CounterActivity.java is fully native Java code without any Flutter module and it mostly contains the logic for the score keeper.

/**
 * This activity keeps track of the basketball score for 2 teams.
 */
public class CounterActivity extends AppCompatActivity {

    // Tracks the score for Team A
    int scoreTeamA = 0;

    // Tracks the score for Team B
    int scoreTeamB = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_counter);
    }

    /**
     * Increase the score for Team A by 1 point.
     */
    public void addOneForTeamA(View v) {
        scoreTeamA = scoreTeamA + 1;
        displayForTeamA(scoreTeamA);
    }

    /**
     * Increase the score for Team A by 2 points.
     */
    public void addTwoForTeamA(View v) {
        scoreTeamA = scoreTeamA + 2;
        displayForTeamA(scoreTeamA);
    }

    /**
     * Increase the score for Team A by 3 points.
     */
    public void addThreeForTeamA(View v) {
        scoreTeamA = scoreTeamA + 3;
        displayForTeamA(scoreTeamA);
    }

    /**
     * Increase the score for Team B by 1 point.
     */
    public void addOneForTeamB(View v) {
        scoreTeamB = scoreTeamB + 1;
        displayForTeamB(scoreTeamB);
    }

    /**
     * Increase the score for Team B by 2 points.
     */
    public void addTwoForTeamB(View v) {
        scoreTeamB = scoreTeamB + 2;
        displayForTeamB(scoreTeamB);
    }

    /**
     * Increase the score for Team B by 3 points.
     */
    public void addThreeForTeamB(View v) {
        scoreTeamB = scoreTeamB + 3;
        displayForTeamB(scoreTeamB);
    }

    /**
     * Resets the score for both teams back to 0.
     */
    public void resetScore(View v) {
        scoreTeamA = 0;
        scoreTeamB = 0;
        displayForTeamA(scoreTeamA);
        displayForTeamB(scoreTeamB);
    }

    /**
     * Displays the given score for Team A.
     */
    public void displayForTeamA(int score) {
        TextView scoreView = findViewById(R.id.team_a_score);
        scoreView.setText(String.valueOf(score));
    }

    /**
     * Displays the given score for Team B.
     */
    public void displayForTeamB(int score) {
        TextView scoreView = findViewById(R.id.team_b_score);
        scoreView.setText(String.valueOf(score));
    }
}

The code for the activity_counter.xml is given below:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fontFamily="sans-serif-medium"
                android:gravity="center"
                android:padding="16dp"
                android:text="Team A"
                android:textColor="#616161"
                android:textSize="14sp" />

            <TextView
                android:id="@+id/team_a_score"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fontFamily="sans-serif-light"
                android:gravity="center"
                android:paddingBottom="24dp"
                android:text="0"
                android:textColor="#000000"
                android:textSize="56sp" />

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginLeft="24dp"
                android:layout_marginRight="24dp"
                android:onClick="addThreeForTeamA"
                android:text="+3 Points" />

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginLeft="24dp"
                android:layout_marginRight="24dp"
                android:onClick="addTwoForTeamA"
                android:text="+2 Points" />

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginLeft="24dp"
                android:layout_marginRight="24dp"
                android:onClick="addOneForTeamA"
                android:text="Free throw" />
        </LinearLayout>

        <View
            android:layout_width="1dp"
            android:layout_height="match_parent"
            android:layout_marginTop="16dp"
            android:background="@android:color/darker_gray" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fontFamily="sans-serif-medium"
                android:gravity="center"
                android:padding="16dp"
                android:text="Team B"
                android:textColor="#616161"
                android:textSize="14sp" />

            <TextView
                android:id="@+id/team_b_score"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fontFamily="sans-serif-light"
                android:gravity="center"
                android:paddingBottom="24dp"
                android:text="0"
                android:textColor="#000000"
                android:textSize="56sp" />

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginLeft="24dp"
                android:layout_marginRight="24dp"
                android:onClick="addThreeForTeamB"
                android:text="+3 Points" />

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginLeft="22dp"
                android:layout_marginRight="22dp"
                android:onClick="addTwoForTeamB"
                android:text="+2 Points" />

            <Button
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginLeft="22dp"
                android:layout_marginRight="22dp"
                android:onClick="addOneForTeamB"
                android:text="Free throw" />
        </LinearLayout>
    </LinearLayout>

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="32dp"
        android:onClick="resetScore"
        android:text="Reset" />

</RelativeLayout>

This completes the native Android app. Now, we have to add the splash screen UI in Dart using the Flutter module.

Defining the Splash Screen UI using Flutter module

  1. Open the Flutter module folder in another window from Android Studio.
  2. Go to the main.dart file inside the lib folder.
  3. Now, here you will find the Flutter demo counter app code already defined.
  4. Delete the whole code and start from scratch.
  5. First of all, import the dart:ui library and material.dart package.
  import 'dart:ui';

  import 'package:flutter/material.dart';
  1. Here, we have to define the route which is used in the Android app to specify what to show in the Flutter View.

    The name that I had given to the route was splashRoute. I am using a switch case to show the UI defined in MyFlutterView(), otherwise show a default UI with a Text widget.

  void main() => runApp(chooseWidget(window.defaultRouteName));

  Widget chooseWidget(String route) {
  switch (route) {
    // name of the route defined in the host app
    case 'splashRoute':
      return MyFlutterView();

    default:
      return Center(
        child: Text('Unknown'),
      );
  }
}
  1. Now, we have to define the MyFlutterView(). This will be a StatelessWidget which consists of another class named SplashScreen() which has the whole UI defined.
class MyFlutterView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: SplashScreen(),
    );
  }
}

class SplashScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: Container(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Court Counter',
              style: TextStyle(
                color: Colors.orange[800],
                fontSize: 40,
                fontWeight: FontWeight.bold,
              ),
            ),
            Image.asset('assets/images/basketball.jpg'),
          ],
        ),
      ),
    );
  }
}
  1. Also, don’t forget to import the image using pubspec.yaml file.

So, your Flutter module is now ready to use with the native Android app.

Running the Android app with Flutter module

Before running the Android app on your emulator or physical device, verify some of the basic things so that you do not run into any error at the starting of the app.

TIPS:

  1. Make sure that the minSdkVersion is same for both build.gradle file of the Native app and Flutter app.

  2. If you are using androidx in your Native Android app then you have to configure it for the Flutter module also.

    • Otherwise, if you do not require androidx support in your host app, comment out all the code in gradle.properties file.

    • Also, use AppCompatActivity of the android.support.v7.app.AppCompatActivity, instead of the androidx version of it.

To run the Android app on your device:

  1. Go to the Naive Android app project folder in Android Studio.
  2. Make sure that the configuration is set to app in the top bar.
Android Studio
Android Studio
  1. Run the app using the green Play button.

This should run the Android app on your device and use the Flutter module as a dependency in it.

Using Hot Reload & Hot Restart

Though you are adding Flutter as a dependency to the native Android app, but you can still use one of the best features of Flutter, i.e. Hot reload & Hot restart.

To use Hot reload & Hot restart use follow these steps:

  1. Go to your Flutter project from the Android Studio.

  2. Run a command from the Terminal in the IDE, while you are inside the flutter project directory.

     flutter attach
    
  3. Then, go to the Android project from the Android Studio.

  4. Run the app using the green Play button.

  5. Now, if you see the terminal, then you will notice that the connection is successful and you can now use Hot reload & Hot restart for the Flutter module running inside a native Android app.

Building on Codemagic (using YAML)

With the introduction of the codemagic.yaml, it is now much easier to build and test Flutter module on the Codemagic CI/CD platform.

I will show you how to use the Codemagic YAML file for defining the correct pipeline for the Android app using Flutter module.

  1. First of all, commit the code to git.

Make sure that you include both the Android project folder as well as the Flutter module folder in git, in order to do so initialize git from the main folder containing both the parts.

  1. Push it to a GitHub repo.
  2. Now, go to the Codemagic UI.
  3. Login with your GitHub account.
  4. In the Applications dashboard, search for your project and go to it’s Settings.
Applications dashboard
Applications dashboard
  1. Then expand the Advanced configurations (beta) tab.
Advanced configurations
Advanced configurations
  1. From here you can download the initial codemagic.yaml file with some default configurations.
Download configuration
Download configuration
  1. After downloading the file, open it in a code editor.

  2. You will see a default pipeline for any Flutter app is written in the file.

    Default YAML
    Default YAML

    But this pipeline will not work properly in our case, because we are using a Flutter as a dependency to the host Android app. So, running the standalone Flutter app will give us errors. We need to make some modifications to the configuration.

    • Under set up local properties comment, we need to provide the correct Flutter SDK path and add build commands for generating the apk.
    • Delete the commands below this comment and replace with this one:
    # set up local properties
    
    echo "flutter.sdk=$HOME/programs/flutter" > "$FCI_BUILD_DIR/name_of_module/.android/local.properties"
    cd $FCI_BUILD_DIR/nameOfAndroidFolder && ./gradlew assembleDebug
    
    • This will generate the debug apk of the app.
    • To retrieve this debug apk, we need the define the correct path under the artifacts.
    • Under artifacts define this path to retrieve the debug apk:
    artifacts:
        - nameOfAndroidFolder/app/build/**/outputs/**/*.apk
    
    • Replace the nameOfAndroidFolder with the correct Android project folder name.

    The modified codemagic.yaml file will look like this:

Modified codemagic.yaml
Modified codemagic.yaml
  1. Place this codemagic.yaml file in your root project folder along with the Android and Flutter project folders.
After adding YAML file
After adding YAML file
  1. Commit and push it to GitHub.

  2. To use this YAML file in Codemagic UI while building, you have to go to the Settings of the application and click on Start new build.

Project View
Project View
  1. Then click on Select workflow from codemagic.yaml, so the YAML file commands will be used now for building the app.
Select workflow from YAML
Select workflow from YAML
  1. Click on Start new build to proceed with the build using the YAML file.
Start new build
Start new build
  1. This will start the build and after the completion of the build you will get a app-debug.apk as the output.

Testing on Codemagic (using YAML)

Let’s add a very simple widget test for the Flutter module which will just test if the Image and the Text widget is present on the screen.

The code for the test is given below:

// widget_test.dart

void main() {
  Widget makeTestableWidget({Widget child}) {
    return MaterialApp(
      home: child,
    );
  }

  testWidgets('Splash screen test', (WidgetTester tester) async {
    SplashScreen splashScreen = SplashScreen();

    await tester.pumpWidget(makeTestableWidget(child: splashScreen));


    expect(find.text('Court Counter'), findsOneWidget);
    expect(find.byType(Image),findsOneWidget);


    print('Found app title text.');
    print('Found splash screen image.');
  });
}

To test it on Codemagic CI/CD, we have to add one more line to the codemagic.yaml file.

We have to run the test from the Flutter project folder. So, for doing this using the YAML, add the following line to the file.

cd $FCI_BUILD_DIR/name_of_module && flutter test

The complete codemagic.yaml file will look like this.

After adding test
After adding test

Conclusion

The codemagic.yaml file makes it a lot simpler to build and test Flutter module on Codemagic CI/CD. You have to write the YAML configuration file once and then you can use it for all your CI/CD builds for this project. It also gets auto-detected from your GitHub, so you just have to push the modified codemagic.yaml file to GitHub if you want to apply any build configuration changes.

The official documentation for Codemagic YAML is available here.

You can find the GitHub repo for this project in this link.

Codemagic CI for Flutter