Creating Cake script for building and deploying Xamarin app: Part 2 building Xamarin app

In this post, I’ll present how to use Cake to build Xamarin app for different environments. You will learn how to update platform specific files (AndroidManifest.xml and Info.plist) and create platform specific packages (APK and IPA).

Introduction

In this post, I’ll present how to use Cake to build Xamarin app for different environments. You will learn how to update platform specific files (AndroidManifest.xml and Info.plist) and create platform specific packages (APK and IPA).

Below you can find previous post from the series:

Preparing build information

For most of the apps, there are couple of properties that need to be set:

  • API url
  • app build number
  • app version
  • app name
  • package name
  • APK file name
  • IPA file name

For these properties, we need a class, that will be used in different Tasks in script:

public class BuildInfo
{
    public string ApiUrl { get; }
    public int BuildNumber { get; }
    public string AppVersion { get; }
    public string AppName { get; }
    public string PackageName { get; }
    public string AndroidAppCenterAppName { get; }
    public string ApkFileName { get; }
    public string IosAppCenterAppName { get; }
    public string IpaFileName { get; }
    public string Environment { get; }

    public BuildInfo(
      string apiUrl, 
      int buildNumber, 
      string appVersion,
      string appName,
      string packageName,
      string androidAppCenterAppName,
      string apkFileName,
      string iosAppCenterAppName,
      string ipaFileName,
      string environment)
    {
        ApiUrl = apiUrl;
        BuildNumber = buildNumber;
        AppVersion = appVersion;
        AppName = appName;
        PackageName = packageName;
        AndroidAppCenterAppName = androidAppCenterAppName;
        ApkFileName = apkFileName;
        IosAppCenterAppName = iosAppCenterAppName;
        IpaFileName = ipaFileName;
        Environment = environment;
    }
}

These properties will have different values depending on environment for which app build is being prepared for. In our Cake script, I have assumed three environments:

  • DEV
  • STAGING
  • PROD

In order to properly set values for these properties based on environment, we can use Setup method that will be executed once before all Tasks:

Setup<BuildInfo>(setupContext => 
{
    var gitVersion = GitVersion();
    var apiUrl = "https://dev.tastyformsapp.com";
    var appName = $"{APP_NAME}.dev";
    var packageName = "com.tastyformsapp.dev";
    var androidAppCenterAppName = "TastyFormsApp/TastyFormsApp-Android-DEV";
    var iosAppCenterAppName = "TastyFormsApp/TastyFormsApp-iOS-DEV";
    var ipaFileName = $"{APP_NAME}.iOS.ipa";
    var currentEnvironment = GetEnvironment();

    if (currentEnvironment == STAGING_ENV)
    {
        apiUrl = "https://staging.tastyformsapp.com";
        appName = $"{APP_NAME}.staging";
        packageName = "com.tastyformsapp.staging";
        androidAppCenterAppName = "TastyFormsApp/TastyFormsApp-Android-staging";
        iosAppCenterAppName = "TastyFormsApp/TastyFormsApp-iOS-staging";
    }
    else if (currentEnvironment == PROD_ENV)
    {
        apiUrl = "https://prod.tastyformsapp.com";
        appName = APP_NAME;
        packageName = "com.tastyformsapp";
    }

    var apkFileName = $"{packageName}-Signed.apk";

    return new BuildInfo(
      apiUrl,
      buildNumber: (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
      appVersion: gitVersion.MajorMinorPatch,
      appName,
      packageName,
      androidAppCenterAppName,
      apkFileName,
      iosAppCenterAppName,
      ipaFileName,
      currentEnvironment);
});

public string GetEnvironment()
{
    if (String.IsNullOrEmpty(environmentArg))
    {
        var gitVersion = GitVersion();
        var branchName = gitVersion.BranchName;

        if (branchName.StartsWith("release/"))
        {
            return STAGING_ENV;
        }
        else if (branchName.Equals("master"))
        {
            return PROD_ENV;
        }
        else
        {
            return DEV_ENV;
        }
    }
    else
    {
        return environmentArg;
    }
}

Setup method is creating instance of BuildInfo class with proper values based on environment.

To detect for which environment values needs to be set we are using method GetEnvironment. If environment is passed as a script argument, then this method will return argument value. Otherwise, script uses GitVersion to detect which Git branch script is being executed on. If name of branch starts with “release/” then we assume this is STAGING environment. If branch name starts with “master” then we assume this is PROD environment. Default environment is DEV for other branches.

GitVersion is also used in Setup method to set value for appVersion property. To set value for this property, we are using value of MajorMinorPatch. GitVersion has a few workflows for Version Incrementing. For example for branch named “release/2.3.4” GitVersion will return value 2.3.4 for property MajorMinorPatch.

One more thing worth to mention is how Setup method sets value for buildNumber. Cake script can be run on any computer in the development team, so we can’t save current build number anywhere. To solve this issue, we simply can set current unix timestamp as our build number. Unix timestamp will be always bigger, so it makes perfect value for build number.

Updating config files

API url for our sample app is being kept in AppSettings file:

namespace TastyFormsApp
{
    public class AppSettings
    {
        #if DEBUG
        
        public const string ApiUrl = "https://dev.tastyformsapp.com";
        
        #else

        public const string ApiUrl = "<%API_URL%>";
        
        #endif
    }
}

If application is build in DEBUG configuration then hard coded url will be used. For other build configurations, we will update url with proper value depending on environment:

Task("UpdateConfigFiles")
  .Does<BuildInfo>(buildInfo =>
  {
      var appSettingsFile = File("TastyFormsApp/AppSettings.cs");
      TransformTextFile(appSettingsFile)
        .WithToken("API_URL", buildInfo.ApiUrl)
        .Save(appSettingsFile);
  });

Task UpdateConfigFiles is using instance of BuildInfo class to get API url. Method WithToken is used to replace key with value.

Android

Updating Android Manifest

Before building an APK, we need to update AndroidManifest.xml file with proper values:

Task("UpdateAndroidManifest")
  .Does<BuildInfo>(buildInfo => 
{
    var androidManifestFilePath = new FilePath(PATH_TO_ANDROID_MANIFEST_FILE);
    var manifest = DeserializeAppManifest(androidManifestFilePath);

    manifest.VersionName = buildInfo.AppVersion;
    manifest.VersionCode = buildInfo.BuildNumber;
    manifest.ApplicationLabel = buildInfo.AppName;
    manifest.PackageName = buildInfo.PackageName;
    manifest.Debuggable = false;

    SerializeAppManifest(androidManifestFilePath, manifest);
});

UpdateAndroidManifest task is using BuildInfo instance to get proper values. AndroidAppManifest adding is used to DeserializeAppManifest and SerializeAppManifest.

Building Android Application Package

To build Android APK, we need to use Cake.Xamarin adding in order to invoke method BuildAndroidApk:

Task("PublishAPK")
  .IsDependentOn("RunUnitTests")
  .IsDependentOn("UpdateConfigFiles")
  .IsDependentOn("UpdateAndroidManifest")
  .Does<BuildInfo>(buildInfo => 
{
    FilePath apkFilePath = null;

    if (buildInfo.Environment == PROD_ENV)
    {
        apkFilePath = BuildAndroidApk(PATH_TO_ANDROID_PROJECT, sign: true, configurator: msBuildSettings => 
        {
            msBuildSettings.WithProperty("AndroidKeyStore", "True")
                           .WithProperty("AndroidSigningKeyAlias", ANDROID_KEYSTORE_ALIAS)
                           .WithProperty("AndroidSigningKeyPass", ANDROID_KEYSTORE_PASSWORD)
                           .WithProperty("AndroidSigningKeyStore", PATH_TO_ANDROID_KEYSTORE_FILE)
                           .WithProperty("AndroidSigningStorePass", ANDROID_KEYSTORE_PASSWORD);
        });
    }
    else
    {
        apkFilePath = BuildAndroidApk(PATH_TO_ANDROID_PROJECT, sign: true);
    }

    MoveAppPackageToPackagesFolder(apkFilePath);
});

After successfully build, we are moving APK to app package folder using method MoveAppPackageToPackagesFolder:

public string MoveAppPackageToPackagesFolder(FilePath appPackageFilePath)
{
    var packageFileName = appPackageFilePath.GetFilename();
    var targetAppPackageFilePath = new FilePath($"{APP_PACKAGE_FOLDER_NAME}/" + packageFileName);

    if (FileExists(targetAppPackageFilePath))
    {
        DeleteFile(targetAppPackageFilePath);
    }

    EnsureDirectoryExists($"{APP_PACKAGE_FOLDER_NAME}");
    MoveFile(appPackageFilePath, targetAppPackageFilePath);

    return targetAppPackageFilePath.ToString();
}

iOS

Updating Info.plist

Before building an IPA, we need to update Info.plist file with proper values:

Task("UpdateIosInfoPlist")
  .Does<BuildInfo>(buildInfo =>
  {
    var plist = File(PATH_TO_INFO_PLIST_FILE);
    dynamic data = DeserializePlist(plist);

    data["CFBundleShortVersionString"] = buildInfo.AppVersion;
    data["CFBundleVersion"] = buildInfo.BuildNumber.ToString();
    data["CFBundleName"] = buildInfo.AppName;
    data["CFBundleDisplayName"] = buildInfo.AppName;
    data["CFBundleIdentifier"] = buildInfo.PackageName;

    SerializePlist(plist, data);
  });

UpdateIosInfoPlist task is using BuildInfo instance to get proper values. Cake.Plist adding is used to DeserializePlist and SerializePlist.

Building IPA

To build iOS IPA we need to use Cake.Xamarin adding in order to invoke method BuildiOSIpa:

Task("PublishIPA")
  .IsDependentOn("RunUnitTests")
  .IsDependentOn("UpdateConfigFiles")
  .IsDependentOn("UpdateIosInfoPlist")
  .Does<BuildInfo>(buildInfo =>
  {
    var buildConfiguration = "Release";

    if (buildInfo.Environment == PROD_ENV)
    {
        buildConfiguration = "AppStore";
    }

    var ipaFilePath = BuildiOSIpa(PATH_TO_IOS_PROJECT, buildConfiguration);
    MoveAppPackageToPackagesFolder(ipaFilePath);
  });

Full script

Here is a full script including Tasks from previous posts in the series:

#addin "Cake.Xamarin"
#addin "Cake.Plist"
#addin "Cake.AndroidAppManifest"
#tool "nuget:?package=GitVersion.CommandLine&amp;version=5.0.1" // Reference older version because newest doesn't work on macOS.

var target = Argument("target", (string)null);
var environmentArg = Argument("env", (string)null);

//====================================================================
// Consts

// Environment
const string DEV_ENV = "dev";
const string STAGING_ENV = "staging";
const string PROD_ENV = "prod";

// General
const string APP_NAME="TastyFormsApp";
const string PATH_TO_SOLUTION = "TastyFormsApp.sln";
const string PATH_TO_UNIT_TESTS_PROJECT = "TastyFormsApp.Tests/TastyFormsApp.Tests.csproj";
const string APP_PACKAGE_FOLDER_NAME = "AppPackages";

// Android
const string PATH_TO_ANDROID_PROJECT = "TastyFormsApp.Android/TastyFormsApp.Android.csproj";
const string PATH_TO_ANDROID_MANIFEST_FILE = "TastyFormsApp.Android/Properties/AndroidManifest.xml";
readonly string PATH_TO_ANDROID_KEYSTORE_FILE = EnvironmentVariable("TASTYFORMSAPP_KEYSTORE_PATH");
readonly string ANDROID_KEYSTORE_ALIAS = EnvironmentVariable("TASTYFORMSAPP_KEYSTORE_ALIAS");
readonly string ANDROID_KEYSTORE_PASSWORD = EnvironmentVariable("TASTYFORMSAPP_KEYSTORE_PASSWORD");

// iOS
const string PATH_TO_IOS_PROJECT = "TastyFormsApp.iOS/TastyFormsApp.iOS.csproj";
const string PATH_TO_INFO_PLIST_FILE = "TastyFormsApp.iOS/Info.plist";

//====================================================================
// Moves app package to app packages folder

public string MoveAppPackageToPackagesFolder(FilePath appPackageFilePath)
{
    var packageFileName = appPackageFilePath.GetFilename();
    var targetAppPackageFilePath = new FilePath($"{APP_PACKAGE_FOLDER_NAME}/" + packageFileName);

    if (FileExists(targetAppPackageFilePath))
    {
        DeleteFile(targetAppPackageFilePath);
    }

    EnsureDirectoryExists($"{APP_PACKAGE_FOLDER_NAME}");
    MoveFile(appPackageFilePath, targetAppPackageFilePath);

    return targetAppPackageFilePath.ToString();
}

//====================================================================
// Class that hold information for current build.

public class BuildInfo
{
    public string ApiUrl { get; }
    public int BuildNumber { get; }
    public string AppVersion { get; }
    public string AppName { get; }
    public string PackageName { get; }
    public string AndroidAppCenterAppName { get; }
    public string ApkFileName { get; }
    public string IosAppCenterAppName { get; }
    public string IpaFileName { get; }
    public string Environment { get; }

    public BuildInfo(
      string apiUrl, 
      int buildNumber, 
      string appVersion,
      string appName,
      string packageName,
      string androidAppCenterAppName,
      string apkFileName,
      string iosAppCenterAppName,
      string ipaFileName,
      string environment)
    {
        ApiUrl = apiUrl;
        BuildNumber = buildNumber;
        AppVersion = appVersion;
        AppName = appName;
        PackageName = packageName;
        AndroidAppCenterAppName = androidAppCenterAppName;
        ApkFileName = apkFileName;
        IosAppCenterAppName = iosAppCenterAppName;
        IpaFileName = ipaFileName;
        Environment = environment;
    }
}

//====================================================================
// Setups script.

Setup<BuildInfo>(setupContext => 
{
    var gitVersion = GitVersion();
    var apiUrl = "https://dev.tastyformsapp.com";
    var appName = $"{APP_NAME}.dev";
    var packageName = "com.tastyformsapp.dev";
    var androidAppCenterAppName = "TastyFormsApp/TastyFormsApp-Android-DEV";
    var iosAppCenterAppName = "TastyFormsApp/TastyFormsApp-iOS-DEV";
    var ipaFileName = $"{APP_NAME}.iOS.ipa";
    var currentEnvironment = GetEnvironment();

    if (currentEnvironment == STAGING_ENV)
    {
        apiUrl = "https://staging.tastyformsapp.com";
        appName = $"{APP_NAME}.staging";
        packageName = "com.tastyformsapp.staging";
        androidAppCenterAppName = "TastyFormsApp/TastyFormsApp-Android-staging";
        iosAppCenterAppName = "TastyFormsApp/TastyFormsApp-iOS-staging";
    }
    else if (currentEnvironment == PROD_ENV)
    {
        apiUrl = "https://prod.tastyformsapp.com";
        appName = APP_NAME;
        packageName = "com.tastyformsapp";
    }

    var apkFileName = $"{packageName}-Signed.apk";

    return new BuildInfo(
      apiUrl,
      buildNumber: (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
      appVersion: gitVersion.MajorMinorPatch,
      appName,
      packageName,
      androidAppCenterAppName,
      apkFileName,
      iosAppCenterAppName,
      ipaFileName,
      currentEnvironment);
});

public string GetEnvironment()
{
    if (String.IsNullOrEmpty(environmentArg))
    {
        var gitVersion = GitVersion();
        var branchName = gitVersion.BranchName;

        if (branchName.StartsWith("release/"))
        {
            return STAGING_ENV;
        }
        else if (branchName.Equals("master"))
        {
            return PROD_ENV;
        }
        else
        {
            return DEV_ENV;
        }
    }
    else
    {
        return environmentArg;
    }
}

//====================================================================
// Cleans all bin and obj folders.

Task("Clean")
  .Does(() =>
{
  CleanDirectories("**/bin");
  CleanDirectories("**/obj");
});

//====================================================================
// Restores NuGet packages for solution.

Task("Restore")
  .Does(() =>
{
  NuGetRestore(PATH_TO_SOLUTION);
});

//====================================================================
// Run unit tests

Task("RunUnitTests")
  .IsDependentOn("Clean")
  .IsDependentOn("Restore")
  .Does(() =>
  {
     var settings = new DotNetCoreTestSettings
     {
         Configuration = "Release",
         ArgumentCustomization = args=>args.Append("--logger trx")
     };

      DotNetCoreTest(PATH_TO_UNIT_TESTS_PROJECT, settings);
  });

//====================================================================
// Updates config files with proper values

Task("UpdateConfigFiles")
  .Does<BuildInfo>(buildInfo =>
  {
      var appSettingsFile = File("TastyFormsApp/AppSettings.cs");
      TransformTextFile(appSettingsFile)
        .WithToken("API_URL", buildInfo.ApiUrl)
        .Save(appSettingsFile);
  });

//==================================================================== Android ====================================================================

//====================================================================
// Update Android Manifest

Task("UpdateAndroidManifest")
  .Does<BuildInfo>(buildInfo =>
{
    var androidManifestFilePath = new FilePath(PATH_TO_ANDROID_MANIFEST_FILE);
    var manifest = DeserializeAppManifest(androidManifestFilePath);

    manifest.VersionName = buildInfo.AppVersion;
    manifest.VersionCode = buildInfo.BuildNumber;
    manifest.ApplicationLabel = buildInfo.AppName;
    manifest.PackageName = buildInfo.PackageName;
    manifest.Debuggable = false;

    SerializeAppManifest(androidManifestFilePath, manifest);
});

//====================================================================
// Publish Android APK

Task("PublishAPK")
  .IsDependentOn("RunUnitTests")
  .IsDependentOn("UpdateConfigFiles")
  .IsDependentOn("UpdateAndroidManifest")
  .Does<BuildInfo>(buildInfo => 
{
    FilePath apkFilePath = null;

    if (buildInfo.Environment == PROD_ENV)
    {
        apkFilePath = BuildAndroidApk(PATH_TO_ANDROID_PROJECT, sign: true, configurator: msBuildSettings => 
        {
            msBuildSettings.WithProperty("AndroidKeyStore", "True")
                           .WithProperty("AndroidSigningKeyAlias", ANDROID_KEYSTORE_ALIAS)
                           .WithProperty("AndroidSigningKeyPass", ANDROID_KEYSTORE_PASSWORD)
                           .WithProperty("AndroidSigningKeyStore", PATH_TO_ANDROID_KEYSTORE_FILE)
                           .WithProperty("AndroidSigningStorePass", ANDROID_KEYSTORE_PASSWORD);
        });
    }
    else
    {
        apkFilePath = BuildAndroidApk(PATH_TO_ANDROID_PROJECT, sign: true);
    }

    MoveAppPackageToPackagesFolder(apkFilePath);
});

//==================================================================== iOS ====================================================================

//====================================================================
// Update iOS Info.plist

Task("UpdateIosInfoPlist")
  .Does<BuildInfo>(buildInfo =>
  {
    var plist = File(PATH_TO_INFO_PLIST_FILE);
    dynamic data = DeserializePlist(plist);

    data["CFBundleShortVersionString"] = buildInfo.AppVersion;
    data["CFBundleVersion"] = buildInfo.BuildNumber.ToString();
    data["CFBundleName"] = buildInfo.AppName;
    data["CFBundleDisplayName"] = buildInfo.AppName;
    data["CFBundleIdentifier"] = buildInfo.PackageName;

    SerializePlist(plist, data);
  });

//====================================================================
// Publish iOS IPA

Task("PublishIPA")
  .IsDependentOn("RunUnitTests")
  .IsDependentOn("UpdateConfigFiles")
  .IsDependentOn("UpdateIosInfoPlist")
  .Does<BuildInfo>(buildInfo =>
  {
    var buildConfiguration = "Release";

    if (buildInfo.Environment == PROD_ENV)
    {
        buildConfiguration = "AppStore";
    }

    var ipaFilePath = BuildiOSIpa(PATH_TO_IOS_PROJECT, buildConfiguration);
    MoveAppPackageToPackagesFolder(ipaFilePath);
  });

//====================================================================

RunTarget(target);

Summary

The script with sample Xamarin.Forms app project is already available on my GitHub. In the next post, I will show how to deploy Xamarin app to App Center, Google Play Internal and Test Flight.

Leave a Reply

Your email address will not be published. Required fields are marked *