π About: IOTA for Flutter
A Tutorial to build modern apps using IOTA, the next generation Distributed Ledger Technology
Hey there!!
Ready to add some sparkle to your app development? Stay here, where we back the IOTA Mainnet and the Shimmer Network, the staging network of IOTA! Applying this approach, developers can effortlessly incorporate the Stardust Protocol version into their projects (and let's be honest, who doesn't love a little shimmer and stardust in their code)? So why wait? Say hello to Flutter and Rust and join me on this exciting journey and let us make your apps shine!
Since the Stardust protocol upgrade on the IOTA Mainnet on October 4th, 2023, developers can leverage the latest enhancements not only in the Shimmer network but also on the IOTA Mainnet. π Β Stardust protocol upgrade
Background
Originally I was searching for a workflow to build real-world products in the form of mobile and desktop applications backed by the Distributed Ledger Technology of IOTA. My goal was and is to facilitate the adoption of IOTA.
Just to clarify, every time I mention IOTA, I'm also referring to Shimmer.
I wanted to create a flexible solution that would allow developers to use a reduced technology stack for their projects. With IOTA for Flutter, I believe we've achieved that goal, and I'm excited to share it with the world. And believe me: Coming from Java and JavaScript frameworks I was not at all familiar with Flutter and Rust in the beginning.
I remember those days - the endless hours of debugging, the frustration of hitting the same roadblocks over and over again. But after four long months of trial and error, I finally found the solution with IOTA for Flutter.
This documentation is a condensed tutorial that summarizes everything I learned during that grueling proof-of-concept process. And now, I want to share that knowledge with you. Think of my tutorial as your personal guide to app development for IOTA - a way to save you time and headaches, so you can focus on what really matters.
Now, before you get too excited, I should warn you that app development is still app development, and there's always a chance that things might not work out the way you planned. Trust me, I've been there. But hey, that's half the fun, right? The thrill of the chase, the excitement of finally figuring out that pesky bug - it's all part of the process. Just don't blame me if you find yourself cursing my name at three in the morning when something doesn't work quite right. But hey, if it was easy, everyone would be doing it, right? So let's roll up our sleeves and get to work!
IOTA for Flutter is a sponsored project by the Tangle Community Treasury. The funding allows me to organize my collected notes and prepare them for you. At this point, a big thank you to everyone involved!
You know Flutter but not IOTA?
By exploring IOTA, a Distributed Ledger Technology (DLT), you can expand your knowledge and explore the exciting possibilities of new use cases beyond the limitations of Web 2.0. This technology has the potential to revolutionize various industries, such as supply chain management, smart city infrastructure, and digital identity verification.
IOTA is unique in that it is a permissionless, decentralized system designed to enable secure and feeless exchange of value and data transfer between connected actors. Unlike traditional blockchain-based systems, IOTA does not rely on miners to validate transactions. Instead, each transaction verifies two previous transactions, creating a web-like network of transactions called the Tangle. This makes IOTA's DLT highly scalable and able to handle large amounts of transactions with zero fees.
If you're a Flutter developer who's not familiar with IOTA or Shimmer (which is the staging network of IOTA), don't worry! Since October 2023, a series of outstanding blog posts has been emerging, explaining IOTA from the outset and progressively delving deeper over time. Start with π Β Digital Autonomy for Everyone: The Future of IOTA
There's no substitute for hands-on experience, and that's where IOTA for Flutter comes in. While the official websites for IOTA and Shimmer provide a great starting point, the best way to truly understand how to use these technologies in your Flutter projects is to give it a try yourself.
IOTA for Flutter provides a tutorial-style documentation that walks you through the process of integrating IOTA and Shimmer step-by-step. So don't be afraid to jump in and give it a shot! Who knows, you might just surprise yourself with what you can accomplish.
π Β Official Shimmer website
π» What you'll get
Explore the world of Distributed Ledger Technology with a hands-on guide using this Github page, companion repositories, and YouTube videos
Seamless User Interfaces for human interaction
The focus of this project is to provide a user interface or interaction capability with IOTA for humans. While IOT devices, machines or smart contracts can also interact with IOTA, the primary goal here is to create a seamless experience for people to connect and engage with IOTA's Layer 1 network.
The main goal is achieved by building mobile and desktop apps. The focus is on the target platforms that Flutter supports, with the caveat that the proof-of-concept has only been tested on iOS, Android, and macOS.
Rust plays a crucial role in IOTA for Flutter, too. All of IOTA's libraries are written in Rust and are referred to as the Single Source of Truth. I will explain how these Rust libraries are used as a dependency in a custom library which is cross-compiled and integrated into Flutter.
Tutorial Structure: Overview, Fundamentals, and Practical Chapters
The IOTA for Flutter tutorial is divided into several sections, including a fundamentals section for preparing what you need, an overview for getting the key concept, and several practical chapters that focus on building real-world products.
To make it easier to follow along with the written text, each practical chapter has a corresponding repository on GitHub. I also provide some videos to demonstrate the resulting app, the workflow and guide users through important steps, so you can watch over my shoulder as I work through the examples.
The Three Pillars of the Tutorial: Github Page, Repositories and Videos
The practical chapters are structured as follows: first, we start with an introductory chapter to build an app with Flutter and Rust without IOTA. This will introduce the Flutter-Rust-Bridge, the glueing part that brings Flutter and Rust together. Then, we dive into building a simple IOTA-powered app, followed by a comprehensive "Shimmer playground" app to demonstrate all Rust libraries in one app.
As a bonus, I provide the code for the MQTT Chat App. In this chapter, I assume that you have gained enough knowledge and experience from the previous practical chapters.
β When you are JS developer
Flutter vs. JS Frameworks
Hold on to your keyboards because it's time for a chapter that will be more exciting than a cat chasing a laser pointer. We're about to explore how familiar Flutter looks when you compare it to JavaScript frameworks and what other reasons there are top use it - and let me tell you, it's going to be so much fun you might want to put on a party hat.
Well, I know some of you might be thinking, βWait a minute, I'm a JavaScript developer, why should I care about Flutter?β Well, let me ask you, have you ever heard the phrase βDiversity is the spice of life " heard? Yes, that also applies here. By expanding your skills and learning more about Flutter, you might discover a whole new world of possibilities and solutions to your coding challenges.
So get ready to rock and roll, code warriors, because it's time to expand your programming horizons and discover why Flutter should play a part in your life.
When I first started learning Flutter, I had zero prior knowledge. To give myself a boost of motivation, I turned to videos to get a better understanding of what Flutter is all about. Watching videos not only helped me to get started with Flutter, but also gave me the confidence to continue learning and building my own apps.
Fun factor
Google and others provide tools and tons of material that make working with Flutter fun. Here are two examples.
-
The YouTube Channel of Mitch Koko - he creates stunning user interfaces:
π Β π±I create apps & tutorials about creating appsπ¨π½βπ»
-
The official Flutter YouTube Channel - you'll find amongst others:
-
Packages - widgets for all purposes:
π Β The official package repository for Dart and Flutter apps
Flutter is like a treasure trove of creative possibilities, just waiting for you to unlock its secrets. With Flutter, you can build stunning user interfaces, craft intricate animations and interactions, and even dip your toes into the wild and wacky world of game development. You'll discover lots of sources of inspiration that will leave you buzzing with excitement.
Later on, you will realize how interacting with Rust libraries empowers you to develop potent products - precisely what you are aiming for.
Everything as code!?
In Flutter, a component is a self-contained, reusable piece of a user interface. It is a widget that can be used in a Flutter app to create a specific visual and/or interactive element, such as a button, text field, or image. A component in Flutter can be composed of other components, making it possible to create complex UIs by assembling smaller, reusable parts.
Doesn't that sound familiar to you!?
Let's compare a single Flutter widget with a Vue.js component. In Flutter you write the whole component as Dart code:
import 'package:flutter/material.dart';
class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
print('Button clicked');
},
child: Text(
'Klick mich!',
style: TextStyle(color: Colors.white),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.green),
shape: MaterialStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
);
}
}
In Vue.js it looks like:
<template>
<button @click="handleClick" class="my-button">Klick mich!</button>
</template>
<script>
export default {
methods: {
handleClick() {
console.log('Button clicked');
}
}
}
</script>
<style>
.my-button {
background-color: green;
color: white;
padding: 10px;
border: none;
border-radius: 20px;
cursor: pointer;
}
</style>
Yes, yes, yes, I hear some of you saying that everything as code isn't nice.
So where is the benefit?
Flutter's approach of writing everything as code provides a more streamlined and cohesive development experience compared to the traditional separation of HTML, CSS, and JS. With Flutter, you can create custom user interfaces and animations with greater ease and control, and the resulting apps are faster and smaller. Plus, no more switching between different languages and files like a mad scientist!
A very important difference to JavaScript Frameworks is: Flutter compiles the Dart code to native machine code. Theoretically you have the control over every pixel on the screen. BTW, this also differs from React Native where written code is compiled only to the native UI components.
Dart vs. JavaScript
Just a few examples to show you'll feel familiar - but you'll also have to learn new rules
Below, you'll find a completely incomplete list π€ͺ, but it is intended to show that there are many similarities between Dart and JavaScript, making it easier to switch between the two languages or even learn both simultaneously. There are other rules too, but you can easily learn them.
Some more comprehensive information you can find in the official docs. Here are two links:
π Β Flutter for web developers
π Β Learning Dart as a JavaScript developer
Variable declaration
Like in JavaScript, in Dart var
is short for variable. It is used to declare a variable whose type is statically inferred:
var mystr = "shimmer";
In the example above, we declare a variable of type String
. One of the convenient features of Dart is that it's able to infer the type of a variable based on the value that's assigned to it. Here, it's quite evident that it's a String type.
But unlike JavaScript, you CANNOT change the type of the variable afterwards!
var mystr = "shimmer";
mystr = 10; // THIS ASSIGNMENT DOESN'T WORK!
When we attempt to change the type of a variable in Dart, the compiler will generate an error. This is because Dart is a statically typed language, which means that the data type of a variable is determined at compile-time and cannot be changed during runtime.
Another way to declare variables is the explicit type declaration:
String mystr = "shimmer";
In Dart, it's generally considered good practice to explicitly declare the data type of a property when defining a class. This helps to ensure that the code is more easily understood and maintainable, especially when working on larger projects with multiple developers.
For variables within smaller scopes, such as a method, the "var" keyword can be used instead of explicitly declaring the data type. This is because the scope is smaller and it's often easier to infer the type of the variable based on the context of the code.
class MyCat {
String cat = "Bob";
void someMethod() {
var anotherVariable = "Tom";
}
}
Defining a function
JavaScript
function addNumbers(a, b) {
return a + b;
}
Dart
int addNumbers(int a, int b) {
return a + b;
}
Working with arrays
JavaScript
var fruits = ["apple", "strawberry", "cherry"];
fruits.push("kiwi");
Dart
List<String> fruits = ['apple', 'strawberry', 'cherry'];
fruits.add('kiwi');
Using dot notation to access object properties and methods
JavaScript
class Person {
constructor() {
this.name = "";
this.age = 0;
}
greet() {
console.log(
`Hello, my name is ${this.name} and I'm ${this.age} years old.`
);
}
}
var person = new Person();
person.name = "Joe";
person.age = 44;
person.greet();
Dart
class Person {
String name;
int age;
void greet() {
print("Hello, my name is $name and I'm $age years old.");
}
}
var person = Person();
person.name = "Joe";
person.age = 44;
person.greet();
Usage of string interpolation
JavaScript
var name = "Joe";
console.log(`Hello, my name is ${name}`);
Dart
var name = "Joe";
print("Hello, my name is $name");
Usage of operators
JavaScript
var x = 10;
var y = 5;
var z = x + y;
Dart
var x = 10;
var y = 5;
var z = x + y;
Usage of if
statements
JavaScript
var age = 25;
if (age >= 18) {
console.log("You are an adult");
} else {
console.log("You are a minor");
}
Dart
var age = 25;
if (age >= 18) {
print("You are an adult");
} else {
print("You are a minor");
}
Usage of switch
statements
JavaScript
var fruit = "apple";
switch (fruit) {
case "banana":
console.log("This is a banana");
break;
case "apple":
console.log("This is an apple");
break;
default:
console.log("This is not a fruit");
}
Dart
var fruit = "apple";
switch (fruit) {
case "banana":
print("This is a banana");
break;
case "apple":
print("This is an apple");
break;
default:
print("This is not a fruit");
}
Usage of try-catch
blocks
JavaScript
try {
// code that may throw an error
} catch (error) {
console.log(`Error: ${error.message}`);
}
Dart
try {
// code that may throw an error
} catch (error) {
print("Error: ${error.toString()}");
}
β¨ Which resources you need
Meeting listed requirements of the used tools is a must, with some additional needs
Lots of free GB
XCode, Android Studio, the SDKs and Virtual Devices, all that stuff needs GBs on your disc. Welcome to app development! Additionally it turns out that the cross-compiling process to build the libraries takes an enormous amount of disk space PER target, too.
XCode
As you continue to use Xcode, the available storage on your hard drive gradually decreases. This is because numerous files are generated automatically during project builds, with a significant amount of them being stored in the Derived Data folder. This folder alone can occupy anywhere from a few hundred MB to several GB of space.
Fortunately, you can easily remove files from this folder without any adverse effects. By doing so, you can recover valuable space on your Mac, making it more efficient to work with Xcode.
Building apps
If you build more apps and cross-compile more targets, the disk space usage increases. However, you can safely remove these subfolders. The only difference it makes is in terms of time - the first build will take longer if the target folder has been deleted.
Required Devices for Testing and Running
Although you can develop code for all platforms on a single machine, you can only execute and test iOS and macOS applications on macOS devices, Windows applications on Windows devices, and Linux applications on Linux devices.
I haven't tried Windows and Linux Cross-Platform Development, see also: What does this tutorial NOT contain
The Key Resource
Finally, the most important resources is: YOU
You need to be patient, you may experience some hair-pulling moments when things grind to a halt, but when it finally all comes together, it's like hitting a bullseye on a dartboard - satisfying as heck!
π§ How to get the most out of this tutorial
Recommendations for a successful integration
Chapter by chapter I will explain you the steps to connect Flutter and Rust to IOTA. I therefore recommend that you also read section by section and follow the steps.
Second, I advise you to set up a project from scratch. I know it can be tempting to just download the code from the repository and run it, but there's a good chance you'll run into problems.
Due to the different versions of Rust and Flutter you may have, it's safest to follow the step-by-step instructions and selectively include specific files in your own project.
Another reason is that not only will you learn more about the intricacies of setup, but you'll also be better equipped to troubleshoot any problems that arise.
I want you to be successful and not frustrated.
Finally, when you include the code snippets, feel free to try refactoring them if you don't like the coding style. Keep in mind that I'm not producing the cleanest, best code, I just want to get things working. Everything else is nice to have.
β οΈ What does this tutorial NOT contain?
A disclaimer before the main event starts
Welcome to the section where we clear up any misunderstandings before you dive in! Think of it as the disclaimer before the main event, like when they tell you not to try the stunts you see in action movies at home.
So, what can you expect from this tutorial? We'll cover how to connect Flutter and Rust to IOTA, but let's make it crystal clear what's not on the menu.
First, we won't go into detailed explanations of IOTA - we'll leave the philosophical musings to the experts.
Also, this tutorial assumes you've already got Xcode, Android Studio, Flutter, and Rust up and running, so we won't bore you with deep installation instructions. Yes, there are sections called "Set up", but please understand that I can't deeply dive in but only can give you the right links and useful hints.
And sorry, no teaching of Dart or Rust - I'll assume that it's on your bucket list and you know what you're doing!
Finally, I won't be exploring the proof-of-concept for Linux, Windows, browsers, or browser extensions - this ain't no Cirque du Soleil show. I am using a Mac and so my personal means are limited.
And don't expect any deployment or production readiness insights - we'll let you handle the red carpet rollout yourself.
So, now that we've cleared the air, let's get started. Grab your coffee, fire up your IDE and let's dive into the world of Flutter, Rust and IOTA!
π‘ READ ME !
IOTA SDK = iota.rs + wallet.rs
About this Tutorial
The tutorial was written between April 2023 and February 2024. The contents come together to form a whole, namely: texts and images on this homepage, the source code on Github, and the videos on Youtube.
All information therein refered to the latest Rust libraries from IOTA during the mentioned period: iota.rs (client library/wrapper for IOTA node's API), wallet.rs (including Stronghold), and identity.rs. Only in the chapter about the Playground App did I use the IOTA SDK.
The IOTA SDK
Around the same time as the tutorial (April 2023), the IOTA Foundation began writing a new library called the IOTA SDK. This library combines client and wallet functionalities, leading to iota.rs and wallet.rs libraries being considered deprecated.
According to the IOTA Foundation, the IOTA SDK is the library to be used from now on. While the IOTA Foundation has created a sensible consolidation, this fact poses a problem for me.
What should happen with the Tutorial?
In short: I don't want to tear apart the contents of the tutorial. It would be confusing if the information shown in the video no longer matched the texts of the tutorial or the source code on GitHub.
Texts could be relatively easily replaced. On the other hand, it's too much work to recreate all images and videos.
Side Note: The chapter on the Playground App already contains content (texts, images, videos, code) about the new IOTA SDK.
So, another solution is needed to keep the tutorial current. And to do that, it's necessary to understand the impact of a different Rust library.
What does a new Rust Library mean for the Tutorial?
In essence, not as much as one might think.
The focus and purpose of the tutorial are not to detail IOTA's Rust libraries. The core of the tutorial is: how to use them in an app or what workflow to follow to create apps with Flutter and Rust.
The specific libraries used are secondary.
Of course, there are IOTA-specific issues because third-party libraries like rocksdb (for the wallet) and libsodium (for Stronghold) are used. However, these issues do not change with the IOTA SDK and are discussed in this tutorial.
The most significant difference, in my opinion, is in the Rust source code. Here, the module structure, access to individual functions, or sometimes even the naming has changed.
The IOTA SDK provides the same Rust examples as the predecessor libraries iota.rs and wallet.rs. Since my Rust code largely uses IOTA's official examples, it's possible to compare the Rust code of the examples.
Tutorial Update (completed March 2024)
To ensure the tutorial's relevance regarding the IOTA SDK, I considered the following specific measures sensible:
- The original version of the tutorial remains intact.
- Alongside the Rust code for iota.rs and wallet.rs, I provide a version for the IOTA SDK v1.1.4: A separate tab for the IOTA SDK Library is included at the respective places, revealing a separate content with the IOTA SDK code. Note the yellow tab in this example:
- Only if necessary, the texts are updated from the original page. Example: I renamed the page "Core API and iota.rs" to "Core API and IOTA's Rust library".
- There is an update notice below an image if it contains iota.rs or wallet.rs-specific content that needs to be abstracted with the IOTA SDK. Note the yellow caption in this example:
- The source code on GitHub remains unchanged.
- The videos on YouTube remain unchanged.
- The code of the MQTT chat app (which is a bonus) remains unchanged. It's a good exercise to convert this app yourself.
Android Studio
For macOS, Windows and Linux: Get the official Integrated Development Environment (IDE) for Android app development.
We primarily use Android Studio for managing SDKs and virtual devices, building and running apps, as well as for deployment purposes. However, when it comes to actual coding, we'll be using VS Code as our preferred editor.
So - in the context of Flutter - Android Studio is required for its tools used by Flutter under the hood.
Set up
Installing Android Studio on your system.
Installing Android Studio
Install the latest stable version of Android Studio:
After downloading (several GBs...), install Android Studio:
- On Mac, drag Android Studio into the Applications folder.
- On PC, execute the installer and follow the wizard. Make sure to install also the "Android Virtual Device".
Avoid Special Characters in your installation path!
Starting Android Studio the first time, a configuration wizard will start. Use "Custom" option, in order to check "Android SDK", an Android SDK Platform like "API 33: Android 13.0 (Tiramisu)" and, if selectable, "Android Virtual Device".
Go on to the next chapter, to get some essentials about the requirements for the development with Flutter and the Flutter Rust Bridge.
Essentials
Android Studio with the Flutter Framework and the Flutter Rust Bridge.
Managing the SDKs
The SDK Manager is used for managing the software development kit (SDK) components of the Android platform. Its primary purpose is to facilitate the installation, removal, and updating of various SDK packages required for developing Android applications. It allows also to manage and update various SDK Tools that are part of the Android development toolkit, such as the Android SDK Build Tools, the Native Development Kit (NDK), and more.
How to open SDK Manager
Start Android Studio. You can open the SDK Manager in one of the following ways:
- via the "Welcome to Android Studio" Page:
If this page is open, there is dropdown menu "More Actions". Open it and select "SDK Manager". - via the "Tools" Menu:
If Android Studio is open, open the "Tools" menu. You will find the item "SDK Manager". - via the "Settings":
Open the "Settings..." in the "Android Studio" menu.
In the window, select the "Appearance & Behavior" -> "System Settings" -> "Android SDK" section.
Hint: The Android SDK Location can be found here, too.
SDK Platforms
To start the development with Flutter use the latest Android SDK Platform.
SDK Tools
In the second Tab, you can select the SDK Tools.
For Flutter development, select:
- Android SDK Build-Tools
- Android SDK Command-line Tools
- Android Emulator
- Android SDK Platform-Tools
To use the Flutter-Rust-Bridge, also select:
- NDK
Managing the Virtual Devices
The Virtual Device Manager is a tool used for managing and creating virtual devices, also known as emulators. Emulators are software-based virtual devices that simulate the hardware and software configurations of real Android devices, allowing developers to test and run their applications without needing physical devices.
How to open the Virtual Device Manager
Start Android Studio. You can open the Virtual Device Manager in one of the following ways:
- via the "Welcome to Android Studio" Page:
If this page is open, there is dropdown menu "More Actions". Open it and select "Virtual Device Manager". - via the "Tools" Menu:
If Android Studio is open, open the "Tools" menu. You will find the item "Device Manager".
Which Virtual Device?
If you don't have a Virtual Device yet, click on "Create Device".
- Choose a phone, e.g. the "Pixel 6" or "Pixel 3a"
- As system image, choose the one corresponding to the installed Android SDK platform!
Keep the ABI in mind, here "arm64-v8a". The ABI information is used to configure the build.gradle file later, in the context of the Flutter-Rust-Bridge configuration. - For better performance, choose "Hardware - GLES 2.0" for Graphics.
Device File Explorer
Open the Virtual Device Manager and start your Virtual Device by clicking on the Play button. You'll notice that there is section called "Device File Explorer".
Later on, you can refer to the following location to retrieve information regarding stored files (RockDB, Stronghold Snapshot file) in your application's filesystem.
Path to AVD's filesystem: Root -> data -> data -> {app.id}
For your information, the location of the cross-compiled Rust library can be found here:
Root -> data -> app -> {temporary folder - use date to find the correct one} -> {app.id} -> lib
Xcode
For macOS only: Apple's Integrated Development Environment (IDE) for iOS, macOS, watchOS, and tvOS Applications.
Similar to the usage of Android Studio, we use Xcode for managing virtual devices, building and running apps, as well as for deployment purposes. However, when it comes to actual coding, we'll be using VS Code as our preferred editor.
So - in the context of Flutter - Xcode is required for its tools used by Flutter under the hood.
Important note
During the work with certain IOTA libraries and the Flutter Rust Bridge, it was sometimes necessary to launch the app directly from Xcode. In certain situations, it deviated from the usual procedure of starting the app with "flutter run" and instead required a workaround through Xcode.
Set up
Installing Xcode on your system.
Installing Xcode
Download (several GBs...) and install the latest stable version of Xcode using one of these sources:
Essentials
Xcode: Where Complexity Unfolds, Humor Helps Break the Mold.
At first glance, Xcode resembles an airplane cockpit: Overwhelming and seemingly impossible to navigate. But here's the thing: You don't need to fiddle with most of the settings.
Flutter has taken care of generating the Xcode project for you. As for the second Xcode project for the Rust library (mentioned below), that's created by cargo-xcode.
So, don't worry! All you need to do is grasp a few essential concepts and locate some helpful resources to find your way around. And hey, who knows, maybe you'll even learn to enjoy the Xcode experience, despite its initial complexity!
Project structure
A Xcode project follows a specific structure that organizes the various files and resources used to build an macOS or iOS application.
Project File: A project file with the extension .xcodeproj or .xcworkspace serves as the entry point for the Xcode project. It contains information about the project settings, build configurations, and references to all the project files.
- The .xcodeproj file is the traditional project file used in Xcode. It represents a single Xcode project and is used for organizing and building a single target, such as an iOS app, extension, or framework. It contains project settings, build configurations, and references to all the project files specific to that target.
- The .xcworkspace file, on the other hand, is used when you have multiple Xcode projects or multiple targets that depend on each other. It is a workspace file that can include one or more .xcodeproj files and their associated targets. The .xcworkspace file acts as a container that allows you to work on and build multiple projects or targets together in a unified workspace.
Targets: An Xcode project can have one or more targets. Each target represents a distinct product, such as the main app, extensions, or test suites. Targets contain the necessary build settings, dependencies, and references to source files.
Source Code: The source code files are organized within groups or folders. The default group is typically named after the project and contains the main application's source code files. Additional groups can be created to organize code files into logical categories or features.
Frameworks and Libraries: Xcode projects often use external frameworks and libraries to extend functionality or reuse code. These dependencies are managed within the project and are listed in the "Frameworks and Libraries" section. They can be system frameworks, third-party libraries, or custom frameworks.
In the Flutter/Rust context, the Rust Code is a separate Xcode Project which is included as "subproject" into the main project. This Xcode Project is created by the tool cargo-xcode.
Configuration Files: Configuration files, such as Info.plist
, contain essential metadata about the application, including its bundle identifier, version, required device capabilities, and permissions.
Build Settings: Xcode provides an interface to configure build settings for the project and individual targets. These settings control compiler flags, optimization settings, code signing, deployment targets, and various other project-specific configurations.
The interaction of this structure, composed of multiple Xcode projects, becomes more apparent when we build applications for macOS resp. iOS in the practical chapters.
At this stage, it's worth mentioning that there exists a dedicated Rust Xcode project rust.xcodeproj. This project is created during an initialization step of the Flutter Rust Bridge for macOS or iOS. It includes a script ("Build Rule") that serves the purpose of generating a static or dynamic library and integrating it into the build process of the main Xcode project Runner.xcodeproj for the app.
Static and dynamic libraries
Static and dynamic libraries are both forms of code libraries, but they differ in how they are linked and loaded into an application.
Static Libraries:
- A static library, also known as a static link library, is a compiled set of object code that is linked directly into an executable at the time of compilation. The library code becomes part of the final executable binary.
- When an application is built with a static library, all the library code is copied into the resulting binary. This means that the application becomes self-contained and doesn't rely on the presence of the library during runtime.
- Static libraries are typically denoted by file extensions like .a (on macOS and iOS) or .lib (on Windows). They provide a way to distribute pre-compiled code that can be linked with multiple applications without needing to distribute the library separately.
Dynamic Libraries:
- A dynamic library, also called a shared library or dynamic link library (DLL), is a separate binary file containing compiled code that can be loaded and linked by multiple applications at runtime.
- Unlike static libraries, dynamic libraries are not copied into the application binary. Instead, the application references the dynamic library and loads it dynamically during runtime.
- Dynamic libraries offer advantages such as code sharing among multiple applications and the ability to update the library independently without recompiling the applications that depend on it.
- Dynamic libraries are usually denoted by file extensions like .dylib (on macOS and iOS) or .dll (on Windows).
IDE Basics
Project Navigator
Click on the leftmost symbol in the left pane. This will activate the Project Navigator. It can be used to configure project settings or adjust the project structure, such as adding libraries or other Xcode projects (by dragging them into the workspace).
Device or Simulator Selection / Launch the app manually
Normally, Flutter automatically builds and launches the macOS/iOS app. It uses the preselected device (if connected to your Mac) or Simulator.
You can switch the device by selecting a different one from the drop-down menu.
In some cases, you may need to manually start your app instead of using Flutter. In such cases, use the Play button.
Report Navigator
Flutter
Building Cross-Platform Apps with Simplicity
Flutter, developed by Google, aims to provide developers with a unified codebase to build cross-platform applications that can be deployed on various targets. Originally designed to support mobile devices, Flutter has expanded its reach to include desktop platforms such as macOS, Windows, and Linux, as well as browser applications.
At its core, Flutter consists of two main components: the UI Framework and a Collection of Tools. The UI Framework encompasses a rich set of code packages that empower developers to create visually appealing user interfaces. The collection of tools, on the other hand, enables the transformation of a single codebase into machine code capable of running on diverse platforms.
Flutter uses Dart as programming language. It's important to note that while you can write Dart code for all platforms using Flutter, there are specific requirements for building and testing applications on different targets. Building apps for iOS/macOS necessitates a macOS computer with XCode installed, while building and running Windows apps requires a Windows machine, and the same goes for Linux. However, developing Android and browser apps can be done on any host machine, as Android Studio is compatible with multiple operating systems.
Set up
Installing and upgrading Flutter on your system.
Installing Flutter
For the most accurate and up-to-date installation instructions, please refer to the official guide:
π Β Flutter Website - Get Started - Install Flutter
Avoid Special Characters and Blanks in your installation path!
When installing Flutter, it's important to note the differences between Mac and Windows systems. One of the system requirements is having Git installed, as Flutter utilizes it for installation and updates. On Windows, Git needs to be installed separately. However, on Mac, Git is conveniently included in Xcode. For a smooth installation experience, it is recommended to install Xcode before Flutter on Mac systems.
Mac Users
Even if you already have Xcode installed, go through the installation guide and check each step! For example, don't forget to add the Xcode Command-line tools.
Side notes
- To find out which shell you are using:
echo $SHELL
- To toggle hidden directories and files:
command + shift + .
Installation Tipp
If you're more inclined to watch an installation video, I've got a fantastic recommendation for you: I highly recommend checking out the tutorials by Maximilian SchwarzmΓΌller from Academind.
Now, let me make one thing clear β I'm not affiliated with Academind, and they definitely haven't slipped me any advertising cash (unfortunately). But I genuinely appreciate Max's work and the crystal-clear instructions he provides.
So, I wholeheartedly encourage you to check out his tutorial about Flutter & Dart ("The complete guide [2023 Edition]") and see for yourself. Don't just take my word for it, folks! Discover the wonders of Flutter installation with Max's delightful guidance. There are several free videos: amongst others you'll find a guide about the macOS Setup and one about the Windows Setup!
Upgrading Flutter
As a general recommendation, I suggest deleting the
build
andtarget
folders after upgrading the Flutter version.
To upgrade the Flutter SDK, navigate to the Flutter SDK directory - you'll find it using the command:
flutter doctor -v
Then use this command:
flutter upgrade
To upgrade to the latest compatible versions of all the dependencies listed in the pubspec.yaml file, use the flutter pub command:
flutter pub upgrade
Use this link for more information:
π Β Flutter Docs - Stay up to date - Upgrade
Useful commands
-
To find out where your Flutter SDK is located:
which flutter
orwhere flutter
-
To find out which Flutter Version is installed:
flutter --version
-
To check the Flutter related tools on your working environment:
flutter doctor
Essentials
A glimpse into the project basics.
Project structure
A typical Flutter project follows a specific structure that organizes the various components and resources of the application. Here is a description of the common project structure in a Flutter project:
android: This directory contains the Android-specific files and configurations for the Flutter project. It includes the Android manifest file, Gradle build scripts, and other resources specific to the Android platform.
ios: This directory holds the iOS-specific files and configurations. It includes the Xcode project, property list files, and other resources specific to iOS development.
lib: The lib directory is where the main Dart code for the Flutter application resides. It contains the main.dart file, which serves as the entry point for the application. Additionally, you can organize your code into multiple files or directories within the lib directory to improve code modularity and maintainability.
macos: This directory holds the macOS-specific files and configurations. It includes the Xcode project, property list files, and other resources specific to macOS development.
test: The test directory is used for writing unit tests and integration tests for your Flutter application. It typically contains test files and directories to organize your test code.
assets: The assets directory is where you can store static files such as images, fonts, and other resources required by your application. These files can be accessed using Flutter's asset management system.
pubspec.yaml: The pubspec.yaml file is a YAML-formatted configuration file that defines the project's metadata, dependencies, and assets. It specifies the required packages, version constraints, and additional resources like fonts or images.
Packages
Packages ("Plugins") refer to pre-built libraries or modules that developers can use to add specific functionalities or features to their Flutter applications. They are created by the Flutter community, as well as by the official Flutter team at Google, and they help streamline the development process by providing ready-to-use code solutions for common tasks.
π Β The official package repository for Dart and Flutter apps.
Example: Flutter Chat UI
In my "MQTT Chat App" project, I utilized the "Flutter Chat UI" package to swiftly integrate a chat user interface and interactive functionality with just a single installation instruction!
Package Manager & Configuration file
The Package Manager is used to manage dependencies, define project metadata, configure project-specific settings and facilitate the development workflow.
Flutter / Dart | Rust | JavaScript / Node.js | |
---|---|---|---|
Package Manager | Pub | Cargo | npm |
Configuration File | pubspec.yaml | Cargo.toml | package.json |
Package Repository | https://pub.dev | https://crates.io | https://npmjs.org |
To add a package/plugin use the command:
flutter pub add <package>
Once the package is installed, you'll find a list entry in the pubspec.yaml file, as dependency.
Example: pubspec.yaml of MQTT Chat App
name: mqttchat
description: A new Flutter project.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ">=2.19.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
ffi: ^2.0.1
flutter_rust_bridge: ^1.61.1
freezed_annotation: ^2.2.0
flutter_chat_ui: ^1.6.6
...
uuid: ^3.0.7
shared_preferences: ^2.0.17
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
ffigen: ^7.2.4
build_runner: ^2.3.3
freezed: ^2.3.2
flutter:
uses-material-design: true
assets:
- assets/messages.json
- assets/smr.png
Creating a project
Very important hints
Don't use special characters or blanks (use "_" instead) as project name and in your absolute project path!
Don't name your project like an existing public package! It could lead to circular dependencies. See the Logging Example App -> Resources section as example.
To create an "empty" app use the command:
flutter create --empty <project_name>
To create Flutter's "default" app use the command:
flutter create <project_name>
Rust
Rust is a powerful and modern programming language that prioritizes performance, memory safety, and concurrency.
Set up
Installing Rust on your system.
Installing Rust
Quickly set up your Rust development environment:
Alternatively, use the "Getting started" website:
When installing Rust, the recommended approach is to use rustup, a versatile tool that serves as both an installer and version manager for Rust.
With rustup, you gain access to a comprehensive toolset that enhances your Rust development experience. It includes rustc, the rust compiler responsible for translating your Rust code into executable binaries or libraries.
Cargo, another essential component, is the Rust package manager. It also simplifies dependency management, project building, testing, and more, serving as a valuable asset in your Rust workflow.
rustup also offers rustfmt and clippy. rustfmt is a source code formatter that ensures consistent and elegant formatting in your Rust codebase. On the other hand, clippy is a Rust linter that helps identify potential issues and provides helpful suggestions to improve your code.
So, everything starts with installing rustup.
On macOS, Linux, or another Unix-like OS, to download rustup and install Rust, run the following in your terminal, then follow the on-screen instructions.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Was the installation successful?
Open a new shell and type the following:
rustc --version
If there is a meaningful output displaying rustc <version>
then the installation was successful.
Updating Rust
Rust updates very frequently. If you have installed Rustup some time ago, chances are your Rust version is out of date. Get the latest version of Rust by running:
rustup update
Essentials
A glimpse into the project basics.
In the course of our Flutter-Rust project, when creating a Rust library with an API, we generate a Rust Package. For larger projects like the IOTA Libraries, the source code can be further organized using another Rust feature called Workspace. The complete organizational structure is explained in more detail in the next chapter titled Project Structure.
Content of a Package
A Rust Library Project includes the following elements:
src: This directory serves as the main location for the library's source code.
-
lib.rs: The
lib.rs
file acts as the entry point. It acts as a central module where you can declare and organize the public items (functions, structs, enums, traits, etc.) that are intended to be accessible to other crates (projects) that depend on the library. These items are marked with the pub visibility keyword to make them visible outside of the library's crate. -
Additional source code files and subdirectories can be organized within the src directory to maintain a modular and organized codebase.
In the context of Flutter, Rust and the Flutter Rust Bridge, we organize the structure in the following way: The public functions and structs that are intended to be exposed in the API are code in the file
api.rs
. This file is then integrated as module into thelib.rs
file.
target: This directory serves as the build location for the library. It will be automatically created once the cargo build
command is used. In this directory, your storage space vanishes into thin air like a magical bunny in a hat! You can remove it whenever you like - but: Deleting comes at the expense of time. During the first build, all necessary resources are loaded, which takes time. If the directory exists, all subsequent builds are faster.
Cargo.toml: The Cargo.toml file acts as the manifest for the library project, defining metadata such as the package name, version, dependencies, and build configurations.
Cargo.lock: This file is an automatically generated file. When you build a Rust project using Cargo, it resolves the dependencies specified in the Cargo.toml manifest file and generates the Cargo.lock file. This file includes the specific versions of each dependency and their transitive dependencies that were resolved during the build process.
What are Crates?
A Crate acts as a unit of code organization and encapsulation, providing a way to manage and share code functionality.
Crates can also have dependencies on other crates, allowing them to utilize external code and libraries. The dependencies are declared in the Cargo.toml file of the crate, under the [dependencies] section.
In our context, the wallet.rs library (crate name: "iota-wallet") library includes the iota.rs library (crate name: "iota-client"), amongst others.
π Β The Rust community's crate registry
Example: Search for "IOTA" crates
Package Manager & Configuration file
The Package Manager is used to manage dependencies, define project metadata, configure project-specific settings and facilitate the development workflow.
Flutter / Dart | Rust | JavaScript / Node.js | |
---|---|---|---|
Package Manager | Pub | Cargo | npm |
Configuration File | pubspec.yaml | Cargo.toml | package.json |
Package Repository | https://pub.dev | https://crates.io | https://npmjs.org |
To add a package/crate use the command:
cargo add <crate_name>
I use the alternative way to include a crate:
Simply add it directly to the [dependencies] section of the Cargo.toml manifest file.
Rustup and Toolchains
π Β The Rustup documentation
Rustup is a version manager that allows you to easily install, manage, and switch between different toolchains.
A toolchain refers to a specific version of the Rust compiler and associated tools that are used to compile, build, and manage projects. It includes the Rust compiler itself, the standard library, and other essential components required for Rust development.
Having multiple toolchains allows developers to work with different Rust language features, test compatibility across versions, and ensure their code works as intended in different Rust environments.
The Terminal command
rustup show
provides an overview of the Rust installation details, such as the installed toolchains, the currently active toolchain, and the associated components like cross-compiling targets.
Creating a new Rust Library Project
Important: Before you create the new project ensure that you are in the correct folder. To create the Rust Library Project for IOTA for Flutter, the Flutter Project must exist, and your Terminal prompt needs to be in the root directory of the Flutter Project.
To create the Rust Library Project, execute the command:
cargo new --lib <crate_name>
I always use the crate name rust. In that case, a new subfolder rust is created.
Project Structure
Code's organization: Learn the basics of Rust's module system to analyze a project.
Workspace and Packages
A Workspace is a feature provided by Cargo (Rust's package manager and build tool) that allows you to manage multiple related Packages within a single directory. Workspaces are optional.
By organizing packages as part of a workspace, you can share dependencies, coordinate builds, and simplify the development and testing of interconnected projects.
Workspace and packages each have their own Cargo.toml file.
Crates
A Crate is a self-contained unit of code that encapsulates a set of functionality, typically organized into modules, structs, enums, traits, and functions. This unit of code can be shared, imported, and used in other codebases.
Crates can be published to the Rust community's Crate Registry, allowing developers to include them as dependencies in their projects:
π Β The Rust communityβs Crate Registry (https://crates.io/)
Hint: Search for the keyword iota in the registry to get a list of IOTA related crates.
There are two types of crates: library crates expose public functions or items , and binary crates which are executable programs. A package can contain the source code of one or several crates.
Modules
A Module is a way to organize and group related code within a crate. It allows for logical separation and encapsulation of functionality, helping to keep code organized and maintainable.
Declaring and defining a module
There is a distinction between declaring and defining a module.
Declaring a module: Declaring a module is the process of creating a module and specifying its name and structure. It is done using the mod
keyword, followed by the module name and a block of code that defines the contents of the module. When you declare a module, you are essentially creating a namespace and organizing code within that namespace. You can declare modules in the same file or in separate files, and you can nest modules within other modules.
Defining a module: Defining a module involves implementing the functionality and providing the actual code within the declared module. It includes writing functions, structs, traits, and other items that make up the module's implementation. When you define a module, you are filling it with the necessary code and logic to perform specific tasks or provide certain functionality.
Here's an example to illustrate the difference:
// Declaration of a module named "department" (here: inline within a file)
pub mod department {
// Definition of a struct within the module
pub struct Employee {
// struct fields
}
// Definition of a function within the module
pub fn list_employees() {
// function implementation
}
}
// Usage
fn main() {
// Accessing the defined module and its items
let employee = department::Employee {};
department::list_employees();
}
Paths
A Path refers to the location of a module or item (e.g. structs, enums, functions) within the project's directory structure. It represents the hierarchical structure of directories and subdirectories.
The use
keyword in Rust is used to bring items from a module or crate into scope, allowing them to be accessed without fully qualifying their paths. It provides a way to conveniently reference items by their short names instead of using their full paths every time.
The requirement, however, is that the used modules and items are public (indicated by the keyword pub
).
In Rust, paths and namespaces are interrelated concepts that help organize and reference code elements. For example:
// Declaration of the modules (here: inline within a file)
mod company {
pub mod department {
pub fn list_employees() {
// Function implementation
}
}
}
// Usage
fn main() {
company::department::list_employees();
}
And here are some official links:
π Β Rust documentation - Managing Growing Projects with Packages, Crates, and Modules
π Β Rust documentation - Cargo Workspaces
π Β Rust reference - Visibility and Privacy
What else is good to know?
In Rust, the implementation of structs can be split into different files to improve code organization and maintainability. This allows you to separate different aspects of the struct's implementation, such as methods, associated functions, and trait implementations, into separate files.
However, it's important to note that a struct itself can only be defined in a single file. This ensures that the struct has a single, unambiguous definition within your project.
File 1: employee.rs
// employee.rs
pub struct Employee {
pub name: String,
pub age: u32,
pub position: String,
}
impl Employee {
pub fn new(name: String, age: u32, position: String) -> Self {
Employee {
name,
age,
position,
}
}
pub fn display_info(&self) {
println!("Name: {}, Age: {}, Position: {}", self.name, self.age, self.position);
}
}
File 2: employee_utils.rs
// employee_utils.rs
impl Employee {
pub fn calculate_salary(&self) -> u32 {
// Calculation logic goes here
5000
}
}
Create Rust Docs
Documentation in Rust: Can be helpful, but ... crate docs can also be quite incomplete.
Another way to familiarize yourself with a library is to look at the documentation. With Rust, we can instantly create documentation based on the comments in the source code.
There is the rustdoc
command, but alternatively you can use cargo doc
which uses rustdoc behind the scenes.
π Β The cargo book - cargo doc
Hints
By default, the rustdoc engine evaluates only the enabled features. To create the documentation that covers the whole code, use the option
--all-features
.To include non-public items, use the
--document-private-items
flag.All options are displayed by executing
cargo doc --help
.
So my favorite command is:
cargo doc --all-features --document-private-items --target-dir "rustdocs" --open
When you run the command, rustdoc will scan all files and create a working folder debug/
and an output folder doc/
inside the target directory. Depending on the number of scanned crates this might take a while.
Rust Docs for IOTA SDK
Download the library from Github:
Open VS Code and run the commands:
cd sdk
cargo doc --all-features --document-private-items --target-dir "rustdocs" --open
It will run several minutes and create the output folder "rustdocs":
Eventually, the browser will open automatically and present the root page of the documentation.
Tip: Delete the
target/
folder afterwards - it occupies 4.4 GB of space.
Rust Docs for iota-client (deprecated)
Prior to executing the cargo doc ...
command mentioned above, switch to the client/
directory located at the root, rather than the sdk/
directory.
Sometimes you are facing unresolved links
When executing the command, I encountered a handful of error messages due to unresolved links. In rustdoc, links are indicated by square brackets
[...]
. Fortunately, fixing these issues is a breeze: simply remove the square brackets i.e. the link, and the problematic areas will be swiftly resolved.
Dependencies and Features
Dependencies, Features, Cargo.toml and your friend: Cargo.lock
Motivation
Imagine that you have created your Rust API in the directory rust/
within your Flutter project, added the necessary dependencies, and your code appears to be error-free.
With a hopeful spirit, you press the play button. The code starts compiling. It compiles and compiles, and then it happens: a red error message halts your enthusiasm.
It's possible that you have included a dependency whose version is not compatible with the target platform - such as the iota-wallet. What works on iOS may not necessarily function on Android, and vice versa. While we aim to cross-compile Rust code, there are a few cases where it doesn't go smoothly.
To find a solution to the problem, you need to first determine:
- Which library is causing the error?
- Which version of the library is being used?
- What is the dependency hierarchy that has been established?
By understanding these aspects, you can begin troubleshooting and seeking a resolution for the issue at hand.
Dependencies
Dependencies are managed using the Cargo.toml
file. This file serves as a manifest for the project, where you declare the dependencies required for your code to compile and run. Dependencies are specified under the [dependencies] section, where you can list the name and version of each dependency. Cargo uses this information to fetch and manage the dependencies automatically.
Several options provide flexibility in managing dependencies. Version-based specifications ensure compatibility with specific versions, while Git-based specifications allow fetching libraries directly from Git repositories, enabling experimentation with different branches, revisions, or even forks of a library.
Version-based specifications
Version-based specifications are downloaded from the Crate Repository at https://crates.io.
[dependencies]
library-name = "1.2.0"
In this example, the project depends on version 1.2.0 of the "library-name" crate.
Developers can also use version constraints:
[dependencies]
library-name = ">= 1.0, < 2.0"
Here, the project allows any version from 1.0 (inclusive) up to, but not including, version 2.0 of the "library-name" crate.
Git-based specifications
[dependencies]
library-name = { git = "https://github.com/username/library.git", branch = "develop" }
In this example, the project fetches the "library-name" crate from the specified Git repository, using the "develop" branch.
Warning
When employing this form of dependency specification, the latest commit from the specified branch will be fetched each time changes are made to Cargo.toml. It is essential to be aware that by relying on this method, there is a possibility that library developers may introduce unintended modifications to the library.
Developers can also specify a specific commit hash (revision):
[dependencies]
library-name = { git = "https://github.com/username/library.git", rev = "abcdef123456" }
Here, the project fetches the "library-name" crate at the specified commit with the hash "abcdef123456".
Exercise
Find the hash value of the last commit BEFORE rocksdb 0.19 was brought back into wallet.rs.
- Start on the page https://github.com/iotaledger/wallet.rs/commits/develop.
- Go back in history by clicking the "Older" button at the bottom of the page.
- Continue navigating through the commits until you find the commit titled "Bring back rocksdb 0.19".
- Copy the full SHA (hash) of the commit that is immediately before the "Bring back rocksdb 0.19" commit.
The corresponding hash value should be: 05fcb303c657c6faf3cb772f3a3908647614d545. You could use this hash as the value for
rev = "..."
in the dependency definition for iota-wallet, where rocksdb version 0.18 is included.Tipp: It's somewhere in December 2022.
Reading the Cargo.lock file
The Cargo.lock file is an automatically generated file in Rust projects that serves as a lock file, ensuring deterministic builds. It records the exact versions of all dependencies used in the project, including transitive dependencies.
This file helps in guaranteeing that subsequent builds of the project will use the same dependency versions, providing consistency and reproducibility.
The Cargo.lock file is automatically updated by Cargo when dependencies are added, removed, or updated, and it should be committed to version control to ensure consistent builds across different environments.
Features
Features are a way to enable or disable optional functionalities in the dependencies of a library or package. They allow developers to reduce the size and complexity of dependencies by selecting only the features they actually need.
You can imagine that by configuring features in Cargo.toml
, you can switch on or switch off specific functionalities. By disabling certain features, you also ensure that the associated dependent libraries are not compiled into your source code. So, if you encounter issues with specific third-party libraries, you can narrow down the problems by disabling certain features for testing purposes.
Features can be specified in the Cargo.toml
file of a Rust project. You can find an explanation of the configuration with examples in this article:
π Β Cargo [features] explained with examples
IDE: Visual Studio Code
In IOTA for Flutter I'm using Visual Studio Code as IDE. But Flutter is a friendly framework that plays well with other editors too!
To use a different editor, please refer to Flutter's website and the different tabs provided:
Set up
Installing Visual Studio Code on your system.
Installing VS Code
Install the latest stable version of VS Code:
The installation of extensions is optional and often a matter of personal work style. Below, I have listed a sample selection of extensions.
Flutter Extension
I recommend to install the Flutter Extension for VS Code. Read the section "Install the Flutter and Dart plugins" and follow the instructions:
π Β Set up an editor - Install the Flutter and Dart plugins
Additionally I have installed the Awesome Flutter Snippets extension:
Rust Extension
I am also using the rust-analyzer Extension for VS Code. Read the section "Install the rust-analyzer extension" and follow the instructions:
π Β Rust in Visual Studio Code
Others
The Material Icon Theme extension provides pretty icons. It's up to you...
π‘ How everything works together
The Big Picture
In this next section, I'll provide an overview of the workflow involved in integrating Flutter, Rust, and IOTA. This will give you a better understanding of what a developer needs to do in order to successfully connect these technologies and create an app using the Flutter-Rust-Bridge.
Understanding the Workflow
Take a simple example: An app with just a button and a text field. When the user clicks the button, it should establish a connection to a Shimmer node and retrieve information about it. This information will then be displayed in the text field.
App Processes
Within the application itself, the following occurs: in the frontend, when the button is clicked, it triggers its handler. This handler utilizes a method provided by the Foreign Function Interface (FFI). FFI is a mechanism that enables programming languages to call functions from libraries written in a different language. In the context of Flutter and Rust, FFI facilitates communication between the Flutter frontend and the Rust backend. Essentially, the button handler invokes a Rust method. This method then forwards the request to a function in the IOTA Rust library, which retrieves the desired information from the node. The information is then relayed back using the same pathway.
Various Stages
During the Development Phase (see above), this means that the UI and handlers are written in the Dart programming language, while the backend functions are implemented in Rust. This separation allows for leveraging the strengths of both languages: Dart for the frontend user interface and interactivity, and Rust for robust backend functionality and integration with libraries like IOTA.
During the Build Phase (see above), the Rust code is compiled into a library that is then incorporated into the Flutter app. The entire codebase is compiled into machine code that can be executed on the target platform. This process ensures that the Rust functionality seamlessly integrates with the Flutter app, allowing for efficient and effective execution on the desired platform.
Workflow
A step-by-step guide
The workflow is identical to the one demonstrated in the video. I will also provide the commands used in the video for reference. Please keep in mind that this is only a high-level overview for now. More detailed explanations will be provided later on.
β οΈ If you genuinely want to practically code the described steps outlined below, I assume that you have completed the setup of your workplace and all tools, as described in the "Fundamentals" section.
1. Check the Setup
flutter doctor
rustup show
Before you go on, be sure that possible errors are fixed.
2. Initialization Steps
In the first step, the following command sets up a new Flutter project with the necessary file structure and dependencies, ready for you to start developing your app. It creates a directory with the specified project name and populates it with the required Flutter files and folders. Now you're all set to unleash your creativity and build amazing Flutter applications!
flutter create --empty example1
Β
This includes the required structure for the Rust code and any additional resources or dependencies needed for the backend implementation:
cargo new --lib rust
Β
cargo install flutter_rust_bridge_codegen
flutter pub add --dev ffigen:9.0.1 && flutter pub add ffi
flutter pub add flutter_rust_bridge
flutter pub add -d build_runner
flutter pub add -d freezed
flutter pub add freezed_annotation
Hint: ffigen is already available with a higher version. The Flutter Rust Bridge v1.0 is still working with ffigen version >=8.0.0 and <10.0.0. So I am using version v9.0.1 here.
cargo install cargo-ndk
In Cargo.toml:
[dependencies]
flutter_rust_bridge = "1"
[lib]
crate-type = ["staticlib", "cdylib"]
In android/app/build.gradle, fix error (only if GradleException is found):
Replace GradleException by FileNotFoundException
In android/app/build.gradle, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
commandLine 'cargo', 'ndk',
'-t', 'arm64-v8a',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
Your Android Virtual Device should be compatible to "arm64-v8a". "arm64-v8a" is an architecture designation for Android devices. It refers to the 64-bit version of the ARM architecture commonly used in modern Android devices.
The term "arm64" represents the 64-bit version of the ARM architecture, while "v8a" indicates the ABI (Application Binary Interface) associated with that architecture. Thus, the combination of "arm64" and "v8a" refers to the 64-bit ARM architecture running on devices with that ABI.
3. Development Steps
Add a Flutter package that beautifies JSON outputs:
flutter pub add flutter_json_viewer
Write this content into main.dart:
import 'package:flutter/material.dart';
import 'package:flutter_json_viewer/flutter_json_viewer.dart';
import 'dart:convert';
//import 'ffi.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter - Rust - IOTA',
theme: ThemeData(
primarySwatch: Colors.green,
),
home: const MyHomePage(title: 'Get Node Info'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
String? _ffiNodeInfo;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: ListView(
children: [
Container(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
const Text('Result Pane',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
Container(height: 8),
const Divider(height: 1.0, thickness: 1.0),
Container(height: 24),
JsonViewer(json.decode(_ffiNodeInfo ?? "{}")),
//JsonViewer(testArray),
Container(height: 24),
const Divider(height: 1.0, thickness: 1.0),
Container(height: 12),
ElevatedButton(
onPressed: _callFfiGetNodeInfo,
child: const Icon(Icons.play_arrow),
),
],
),
),
),
),
],
),
);
}
Future<void> _callFfiGetNodeInfo() async {
const receivedText = '{"name": "HORNET","version": "2.0.0-rc.5"}';
//final receivedText = await api.getNodeInfo();
if (mounted) setState(() => _ffiNodeInfo = receivedText);
}
}
The backend call _callFfiGetNodeInfo() is mocked, meaning that instead of making a real request to the server, a simulated response is generated. This approach allows us to emulate the behavior of the backend without actually relying on a live server. By mocking the backend call, we can focus on testing and developing the frontend functionality independently, ensuring that the app's features and user interactions are working as intended.
Important: Before starting the application, make sure that your Virtual Android Device is running.
You can start the application with the command:
flutter run
Β
The first step on the Rust side is to include the IOTA library and other necessary resources.
In Cargo.toml add:
[dependencies]
...
iota-client = {
version = "2.0.1-rc.7",
default-features = false,
features = [ "tls" ]
}
serde_json = { version = "1.0.89", default-features = false }
anyhow = "1.0.66"
tokio = { version = "1.21.2", default-features = false, features = ["macros"] }
[dependencies]
...
iota-sdk = { version = "1.1.4", default-features = false, features = [
"client",
"tls",
] }
serde_json = { version = "1.0.108", default-features = false }
anyhow = "1.0.75"
tokio = { version = "1.34.0", default-features = false, features = ["full"] }
Create the file api.rs. The file api.rs is YOUR RUST WORKING FILE. The Flutter-Rust-Bridge code generator will identify all public functions within the api.rs
file and generate the corresponding Dart Interface from these methods. This means that all public functions available in the Rust code will be exposed and accessible for utilization within the Flutter app.
By automatically generating the Dart Interface, the Flutter-Rust-Bridge simplifies the process of bridging the communication between the Flutter frontend and the Rust backend, enabling seamless interaction and integration between the two languages.
Add this content to api.rs:
use iota_client::Client;
use anyhow::Result;
use tokio::runtime::Runtime;
pub fn get_node_info() -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = "https://api.testnet.shimmer.network";
// Create a client with that node.
let client = Client::builder()
.with_node(&node_url)?
.with_ignore_node_health()
.finish()?;
// Get node info.
let info = client.get_info().await?;
Ok(serde_json::to_string_pretty(&info).unwrap())
})
}
use iota_sdk::client::Client;
use anyhow::Result;
use tokio::runtime::Runtime;
pub fn get_node_info() -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = "https://api.testnet.shimmer.network";
// Create a client with that node.
let client = Client::builder()
.with_node(&node_url)?
.with_ignore_node_health()
.finish()
.await?;
// Get node info.
let info = client.get_info().await?;
Ok(serde_json::to_string_pretty(&info).unwrap())
})
}
Replace the content in lib.rs by:
mod api;
Β
This one is easy! It's one of the tasks you need to do whenever the Rust API has changed (e.g. after changing method signatures or add/removing methods). In our example, generate the Dart Interface by executing this command:
flutter_rust_bridge_codegen --rust-input rust/src/api.rs --dart-output ./lib/bridge_generated.dart --dart-decl-output ./lib/bridge_definitions.dart
Β
Next to main.dart, add a new file called ffi.dart and add this content:
// This file initializes the dynamic library and connects it with the stub
// generated by flutter_rust_bridge_codegen.
import 'dart:ffi';
import 'bridge_generated.dart';
import 'bridge_definitions.dart';
export 'bridge_definitions.dart';
// Re-export the bridge so it is only necessary to import this file.
export 'bridge_generated.dart';
import 'dart:io' as io;
const _base = 'rust';
// On MacOS, the dynamic library is not bundled with the binary,
// but rather directly **linked** against the binary.
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
final Rust api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
Integrating the library involves loading it into our project, enabling us to execute its methods and utilize its functionalities.
Now, in main.dart, comment out line 4:
ffi.dart // remove the two slashs
The final step is to insert the appropriate function calls to invoke the desired methods from the library. In main.dart, update the function _callFfiGetNodeInfo().
Replace:
const receivedText = '{"name": "HORNET","version": "2.0.0-rc.5"}';
//final receivedText = await api.getNodeInfo();
by:
//const receivedText = '{"name": "HORNET","version": "2.0.0-rc.5"}';
final receivedText = await api.getNodeInfo();
4. Build and Run Step
Here: We build an run the app on Android!
Important: Before starting the application, make sure that your Virtual Android Device is running.
Open the App with the command:
flutter run
To inform Flutter about the target platform for which it should build with flutter run, the corresponding platform is initiated beforehand.
During the build process, the Rust code is cross-compiled into a library specific to the target platform (here: arm64-v8a). This compiled library is then automatically copied into the Android project folder.
Then the app is launched.
Summary
Here is a brief summary about all steps, in one picture:
Handling Code Changes
What to do in various situations when working with Flutter, Rust, and the Flutter-Rust Bridge.
If the Rust API changes
If the Rust API changes, such as the addition of a new pub fn
function or pub struct
, or if parameters in an existing function change, you will need to call the flutter_rust_bridge_codegen
function again.
Note: Sometimes I've noticed that the state in VS Code doesn't get updated and still shows faulty files, after the code generation. In this case, it helps to close and reload the project window...
If the business logic in Rust changes
If the business logic in Rust changes and the server is currently running, you'll need to stop and restart it. During the startup process, the library will be recompiled automatically with cargo build
and linked to the app. You will observe in the console that the library named rust
is being compiled.
If there are changes in Flutter UI/business logic
If there are changes in Flutter UI/business logic, you may not have to do anything if the server is running (hot reload is triggered by saving), or you may need to reload the app if necessary. Exception: if you started the app by flutter run
, you'd need to use the keyboard, e.g. "r" for hot reload and "R" for reload.
π Cross-Compiling
Exploring the required Targets.
The Rust compiler functions as a cross-compiler by default. It allows us to translate our Rust code into the target platform and package it as a library. However, to enable this functionality, we need to ensure that all the required targets are installed on the host computer. In this chapter, we will delve into the relevant targets for IOTA for Flutter.
About Targets
In the context of software development, a "target" typically refers to the platform or environment for which software is being developed or compiled. The Components of a target specification can vary depending on the context and tooling being used.
-
Architecture: The target architecture specifies the instruction set and hardware architecture for which the software is being compiled or built. Examples include x86, x86_64, ARMv7, ARMv8, etc.
-
Vendor: The vendor component indicates the company or organization associated with the target platform. It helps identify the specific platform or ecosystem for which the software is intended. Examples include apple, android, linux, windows, etc.
-
Operating System: The operating system component represents the software layer that manages system resources and provides services to applications. It defines the environment in which the software will run. Examples include darwin (macOS, iOS), linux, windows, android, etc.
-
ABI (Application Binary Interface): The ABI component defines the interface between the compiled application code and the operating system and hardware. It defines how functions are called, how parameters and return values are passed, how memory is allocated, and other low-level details of interaction between software and the underlying system.
Targets needed for macOS and iOS Development
The Target Structure typically consists of the following Components:
<Architecture>-<Vendor>-<Operating System>
Target | Meaning |
---|---|
aarch64-apple-darwin | Targeting Apple devices running macOS with ARM 64-bit architecture (Apple Silicon). |
x86_64-apple-darwin | Targeting Apple devices running macOS with Intel 64-bit x86 architecture . |
aarch64-apple-ios | Targeting iOS devices with ARM 64-bit architecture, as used since the iPhone 5S and later, the iPad Air, Air 2 and Pro, with the A7 and later chips. |
armv7s-apple-ios | Targeting iOS devices with 32-bit ARMv7s architecture ("old"), used in A6 and A6X chips on iPhone 5, iPhone 5C and iPad 4. |
armv7-apple-ios | Targeting iOS devices with 32-bit ARMv7 architecture, used in A5 chip ("old"). |
aarch64-apple-ios-sim | Targeting iOS Simulator for Xcode 12 and later on hosts with ARM 64-bit architecture. |
x86_64-apple-ios-sim | Targeting iOS Simulator for Xcode 12 and later on hosts with 64-bit x86 architecture. |
i386-apple-ios | Targeting iOS Simulator on hosts with 32-bit x86 architecture ("old"). |
Example: My workstation computer is a MacBook Air with an M1 chip. Therefore, the following targets are essential for me when developing for macOS and iOS: aarch64-apple-darwin (for macOS), aarch64-apple-ios-sim (simulator for the M1 host environment), and aarch64-apple-ios (for iOS).
Targets needed for Android Development
In the context of IOTA for Flutter the Android Native Development Kit (NDK) must be installed. It is used to provide the necessary tools and APIs to interface with the cross-compiled library. The Target Structure then follows the format:
<Architecture>-<Operating_system>-<ABI*>
ABI* : As part of the Target, the ABI component is different to the correct ABI naming, due to historical reasons or to maintain compatibility. The correct ABI naming is listed in the second column.
Target | ABI | Meaning |
---|---|---|
aarch64-linux-android | arm64-v8a | Targeting Android devices on ARM 64-bit architecture (most modern ARM-based Android devices). |
armv7-linux-androideabi | armeabi-v7a | Targeting Android devices on ARMv7 architecture (older ARM-based Android devices). |
x86_64-linux-android | x86_64 | Targeting Android devices on 64-bit x86 architecture (Android emulators, modern x86-based devices). |
i686-linux-android | x86 | Targeting Android devices on 32-bit x86 architecture (Android emulators, older x86-based devices). |
Example: My only current Android Virtual Device is a Pixel 3a phone with arm64-v8a system image. Therefore the only interesting target for development is aarch64-linux-android.
Which targets are installed on my system?
To find out which targets are installed on your system, run the following command:
rustc --print target-list
How to add missing targets
When you first install a toolchain, rustup installs only the standard library for your host platform - that is, the architecture and operating system you are presently running. To compile to other platforms you must install other target platforms. This is done with the command:
rustup target add <target>
For example:
rustup target add armv7-linux-androideabi
π Β The Rustup Book - Cross-compilation
How to manually cross-compile to a target of your choice
Sometimes, you might need to cross-compile your Rust code to a specific target separately from the Flutter build process. To ensure that everything runs smoothly, make sure you are in the rust folder (on the same level as Cargo.toml).
Manually cross-compile for macOS or iOS
On macOS and/or iOS you can start the process by executing this command in the Terminal:
cargo build --target <target>
For example:
cargo build --target aarch64-apple-ios
Manually cross-compile for Android
On Android you can start the process by executing this command in the Terminal:
cargo ndk -t <abi> build
For example:
cargo ndk -t arm64-v8a build
π Flutter Rust Bridge
Unifying Flutter and Rust: Harnessing the power of two technologies.
On one hand, we have Flutter, a powerful framework for building user interfaces and applications. On the other hand, we have Rust, a high-performance programming language known for its safety and efficiency. But how can we combine these two technologies?
Enter the Flutter Rust Bridge, a GitHub project designed to address this very challenge of integrating both technologies.
π Β Flutter Rust Bridge - User Guide
π Β Flutter Rust Bridge - Github Page
It is important to highlight that, currently, the missing component in this equation is IOTA. However, this tutorial will delve into its integration in the later sections.
Code Generator
The Code Generator is a Rust executable that processes Rust code and produces two outputs: a generated module bridge_generated.rs
on the Rust side and a Dart file bridge_generated.dart
with definitions and implementations on the Dart side. The two files act as a bridge between the two programming languages.
The Code Generator offers a range of options that you can access by using this terminal command:
flutter_rust_bridge_codegen --help
This command provides you with a list of available options and their respective descriptions, allowing you to customize the code generation process according to your specific needs.
In the Workflow section documenting an Android demo, I used these options:
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \ // Source file
--dart-output ./lib/bridge_generated.dart \ // Output in Dart folder
--dart-decl-output ./lib/bridge_definitions.dart // Seperate definition file in Dart folder
When working on code for iOS, you will require the following options for the Code Generator:
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \ // Source file
--dart-output ./lib/bridge_generated.dart \ // Output in Dart folder
--dart-decl-output ./lib/bridge_definitions.dart \ // Seperate definition file in Dart folder
--c-output ios/Runner/bridge_generated.h // Generate a C header in the correct iOS folder
Similar for macOS:
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \ // Source file
--dart-output ./lib/bridge_generated.dart \ // Output in Dart folder
--dart-decl-output ./lib/bridge_definitions.dart \ // Seperate definition file in Dart folder
--c-output macos/Runner/bridge_generated.h // Generate a C header in the correct macOS folder
A task for you
For other target platforms such as Browser, Linux, and Windows, please refer to the User Guide for specific instructions on using the Code Generator. Additionally, you have the option to create a YAML config file and utilize the command
flutter_run_bridge_codegen [CONFIG_FILE]
to streamline the code generation process. I encourage you to give it a try and explore the flexibility and convenience it offers.
About
Bridging the gap: The power of Flutter Rust Bridge
The Flutter Rust Bridge serves as the foundation for the "IOTA for Flutter" project. I was glad to have found this solution for the glueing task, with extensive documentation. There may be other alternatives out there, but I didn't look further because the Flutter Rust Bridge met my needs. The scope and features are so vast that I haven't even explored them all myself.
One of the selection criteria was the project's open-source nature and the fact that it is an active project. From my impression, regular updates ensure that the Flutter Rust Bridge stays up to date.
Additionally, it was important to me that I found support when I had questions. I received friendly and helpful answers, which was reassuring. It's great to know that there are dedicated individuals willing to help and facilitate the development process.
For developers embarking on the IOTA for Flutter journey, the GitHub page of the Flutter Rust Bridge is a valuable resource. Whether you have questions or can support others with your knowledge, it's a place to turn to.
How does IOTA for Flutter differ from Flutter Rust Bridge?
While the workflow of IOTA for Flutter utilizes the Flutter Rust Bridge as its foundation, it offers distinct advantages through its context-specific content centered around Shimmer and IOTA. The focus of IOTA for Flutter is to enhance the application and installation process, which has sometimes proven to be challenging. By addressing these difficulties, IOTA for Flutter aims to provide a smoother and more user-friendly experience.
What further convinced me is the power of the project itself: the Flutter Rust Bridge is packed with features! It's like a treasure chest waiting to be explored. I haven't discovered all its possibilities yet, and that's something for you to explore as well.
For example, IOTA for Flutter has not yet utilized the capability to create multiple API files, to run Flutter Unit tests or the integration of the command runner just, which could potentially optimize the workflow, among other things.
All in all, the Flutter Rust Bridge has become the indispensable tool for this project. I'm grateful to have found a solution that perfectly fits our needs and simplifies the development process.
Language translations
Exploring the possibilities and limitations of integrating Dart and Rust.
The user guide devotes a large portion of Rust code vs. Dart code comparisons to highlight the possibilities and limitations of using the Flutter Rust Bridge. It is highly recommended to consult the user guide when starting to write your own code in api.rs
:
π Β Flutter Rust Bridge - User Guide - Features - Language translations
In addition to these chapters in the user guide, the frb example folder in the GitHub repository serves as a valuable resource. It contains two files: one with Dart code with numerous examples, and another with corresponding Rust code demonstrating the same examples. This resource provides practical illustrations of how integration between Rust and Dart can be implemented and provides insight and inspiration for your own development projects.
Compare that | to that |
---|---|
π Dart code examples | π Rust code examples |
Alternatives
"The world keeps turning. Are there alternatives to the Flutter Rust Bridge?"
A user has pointed out to me that there's another project connecting the realms of Flutter as a GUI and Rust as a backend.
Rinf: Rust in Flutter
In essence, the goal is to have a way to make requests from Flutter to the Rust backend. This is precisely what Rinf accomplishes. It resembles more of an API that allows you to make RustRequests associated with a specific operation from Flutter. In return, the call yields RustResponses as the response.
What matters most is the workflow. If another tool proves to offer a simpler and/or less error-prone workflow, it's worth exploring and evaluating. At the moment, I cannot personally assess whether this applies to Rinf. Therefore, I'm just highlighting this alternative and leave it to you, the developers, to try it out and make your own judgment.
In this tutorial, I consistently use the Flutter Rust Bridge. At this point (December 2023), it doesn't make sense for me to introduce another tool as the intermediary between Flutter and Rust.
Feel free to share your experiences with me on Twitter (@dj_kaiota). I'd love to hear about it!
π IOTA libraries
How should one approach IOTA's Rust libraries? What sources of information are available? How can one discover the information needed for their work?
Note from 30/11/2023 : Please note that the iota.rs and wallet.rs libraries have been deprecated. The recommended approach for development is now using the new IOTA SDK, which consolidates iota.rs and wallet.rs into a single library.
It's important to be aware that, despite this not being the preferred choice from now on, the tutorial primarily utilizes the older libraries in most chapters. This decision is based on the fact that the tutorial and accompanying videos were initially created using the deprecated libraries.
While the workflow remains unchanged, there are slight differences in dependencies within Cargo.toml and the Rust backend code. To maintain consistency, the tutorial retains the use of the older libraries. However, an additional chapter will be included for the Playground App (see chapter "Building a Comprehensive App"), where the Rust code based on the IOTA SDK will be provided.
To approach IOTA's Rust libraries, there are several sources of information available to help you understand and work with them effectively:
Official Wiki Documentation: Start by referring to the official documentation provided by IOTA. It typically includes guides, tutorials, and API references specific to the Rust libraries. The documentation will give you an overview of the available functionality and important concepts to consider. You will also find some ...
... Examples: Look for examples that demonstrate the usage of IOTA's Rust libraries. These resources can provide hands-on guidance and practical insights into integrating the libraries into your own projects. I will use some of them in the later sections where I build the "Playground" app.
π Β Wiki - IOTA's Identity Framework
GitHub Repositories: Visit the GitHub repositories for the IOTA Rust libraries. It serves as the central hub for code ("single source of truth"), issue tracking, and community discussions. Explore the repositories to access the source code, documentation files, and discussions related to the libraries. You can also open issues or participate in discussions to seek clarification or contribute to the project.
Communication Channels: Stay connected with the IOTA community through official communication channels like the IOTA Discord server, where you can interact with developers, ask questions, and receive support. The Discord server is often a great place to connect with fellow developers and learn from their experiences.
Deprecated Libraries
Github
π Β GitHub - iota.rs (deprecated)
π Β GitHub - wallet.rs (deprecated)
IOTA SDK and identity.rs
Code's organization: How are IOTA's libraries structured?
Good news: it's getting easier!
To remind you: it is not the task of this tutorial to explain the libraries in detail. The purpose of the listed modules and features will become clearer when you create a project and use them. It's your responsibility to dive deep into it.
A big step in the right direction
The IOTA SDK consolidates the two deprecated libraries iota.rs and wallet.rs. It also addresses the issue with rocksdb, making it easier for us to use on Android and iOS.
IOTA SDK
In order to better analyze the code, I recommend:
- Downloading the latest version of the source code (either by downloading and extracting the zip file or using git clone) and opening it in your IDE.
- Creating the Rust Docs as described in the chapter Create Rust Docs.
Take a look inside the src/
folder. You'll find the client
and wallet
directories there.
Module | Description |
---|---|
client | A general purpose IOTA client for interaction with the IOTA network (Tangle). High-level functions are accessible via the Client struct. |
wallet | The IOTA Wallet Library to create and use Accounts which can be secured by Stronghold and can be persisted in a database (rocksdb). Needed to send and receive values. |
pow | Provides proof of work implementations and scoring for the IOTA protocol as a means to rate-limit the network. See Message PoW. |
utils | Utility functions for serialization and deserialization. |
typesΒ | Common types required by nodes and clients APIs like blocks, responses and DTOs. |
Your task: Review the feature definitions in sdk/Cargo.toml
. Upon inspection, you'll notice that enabling the wallet feature will inherently incorporate the client feature.
Compare your insights with the source code in sdk/src/lib.rs
:
The source code of the modules client, wallet and pow can be included or excluded from the IOTA SDK Library, depending on the definition in YOUR PROJECT's Cargo.toml
.
identity.rs
The main package of this workspace is located in the identity_iota/
directory. This package will be built as a crate with the name "identity_iota" (use this name to search for it in https://crates.io). The other packages of this workspace are dependencies of the main package.
π Β Complete latest Documentation
π Β Wiki - IOTA's Identity Framework
The main module "identity_iota" contains the IOTA DID method implementation for the IOTA ledger. It implements the W3C Decentralized Identifiers (DID) and Verifiable Credentials specifications.
π Β Decentralized Identifiers (DID)
A look at identity_iota/Cargo.toml
reveals the features of the library crate.
iota.rs, wallet.rs and identity.rs
DEPRECATED - please refer to chapter IOTA SDK and identity.rs
Code's organization: How are IOTA's libraries structured?
iota.rs (deprecated)
The main package of this workspace is located in the client/
directory. This package will be built as a crate with the name "iota-client" (use this name to search for it in https://crates.io).
A look at client/Cargo.toml
reveals the features of the library crate.
If you're looking for another entry point into iota.rs, you can check out the chapter titled Simple App -> Core API and iota.rs. This chapter provides more information about the structure of the iota-client using an example.
wallet.rs (deprecated)
There is no workspace but only a package which will be built as a crate with the name "iota-wallet" (use this name to search for it in https://crates.io).
A look at Cargo.toml
reveals the features of the library crate.
identity.rs
The main package of this workspace is located in the identity_iota/
directory. This package will be built as a crate with the name "identity_iota" (use this name to search for it in https://crates.io). The other packages of this workspace are dependencies of the main package.
π Β Complete latest Documentation
π Β Wiki - IOTA's Identity Framework
The main module "identity_iota" contains the IOTA DID method implementation for the IOTA ledger. It implements the W3C Decentralized Identifiers (DID) and Verifiable Credentials specifications.
π Β Decentralized Identifiers (DID)
A look at identity_iota/Cargo.toml
reveals the features of the library crate.
Library Versions
DEPRECATED - please refer to chapter IOTA SDK and identity.rs
This chapter focuses heavily on deprecated libraries like iota.rs and wallet.rs. Additionally, it addresses two different protocol versions: Chrysalis and Stardust, which were only relevant at the time of writing mid 2023. If you're new here, the information provided is outdated.
When configuring dependencies, it's crucial to consider the compatibility between different libraries and their versions, as well as their support for specific target platforms.
While some third-party crates like OpenSSL and RustLS may have mutually exclusive usage, this isn't a concern with the IOTA libraries. Instead, the key focus lies in addressing the following two questions:
-
Which versions are designated for Stardust, the current protocol version of the IOTA mainnet and Shimmer network, and which versions are intended for Chrysalis, the outdated protocol version of the IOTA mainnet?
-
Which versions can be successfully cross-compiled for the respective platform targets, including iOS, Android, and macOS?
Stardust and Chrysalis
Stardust and Chrysalis are both IOTA protocol versions.
The first question (see above) can be relatively straightforward to address.
Current Library: IOTA SDK
Just use the current version of iota-sdk. It's compatible to Stardust, the current protocol on both Shimmer Network and IOTA Mainnet.
Deprecated Libraries: iota-client.rs / wallet.rs
To support both current networks, the deprecated libraries' versions used should start with major version 2, such as 2.0.1.rc-7. As a developer, you can verify this by checking the Cargo.lock file.
Why?
Browse the list of TIPs, and you will note that every item is tagged with Chrysalis (outdated) or Stardust.
TIP-0013 is labeled as Chrysalis, and it is associated with the REST API, which includes API calls using
api/v1
. These requests are utilized in version 1.4.0.TIP-0025 is labeled as Stardust, and it is associated with the Core REST API that involves API calls using
api/v2
. These requests are utilized in versions 2.x.y.
Versions used for "full-featured" app - based on the deprecated libraries iota.rs and wallet.rs
The second question (see above) is more complex. It primarily revolves around determining which third-party libraries are used by the IOTA libraries and what dependencies are employed by those libraries, and so on.
Because each additional library increases the risk that it may not be cross-compiled for a specific target platform (iOS, Android, etc.). I specifically want to mention the two libraries, libsodium and rocksdb, at this point. Unfortunately, there are often issues when compiling for different targets with these libraries.
Here is a matrix illustrating the library versions utilized in a "full-featured" Stardust app. The objective is to employ iota-client, iota-wallet, and identity_iota simultaneously, along with the stronghold feature.
Status as of Jan 2023
Library Crate | Android | iOS/macOS |
---|---|---|
iota-client | iota-client = { version = "2.0.1-rc.7", default-features = false, features = [ "stronghold" ] } | iota-client = { version = "2.0.1-rc.7", default-features = false, features = [ "stronghold" ] } |
iota-wallet | iota-wallet = { git = "https://github.com/iotaledger/wallet.rs", rev = "05fcb303c657c6faf3cb772f3a3908647614d545", default-features = true} | iota-wallet = { git = "https://github.com/iotaledger/wallet.rs", branch = "develop", default-features = true} |
(includes rocksdb v0.18.0) | (includes rocksdb v0.19.0) | |
identity_iota | identity_iota = { version = "0.7.0-alpha.6", default-features = true } | identity_iota = { version = "0.7.0-alpha.6", default-features = true } |
You need to include different dependencies for the crate iota-wallet. This is due to an unsolved issue with regard to the third-party library of rocksdb.
Deeper Insights
A personal wish: Gaining Deeper Insights
Kudos to the library developers
The library developers are doing an exceptional job, which I greatly appreciate and admire. Their dedication and expertise shine through in the creation of such remarkable libraries. The thought and effort they have put into crafting a robust and user-friendly tool is truly commendable. I am genuinely grateful for their hard work and the invaluable contributions they have made to the development community. Kudos to the library developers for their outstanding efforts!
From application developer's perspective
Ah, the mystical world of libraries and their captivating features! As an application developer, I yearn for the wisdom hidden within their depths. But lo and behold, it is not enough to wander through the labyrinth of documentation and tutorials, analyzing structures and classes. What I truly desire are the sacred scrolls of knowledge handed down by the library sages themselves β the library developers!
For who else possesses the arcane secrets of their creations? Only they hold the key to unlock the mysteries of design, capabilities, and best practices. With their insights and expertise, we can navigate the treacherous landscape of options and make informed choices.
There are examples, which are indeed quite helpful. Even if I apply them myself and try to understand them, it's still not easy to come up with my own use cases. It's like trying to tame a wild library beast β I lack the intuition to navigate its vast capabilities. The options are overwhelming, and the amount of knowledge I need feels like an insurmountable mountain. It's as if I'm standing at the entrance of a grand library, filled with books of endless possibilities, yet unsure of where to start.
How can one easily develop a sense of familiarity with a library?
One possible solution might be that library developers provide a greater number of examples and, in addition, offering more comprehensive explanations about it to enhance our understanding of the subject matter.
I as app developer, I'm curious to know which functions are being used and why, how they are structured, and what options are maybe available. How are options implemented? Are there any alternative approaches worth considering? What are the peculiarities of these examples, and when would you recommend opting for an alternative solution? Thorough elucidation of these aspects by library developers would be immensely welcome and help me navigate the library's intricacies with confidence.
Flutter only
Getting a bit familiar with Flutter Development in Visual Studio Code.
Flutter Standard App
The purpose of this section is to get familiar with Flutter and how to work with it in Visual Studio Code. The Flutter Default App is a small app that consists of only one file (main.dart). This app can be generated quickly, allowing you to practice how to launch the app on different target platforms using Visual Studio.
Create the Flutter Standard App in your Terminal App
flutter create <flutter_project_name>
Executing this command will generate a new folder in the current directory that includes all necessary files and subfolders for a Flutter project. The default application is a Counter App, featuring a screen with a number that can be incremented by tapping on a Floating Action Button.
Open the app in Visual Studio code
a) Switch into the subfolder flutter_project_name
cd <flutter_project_name>
b) Open VS Code by
code .
On Windows, the command
code .
should work out of the box.If not working on Mac, you need to install the 'code' command once: Open up VS Code, go to View -> Command Palette (or use the shortcut, see Keyboard Shortcuts below), type 'shell' and search for Shell Command: Install 'code' command in PATH. Click on it and it will install in seconds.
Launch the App in different ways
Once Visual Studio Code is open you can launch the app in different ways.
a) By using flutter run
command:
Open the Terminal in VS Code (see Shortcut below) and type in flutter run
. If there are several targets, the execution is paused and you need to select the target:
b) By selecting the Target Platform first, and then by using one of several starting options (ensure that the main.dart file is open):
When you launch the app using this method, any code changes you make will trigger a hot reload automatically. If you were to use "flutter run", you would need to manually press the "r" key to initiate a hot reload of the app.
Keyboard Shortcuts (Visual Studio Code)
I find these ones especially practical in my daily use of VS Code to boost my productivity:
Function | Windows | Mac | Usage |
---|---|---|---|
Open Files quickly | Ctrl + P | Command + P | Use the shortcut and start typing the name of the file... |
Toggle left Sidebar | Ctrl + B | Command + B | |
Toggle Terminal | Ctrl + J | Command + J | |
Multi-Select Cursor | Ctrl + D | Command + D | When you need to change all occurences of a function or tag in a file, select one. Then use the short cut to select all and make your changes. |
Copy Line | Shift + Alt + Up Shift + Alt + Down |
Option + Shift + Up Option + Shift + Down | Click in one line and use the shortcut. |
Open Command Palette | Ctrl + Shift + P | Command + Shift + P | Use the shortcut and start typing the command... |
Flutter Tutorial For Beginners In 1 Hour
When I first started exploring Flutter, I found this video to be particularly valuable. In addition to learning about the interplay of widgets and arguments, the tips on practical handling in VS Code were also very helpful to me.
π Β Flutter Tutorial For Beginners In 1 Hour
Codelab from Google
As the creator of Flutter and Dart, Google has some excellent resources to make it easy for beginners to get started with Flutter. Here is a codelab that guides developers through the steps, which they can follow in their IDE. Simply click on the button Start codelab.
π Β A Codelab to write your first app
Flutter and Rust
Unstoppable: The Power of Flutter, Rust, and FRB - Flutter Rust Bridge
The sun beat down mercilessly on the dusty parking lot. In the distance, the faint hum of a fan could be heard. Suddenly, two figures emerged from the shadows. One wore a colorful hat and a fluttering cape, the other a sturdy leather jacket and casually tossed a yo-yo.
Flutter and Rust stared deep into each other's eyes, as if they could read each other's thoughts. Suddenly, Flutter broke the silence and said, "I think we could achieve even more if we work together. Your strengths complement my weaknesses and vice versa."
Rust pondered for a moment before replying, "I agree. But how do we do it? How can we combine our powers?"
Flutter grinned and pulled out his phone. "I have an idea," he said, opening up the Flutter framework. "Let's develop an app that combines the best of both worlds. Speed and flexibility from me, and security and robustness from you."
Rust nodded in agreement. "But we'll need FRB to make it happen," he said. Flutter smiled. "Of course. With FRB, we're unbeatable."
With the super power of FRB, Flutter and Rust worked tirelessly to develop the perfect app. And when they finally launched it, the response was overwhelming. Users raved about its speed, security, and functionality.
As they basked in the success of their app, Flutter and Rust couldn't help but wonder, "Could we be even more successful? What if we brought in IOTA?"
But for now, they were content with their partnership and the success they had achieved together. Who knows what the future held? All they knew was that they were ready for whatever challenges lay ahead.
Flutter Rust Bridge (FRB) Template App
FRB Template App - Integrating Rust with Flutter.
I would like to showcase the Flutter Rust Bridge Template app in this chapter. It's from one of the main contributors of the Flutter Rust Bridge, Viet Dinh.
π Β Flutter Rust Bridge Template on Github
This chapter is intended as an additional exercise to become more acquainted with integrating Rust into a Flutter project, without the added complexity of integrating with IOTA.
Additionally, this chapter will cover the configuration steps for macOS and iOS, and as a consequence, it will introduce a modified workflow.
I will start this project from scratch and only take the necessary code from the FRB repository.
From the "straight forward" to a "modified" workflow
Remember the workflow used in the introduction chapter How everything works together.
You can simply list all the steps one after the other in a straight forward workflow.
This works for Android.
When working on macOS and iOS, you may encounter a Chicken and Egg problem, which is not a serious issue but may result in error messages that we would like to avoid.
The issue at hand is that the setup for FRB requires configuring a C header file that contains a list of all the exported symbols from the Rust library. However, this header file does not yet exist during the setup process. It will only be generated later when the Dart interface is generated.
To resolve this issue, I combine the Initialization and Development steps.
The Step about Setting up the Flutter Rust Bridge is now separated into two parts.
I will be following the modified workflow in the upcoming subchapters. There are platform specific instructions for setting up the FRB on Android, macOS, and iOS. However, there are also some general steps that apply to all target platforms, which I will cover before and after the specific subchapters.
To create the FRB example app successfully, it's important to follow each subchapter in order, starting from the beginning and moving forward.
Part 1: Initialization and Rust part
FRB Template App: Applying the modified Workflow.
Create the Flutter App
In the Terminal App, create an empty Flutter project - I'll name it example2 - and open it in Visual Code:
flutter create --empty example2
cd example2
code .
Create the Rust Library Project
In VS Code open the terminal and execute:
cargo new --lib rust
Set up the Flutter Rust Bridge (1)
cargo install flutter_rust_bridge_codegen
flutter pub add --dev ffigen && flutter pub add ffi
flutter pub add flutter_rust_bridge
flutter pub add -d build_runner
flutter pub add -d freezed
flutter pub add freezed_annotation
In Cargo.toml add:
[dependencies]
flutter_rust_bridge = "1"
[lib]
crate-type = ["staticlib", "cdylib"]
Development of the Rust API of the FRB Template App
App specific dependencies
The Cargo.toml
should look like (add anyhow):
[dependencies]
anyhow = "1"
flutter_rust_bridge = "1"
Rust code
Create the file rust/src/api.rs
and add this content:
// This is the entry point of your Rust library.
// When adding new code to your project, note that only items used
// here will be transformed to their Dart equivalents.
// A plain enum without any fields. This is similar to Dart- or C-style enums.
// flutter_rust_bridge is capable of generating code for enums with fields
// (@freezed classes in Dart and tagged unions in C).
pub enum Platform {
Unknown,
Android,
Ios,
Windows,
Unix,
MacIntel,
MacApple,
Wasm,
}
// A function definition in Rust. Similar to Dart, the return type must always be named
// and is never inferred.
pub fn platform() -> Platform {
// This is a macro, a special expression that expands into code. In Rust, all macros
// end with an exclamation mark and can be invoked with all kinds of brackets (parentheses,
// brackets and curly braces). However, certain conventions exist, for example the
// vector macro is almost always invoked as vec![..].
//
// The cfg!() macro returns a boolean value based on the current compiler configuration.
// When attached to expressions (#[cfg(..)] form), they show or hide the expression at compile time.
// Here, however, they evaluate to runtime values, which may or may not be optimized out
// by the compiler. A variety of configurations are demonstrated here which cover most of
// the modern oeprating systems. Try running the Flutter application on different machines
// and see if it matches your expected OS.
//
// Furthermore, in Rust, the last expression in a function is the return value and does
// not have the trailing semicolon. This entire if-else chain forms a single expression.
if cfg!(windows) {
Platform::Windows
} else if cfg!(target_os = "android") {
Platform::Android
} else if cfg!(target_os = "ios") {
Platform::Ios
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Platform::MacApple
} else if cfg!(target_os = "macos") {
Platform::MacIntel
} else if cfg!(target_family = "wasm") {
Platform::Wasm
} else if cfg!(unix) {
Platform::Unix
} else {
Platform::Unknown
}
}
// The convention for Rust identifiers is the snake_case,
// and they are automatically converted to camelCase on the Dart side.
pub fn rust_release_mode() -> bool {
cfg!(not(debug_assertions))
}
Insert this line in lib.rs
:
mod api;
Part 2: Android specific instructions
FRB Template App: Applying the modified Workflow.
Android steps
Generate the Dart Interface
Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
Set up the Flutter Rust Bridge (2)
To install the cargo-ndk
command use:
cargo install cargo-ndk
In android/app/build.gradle, fix error:
Replace GradleException by FileNotFoundException
In android/app/build.gradle, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
commandLine 'cargo', 'ndk',
'-t', 'arm64-v8a',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
I only included the ABI arm64-v8a from my Android Emulator.
Part 3: Common macOS/iOS specific instructions
FRB Template App: Applying the modified Workflow.
Common macOS and iOS steps
An common step for macOS / iOS is needed: creating an Xcode project inside of the Rust library project folder (rust/). This can be done using the cargo-xcode
command.
This Cargo subcommand is used to generate all Xcode project files for Rust projects. It will also create a build rule that will be used to create a dynamic and a static library from the Rust library code in Xcode's build step. If you don't remember, take a look back and read the section Xcode Essentials.
To install the cargo-xcode
command use:
cargo install cargo-xcode@1.5.0
After the installation of the command, create the Rust Xcode project. Make sure to be in the rust/ directory. From the project's root folder you may switch into the right directory:
cd rust
cargo xcode
cd ..
In this picture puzzle, you need to find the differences between two images. First, carefully examine the "before" image above β¬οΈ - take note of all the details - and then look at the "after" image below β¬οΈ and try to identify the differences.
Part 4: macOS specific instructions
FRB Template App: Applying the modified Workflow.
macOS steps
Generate the Dart Interface
Our next task is to create the generated code. This will also copy the C header file bridge_generated.h
into the folder macos/Runner/. Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--c-output macos/Runner/bridge_generated.h
Create the subproject
How is a subproject created in Xcode?
Simply open the macos/Runner.xcodeproj in Xcode, open the rust/ directory in Finder and drag the rust.xcodeproj into the Runner folder. The next images will illustrate the steps.
Adjust the Runner Target's Build Phases
For macOS, FRB recommends to include the dynamic library.
a) In Runner Target's Build Phase -> Target Dependencies:
Click on "+" and select rust-cdylib
.
b) In Runner Target's Build Phase -> Link Binary with Libraries:
Click on "+" and select rust.dylib
.
Adjust the Runner Target's Build Settings
Start typing "Objective-C Bridging Header" in the filter... the hard-to-find setting is in the Swift Compiler - General section of the settings.
As value, insert:
Runner/bridge_generated.h
Adjust Minimum Deployments
To ensure that your app can run on your host computer and Xcode version, you may only be able to support newer macOS versions. To set the minimum supported macOS version for your app, go to the General tab and select macOS version 13.1
as the Minimum Deployments target.
Adjust the AppDelegate.swift
file
Switch to Visual Studio Code and open the file macos/Runner/AppDelegate.swift
. We need to call the function dummy_method_to_enforce_bundling() (from FRB) somewhere to avoid that Xcode handles our library as dead code.
Add:
dummy_method_to_enforce_bundling()
Your file should look like:
import Cocoa
import FlutterMacOS
@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
dummy_method_to_enforce_bundling()
return true
}
}
Just for your information
When it comes to building the libraries, you might be curious about the process. When you use the cargo-xcode
command to create the Rust Xcode project, it also installs build rules. These build rules tell Xcode how to create the libraries during the build process. So, when Xcode builds the application, it first builds the subproject and then follows the build rules to create the necessary libraries.
Look twice! It's not the target of the Runner Project, but the target of the Rust subproject!
Problem: Flutter doesn't find the dynamic library
When you have installed the version 1.5.0 from cargo-xcode
(as you can see in the Build rules image above), Flutter will not be able to find the dynamic library. You'll get an error like this:
Launching lib/main.dart on macOS in debug mode...
--- xcodebuild: WARNING: Using the first of multiple matching destinations:
{ platform:macOS, arch:arm64, id:00008103-001251441A62001E }
{ platform:macOS, arch:x86_64, id:00008103-001251441A62001E }
Building macOS application...
dyld[64001]: Library not loaded: /usr/local/lib/rust.dylib
Referenced from: <29A02B41-EAF9-315B-977F-429B4DD80404> /Users/kaimueller/Documents/iota_for_flutter/example2/build/macos/Build/Products/Debug/example2.app/Contents/MacOS/example2
Reason: tried: '/usr/local/lib/rust.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/usr/local/lib/rust.dylib' (no such file), '/usr/local/lib/rust.dylib' (no such file), '/usr/lib/rust.dylib' (no such file, not in dyld cache)
Error waiting for a debug connection: The log reader stopped unexpectedly, or never started.
Error launching application on macOS.
To find out the version of cargo-xcode, you can run the command
cargo install --list
to list all installed Cargo subcommands along with their versions.
Solution 1
Here's a first solution:
- In VS Code, open file rust/rust.xcodeproj/project.pbxproj.
- Search for the 2 lines with the text
CARGO_XCODE_FEATURES = "";
- Insert a new line after each of these lines and insert
DYLIB_INSTALL_NAME_BASE = "$(TARGET_BUILD_DIR)";
Solution 2
After completing this chapter, another solution emerged.
The alternative solution is outlined in the tutorial's chapter titled Building a Simple App, see Building for macOS.
Part 5: iOS specific instructions
FRB Template App: Applying the modified Workflow.
iOS steps
Generate the Dart Interface
Our next task is to create the generated code. This will also copy the C header file bridge_generated.h
into the folder ios/Runner/. Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--c-output ios/Runner/bridge_generated.h
Create the subproject
How is a subproject created in Xcode?
Simply open the ios/Runner.xcodeproj in Xcode, open the rust/ directory in Finder and drag the rust.xcodeproj into the Runner folder. The next images will illustrate the steps.
Adjust the Runner Target's Build Phases
For iOS, FRB recommends to include the static library.
a) In Runner Target's Build Phase -> Target Dependencies:
Click on "+" and select rust-staticlib
.
b) In Runner Target's Build Phase -> Link Binary with Libraries:
Click on "+" and select librust_static.a
.
Adjust the Runner-Bridging-Header.h file
Switch to Visual Studio Code and open the file ios/Runner/Runner-Bridging-Header.h
to add our generated header file bridge_generated.h
.
Add the line:
#import "bridge_generated.h"
The content should look like:
#import "GeneratedPluginRegistrant.h"
#import "bridge_generated.h"
Adjust the AppDelegate.swift
file
In Visual Studio Code, open the file ios/Runner/AppDelegate.swift
. We need to call the function dummy_method_to_enforce_bundling() (from FRB) somewhere to avoid that Xcode handles our library as dead code.
Add:
let dummy = dummy_method_to_enforce_bundling()
print(dummy)
Your file should look like:
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let dummy = dummy_method_to_enforce_bundling()
print(dummy)
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
Part 6: Flutter part
FRB Template App: Applying the modified Workflow.
Flutter instructions
Create helper file
Let's make a new file in the lib/ directory called ffi.dart
. This file will help us load the library into our code. Here's what you should put inside the file:
// This file initializes the dynamic library and connects it with the stub
// generated by flutter_rust_bridge_codegen.
import 'dart:ffi';
import 'bridge_generated.dart';
import 'bridge_definitions.dart';
export 'bridge_definitions.dart';
// Re-export the bridge so it is only necessary to import this file.
export 'bridge_generated.dart';
import 'dart:io' as io;
const _base = 'rust';
// On MacOS, the dynamic library is not bundled with the binary,
// but rather directly **linked** against the binary.
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
final Rust api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
Create your main.dart
file
This is where your Flutter app starts its journey! Usually, you'd have to write all the code yourself, but in this case, simply replace the entire content of the file with:
import 'package:flutter/material.dart';
import 'ffi.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// Try running your application with "flutter run". You'll see the
// application has a blue toolbar. Then, without quitting the app, try
// changing the primarySwatch below to Colors.green and then invoke
// "hot reload" (press "r" in the console where you ran "flutter run",
// or simply save your changes to "hot reload" in a Flutter IDE).
// Notice that the counter didn't reset back to zero; the application
// is not restarted.
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// These futures belong to the state and are only initialized once,
// in the initState method.
late Future<Platform> platform;
late Future<bool> isRelease;
@override
void initState() {
super.initState();
platform = api.platform();
isRelease = api.rustReleaseMode();
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Invoke "debug painting" (press "p" in the console, choose the
// "Toggle Debug Paint" action from the Flutter Inspector in Android
// Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
// to see the wireframe for each widget.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text("You're running on"),
// To render the results of a Future, a FutureBuilder is used which
// turns a Future into an AsyncSnapshot, which can be used to
// extract the error state, the loading state and the data if
// available.
//
// Here, the generic type that the FutureBuilder manages is
// explicitly named, because if omitted the snapshot will have the
// type of AsyncSnapshot<Object?>.
FutureBuilder<List<dynamic>>(
// We await two unrelated futures here, so the type has to be
// List<dynamic>.
future: Future.wait([platform, isRelease]),
builder: (context, snap) {
final style = Theme.of(context).textTheme.headline4;
if (snap.error != null) {
// An error has been encountered, so give an appropriate response and
// pass the error details to an unobstructive tooltip.
debugPrint(snap.error.toString());
return Tooltip(
message: snap.error.toString(),
child: Text('Unknown OS', style: style),
);
}
// Guard return here, the data is not ready yet.
final data = snap.data;
if (data == null) return const CircularProgressIndicator();
// Finally, retrieve the data expected in the same order provided
// to the FutureBuilder.future.
final Platform platform = data[0];
final release = data[1] ? 'Release' : 'Debug';
final text = const {
Platform.Android: 'Android',
Platform.Ios: 'iOS',
Platform.MacApple: 'MacOS with Apple Silicon',
Platform.MacIntel: 'MacOS',
Platform.Windows: 'Windows',
Platform.Unix: 'Unix',
Platform.Wasm: 'the Web',
}[platform] ??
'Unknown OS';
return Text('$text ($release)', style: style);
},
)
],
),
),
);
}
}
Part 7: Build and Run
FRB Template App: Applying the modified Workflow.
All the necessary steps have been completed. If you're unsure about how to build and run the app for different targets, please refer to the launch instructions provided in Flutter only.
Build and run on Android
Build and run on macOS
Build and run on iOS
Logging Example App
Logging: Stream Rust log messages (trace, debug, warn, info, error) to Flutter.
Stream
This chapter will focus on implementing logging in a Flutter + Rust application. When it comes to handling scenarios where you create a data consumer once and keep adding data to it continuously, Flutter provides a very useful abstraction called a Stream
.
In this way, it is possible to create a function with little code that sends log messages from Rust to Flutter. Streaming in the opposite direction, from Flutter to Rust, unfortunately is not possible.
What is our goal?
The aim is to use logger macros on the Rust side, such as trace!, debug!, warn!, info!, and error!, and integrate them into the Rust code in the application. Flutter is supposed to process the incoming log entries.
Here, in a slightly modified form, I present the logging example of the Flutter-Rust-Bridge:
π Β Flutter Rust Bridge - Logging
The app uses the Flutter Chat UI plugin, which displays a list of incoming messages and allows users to send their own messages. For demonstration purposes, this message is converted into multiple log messages on the Rust side. Flutter receives the entries and can decide using a switch whether the new entry should be streamed to the chat list or to the console.
Resources
Watch the video, check the source code and explore the Github Repository.
WARNING: The name of your project matters!
Don't name your project "logging"!
"logging" is the name of an existing Flutter package (search for it in Flutter packages)...
... which is a dependency of the package "ffigen" ...
... which is a dependency of YOUR project!If your project name was "logging" you would run into serious troubles at some point:
Rust Code Snippets
Cargo.toml
[package]
name = "rust"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
flutter_rust_bridge = "1"
# For the logger
lazy_static = "1.4.0"
log = "0.4.17"
simplelog = "0.12.0"
parking_lot = "0.12.1"
anyhow = "1"
[lib]
crate-type = ["staticlib", "cdylib"]
logger.rs
use std::sync::Once;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use flutter_rust_bridge::StreamSink;
use lazy_static::lazy_static;
use log::{error, info, warn, Log, Metadata, Record};
use parking_lot::RwLock;
use simplelog::*;
use crate::api::LogEntry;
static INIT_LOGGER_ONCE: Once = Once::new();
pub fn init_logger() {
INIT_LOGGER_ONCE.call_once(|| {
let level = LevelFilter::Debug;
assert!(
level <= log::STATIC_MAX_LEVEL,
"Should respect log::STATIC_MAX_LEVEL={:?}, which is done in compile time. level{:?}",
log::STATIC_MAX_LEVEL,
level
);
CombinedLogger::init(vec![
Box::new(SendToDartLogger::new(level)),
// Box::new(MyMobileLogger::new(level)),
// #[cfg(not(any(target_os = "android", target_os = "ios")))]
TermLogger::new(
level,
ConfigBuilder::new()
//.set_time_format_str("%H:%M:%S%.3f")
//.set_time_format_custom(format_description!("[hour]:[minute]:[second].[subsecond]"))
.build(),
TerminalMode::Mixed,
ColorChoice::Auto,
),
])
.unwrap_or_else(|e| {
error!("init_logger (inside 'once') has error: {:?}", e);
});
info!("init_logger (inside 'once') finished");
warn!(
"init_logger finished, chosen level={:?} (deliberately output by warn level)",
level
);
});
}
lazy_static! {
static ref SEND_TO_DART_LOGGER_STREAM_SINK: RwLock<Option<StreamSink<LogEntry>>> =
RwLock::new(None);
}
pub struct SendToDartLogger {
level: LevelFilter,
}
impl SendToDartLogger {
pub fn set_stream_sink(stream_sink: StreamSink<LogEntry>) {
let mut guard = SEND_TO_DART_LOGGER_STREAM_SINK.write();
let overriding = guard.is_some();
*guard = Some(stream_sink);
drop(guard);
if overriding {
warn!(
"SendToDartLogger::set_stream_sink but already exist a sink, thus overriding. \
(This may or may not be a problem. It will happen normally if hot-reload Flutter app.)"
);
}
}
pub fn new(level: LevelFilter) -> Self {
SendToDartLogger { level }
}
fn record_to_entry(record: &Record) -> LogEntry {
let time_millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_millis() as i64;
let level = match record.level() {
Level::Trace => Self::LEVEL_TRACE,
Level::Debug => Self::LEVEL_DEBUG,
Level::Info => Self::LEVEL_INFO,
Level::Warn => Self::LEVEL_WARN,
Level::Error => Self::LEVEL_ERROR,
};
let whole_msg = format!("{}", record.args());
let tag;
let user_id;
let user;
let msg;
if whole_msg.starts_with("my_domain") {
let vector: Vec<&str> = whole_msg.split("@@@").collect();
//Position 1: Domain
//Position 2: Tag
tag = format!("{}", vector[1]);
//Position 3: User-ID
user_id = format!("{}", vector[2]).to_string();
//Position 4: User
user = format!("{}", vector[3]).to_string();
//Position 5: Message
msg = format!("{}", vector[4]);
} else {
tag = record.file().unwrap_or_else(|| record.target()).to_owned().to_string();
user_id = "".into();
user = "".into();
msg = format!("{}", record.args());
}
LogEntry {
time_millis,
level,
tag,
user_id,
user,
msg,
}
}
const LEVEL_TRACE: i32 = 5000;
const LEVEL_DEBUG: i32 = 10000;
const LEVEL_INFO: i32 = 20000;
const LEVEL_WARN: i32 = 30000;
const LEVEL_ERROR: i32 = 40000;
}
impl Log for SendToDartLogger {
fn enabled(&self, _metadata: &Metadata) -> bool {
true
}
fn log(&self, record: &Record) {
let entry = Self::record_to_entry(record);
if let Some(sink) = &*SEND_TO_DART_LOGGER_STREAM_SINK.read() {
sink.add(entry);
}
}
fn flush(&self) {
// no need
}
}
impl SharedLogger for SendToDartLogger {
fn level(&self) -> LevelFilter {
self.level
}
fn config(&self) -> Option<&Config> {
None
}
fn as_log(self: Box<Self>) -> Box<dyn Log> {
Box::new(*self)
}
}
api.rs
use crate::logger;
use anyhow::Result;
use flutter_rust_bridge::StreamSink;
use log::{trace, debug, warn, info, error};
pub struct LogEntry {
pub time_millis: i64,
pub level: i32,
pub tag: String,
pub user_id: String,
pub user: String,
pub msg: String,
}
// Dummy function to fix Rust compiler complaints...
// See https://github.com/fzyzcjy/flutter_rust_bridge/issues/398
// Workaround:
// 1. Save Rust Code
// 2. Execute flutter_rust_bridge_codegen command
// 3. Make any change to Rust code (e.g. add blank) and save again
// -> next compile is ok
#[allow(dead_code, unused_variables)]
pub fn dummy(a: LogEntry) {}
pub fn rust_set_up() -> String {
logger::init_logger();
"Logger was initialized".into()
}
pub fn create_log_stream(s: StreamSink<LogEntry>) -> Result<()> {
logger::SendToDartLogger::set_stream_sink(s);
Ok(())
}
pub fn publish_message(message: String) {
trace!("TRACE --------------- {}", message );
debug!("DEBUG --------------- {}", message );
warn!("WARNING --------------- {}", message );
info!("INFO --------------- {}", message );
error!("ERROR --------------- {}", message );
debug!("my_domain@@@my_tag@@@uuid@@@name@@@This logger message comes from RUST:\n{}", message );
}
lib.rs
mod api;
mod logger;
Flutter / Dart Code Snippets
pubspec.yaml
Don't just copy & paste the content of pubspec.yaml
You have to adjust the property name in line 1:
replace the value <NAME_OF_YOUR_PROJECT> by the name of your project
name: <NAME_OF_YOUR_PROJECT>
description: Logging Example App.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: '>=2.19.6 <3.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
ffi: ^2.0.2
flutter_rust_bridge: ^1.75.2
freezed_annotation: ^2.2.0
flutter_chat_ui: ^1.6.6
file_picker: ^5.3.0
image_picker: ^0.8.7+4
open_filex: ^4.3.2
path_provider: ^2.0.15
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
ffigen: ^7.2.11
build_runner: ^2.3.3
freezed: ^2.3.3
flutter:
uses-material-design: true
ffi.dart
// This file initializes the dynamic library and connects it with the stub
// generated by flutter_rust_bridge_codegen.
import 'dart:ffi';
import 'bridge_generated.dart';
import 'bridge_definitions.dart';
export 'bridge_definitions.dart';
// Re-export the bridge so it is only necessary to import this file.
export 'bridge_generated.dart';
import 'dart:io' as io;
const _base = 'rust';
// On MacOS, the dynamic library is not bundled with the binary,
// but rather directly **linked** against the binary.
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
final Rust api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
main.dart
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:mime/mime.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import 'ffi.dart';
void main() {
initializeDateFormatting().then((_) => runApp(const MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(
home: ChatPage(),
);
}
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
List<types.Message> _messages = [];
final _user = const types.User(id: '82091008-a484-4a89-ae75-a22bf8d6f3ac');
final _tag = "my_tag";
@override
void initState() {
super.initState();
_setup();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Chat(
messages: _messages,
onAttachmentPressed: null, //_handleAttachmentPressed,
onMessageTap: _handleMessageTap,
onPreviewDataFetched: null, //_handlePreviewDataFetched,
onSendPressed: _handleSendPressed,
showUserAvatars: true,
showUserNames: true,
user: _user,
),
);
void _addMessage(types.Message message) {
setState(() {
_messages.insert(0, message);
});
}
void _handleAttachmentPressed() {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) => SafeArea(
child: SizedBox(
height: 144,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
_handleImageSelection();
},
child: const Align(
alignment: AlignmentDirectional.centerStart,
child: Text('Photo'),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
_handleFileSelection();
},
child: const Align(
alignment: AlignmentDirectional.centerStart,
child: Text('File'),
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Align(
alignment: AlignmentDirectional.centerStart,
child: Text('Cancel'),
),
),
],
),
),
),
);
}
void _handleFileSelection() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
);
if (result != null && result.files.single.path != null) {
final message = types.FileMessage(
author: _user,
createdAt: DateTime.now().millisecondsSinceEpoch,
id: const Uuid().v4(),
mimeType: lookupMimeType(result.files.single.path!),
name: result.files.single.name,
size: result.files.single.size,
uri: result.files.single.path!,
);
_addMessage(message);
}
}
void _handleImageSelection() async {
final result = await ImagePicker().pickImage(
imageQuality: 70,
maxWidth: 1440,
source: ImageSource.gallery,
);
if (result != null) {
final bytes = await result.readAsBytes();
final image = await decodeImageFromList(bytes);
final message = types.ImageMessage(
author: _user,
createdAt: DateTime.now().millisecondsSinceEpoch,
height: image.height.toDouble(),
id: const Uuid().v4(),
name: result.name,
size: bytes.length,
uri: result.path,
width: image.width.toDouble(),
);
_addMessage(message);
}
}
void _handleMessageTap(BuildContext _, types.Message message) async {
if (message is types.FileMessage) {
var localPath = message.uri;
if (message.uri.startsWith('http')) {
try {
final index =
_messages.indexWhere((element) => element.id == message.id);
final updatedMessage =
(_messages[index] as types.FileMessage).copyWith(
isLoading: true,
);
setState(() {
_messages[index] = updatedMessage;
});
final client = http.Client();
final request = await client.get(Uri.parse(message.uri));
final bytes = request.bodyBytes;
final documentsDir = (await getApplicationDocumentsDirectory()).path;
localPath = '$documentsDir/${message.name}';
if (!File(localPath).existsSync()) {
final file = File(localPath);
await file.writeAsBytes(bytes);
}
} finally {
final index =
_messages.indexWhere((element) => element.id == message.id);
final updatedMessage =
(_messages[index] as types.FileMessage).copyWith(
isLoading: null,
);
setState(() {
_messages[index] = updatedMessage;
});
}
}
await OpenFilex.open(localPath);
}
}
void _handlePreviewDataFetched(
types.TextMessage message,
types.PreviewData previewData,
) {
final index = _messages.indexWhere((element) => element.id == message.id);
final updatedMessage = (_messages[index] as types.TextMessage).copyWith(
previewData: previewData,
);
setState(() {
_messages[index] = updatedMessage;
});
}
void _handleSendPressed(types.PartialText message) {
final textMessage = types.TextMessage(
author: _user,
createdAt: DateTime.now().millisecondsSinceEpoch,
id: const Uuid().v4(),
text: message.text,
);
//_addMessage(textMessage);
_callFfiPublishMessage(message.text);
}
void _loadMessages() async {
final response = await rootBundle.loadString('assets/messages.json');
final messages = (jsonDecode(response) as List)
.map((e) => types.Message.fromJson(e as Map<String, dynamic>))
.toList();
setState(() {
_messages = messages;
});
}
//------- STREAMING & LOGGING --------------------------------------------
types.TextMessage createTextMessage(
String userId, String user, String newMessage, int timeMillis) {
final userObject = types.User(
id: userId,
);
final textMessage = types.TextMessage(
author: userObject,
createdAt: timeMillis,
id: const Uuid().v4(),
text: newMessage,
);
return textMessage;
}
Future<void> _callFfiPublishMessage(String message) async {
await api.publishMessage(message: message);
}
Future<void> _setup() async {
String _result = await api.rustSetUp();
_addMessage(createTextMessage(
"uuid", "", _result, DateTime.now().millisecondsSinceEpoch));
api.createLogStream().listen((event) {
if (event.tag == _tag) {
_addMessage(createTextMessage(
event.userId, event.user, event.msg, event.timeMillis));
} else {
print(
'LOG FROM RUST: ${event.level} ${event.tag} ${event.userId} ${event.user} ${event.msg} ${event.timeMillis}');
}
});
}
}
Github Repository
You will find the complete source code from the video in this repository:
π Β Repository - Logging Example App
What's the result?
Our target is to create a simple app to send a data message to the tangle, using the IOTA client.
Video
Let's write a User Story
As a user, I want to use an app to create a message and tag it, so that I can then send it to the tangle. I also want to be able to find my tag and message in the tangle in order to check if the data was sent correctly.
Acceptance Criteria:
- The app should provide a user-friendly interface for creating and sending messages.
- The app should allow me to enter the content of the message.
- The app should have a field where I can add a tag to the message.
- The app should have a button to send the message and the tag to the tangle.
- Message and tag are sent as Block with Tagged Data Payload.
- The app should store the tag, message and block id.
- The app should provide a list of the sent tagged data blocks.
- The app should allow to open the tangle explorer for a selected list item.
Note: It is assumed that the user is using the Shimmer Network.
Describing the UI and Screen Flow
An image speaks louder than a thousand words...
Github Repository
You will find the complete source code from the video in this repository:
π Β Repository - Simple App complete
Core API and IOTA's Rust library
Seamless Connection: Utilize an IOTA client for easy integration with Core API of the node, to access the tangle.
In this chapter, I will demonstrate how I utilize an official example as a template to create a function for my own Rust API.
Context
As a developer, you are accustomed to querying REST APIs. The Shimmer and IOTA nodes provide such APIs. At a higher level, IOTA provides a library to support us as Rust developers by encapsulating certain calls and exposing them as functions in the so called client. This relationship has already been shown in the movie clip "How everything works together" starting from minute 2:53.
In the video you can see the Swagger page of a node with its endpoints. Then it is demonstrated that you could make individual endpoint calls using Postman.
Ultimately, the client in the IOTA SDK (formerly: iota.rs) takes over the task to communicate with the node's API.
The structure of a block including a tag and a message is displayed when you expand the accordion with the POST endpoint /api/core/v2/blocks
.
Posting a Block into the tangle
To post a block of this kind using the features provided by the IOTA library, we can utilize the example 004_example_tagged_data.rs as a template. Let's first try it out.
Here's the source code:
// iota.rs -> client/examples/block/004_example_tagged_data.rs
// Copyright 2021 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
//! This example sends a block with a tagged data payload.
//! Run: `cargo run --example block_tagged_data --release -- [NODE URL]`.
use iota_client::{block::payload::Payload, Client, Result};
#[tokio::main]
async fn main() -> Result<()> {
// Take the node URL from command line argument or use one from env as default.
let node_url = std::env::args().nth(1).unwrap_or_else(|| {
// This example uses dotenv, which is not safe for use in production.
dotenv::dotenv().ok();
std::env::var("NODE_URL").unwrap()
});
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish()?;
// Create and send the block with tag and data.
let block = client
.block()
.with_tag(b"Hello".to_vec())
.with_data(b"Tangle".to_vec())
.finish()
.await?;
println!("{block:#?}\n");
if let Some(Payload::TaggedData(payload)) = block.payload() {
println!(
"Tag: {}",
String::from_utf8(payload.tag().to_vec()).expect("found invalid UTF-8")
);
println!(
"Data: {}",
String::from_utf8(payload.data().to_vec()).expect("found invalid UTF-8")
);
}
println!(
"\nBlock with tag and data sent: {}/block/{}",
std::env::var("EXPLORER_URL").unwrap(),
block.id()
);
Ok(())
}
// IOTA SDK -> sdk/examples/client/block/04_block_tagged_data.rs
// Copyright 2021 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
//! This example sends a block with a tagged data payload.
//!
//! Rename `.env.example` to `.env` first, then run the command:
//! ```sh
//! cargo run --release --example block_tagged_data [TAG] [DATA]
//! ```
use iota_sdk::{
client::{Client, Result},
types::block::payload::Payload,
};
#[tokio::main]
async fn main() -> Result<()> {
// This example uses secrets in environment variables for simplicity which should not be done in production.
dotenvy::dotenv().ok();
for var in ["NODE_URL", "EXPLORER_URL"] {
std::env::var(var).expect(&format!(".env variable '{var}' is undefined, see .env.example"));
}
let node_url = std::env::var("NODE_URL").unwrap();
let tag = std::env::args().nth(1).unwrap_or_else(|| "Hello".to_string());
let data = std::env::args().nth(2).unwrap_or_else(|| "Tangle".to_string());
// Create a node client.
let client = Client::builder().with_node(&node_url)?.finish().await?;
// Create and send the block with tag and data.
let block = client
.build_block()
.with_tag(tag.as_bytes().to_vec())
.with_data(data.as_bytes().to_vec())
.finish()
.await?;
println!("{block:#?}\n");
if let Some(Payload::TaggedData(payload)) = block.payload() {
println!(
"Tag: {}",
String::from_utf8(payload.tag().to_vec()).expect("found invalid UTF-8")
);
println!(
"Data: {}",
String::from_utf8(payload.data().to_vec()).expect("found invalid UTF-8")
);
}
println!(
"Block with tag and data sent: {}/block/{}",
std::env::var("EXPLORER_URL").unwrap(),
block.id()
);
Ok(())
}
Hint for the deprecated iota.rs example: If you run the example block_tagged_data using the command line with argument [NODE_URL], e.g. by
cargo run --example block_tagged_data --release -- "https://api.shimmer.network"
then you will get an error message indicating that the EXPLORER_URL is missing. This is because the environment variables have not been loaded. To fix that, include
dotenv::dotenv().ok();
in the line before the lastprintln!
command.This was fixed in the example for IOTA SDK.
Understanding the code
The two important lines in the code above are the ones for
- creating a client instance that can operate and exchange data with the tangle using a node, and
- generating the block, which includes sending the block to the Tangle in the finish method.
I want to exemplify how a client instance is created. A static method of the Client
struct first creates a ClientBuilder
. The builder allows dynamic configuration of the client by chaining multiple methods to override default values. Only when the finish()
method is called, the client instance is returned from the builder.
The principle used for creating instances with builders is frequently employed in the library, such as with the
Node_Manager
,Blocks
(used in the code above), orAddresses
. It's like the builder taking Lego blocks from a storage shelf and assembling them together.
The next important code line is:
// Create and send the block with tag and data.
let block = client
.block()
.with_tag(b"Hello".to_vec())
.with_data(b"Tangle".to_vec())
.finish()
.await?;
// Create and send the block with tag and data.
let block = client
.build_block()
.with_tag(tag.as_bytes().to_vec())
.with_data(data.as_bytes().to_vec())
.finish()
.await?;
Explanation for the deprecated iota.rs
At first, a ClientBlockBuilder is created by calling the function client.block()
. This function can be found in client/src/api/block_builder/high_level.rs
.
If you wonder why this
client
function is not defined in theclient.rs
file let me remind you that in Rust, you can provide implementations for your struct throughout the crate! This language feature is used in iota.rs.
By utilizing the with_* functions in client/src/api/block_builder/mod.rs
a tag and a data payload are added to the block.
The function finish()
examines the block configuration. As a tag is included, the finish_tagged_data()
function is called next, and finally forwards to finish_block()
. This last function not only creates a block instance but it also posts the block to the node api and the tangle using client.post_block_raw()
. Do you find the definition for this post
function? Tipp: there's another impl Client {...}
in client/src/node_api/core/routes.rs
.
Explanation for the new IOTA SDK
At first, a ClientBlockBuilder is created by calling the function client.build_block()
. This function can be found in src/client/api/block_builder/high_level.rs
.
If you wonder why this
client
function is not defined in the same place where the Client struct is defined (that is in theclient/core.rs
file) let me remind you that in Rust, you can provide implementations for your struct throughout the crate! This language feature is used in IOTA SDK.
By utilizing the with_* functions in src/client/api/block_builder/mod.rs
a tag and a data payload are added to the block.
The function finish()
examines the block configuration. As a tag is included, the finish_tagged_data()
function is called next, and finally forwards to finish_block()
. This last function not only creates a block instance but it also posts the block to the node api and the tangle using client.post_block_raw()
. Do you find the definition for this post
function? Tipp: there's another impl Client {...}
in src/client/node_api/core/routes.rs
.
Transforming the example into an API function
The question addressed here is: How can I create a public function in my own Rust API that is exposed to Flutter through our cross-compiled library?
There are some peculiarities in the official example.
- The example contains an async main function.
- NODE_URL and EXPLORER_URL are retrieved either from the .env file or from the command line.
- The result is printed in the console.
So, how can we convert it into our API function?
At first, we should replace the main()
function by a method to be exposed, let's say:
pub fn publish_tagged_data_block(...) {
...
}
It's an absolute must to make the function public, otherwise, it won't be exposed to the outside world.
Next up, our function needs a little something extra. It should accept parameters for the tag and the message. And in return, it should gracefully provide you with the blockId. So, let's add some flavor to our function:
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
...
}
Ah, decisions, decisions! While we could add the node_url as input parameter to our function, I've decided to take matters into my own hands. I'll go ahead and hard code this URL, based on the assumption we made in our User Story.
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
...
}
Inside the function, we need to establish a similar structure to the #[tokio::main]
statement shown in the example. The Flutter-Rust-Bridge User Guide has published an article discussing this specific topic, which can be found at the following link: Async in Rust. Based on my experience, I recommend adopting approach 2, where we explicitly create a new Tokio runtime and use its block_on
function to execute the future until it finishes its execution. This approach has proven to provide a reliable and effective solution.
What is Tokio?
Oh, the confusion! Let's clear things up! While "Tokio" might sound like the capital of Japan, it's actually an asynchronous runtime for Rust. Just like Tokyo is bustling with activity, Tokio handles the execution of asynchronous tasks in your program. It ensures that your API requests are executed asynchronously, so your program doesn't sit there twiddling its thumbs. With Tokio, you get a whole package of features and abstractions, including support for Rust's
async/await
syntax and futures, making your asynchronous code more readable and approachable.
So, let's make some adjustments to the code:
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
...
})
}
Don't forget to add the following dependencies to Cargo.toml
:
[dependencies]
tokio = { version = "1.34.0", features = ["full"] }
anyhow = { version = "1.0.75" }
Now we're almost done. It's time to add the core functionalities which are the two lines to create the client and the block, and to send it.
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish()?;
// Create and send the block with tag and data.
let block = client
.block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
...
})
}
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish().await?;
// Create and send the block with tag and data.
let block = client
.build_block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
...
})
}
To convert a Rust String into a vector of byte literals (VecString::into_bytes
method.
The only missing part is returning the block_id as String, so we try this:
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish()?;
// Create and send the block with tag and data.
let block = client
.block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
let block_id:BlockId = block.id();
Ok(block_id.to_string())
})
}
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish().await?;
// Create and send the block with tag and data.
let block = client
.build_block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
let block_id:BlockId = block.id();
Ok(block_id.to_string())
})
}
BlockId
is defined in package types/block
of the libraries:
- iota.rs: in
types/src/block
- IOTA SDK: in
sdk/src/types/block
.
To avoid that the compiler complains, you need to add the related paths to your api.rs
, see below.
Rust code for your Simple App
In summary, here are the steps you need to take to create an API within your Rust library project, which is located within your Flutter project.
Cargo.toml
In Cargo.toml, add these libraries to the existing ones.
[dependencies]
iota-client = { version = "2.0.1-rc.7", default-features = false, features = [ "tls" ] }
tokio = { version = "1.34.0", features = ["full"] }
anyhow = { version = "1.0.75" }
[dependencies]
iota-sdk = { version = "1.1.4", default-features = false, features = [
"client",
"tls",
] }
tokio = { version = "1.34.0", features = ["full"] }
anyhow = { version = "1.0.75" }
api.rs
use iota_client::{block::BlockId, Client};
use tokio::runtime::Runtime;
use anyhow::Result;
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish()?;
// Create and send the block with tag and data.
let block = client
.block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
let block_id:BlockId = block.id();
Ok(block_id.to_string())
})
}
use iota_sdk::{
client::Client,
types::block::BlockId,
};
use anyhow::Result;
use tokio::runtime::Runtime;
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish().await?;
// Create and send the block with tag and data.
let block = client
.build_block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
let block_id:BlockId = block.id();
Ok(block_id.to_string())
})
}
lib.rs
mod api;
Initialize the Flutter App and Setup the Flutter-Rust-Bridge
Watch the video! Step 1: A guide to connect Flutter and Rust for the Simple App Project.
GitHub Repository
π Β GitHub Repo - Simple App (Flutter only)
Steps in the video
In Terminal
git clone https://github.com/iota-for-flutter/simple_app.git
cd simple_app
code .
In VSCode
cargo new --lib rust
cargo install flutter_rust_bridge_codegen
flutter pub add --dev ffigen:9.0.1 && flutter pub add ffi
flutter pub add flutter_rust_bridge
flutter pub add -d build_runner
flutter pub add -d freezed
flutter pub add freezed_annotation
In Cargo.toml
[dependencies]
flutter_rust_bridge = "1"
[lib]
crate-type = ["staticlib", "cdylib"]
Add the Rust Code from the previous chapter.
In Cargo.toml, add these libraries to the existing ones.
[dependencies]
iota-client = { version = "2.0.1-rc.7", default-features = false, features = [ "tls" ] }
tokio = { version = "1.34.0", features = ["full"] }
anyhow = { version = "1.0.75" }
[dependencies]
iota-sdk = { version = "1.1.4", default-features = false, features = [
"client",
"tls",
] }
tokio = { version = "1.34.0", features = ["full"] }
anyhow = { version = "1.0.75" }
api.rs
use iota_client::{block::BlockId, Client};
use tokio::runtime::Runtime;
use anyhow::Result;
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish()?;
// Create and send the block with tag and data.
let block = client
.block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
let block_id:BlockId = block.id();
Ok(block_id.to_string())
})
}
use iota_sdk::{
client::Client,
types::block::BlockId,
};
use anyhow::Result;
use tokio::runtime::Runtime;
pub fn publish_tagged_data_block(tag: String, message: String) -> Result<String> {
let node_url: String = String::from("https://api.shimmer.network");
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Create a client with that node.
let client = Client::builder().with_node(&node_url)?.finish().await?;
// Create and send the block with tag and data.
let block = client
.build_block()
.with_tag(tag.into_bytes())
.with_data(message.into_bytes())
.finish()
.await?;
let block_id:BlockId = block.id();
Ok(block_id.to_string())
})
}
lib.rs
mod api;
Building for Android
Listing some pitfalls and completing the remaining steps for Android.
Android Setup
To install the cargo-ndk
command use:
cargo install cargo-ndk
In android/app/build.gradle, fix error:
Replace GradleException by FileNotFoundException
In android/app/build.gradle, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
commandLine 'cargo', 'ndk',
// the 2 ABIs below are used by real Android devices
// '-t', 'armeabi-v7a',
'-t', 'arm64-v8a',
// the below 2 ABIs are usually used for Android simulators,
// add or remove these ABIs as needed.
// '-t', 'x86',
// '-t', 'x86_64',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
Pitfalls and Solutions
What is the purpose of the section above in build.gradle?
Well, it is intended to ensure that Rust is recompiled when the app is launched. To make it all work, you need to pay special attention to the following things.
- Adjust the correct build.gradle file:
android/app/build.gradle
-
Include all the targets you need. Recap the chapter about targets required for Android development in Cross-Compiling. Please note that every target you add will consume disk space, and it will take a considerable amount of time to compile when you launch the app for the first time.
In the case above I only included the ABI arm64-v8a from my Android Emulator. The other targets are commented out.
-
Make sure that the workingDir refers to the correct crate package.
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
In my tutorial, I consistently use "rust" as the crate name - in the command
cargo new --lib rust
.
-
To ensure successful compilation, we have installed cargo-ndk as mentioned earlier. In order to utilize it, it is crucial to correctly define the constant ANDROID_NDK path. The recommended approach is to include the path in the ~gradle/gradle.properties file.
On macOS I have added this value:
Completing the Android App
Generate the Dart Interface
Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--wasm
Include the library
Create a file ffi.dart
and paste this content. As you can see it returns the api variable.
// This file initializes the dynamic library and connects it with the stub
// generated by flutter_rust_bridge_codegen.
import 'dart:ffi';
import 'bridge_generated.dart';
import 'bridge_definitions.dart';
export 'bridge_definitions.dart';
// Re-export the bridge so it is only necessary to import this file.
export 'bridge_generated.dart';
import 'dart:io' as io;
const _base = 'rust';
// On MacOS, the dynamic library is not bundled with the binary,
// but rather directly **linked** against the binary.
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
final Rust api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
Wherever you intend to utilize the library functions, import the ffi.dart file into your Dart code. The exposed API function(s) can then be invoked by utilizing the returned api
variable.
Adjust the Dart Code
In lib/pages/editing_note_page.dart adjust:
...
import '../ffi.dart';
...
Future<void> handleNote(String tag, String text) async {
final receivedBlockId =
await api.publishTaggedDataBlock(tag: tag, message: text);
// add the new note
storeAndReturnToHomepage(tag, text, receivedBlockId);
}
void storeAndReturnToHomepage(tag, text, receivedBlockId) {
Provider.of<NoteData>(context, listen: false).addNewNote(Note(
id: widget.note.id, tag: tag, text: text, blockId: receivedBlockId));
Navigator.pop(context);
}
...
Video
Follow the video for the described steps.
Building for macOS
Listing some pitfalls and completing the remaining steps for macOS.
macOS Setup
An common step for macOS / iOS is needed: creating an Xcode project inside of the Rust library project folder (rust/). This can be done using the cargo-xcode
command.
I utilize cargo-xcode v1.5.0 to ensure smooth operation. For example, when I employed version v1.9.0, Xcode flagged an issue regarding the absence of a development team for signing (but: I couldn't add a development team because the "Signing & Capabilities" tab is missing for the target rust-cdylib in the Rust Xcode project).
-
To install the
cargo-xcode
command use:cargo install cargo-xcode@1.5.0
-
After the installation of the command, create the Rust Xcode project. Make sure to be in the rust/ directory. From the project's root folder you may switch into the right directory:
cd rust
cargo xcode
cd ..
-
This step is for macOS only because the macOS app uses the dynamic library:
Open up that
rust/rust.xcodeproj
file with Xcode and select the root item rust, at the left pane on top. Select the Target rust-cdylib and the Build Settings tab. Here, search for Dynamic Library Install Name Base and change the value into$(TARGET_BUILD_DIR)
.
Pitfalls and Solutions
- Make sure that you are REALLY in the
rust/
directory (whereCargo.toml
is located) when executing thecargo xcode
command.
-
When macOS cannot locate the dynamic library:
This might happen due to cargo-xcode version v1.5.0.
To prevent this, execute the third step of the macOS Setup above (maintain the Dynamic Library Install Name Base). Make sure that you have selected the Target for the dynamic library, called rust-cdylib. It enables an macOS executable to properly locate dynamic
*.dylib
library files in the package. Do NOT select rust-staticlib !To find out the version of cargo-xcode, you can run the command
cargo install --list
to list all installed Cargo subcommands along with their versions.FYI, there is an alternative solution described in the tutorial section macOS Instructions at the bottom.
-
The error message no healthy node available, or SocketException: Connection failed is encountered when making API calls:
macOS applications are sandboxed by default. To avoid a SocketException, you need to add the network.client entitlement to
macOS/Runner/DebugProfile.entitlements
:<key>com.apple.security.network.client</key> <true/>
Completing the macOS App
Generate the Dart Interface
Our next task is to create the generated code. This will also copy the C header file bridge_generated.h
into the folder macos/Runner/. Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--c-output macos/Runner/bridge_generated.h
Video
Follow the video for the remaining steps.
Building for iOS
Completing the remaining steps for iOS. After successfully creating macOS, building the iOS version is relatively straightforward.
iOS Setup
An common step for macOS / iOS is needed: creating an Xcode project inside of the Rust library project folder (rust/). This can be done using the cargo-xcode
command.
-
To install the
cargo-xcode
command use:cargo install cargo-xcode@1.5.0
-
After the installation of the command, create the Rust Xcode project. Make sure to be in the rust/ directory. From the project's root folder you may switch into the right directory:
cd rust
cargo xcode
cd ..
Completing the iOS App
Generate the Dart Interface
Our next task is to create the generated code. This will also copy the C header file bridge_generated.h
into the folder ios/Runner/. Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--c-output ios/Runner/bridge_generated.h
Starting the iOS Simulator in VS Code
Option 1
Option 2
As a shortcut, use the terminal and execute:
open -a simulator
Video
Follow the video for the remaining steps.
Troubleshooting
A user informed me about encountering issues when attempting to run the app on the iOS Simulator. Having not used the app for several months, I decided to test it myself and encountered some problems, too. Here is my response to the user.
Upon receiving your notification about the issue, I dedicated time today to rebuild the project. Since I initially set up and completed the project, there have been changes in my personal development environment. Specifically, I upgraded to macOS Sonoma 14.1.1 and installed Xcode 15. Upon launching VS Code, the project prompted me to execute "flutter pub upgrade" due to a change in the Flutter version during this period. Unfortunately, when attempting to run the project on iOS using "flutter run," the build process encountered a failure.
Despite not being aware of your specific circumstances, I managed to resolve all errors, successfully rebuild the project, and run it on the iOS Simulator, specifically using iPhone 15 Pro Max with iOS 17.0.
I followed these steps to address the issue:
- Updated Rust to the latest version using "rustup update."
- Deleted the Cargo.lock file.
- Deleted the rust/target/ folder.
- Deleted the following generated files: rust/src/bridge_generated.io.rs, rust/src/bridge_generated.rs, bridge_definitions.dart, bridge_generated.dart.
- Removed the first generated line in rust/src/lib.rs.
- Restarted VS Code.
- Regenerated the Dart Code using the command in the tutorial, resulting in the creation of two Rust files and two Dart files.
- Initially, VS Code displayed an error in the bridge_generated.rs file. After restarting VS Code, the error disappeared.
- In the Terminal, navigated to the rust/ directory and executed "cargo build --target aarch64-apple-ios-sim," which was successful.
- Returned to the root directory (cd ..) and ran "flutter run"
- I received messages about "Xcode 15 compatibility" (or similar) and ultimately, the library and the app were successfully built, launching without any issues.
These steps were necessary because I initially created the project under macOS 13, Xcode 14, and lower versions of Flutter and Rust. I assume if you build the project from scratch using the Flutter-only version on GitHub and follow the steps outlined in the tutorial, you shouldn't encounter the same issue.
What's the result?
The subsequent two comprehensive sections of the tutorial detail the process of constructing the Playground App - an app using several of IOTA's libraries. Watch the video to understand its purpose.
This section serves as the foundation. The following chapter will utilize the IOTA SDK for implementation.
Video
Locations on the Filesystem
Exploring the File System in a Flutter Rust App: Key Considerations and Tips.
Motivation
When developing apps, especially those that involve file storage, it is important to know where these files can be stored within the app. This knowledge is crucial for both development and app verification.
In the Playground App, a snapshot file and a database file are stored within the app. This is needed for the usage of Stronghold and the Wallet. The creation and storage of these files are handled by the Rust library. However, the Rust library does not know where exactly these files should be stored.
To solve this problem, the Flutter code needs to determine the folder path and pass it to Rust. This is where the path_provider comes into play. This official Flutter package enables Flutter to find commonly used locations on the filesystem. It supports Android, iOS, Linux, macOS and Windows - but not all methods ("directories") are supported on all platforms. You can find a compatibility matrix on the path_provider page:
π Β Flutter Plugin Path Provider
Short Exercise
The installation and usage of the plugin are very simple and should be performed by you as a short exercise (Flutter only). Create a new app (e.g. "location_tester") and open it in VS Code:
flutter create --empty location_tester
cd location_tester
code .
Within your IDE, open a Terminal and install the plugin with the command:
flutter pub add path_provider
Then, copy the Path Provider Example and replace the content of your main.dart
file by the example.
Practice
Launch the Flutter app on different platforms. You will see a bunch of buttons each refering to a certain directory.
Click on a button and observe the result. Then, try to locate the displayed file location:
- In the Android Emulator, open Android Studio and search for the specified directory in the Device File Explorer.
- For macOS and iOS, use Finder to open the corresponding directory. Tip: If you don't see hidden directories or files, use the keyboard shortcut
COMMAND + Shift + .
to toggle the visibility.
Android
macOS
iOS
What is the appropriate directory?
I only focus here on the following two directories.
The Application Support directory is primarily used for storing permanent data and files that are used by the app but may not necessarily need to be visible to the user. It often contains configuration files, caches, and other data necessary for the smooth operation of the app. This directory is intended for app-specific and internal use.
On the other hand, the Application Documents directory is intended for data and files generated by the app or created by the user that should be accessible to the user. It is the recommended location for user data, such as user-created documents, images, or other files managed by the app. It is also typically associated with iCloud synchronization, which means that the data in this directory can be automatically synchronized with the user's other devices.
Playground App
Based on the information provided here, it follows that for storing the database and the snapshot file, only the Application Support directory is suitable. It is supported on all platforms.
Initialize the Flutter App and Setup the Flutter-Rust-Bridge
Watch the video! We start by cloning the pure Flutter project that includes everything but the backend calls.
GitHub Repository
π Β GitHub Repo - Playground App (Flutter only)
Setup steps according to the video
% cargo new --lib rust
% cargo install flutter_rust_bridge_codegen
% flutter pub add --dev ffigen && flutter pub add ffi
% flutter pub add flutter_rust_bridge
% flutter pub add -d build_runner
% flutter pub add -d freezed
% flutter pub add freezed_annotation
[dependencies]
flutter_rust_bridge = "1"
[lib]
crate-type = ["staticlib", "cdylib"]
Facing a problem with missing gradle files?
Are you encountering the "Could not create task ':generateLockfiles" issue after cloning the project and launching VSCode? Here's the cause: Gradle files are absent in the Android subfolder at this stage. These files are fetched when you initially run the project on your Virtual Android device. To resolve it, follow these steps: Run the Flutter app on your Android device, stop it, and then restart VSCode. The error should disappear.
App Characteristics and used Packages
Example Structure
This is a brief explanation of the Example Structure. The examples contain the core functionality of the app and implement the integration with the Rust interface. If you're interested, you can also add your own examples.
Each Example consists of a sequence of ExampleSteps, which have a similar structure.
It's a minor detail: The numbering of Examples and ExampleSteps begin with 1 in the User Interface, but in the code, the index starts with 0. Therefore,
example_0.dart
corresponds to Example 1, and so on.
Along with a title and an information text, an ExampleStep can display an input value as text and/or as an input field.
The "Execute" button invokes a function that can be freely defined. After executing the action, it is useful to present an output text to the user. The action can be repeated any number of times, but in order to proceed to the next step (by clicking the "Continue" button), it must be executed at least once. In the last step, there is no "Continue" button.
The App Provider can be used at any point to retrieve stored data or to store new or updated data.
In my examples, I generally follow a pattern where I use the first step for resetting or entering values, and intermediate steps when I require multiple data inputs from the user to execute the final step. The final step always involves calling the Rust API method.
Important
Some examples assume that previous examples have been executed and the data generated during those executions is stored in the app. For example, the third example (Create Wallet Account) assumes that mnemonics were generated in Example 2. It is best to follow the sequence of executing all the examples one after another, starting with "Get Node Info," when using the app for the first time.
A hidden feature is located behind the "Input" and "Output" labels. When you click on either of these labels, the content of the input or output will be copied to the clipboard.
Rust Code
In the following subchapters we'll prepare the Rust Code for each example. We will only discuss the changes in the Cargo.toml
and api.rs
files.
Initial Situation
This is the situation upon which we are basing our starting point.
Preparation
-
As usual, create an empty file called
api.rs
, at the same level aslib.rs
. -
Include it as module in
lib.rs
:mod api;
New: Checks using cargo build
In the next subchapters, we will test individually for each example whether our library can be cross-compiled to the target platforms Android, macOS, and iOS.
This way, we can independently test the correctness of the dependencies in Cargo.toml
, and the paths and the syntax of our Rust Code in api.rs
, regardless of the Flutter build process.
π Β How to manually cross-compile to a target of your choice
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
cargo ndk -t arm64-v8a build
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator or iOS Device
cargo build --target aarch64-apple-ios-sim
cargo build --target aarch64-apple-ios
Example 1
Rust adjustments for Example "Get Node Information".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
Add these crates to the existing ones.
[dependencies]
iota-client = { version = "2.0.1-rc" }
tokio = { version = "1.28.2", features = ["full"] }
anyhow = { version = "1.0.71" }
# Serialization/Deserialization
serde = { version = "1.0.164", default-features = false, features = ["derive"] }
serde_json = { version = "1.0.97", default-features = false }
api.rs - Used Paths
use iota_client::Client;
use tokio::runtime::Runtime;
use anyhow::Result;
api.rs - Struct NetworkInfo
This struct bundles various network information. Through code generation of the Flutter-Rust-Bridge, the struct is translated into a Flutter class and becomes part of the bridge_definitions.dart
file.
Therefore, it is not necessary to explicitly create it in Flutter! On the other hand, this means that the code generator needs to be executed once for the class to be seamlessly used in Flutter without any errors.
#[derive(Debug, Clone)]
pub struct NetworkInfo {
pub node_url: String,
pub faucet_url: String,
}
api.rs - Function get_node_info()
#[allow(dead_code)]
pub fn get_node_info(network_info: NetworkInfo) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = network_info.node_url;
// Create a client with that node.
let client = Client::builder()
.with_node(&node_url)?
.with_ignore_node_health()
.finish()?;
// Get node info.
let info = client.get_info().await?;
Ok(serde_json::to_string_pretty(&info).unwrap())
//Ok(info.node_info.base_token.name)
})
}
Checks using cargo build
All checks should work without any issue. Please also refer to the corresponding video (2023-09-03: ToDo).
New: Checks using cargo build
In the next subchapters, we will test individually for each example whether our library can be cross-compiled to the target platforms Android, macOS, and iOS.
This way, we can independently test the correctness of the dependencies in Cargo.toml
, and the paths and the syntax of our Rust Code in api.rs
, regardless of the Flutter build process.
π Β How to manually cross-compile to a target of your choice
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
cargo ndk -t arm64-v8a build
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator or iOS Device
cargo build --target aarch64-apple-ios-sim
cargo build --target aarch64-apple-ios
Example 2
Rust adjustments for Example "Generate Mnemonic".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
There is no need to add any crates. It's the same as in Example 1.
api.rs - Used Paths
There is no need to add any path. It's the same as in Example 1.
api.rs - Function get_node_info()
#[allow(dead_code)]
pub fn generate_mnemonic() -> String {
let mnemonic = Client::generate_mnemonic();
mnemonic.unwrap()
}
Checks using cargo build
All checks should work without any issue. Please also refer to the corresponding video (2023-09-03: ToDo).
New: Checks using cargo build
In the next subchapters, we will test individually for each example whether our library can be cross-compiled to the target platforms Android, macOS, and iOS.
This way, we can independently test the correctness of the dependencies in Cargo.toml
, and the paths and the syntax of our Rust Code in api.rs
, regardless of the Flutter build process.
π Β How to manually cross-compile to a target of your choice
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
cargo ndk -t arm64-v8a build
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator or iOS Device
cargo build --target aarch64-apple-ios-sim
cargo build --target aarch64-apple-ios
Example 3
Rust adjustments for Example "Create Wallet Account".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
Let's try to use the iota-client
from iota-wallet
. So first, comment out the dependency:
# iota-client = { version = "2.0.1-rc" }
Second, add this crate to the existing dependencies:
[dependencies]
iota-wallet = { version = "1.0.0-rc" }
By checking Cargo.lock
you can determine the real downloaded versions. At the time of writing this tutorial section (June 2023) we are using iota-wallet version v1.0.0-rc.6. Please refer to Crates.io to get more information about the current versions.
But how can you determine if this version supports the Stardust Protocol, used by the Shimmer network and the IOTA Mainnet (since 4th of October 2023)?
After adding and saving the library in Cargo.toml_
check the Cargo.lock
file. Look for the iota-client
version included. If it's a v2 version, then it's compatible for Stardust. For more information, refer to Stardust and (outdated) Chrysalis Versions documentation.
api.rs - Used Paths
Comment out the usage of iota_client::Client
:
//use iota_client::Client;
Instead, we are using the Client from iota_wallet::iota_client::Client
:
use iota_wallet::{
iota_client::Client, // <- Let's use this one
iota_client::constants::SHIMMER_COIN_TYPE,
ClientOptions,
account_manager::AccountManager,
secret::{stronghold::StrongholdSecretManager as WalletStrongholdSecretManager},
secret::{SecretManager as WalletSecretManager}
};
use std::{env, fs, path::PathBuf};
In Rust you can shorten several lines, e.g.
use iota_wallet::iota_client::Client; use iota_wallet::ClientOptions; use iota_wallet::account_manager::AccountManager;
by using curly brackets:
use iota_wallet::{ iota_client::Client, ClientOptions, account_manager::AccountManager };
api.rs - Struct WalletInfo
This struct bundles various wallet information. Through code generation of the Flutter-Rust-Bridge, the struct is translated into a Flutter class and becomes part of the bridge_definitions.dart
file.
Therefore, it is not necessary to explicitly create it in Flutter! On the other hand, this means that the code generator needs to be executed once for the class to be seamlessly used in Flutter without any errors.
#[derive(Debug, Clone)]
pub struct WalletInfo {
pub alias: String,
pub mnemonic: String,
pub stronghold_password: String,
pub stronghold_filepath: String,
pub last_address: String
}
api.rs - Function create_wallet_account()
There is a lot to talk about here. But first, let's start with the source code.
#[allow(dead_code)]
pub fn create_wallet_account(network_info: NetworkInfo, wallet_info: WalletInfo) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = network_info.node_url;
let stronghold_password = wallet_info.stronghold_password;
let stronghold_filepath = wallet_info.stronghold_filepath;
// Create the needed directory according to the given path
let mut path_buf = PathBuf::new();
path_buf.push(&stronghold_filepath);
let path = PathBuf::from(path_buf);
fs::create_dir_all(path).ok();
// THIS NEXT STEP IS CRUCIAL:
// Point the "current working directory" to the given path
env::set_current_dir(&stronghold_filepath).ok();
// Create the Rust file for the stronghold snapshot file
let mut path_buf_snapshot = PathBuf::new();
path_buf_snapshot.push(&stronghold_filepath);
path_buf_snapshot.push("wallet.stronghold");
let path_snapshot = PathBuf::from(path_buf_snapshot);
let mut secret_manager = WalletStrongholdSecretManager::builder()
.password(&stronghold_password)
.build(path_snapshot)?;
// Storing the mnemonic is ONLY REQUIRED THE FIRST TIME
// calling it TWICE THROWS AN ERROR
secret_manager.store_mnemonic(wallet_info.mnemonic).await?;
// Create a ClientBuilder (= client_options in wallet.rs)
// See wallet.rs:
// -> src/lib.rs
// -> line "pub use iota_client::ClientBuilder as ClientOptions"
let client_options = ClientOptions::new().with_node(&node_url)?;
// Create the account manager with the secret_manager
// and client_options (= ClientBuilder).
// The Client itself is created in the AccountManagerBuilder's finish() method.
// See wallet.rs:
// -> src/account_manager/builder.rs
// -> line "let client = client_options.clone().finish()?;"
let manager = AccountManager::builder()
.with_secret_manager(WalletSecretManager::Stronghold(secret_manager))
.with_client_options(client_options)
.with_coin_type(SHIMMER_COIN_TYPE)
.finish()
.await?;
// Create a new account
let _account = manager
.create_account()
.with_alias((&wallet_info.alias).to_string())
.finish()
.await?;
Ok("Wallet Account was created successfully.".into())
})
}
The two structs passed as input parameters contain data from Flutter that is needed to create the wallet. These include the node URL, the mnemonics, the Wallet Alias, the Stronghold Password, and the directory path where both the wallet's database and the Stronghold Snapshot file will be stored.
In the first part of the function, we need to create a PathBuf. It is commonly used when dealing with file I/O operations and working with file systems in Rust.
PathBuf is a type that represents a platform-dependent path. It is used to manipulate and work with file paths in a convenient and cross-platform manner. PathBuf provides various methods for path manipulation, such as joining paths, appending components, and resolving relative paths.
The crucial part of working with paths in our library is that we change the current working directory to our specific path for the database and snapshot file. By doing so, we can perform file operations relative to that directory or access files located in that specific path.
In the next part of the function, we create a Stronghold Secret Manager that is initialized with our Stronghold password and mnemonics. We have chosen to use Stronghold in our wallet for secure storage and management of sensitive information.
The StrongholdSecretManager uses advanced encryption techniques to protect the stored data and provides secure access methods for retrieving and using the secrets when required. It offers features like encryption, decryption, secure key generation, and secure storage of cryptographic material.
Our goal of creating a wallet account is achieved through the Account Manager. The Account Manager provides a set of functionalities and APIs to manage multiple accounts within a wallet. It allows us to create, modify, and interact with individual accounts, such as generating addresses, sending transactions, and retrieving account information.
The Account Manager is configured with our Stronghold Secret Manager and the Client "Options". ClientOptions is nothing more than the ClientBuilder in the original iota-client. Keep this in mind!
Once we have created the Account Manager, there is only one step left, which is creating a new Account for the given wallet Alias. The account's Alias serves as a name or identifier for the account within the wallet.
By creating a new Account, we establish a distinct entity within the wallet to manage specific funds, transactions, and associated addresses. This step enables us to organize and track different accounts within the wallet application effectively.
Behind the scenes, when creating the Stronghold Secret Manager, the snapshot file is generated. Additionally, when creating the Account Manager, a RocksDB database is created.
It is worth noting that the use of RocksDB has previously caused issues between different target platforms with older versions of wallet.rs. Let's see if this problem still persists. In the next section, I will attempt to build the library for all platforms and investigate if any conflicts arise.
Checks using cargo build
There are issues when cross-compiling to Android and iOS Simulator.
Please also refer to the corresponding video (2023-09-03: ToDo).
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Android
I only check the ABI arm64-v8a.
cargo ndk -t arm64-v8a build
Since the latest Android NDK version 25 is being used, I have been encountering issues compiling the libsodium-sys library (v0.2.7) on macOS (M1 chip). This problem did not exist with the previous NDK version 22, which I was using in January.
It's possible that you may not encounter this issue if you use a different hosting operating system. If you manage to successfully compile libsodium-sys on your host system, consider yourself fortunate.
Fix
Since I haven't identified the root cause of the issue, I am currently unable to propose a specific solution or fix.
Workaround
As a temporary workaround, I can suggest building the libsodium.so library for each target individually and including it in our target Rust library. This can be achieved by utilizing the SODIUM_LIB_DIR and SODIUM_SHARED environment variables, as described in the libsodium-sys documentation. I will elaborate on this approach in the upcoming subsection Libsodium library for Android.
macOS
cargo build --target aarch64-apple-darwin
That one functions flawlessly :-))
iOS Simulator
cargo build --target aarch64-apple-ios-sim
The compilation fails because there is an unknown build target in libsodium-sys build script:
The fact that the libsodium-sys project is marked as deprecated is unfortunate since it limits the opportunity to address and resolve the issue within the repository. See Sodiumoxide's documentation:
"[DEPRECATED] This project has reached the end of its development as a cryptographic library for rust. Feel free to browse the code, and feel free to use it, but it will not see any more updates (unless a security issue arises, those will be fixed)."
Fix
One potential solution could be to add a target to the build.rs
file within the libsodium-sys folder. However, since the repository is not actively maintained, implementing this fix is currently not possible. As a suggestion, I proposed that the Stronghold team could consider forking the repository and taking up maintenance responsibilities. You can find more information about this suggestion here:
π Β Stronghold Discussion - Portability and reliance on libsodium
Workaround
To utilize Stronghold for iOS, the only viable workaround at the moment is to compile the code and employ your iOS Device as a test device. Unfortunately, the Simulator cannot be utilized due to the aforementioned problem.
iOS Device
cargo build --target aarch64-apple-ios
Libsodium library for Android
Please refer to this chapter to learn about a Workaround for Android that can be used if you wish to utilize Stronghold AND if you have problems creating libsodium.
The libsodium library is a widely used library for cryptography, offering a variety of functions for encryption, decryption, hashing, signatures, and more.
libsodium-sys is a Rust wrapper around the libsodium library. It provides the necessary Rust bindings and FFI (Foreign Function Interface) declarations to link and interact with the libsodium library from Rust code.
libsodium-sys is a dependency of the stronghold-runtime crate.
GitHub Repositories
π Β GitHub Repo - libsodium
π Β GitHub Repo - libsodium-sys
Challenge
In general, managing dependencies can be tricky, particularly when your project relies on multiple dependencies and you need to cross-compile it for various targets.
The utilization of the deprecated libsodium-sys project in Stronghold poses such a challenge. It came up that it is a time-intensive show-stopper and places a significant burden on the developer's life.
It's not enjoyable to search for workarounds and complicate workflows with special rules. Unfortunately, encountering such situations is quite common when aiming to support a wide range of target platforms.
Workaround
These are the three steps:
In Step 1, create the libsodium.so
library file for the desired Android target, here e.g. ABI arm64-v8a (which is target aarch64-linux-android).
In Step 2, copy the created libsodium.so
into a project folder and test the build.
In Step 3, include the library in Android's build process within Flutter by defining SODIUM_LIB_DIR and SODIUM_SHARED environment variables.
Step 1: Create libsodium.so
Isn't it pathetic that one has to create the library at all !?
Before proceeding with Step 1 yourself, please note that I provide a download for a prebuilt library (created on macOS) for each of the possible ABIs with the following links. However, it is important to mention that I provide no warranty for its contents or functionality:
If the prebuilt libraries work, you can skip the rest of Step 1.
You'll need to clone the libsodium repository in a new IDE project.
git clone https://github.com/jedisct1/libsodium.git
You will need to install some tools on your macOS:
a) Homebrew
Check with brew --version
if you can skip the installation. If already installed, a version is returned. Otherwise install Homebrew with the command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
b) autoconf
Check with autoconf --version
if you can skip the installation. If already installed, a version is returned. Otherwise install autoconf with the command:
brew install autoconf
c) automake
Check with automake --version
if you can skip the installation. If already installed, a version is returned. Otherwise install automake with the command:
brew install automake
Once you've setup everything, you can navigate into the "libsodium-master" directory and execute:
./autogen.sh -s
This will create the configure
file. The next command will create the library for the ABI arm64-v8a:
./dist-build/android-armv8-a.sh
The console output tells you where you can find the libsodium.so
: It's located in the folder "libsodium-android-armv8-a+crypto/lib/".
You will need to repeat the last command for each ABI/target accordingly.
Step 2: Copy the libsodium.so file and test the build
Copy the created library and paste it into a directory of your choice. Personally, I utilize the folder "android/app/src/main/jniLibs/<ABI>/", where all libraries are stored together in the appropriate ABI directory for libs, see the image below.
Now, I recommend to test the playground_app build in Terminal by setting the following environment variables before executing the commands. Please replace "/path/to/library" with the correct directory path you have chosen (do NOT append "libsodium.so" at the end of the path).
Make a test build by executing:
SODIUM_LIB_DIR="/path/to/library" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
e.g.
SODIUM_LIB_DIR="/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
Step 3: Update build.gradle
In the aforementioned test, we defined the variables SODIUM_LIB_DIR and SODIUM_SHARED within the command line. However, these definitions will not persist.
To ensure proper setup for usage in Flutter, you need to make adjustments to the android/app/build.gradle
file, see below (once again, replace "/path/to/library" with the actual directory path you have selected).
...
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
environment SODIUM_LIB_DIR: "/path/to/library"
environment SODIUM_SHARED: 1
commandLine 'cargo', 'ndk',
...
Be aware: At this point, I am anticipating the Android setup!
In android/app/build.gradle, fix error:
Replace GradleException by FileNotFoundException
In android/app/build.gradle, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
environment SODIUM_LIB_DIR: "/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment SODIUM_SHARED: 1
commandLine 'cargo', 'ndk',
// the 2 ABIs below are used by real Android devices
// '-t', 'armeabi-v7a',
'-t', 'arm64-v8a',
// the below 2 ABIs are usually used for Android simulators,
// add or remove these ABIs as needed.
// '-t', 'x86',
// '-t', 'x86_64',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
Example 4
Rust adjustments for Example "Generate Address".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
There is no need to add any crates. It's the same as in Example 3.
api.rs - Used Paths
There is no need to add any path. It's the same as in Example 3.
api.rs - Function generate_address()
#[allow(dead_code)]
pub fn generate_address(wallet_info: WalletInfo) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let stronghold_filepath = wallet_info.stronghold_filepath;
env::set_current_dir(&stronghold_filepath).ok();
// Create the account manager
let manager = AccountManager::builder().finish().await?;
// Get the account we generated with `01_create_wallet`
let account = manager.get_account((&wallet_info.alias).to_string()).await?;
// Set the stronghold password
manager
.set_stronghold_password(&wallet_info.stronghold_password)
.await?;
let addresses = account.generate_addresses(1, None).await?;
//println!("Generated address: {}", address[0].address().to_bech32());
Ok(addresses[0].address().to_bech32())
})
}
Checks using cargo build
All checks (-> except iOS Simulator) should work without any issue. Please also refer to the explanations of Example 3 and the corresponding video (2023-09-03: ToDo).
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
a) If you've had NO problems with the 3rd party library libsodium, use the command:
cargo ndk -t arm64-v8a build
b) If you've HAD problems with the 3rd party library libsodium, use the command:
SODIUM_LIB_DIR="/path/to/libsodium" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
e.g.
SODIUM_LIB_DIR="/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
-> Why do you need SODIUM_LIB_DIR and SODIUM_SHARED here?
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator
This check will fail, please refer to the explanations of Example 3 (Libsodium).
cargo build --target aarch64-apple-ios-sim
iOS Device
cargo build --target aarch64-apple-ios
Example 5
Rust adjustments for Example "Request Funds".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
There is no need to add any crates. It's the same as in Example 3.
api.rs - Used Paths
Add the function iota_wallet::iota_client::request_funds_from_faucet
:
use iota_wallet::{
iota_client::Client,
iota_client::request_funds_from_faucet, // <- Add this one
iota_client::constants::SHIMMER_COIN_TYPE,
ClientOptions,
account_manager::AccountManager,
secret::{stronghold::StrongholdSecretManager as WalletStrongholdSecretManager},
secret::{SecretManager as WalletSecretManager}
};
api.rs - Function request_funds()
#[allow(dead_code)]
pub fn request_funds(network_info: NetworkInfo, wallet_info: WalletInfo) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let stronghold_filepath = wallet_info.stronghold_filepath;
env::set_current_dir(&stronghold_filepath).ok();
let faucet_url = network_info.faucet_url;
// Use the function iota_wallet::iota_client::request_funds_from_faucet
let faucet_response =
request_funds_from_faucet(&faucet_url, &wallet_info.last_address).await?;
Ok(faucet_response.to_string())
})
}
Checks using cargo build
All checks (-> except iOS Simulator) should work without any issue. Please also refer to the explanations of Example 3 and the corresponding video (2023-09-03: ToDo).
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
a) If you've had NO problems with the 3rd party library libsodium, use the command:
cargo ndk -t arm64-v8a build
b) If you've HAD problems with the 3rd party library libsodium, use the command:
SODIUM_LIB_DIR="/path/to/libsodium" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
e.g.
SODIUM_LIB_DIR="/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
-> Why do you need SODIUM_LIB_DIR and SODIUM_SHARED here?
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator
This check will fail, please refer to the explanations of Example 3 (Libsodium).
cargo build --target aarch64-apple-ios-sim
iOS Device
cargo build --target aarch64-apple-ios
Example 6
Rust adjustments for Example "Check Balance".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
There is no need to add any crates. It's the same as in Example 5.
api.rs - Used Paths
There is no need to add any path. It's the same as in Example 5.
api.rs - Struct BaseCoinBalance
This struct bundles some information about total and available amounts. Through code generation of the Flutter-Rust-Bridge, the struct is translated into a Flutter class and becomes part of the bridge_definitions.dart
file.
Therefore, it is not necessary to explicitly create it in Flutter! On the other hand, this means that the code generator needs to be executed once for the class to be seamlessly used in Flutter without any errors.
#[derive(Debug, Clone)]
pub struct BaseCoinBalance {
/// Total amount
pub total: u64,
/// Balance that can currently be spent
pub available: u64,
}
api.rs - Function check_balance()
#[allow(dead_code)]
pub fn check_balance(wallet_info: WalletInfo) -> Result<BaseCoinBalance> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let stronghold_filepath = wallet_info.stronghold_filepath;
env::set_current_dir(&stronghold_filepath).ok();
// Create the account manager
let manager = AccountManager::builder().finish().await?;
// Get the account we generated with `create_wallet_account`
let account = manager.get_account((&wallet_info.alias).to_string()).await?;
// Sync and get the balance
let _account_balance = account.sync(None).await?;
// If already synced, just get the balance
let account_balance = account.balance().await?;
//let base_coin_balance = account_balance.base_coin;
let base_coin_balance = BaseCoinBalance {
total: account_balance.base_coin.total,
available: account_balance.base_coin.available,
};
//let total = account_balance.base_coin.total;
//println!("{:?}", account_balance);
//Ok(total.to_string())
//Ok(base_coin_balance)
Ok(base_coin_balance)
})
}
Checks using cargo build
All checks (-> except iOS Simulator) should work without any issue. Please also refer to the explanations of Example 3 and the corresponding video (2023-09-03: ToDo).
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
a) If you've had NO problems with the 3rd party library libsodium, use the command:
cargo ndk -t arm64-v8a build
b) If you've HAD problems with the 3rd party library libsodium, use the command:
SODIUM_LIB_DIR="/path/to/libsodium" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
e.g.
SODIUM_LIB_DIR="/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
-> Why do you need SODIUM_LIB_DIR and SODIUM_SHARED here?
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator
This check will fail, please refer to the explanations of Example 3 (Libsodium).
cargo build --target aarch64-apple-ios-sim
iOS Device
cargo build --target aarch64-apple-ios
Example 7
Rust adjustments for Example "Create Decentralized Identifier".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
Add this crate to the existing dependencies:
[dependencies]
identity_iota = { version = "<0.7.0-alpha.6" }
Here, I've employed iota_identity's v0.7.0-alpha.5 release. If you intend to utilize a different version, kindly refer to the documentation for information on the paths used, and adapt them in the sample code provided below.
π Β Crate iota_identity (v0.7.0-alpha.5)
api.rs - Used Paths
Add these two paths for Address and AliasOutput here:
use iota_wallet::{
account_manager::AccountManager,
iota_client::block::address::Address, // <- Add this for Identity-Example
iota_client::block::output::AliasOutput, // <- Add this for Identity-Example
iota_client::constants::SHIMMER_COIN_TYPE,
iota_client::request_funds_from_faucet,
iota_client::Client,
secret::stronghold::StrongholdSecretManager as WalletStrongholdSecretManager,
secret::SecretManager as WalletSecretManager,
ClientOptions,
};
And add this whole section:
use identity_iota::{
crypto::KeyPair,
crypto::KeyType,
iota::IotaClientExt,
iota::IotaDocument,
iota::IotaIdentityClientExt,
iota::NetworkName,
verification::MethodScope,
verification::VerificationMethod,
};
api.rs - Function create_decentralized_identifier()
#[allow(dead_code)]
pub fn create_decentralized_identifier(
network_info: NetworkInfo,
wallet_info: WalletInfo,
) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = network_info.node_url;
let stronghold_password = wallet_info.stronghold_password;
let stronghold_filepath = wallet_info.stronghold_filepath;
env::set_current_dir(&stronghold_filepath).ok();
let mut path_buf_snapshot = PathBuf::new();
path_buf_snapshot.push(&stronghold_filepath);
path_buf_snapshot.push("wallet.stronghold");
let path_snapshot = PathBuf::from(path_buf_snapshot);
// THIS returns a StrongholdAdapter:
// let mut secret_manager = StrongholdSecretManager::builder()
// .password(&stronghold_password)
// .build(path_snapshot)?;
// THIS returns a StrongholdSecretManager:
let secret_manager: WalletSecretManager = WalletSecretManager::Stronghold(
WalletStrongholdSecretManager::builder()
.password(&stronghold_password)
.build(path_snapshot)?,
);
//Create a client with that node.
// let client = Client::builder()
// .with_node(&node_url)?
// .with_ignore_node_health()
// .finish()?;
// Create a new client to interact with the IOTA ledger.
let client: Client = Client::builder()
.with_primary_node(&node_url, None)?
.finish()?;
//let client: Client = Client::builder().with_primary_node(node_url, None)?.finish()?;
// Get the Bech32 human-readable part (HRP) of the network.
let network_name: NetworkName = client.network_name().await?;
// Create a new DID document with a placeholder DID.
// The DID will be derived from the Alias Id of the Alias Output after publishing.
let mut document: IotaDocument = IotaDocument::new(&network_name);
// Insert a new Ed25519 verification method in the DID document.
let keypair: KeyPair = KeyPair::new(KeyType::Ed25519)?;
let method: VerificationMethod = VerificationMethod::new(
document.id().clone(),
keypair.type_(),
keypair.public(),
"#key-1",
)?;
document.insert_method(method, MethodScope::VerificationMethod)?;
// Construct an Alias Output containing the DID document, with the wallet address
// set as both the state controller and governor.
//let address: Address = client.get_addresses(&secret_manager).with_range(0..1).get_raw().await?[0];
// Convert given address (BECH32 string) to Address struct
let (_, address) = Address::try_from_bech32(&wallet_info.last_address)?;
let alias_output: AliasOutput = client.new_did_output(address, document, None).await?;
// Publish the Alias Output and get the published DID document.
let document: IotaDocument = client
.publish_did_output(&secret_manager, alias_output)
.await?;
Ok(document.to_string())
//println!("Published DID document: {:#}", document);
// let governor_address = alias_output.governor_address();
// let result_string = governor_address.to_bech32(network_name.to_string());
// Ok(result_string)
//Ok(document.to_string() + &(address.is_ed25519().to_string()))
//Ok(address.is_ed25519().to_string())
//Ok(document.to_string())
})
}
Checks using cargo build
All checks (-> except iOS Simulator) should work without any issue. Please also refer to the explanations of Example 3 and the corresponding video (2023-09-03: ToDo).
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
a) If you've had NO problems with the 3rd party library libsodium, use the command:
cargo ndk -t arm64-v8a build
b) If you've HAD problems with the 3rd party library libsodium, use the command:
SODIUM_LIB_DIR="/path/to/libsodium" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
e.g.
SODIUM_LIB_DIR="/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
-> Why do you need SODIUM_LIB_DIR and SODIUM_SHARED here?
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator
This check will fail, please refer to the explanations of Example 3 (Libsodium).
cargo build --target aarch64-apple-ios-sim
iOS Device
cargo build --target aarch64-apple-ios
Bin to Hex
Rust adjustments for the left side bar tool "Bin to Hex".
What adjustments do I need to make in Rust?
In summary, here are the steps you need to take to create the API function.
Cargo.toml
There is no need to add any crates.
api.rs - Used Paths
Add this path:
use std::u32;
api.rs - Function bin_to_hex()
#[allow(dead_code)]
pub fn bin_to_hex(val: String, len: usize) -> String {
let n: u32 = u32::from_str_radix(&val, 2).unwrap();
format!("{:01$x}", n, len * 2)
}
Checks using cargo build
All checks (-> except iOS Simulator) should work without any issue. Please also refer to the explanations of Example 3 and the corresponding video (2023-09-03: ToDo).
To examine the various targets, you should navigate from playground_app root directory to the rust directory:
cd rust
Then, within the rust directory, excute the following commands.
Android
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
I only check the ABI arm64-v8a.
a) If you've had NO problems with the 3rd party library libsodium, use the command:
cargo ndk -t arm64-v8a build
b) If you've HAD problems with the 3rd party library libsodium, use the command:
SODIUM_LIB_DIR="/path/to/libsodium" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
e.g.
SODIUM_LIB_DIR="/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" SODIUM_SHARED=1 cargo ndk -t arm64-v8a build
-> Why do you need SODIUM_LIB_DIR and SODIUM_SHARED here?
macOS
cargo build --target aarch64-apple-darwin
iOS Simulator
This check will fail, please refer to the explanations of Example 3 (Libsodium).
cargo build --target aarch64-apple-ios-sim
iOS Device
cargo build --target aarch64-apple-ios
Building for Android
Completing the remaining steps for Android.
In the preceding sections, we've effectively configured Flutter and Rust code independently. Additionally, we've incorporated IOTA's Rust libraries into Cargo.toml
. Furthermore, we've confirmed the validity of the Rust code for each example by compiling the libraries for our target platforms (Android, macOS, and iOS devices). Now, it's time for the finalizing steps.
Android Setup
If you haven't already, install the cargo-ndk
command using:
cargo install cargo-ndk
In android/app/build.gradle, fix error:
Replace GradleException by FileNotFoundException
Integrate cargo build
into the Gradle build process
If you've had NO problem with the 3rd party library libsodium, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
commandLine 'cargo', 'ndk',
// the 2 ABIs below are used by real Android devices
// '-t', 'armeabi-v7a',
'-t', 'arm64-v8a',
// the below 2 ABIs are usually used for Android simulators,
// add or remove these ABIs as needed.
// '-t', 'x86',
// '-t', 'x86_64',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
Otherwise, if you've HAD a problem with the 3rd party library libsodium, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
environment SODIUM_LIB_DIR: "/Users/yourname/playground_app/android/app/src/main/jniLibs/arm64-v8a" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment SODIUM_SHARED: 1
commandLine 'cargo', 'ndk',
// the 2 ABIs below are used by real Android devices
// '-t', 'armeabi-v7a',
'-t', 'arm64-v8a',
// the below 2 ABIs are usually used for Android simulators,
// add or remove these ABIs as needed.
// '-t', 'x86',
// '-t', 'x86_64',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
Please note that due to the manual use of libsodium, there are two additional lines related to SODIUM_LIB_DIR and SODIUM_SHARED (compared to the former configurations) -> please also refer to Libsodium library for Android.
Enabling Dynamic Library Loading
Even if the our dynamic library librust.so
has been successfully compiled in Android, you haven't won yet. It also needs to be able to be loaded.
There is an error message that only becomes apparent when using the app, after you have completed all steps and successfully launched the app in Flutter.
Let's say you want to invoke the "Generate Mnemonic" example. Here's where you stumble when you click on "Execute" in Example 2, as the console displays the following message:
Workaround
As the message text also says: The "library 'libc++_shared.so' is not found". So, adding this library is the solution.
Where do we get this one from?
It is provided by the NDK. For example, if you're using NDK v25.2.9519653 on macOS, navigate to the folder ~/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/ where you will find subfolders for each of the targets:
Alternatively, here are also some download links from Android NDK 25 on macOS:
Where do we need to place it?
Once you have copied or downloaded the libc++_shared.so
library, you should ensure that you place it in a directory where it can be accessed by your application. To simplify the process, you can place it in the same folder as the librust.so
library.
Preparatory steps on the Dart side
Here, you will find the same steps as in the Simple App.
Generate the Dart Interface
Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--wasm
Include the library
Create a file ffi.dart
and paste this content. As you can see it returns the api variable.
// This file initializes the dynamic library and connects it with the stub
// generated by flutter_rust_bridge_codegen.
import 'dart:ffi';
import 'bridge_generated.dart';
import 'bridge_definitions.dart';
export 'bridge_definitions.dart';
// Re-export the bridge so it is only necessary to import this file.
export 'bridge_generated.dart';
import 'dart:io' as io;
const _base = 'rust';
// On MacOS, the dynamic library is not bundled with the binary,
// but rather directly **linked** against the binary.
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
final Rust api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
Adjust the Dart code in the examples
As the final step, we now need to integrate and use the API (ffi.dart) in each example class. I will provide the final code for all examples here.
It's a bit of effort, but take comfort in knowing that you've covered Android, macOS, and iOS all at once.
Example 1: Get Node Information
The source code for Example 1 can be found at lib/examples/example_0.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiNodeInfo()
by:
Future<void> _callFfiNodeInfo() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
String faucetUrl = Provider.of<AppProvider>(context, listen: false)
.currentNetwork
.faucetApiUrl ??
'';
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: faucetUrl);
try {
customOverlay.show(context);
final receivedText = await api.getNodeInfo(networkInfo: networkInfo);
if (mounted) {
Provider.of<AppProvider>(context, listen: false).nodeInfo =
receivedText;
setState(() => exampleSteps[1].setOutput(receivedText));
}
customOverlay.hide();
} on FfiException catch (e) {
setState(() => exampleSteps[1].setOutput(e.message));
customOverlay.hide();
}
}
Example 2: Generate Mnemonics
The source code for Example 2 can be found at lib/examples/example_1.dart
.
At the top, add:
import '../ffi.dart';
Replace the fake code of _callFfiGenerateMnemonic()
by:
Future<void> _callFfiGenerateMnemonic() async {
customOverlay.show(context);
final receivedText = await api.generateMnemonic();
if (mounted) {
setState(() {
Provider.of<AppProvider>(context, listen: false).mnemonic =
receivedText;
exampleSteps[1].setOutput(receivedText);
});
}
customOverlay.hide();
}
Example 3: Create Wallet Account
The source code for Example 3 can be found at lib/examples/example_2.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiCreateWalletAccount()
by:
Future<void> _callFfiCreateWalletAccount() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
String faucetUrl = Provider.of<AppProvider>(context, listen: false)
.currentNetwork
.faucetApiUrl ??
'';
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: faucetUrl);
final WalletInfo walletInfo = WalletInfo(
alias: exampleSteps[1].output ?? 'Account_1',
mnemonic: Provider.of<AppProvider>(context, listen: false).mnemonic,
strongholdPassword:
exampleSteps[2].output ?? 'my_super_secret_stronghold_password',
strongholdFilepath: _strongholdFilePath,
lastAddress: "",
);
try {
customOverlay.show(context);
final receivedText = await api.createWalletAccount(
networkInfo: networkInfo, walletInfo: walletInfo);
if (mounted) {
setState(() => exampleSteps[3].setOutput(receivedText));
}
customOverlay.hide();
} on FfiException catch (e) {
setState(() => exampleSteps[3].setOutput(e.message));
customOverlay.hide();
}
}
Example 4: Generate Address
The source code for Example 4 can be found at lib/examples/example_3.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiGenerateAddress()
by:
Future<void> _callFfiGenerateAddress() async {
final WalletInfo walletInfo = WalletInfo(
alias: exampleSteps[1].output ?? 'Account_1',
mnemonic: Provider.of<AppProvider>(context, listen: false).mnemonic,
strongholdPassword:
exampleSteps[2].output ?? 'my_super_secret_stronghold_password',
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedText = await api.generateAddress(walletInfo: walletInfo);
if (mounted) {
setState(() {
Provider.of<AppProvider>(context, listen: false).lastAddress =
receivedText;
exampleSteps[3].setOutput(receivedText);
});
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[3].setOutput(e.message));
}
}
Example 5: Request Funds
The source code for Example 5 can be found at lib/examples/example_4.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiRequestFunds()
by:
Future<void> _callFfiRequestFunds() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
String faucetUrl = Provider.of<AppProvider>(context, listen: false)
.currentNetwork
.faucetApiUrl ??
'';
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: faucetUrl);
final WalletInfo walletInfo = WalletInfo(
alias: "",
mnemonic: "",
strongholdPassword: "",
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedText = await api.requestFunds(
networkInfo: networkInfo, walletInfo: walletInfo);
if (mounted) {
setState(() => exampleSteps[1].setOutput(receivedText));
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[1].setOutput(e.message));
}
}
Example 6: Check Balance
The source code for Example 6 can be found at lib/examples/example_5.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import 'package:provider/provider.dart';
import '../coin_utils.dart';
import '../data/app_provider.dart';
import '../ffi.dart';
Replace the fake code of _callFfiCheckBalance()
by:
Future<void> _callFfiCheckBalance() async {
String coinCurrency =
Provider.of<AppProvider>(context, listen: false).currentNetwork.coin;
final WalletInfo walletInfo = WalletInfo(
alias: exampleSteps[0].output ?? 'Account_1',
mnemonic: "",
strongholdPassword: "",
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedBaseCoinBalance =
await api.checkBalance(walletInfo: walletInfo);
if (mounted) {
String totalString =
displayBalance(receivedBaseCoinBalance.total, coinCurrency);
String availableString =
displayBalance(receivedBaseCoinBalance.available, coinCurrency);
String result =
'Total: $totalString\nAvailable: $availableString';
setState(() {
exampleSteps[1].setOutput(result);
Provider.of<AppProvider>(context, listen: false).balanceTotal =
receivedBaseCoinBalance.total;
Provider.of<AppProvider>(context, listen: false).balanceAvailable =
receivedBaseCoinBalance.available;
});
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[1].setOutput(e.message));
}
}
Example 7: Create DID
The source code for Example 7 can be found at lib/examples/example_6.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiCreateDecentralizedIdentifier()
by:
Future<void> _callFfiCreateDecentralizedIdentifier() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: '');
final WalletInfo walletInfo = WalletInfo(
alias: "",
mnemonic: "",
strongholdPassword: 'my_super_secret_stronghold_password',
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedText = await api.createDecentralizedIdentifier(
networkInfo: networkInfo, walletInfo: walletInfo);
if (mounted) {
setState(() {
exampleSteps[0].setOutput(receivedText);
});
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[0].setOutput(e.message));
}
}
Bin to Hex
The source code can be found at lib/widgets/my_drawer.dart
.
At the top, add:
import '../ffi.dart';
Replace the fake code in line 59 receivedText = 'fake 0000 0000 0000';
by:
receivedText = await api.binToHex(val: substr, len: 1);
Video
Follow the video for the remaining steps.
Building for macOS
Completing the remaining steps for macOS.
Everything is repeating. So in this section, I consolidate all the macOS instructions outlined in the previous chapters:
- FRB Template App - modified Workflow -> macOS instructions
- Building a Simple App -> Building for macOS
macOS Steps
To integrate our Rust backend, we create an additional Xcode project first and add it as a "subproject" to the existing Flutter Xcode project (macos/Runner.xcodeproj). During the build, the inner Xcode project is built first, enabling its use by the parent project.
Create the XCode project for our Rust library
An common step for macOS / iOS is needed: creating an Xcode project inside of the Rust library project folder (rust/). This can be done using the cargo-xcode
command.
I utilize cargo-xcode v1.5.0 to ensure smooth operation. For example, when I employed version v1.9.0, Xcode flagged an issue regarding the absence of a development team for signing (but: I couldn't add a development team because the "Signing & Capabilities" tab is missing for the target rust-cdylib in the Rust Xcode project).
-
To install the
cargo-xcode
command use:cargo install cargo-xcode@1.5.0
-
After the installation of the command, create the Rust Xcode project. Make sure to be in the rust/ directory. From the project's root folder you may switch into the right directory:
cd rust
cargo xcode
cd ..
-
This step is for macOS only because the macOS app uses the dynamic library:
Open up that
rust/rust.xcodeproj
file with Xcode and select the root item rust, at the left pane on top. Select the Target rust-cdylib and the Build Settings tab. Here, search for Dynamic Library Install Name Base and change the value into$(TARGET_BUILD_DIR)
.
Merge both projects
We need to incorporate the new Rust XCode project (rust/rust.xcodeproj) into our Flutter XCode project which was created inside the macos folder when Flutter initialized our project.
Simply open the macos/Runner.xcodeproj in Xcode, open the rust/ directory in Finder and drag the rust.xcodeproj into the Runner folder. The next images will illustrate the steps.
Adjust the Runner Target's Build Phases
For macOS, FRB recommends to include the dynamic library.
a) In Runner Target's Build Phase -> Target Dependencies:
Click on "+" and select rust-cdylib
.
b) In Runner Target's Build Phase -> Link Binary with Libraries:
Click on "+" and select rust.dylib
.
Adjust the Runner Target's Build Settings
Start typing "Objective-C Bridging Header" in the filter... the hard-to-find setting is in the Swift Compiler - General section of the settings.
As value, insert:
Runner/bridge_generated.h
Adjust Minimum Deployments
To ensure that your app can run on your host computer and Xcode version, you may only be able to support newer macOS versions. To set the minimum supported macOS version for your app, go to the General tab and select macOS version 13.1
as the Minimum Deployments target.
Generate the Dart Interface
Our next task is to create the generated code. This will also copy the C header file bridge_generated.h
into the folder macos/Runner/. Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--c-output macos/Runner/bridge_generated.h
Adjust the AppDelegate.swift
file
Switch to Visual Studio Code and open the file macos/Runner/AppDelegate.swift
. We need to call the function dummy_method_to_enforce_bundling() (from FRB) somewhere to avoid that Xcode handles our library as dead code.
Add:
dummy_method_to_enforce_bundling()
Your file should look like:
import Cocoa
import FlutterMacOS
@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
dummy_method_to_enforce_bundling()
return true
}
}
Add the Security Entitlement
macOS applications are sandboxed by default. To avoid a SocketException, you need to add the network.client entitlement to macOS/Runner/DebugProfile.entitlements
:
<key>com.apple.security.network.client</key>
<true/>
Video
The video summarizes all remaining steps.
Building for iOS
Compiling for iOS with iota.rs, wallet.rs and identity.rs... ironically, challenges arise in the final chapter.
The compilation encounters issues with Xcode 15! Watch the video to observe the outcome.
No instructions are provided here; kindly refer to the section where IOTA SDK is employed:
π Β Building a Comprehensive App using IOTA SDK
Motivation
So, why do we even need this chapter, you ask? Well, buckle up, my advanced coding compadres!
I've decided to skip the nitty-gritty details this time around. No more elaborate tales of how and why I configure things the way I do. We're sticking to the essentials. If you're craving more info, do yourself a favor and flip through the previous chapters.
Now, why the IOTA SDK, you wonder? Picture this: I couldn't get rocksdb in the wallet.rs to play nice with iOS. Enter IOTA SDK to save the day!
And guess what? The IOTA SDK isn't just a random choice; it's the successor to the now-deprecated duo of iota.rs and wallet.rs. Team it up with identity.rs, and you've got yourself the future of Shimmer/IOTA libraries. Future-proofing, baby!
Quick observation: whipping up a wallet and accounts with the IOTA SDK takes way more time than the breezy wallet.rs. The mystery behind this? Still working on cracking that code.
Oh, and I did a bit of code spring cleaning. Pulled out all the wallet-related functions from the behemoth api.rs and corralled them into a cozy wallet_singleton module. The plan? One wallet object to rule them all. Don't call me out if this isn't your standard Rust wizardry; I'm just doing my best here.
Now, the next two chapters are your Android and iOS love stories. I'm walking you through building the entire shebang from scratch and tweaking all configurations for both platforms. These chapters are joined at the hip, so start with Android and then waltz into iOS territory.
Hope that captures the vibe you're aiming for!
Android
Building The Playground App for Android using IOTA SDK and identity.rs.
The steps to follow are entirely analogous to those outlined in the chapters
- Building a Comprehensive App -> Init Flutter App and Setup FRB
- Building a Comprehensive App -> Building for Android
This is a chapter for advanced users. If it is moving too quickly for you, I would like to refer you to the previous chapters where all the steps were explained.
Clone the GitHub Repository
π Β GitHub Repo - Playground App (Flutter only)
In a terminal, execute:
% git clone https://github.com/iota-for-flutter/playground_app.git
% cd playground_app
% code .
Correct the libraries in pubspec.yaml
I forgot the 2 dependencies meta and uuid. So please add in pubspec.yaml:
dependencies:
...
meta: ^1.9.1
uuid: ^4.0.0
You might also encounter a message asking you to update the dependencies. You can execute updates by typing flutter pub upgrade
. There is one exception regarding the dev_dependency ffi_gen: The version number should be
dev_dependencies:
...
ffigen: ^9.0.1
...
Fix the problem with missing gradle files
- Start the Android Emulator
- Run the Flutter app with
flutter run
- Stop the Flutter app
- Restart VSCode
Fix the GradleException error
In android/app/build.gradle, replace GradleException by FileNotFoundException.
The error should disappear.
Setup the Flutter Rust Bridge
% cargo new --lib rust
% cargo install flutter_rust_bridge_codegen
% flutter pub add --dev ffigen && flutter pub add ffi
% flutter pub add flutter_rust_bridge
% flutter pub add -d build_runner
% flutter pub add -d freezed
% flutter pub add freezed_annotation
Add the Rust Code
This is a brief list of the specific versions I utilized:
- flutter_rust_bridge: 1.82.5
- iota_sdk: 1.1.3
- iota_stronghold: 2.0.0
- identity_core: 1.0.0
- rocksdb: 0.21.0
Cargo.toml
[package]
name = "rust"
version = "0.1.0"
edition = "2021"
[dependencies]
flutter_rust_bridge = "1"
iota-sdk = { version = "1.1.3", default-features = false, features = [
"client",
"wallet",
"tls",
"rocksdb",
"stronghold",
] }
identity_iota = { version = "1.0.0", features = ["memstore"] }
tokio = { version = "1.34.0", features = ["full"] }
anyhow = { version = "1.0.75" }
serde = { version = "1.0.193", default-features = false, features = ["derive"] }
serde_json = { version = "1.0.108", default-features = false }
lazy_static = "1.4.0"
once_cell = "1.19.0"
[lib]
crate-type = ["staticlib", "cdylib"]
Make sure to include the necessary features for utilizing Stronghold and RocksDB. Forgetting any feature might render certain code inaccessible. If you're interested, simply download the library code from GitHub and check it out.
Create api.rs
-
As usual, create an empty file called
api.rs
, at the same level aslib.rs
. -
Include it as module in
lib.rs
:mod api;
Add the content of api.rs
use anyhow::Result;
use tokio::runtime::Runtime;
use iota_sdk::{
client::secret::stronghold::StrongholdSecretManager as WalletStrongholdSecretManager,
client::secret::SecretManager as WalletSecretManager,
client::utils::request_funds_from_faucet,
client::Client,
types::block::{address::Bech32Address, output::AliasOutput},
};
use std::{env, path::PathBuf, u32};
use identity_iota::{
iota::{IotaClientExt, IotaDocument, IotaIdentityClientExt, NetworkName},
storage::{JwkDocumentExt, JwkMemStore, KeyIdMemstore, Storage},
verification::{jws::JwsAlgorithm, MethodScope},
};
mod wallet_singleton;
#[derive(Debug, Clone)]
pub struct NetworkInfo {
pub node_url: String,
pub faucet_url: String,
}
#[allow(dead_code)]
pub fn get_node_info(network_info: NetworkInfo) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = network_info.node_url;
// Create a client with that node.
let client = Client::builder()
.with_node(&node_url)?
.with_ignore_node_health()
.finish()
.await?;
// Get node info.
let info = client.get_info().await?.node_info;
Ok(serde_json::to_string_pretty(&info).unwrap())
//Ok(info.node_info.base_token.name)
})
}
#[allow(dead_code)]
pub fn generate_mnemonic() -> String {
let mnemonic = Client::generate_mnemonic();
mnemonic.unwrap().to_string()
}
#[derive(Debug, Clone)]
pub struct WalletInfo {
pub alias: String,
pub mnemonic: String,
pub stronghold_password: String,
pub stronghold_filepath: String,
pub last_address: String,
}
#[allow(dead_code)]
pub fn create_wallet_account(network_info: NetworkInfo, wallet_info: WalletInfo) -> Result<String> {
wallet_singleton::create_wallet_account(network_info, wallet_info)
}
#[allow(dead_code)]
pub fn generate_address(wallet_info: WalletInfo) -> Result<String> {
wallet_singleton::generate_address(wallet_info)
}
#[allow(dead_code)]
pub fn request_funds(network_info: NetworkInfo, wallet_info: WalletInfo) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let stronghold_filepath = wallet_info.stronghold_filepath;
let last_address = wallet_info.last_address;
env::set_current_dir(&stronghold_filepath).ok();
let faucet_url = network_info.faucet_url;
// Convert given address (BECH32 string) to Address struct
let address = Bech32Address::try_from_str(&last_address)?;
// Use the function iota_wallet::iota_client::request_funds_from_faucet
let faucet_response = request_funds_from_faucet(&faucet_url, &address).await?;
Ok(faucet_response.to_string())
})
}
#[derive(Debug, Clone)]
pub struct BaseCoinBalance {
/// Total amount
pub total: u64,
/// Balance that can currently be spent
pub available: u64,
}
#[allow(dead_code)]
pub fn check_balance(wallet_info: WalletInfo) -> Result<BaseCoinBalance> {
wallet_singleton::check_balance(wallet_info)
}
type MemStorage = Storage<JwkMemStore, KeyIdMemstore>;
#[allow(dead_code)]
pub fn create_decentralized_identifier(
network_info: NetworkInfo,
wallet_info: WalletInfo,
) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = network_info.node_url;
let stronghold_password = wallet_info.stronghold_password;
let stronghold_filepath = wallet_info.stronghold_filepath;
let last_address = wallet_info.last_address;
env::set_current_dir(&stronghold_filepath).ok();
let mut path_buf_snapshot = PathBuf::new();
path_buf_snapshot.push(&stronghold_filepath);
path_buf_snapshot.push("wallet.stronghold");
let path_snapshot = PathBuf::from(path_buf_snapshot);
// Create a new client to interact with the IOTA ledger.
let client: Client = Client::builder()
.with_primary_node(&node_url, None)?
.finish()
.await?;
// Create a new secret manager backed by a Stronghold.
let secret_manager: WalletSecretManager = WalletSecretManager::Stronghold(
WalletStrongholdSecretManager::builder()
.password(stronghold_password)
.build(path_snapshot)?,
);
// Convert given address (BECH32 string) to Address struct
let address = Bech32Address::try_from_str(&last_address)?;
// Get the Bech32 human-readable part (HRP) of the network.
let network_name: NetworkName = client.network_name().await?;
// Create a new DID document with a placeholder DID.
// The DID will be derived from the Alias Id of the Alias Output after publishing.
let mut document: IotaDocument = IotaDocument::new(&network_name);
// Insert a new Ed25519 verification method in the DID document.
let storage: MemStorage = MemStorage::new(JwkMemStore::new(), KeyIdMemstore::new());
document
.generate_method(
&storage,
JwkMemStore::ED25519_KEY_TYPE,
JwsAlgorithm::EdDSA,
None,
MethodScope::VerificationMethod,
)
.await?;
// Insert a new Ed25519 verification method in the DID document.
let storage: MemStorage = MemStorage::new(JwkMemStore::new(), KeyIdMemstore::new());
document
.generate_method(
&storage,
JwkMemStore::ED25519_KEY_TYPE,
JwsAlgorithm::EdDSA,
None,
MethodScope::VerificationMethod,
)
.await?;
// Construct an Alias Output containing the DID document, with the wallet address
// set as both the state controller and governor.
let alias_output: AliasOutput = client.new_did_output(*address, document, None).await?;
// Publish the Alias Output and get the published DID document.
let document: IotaDocument = client
.publish_did_output(&secret_manager, alias_output)
.await?;
Ok(document.to_string())
})
}
#[allow(dead_code)]
pub fn bin_to_hex(val: String, len: usize) -> String {
let n: u32 = u32::from_str_radix(&val, 2).unwrap();
format!("{:01$x}", n, len * 2)
}
Create a new file wallet_singleton.rs
Create a folder named api
next to api.rs
. Inside of this folder, create a new file wallet_singleton.rs
and copy/paste this content in it:
use crate::api::{BaseCoinBalance, NetworkInfo, WalletInfo};
use anyhow::{Error, Result};
use iota_sdk::{
client::constants::SHIMMER_COIN_TYPE,
client::secret::stronghold::StrongholdSecretManager as WalletStrongholdSecretManager,
client::secret::SecretManager as WalletSecretManager,
crypto::keys::bip39::Mnemonic,
wallet::{ClientOptions, Wallet},
};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, Once};
use tokio::runtime::Runtime;
struct WalletSingleton {
network_info: NetworkInfo,
wallet_info: WalletInfo,
wallet: Option<Wallet>,
}
impl WalletSingleton {
fn new(network_info: NetworkInfo, wallet_info: WalletInfo) -> Result<Self> {
let mut wallet_singleton = Self {
network_info,
wallet_info,
wallet: None,
};
wallet_singleton.create_wallet()?;
Ok(wallet_singleton)
}
fn create_wallet(&mut self) -> Result<String> {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let node_url = &self.network_info.node_url;
let stronghold_password = self.wallet_info.stronghold_password.clone();
let stronghold_filepath = self.wallet_info.stronghold_filepath.clone();
let mnemonic_string: String = self.wallet_info.mnemonic.clone();
let mnemonic = Mnemonic::from(mnemonic_string);
// Create the needed directory according to the given path
let mut path_buf = PathBuf::new();
path_buf.push(&stronghold_filepath);
let path = PathBuf::from(path_buf);
fs::create_dir_all(path).ok();
// THIS NEXT STEP IS CRUCIAL:
// Point the "current working directory" to the given path
env::set_current_dir(&stronghold_filepath).ok();
// Create the Rust file for the stronghold snapshot file
let mut path_buf_snapshot = PathBuf::new();
path_buf_snapshot.push(&stronghold_filepath);
path_buf_snapshot.push("wallet.stronghold");
let path_snapshot = PathBuf::from(path_buf_snapshot);
let secret_manager = WalletStrongholdSecretManager::builder()
.password(stronghold_password)
.build(path_snapshot)?;
// Storing the mnemonic is ONLY REQUIRED THE FIRST TIME
// calling it TWICE THROWS AN ERROR
secret_manager.store_mnemonic(mnemonic).await?;
// Create a ClientBuilder (= client_options in wallet.rs)
// See wallet.rs:
// -> src/lib.rs
// -> line "pub use iota_client::ClientBuilder as ClientOptions"
let client_options = ClientOptions::new().with_node(&node_url)?;
// Create the account manager with the secret_manager
// and client_options (= ClientBuilder).
// The Client itself is created in the AccountManagerBuilder's finish() method.
// See wallet.rs:
// -> src/account_manager/builder.rs
// -> line "let client = client_options.clone().finish()?;"
self.wallet = Some(
Wallet::builder()
.with_secret_manager(WalletSecretManager::Stronghold(secret_manager))
.with_client_options(client_options)
.with_coin_type(SHIMMER_COIN_TYPE)
.finish()
.await?,
);
Ok("Wallet Account was created successfully.".into())
})
}
fn create_account(&self, wallet_info: WalletInfo) -> Result<String> {
let wallet_singleton = self;
let result = Runtime::new().unwrap().block_on(async {
if let Some(ref wallet) = wallet_singleton.wallet {
let wallet_alias = wallet_info.alias;
let _account = wallet
.create_account()
.with_alias((&wallet_alias).to_string())
.finish()
.await?;
Ok("Account was created successfully.".into())
} else {
Err(Error::msg("No wallet set."))
}
});
result
}
fn generate_address(&self, wallet_info: WalletInfo) -> Result<String> {
let wallet_singleton = self;
let rt = Runtime::new().unwrap();
rt.block_on(async {
if let Some(ref wallet) = wallet_singleton.wallet {
let stronghold_password = wallet_info.stronghold_password;
let wallet_alias = wallet_info.alias;
let account = wallet.get_account((&wallet_alias).to_string()).await?;
wallet.set_stronghold_password(stronghold_password).await?;
let addresses = account.generate_ed25519_addresses(1, None).await?;
Ok(addresses[0].address().to_string())
} else {
Err(Error::msg("No wallet set."))
}
})
}
fn check_balance(&self, wallet_info: WalletInfo) -> Result<BaseCoinBalance> {
let wallet_singleton = self;
let rt = Runtime::new().unwrap();
rt.block_on(async {
if let Some(ref wallet) = wallet_singleton.wallet {
let stronghold_filepath = wallet_info.stronghold_filepath;
env::set_current_dir(&stronghold_filepath).ok();
let wallet_alias = wallet_info.alias;
let account = wallet.get_account((&wallet_alias).to_string()).await?;
// Sync and get the balance
let account_balance = account.sync(None).await?;
let base_coin_balance = BaseCoinBalance {
total: account_balance.base_coin().total(),
available: account_balance.base_coin().available(),
};
//let total = account_balance.base_coin.total;
//println!("{:?}", account_balance);
//Ok(total.to_string())
Ok(base_coin_balance)
} else {
Err(Error::msg("No wallet set."))
}
})
}
}
lazy_static::lazy_static! {
static ref WALLET_SINGLETON: Mutex<Option<WalletSingleton>> = Mutex::new(None);
static ref INIT: Once = Once::new();
}
fn create_wallet_singleton_if_needed(network_info: NetworkInfo, wallet_info: WalletInfo) {
INIT.call_once(|| {
if let Ok(wallet_singleton) = WalletSingleton::new(network_info, wallet_info) {
let mut locked_wallet_singleton = WALLET_SINGLETON.lock().unwrap();
*locked_wallet_singleton = Some(wallet_singleton);
} else {
// Handle the error
// You can log an error, panic, or choose an appropriate action.
panic!("Error creating wallet singleton");
}
});
}
pub fn create_wallet_account(network_info: NetworkInfo, wallet_info: WalletInfo) -> Result<String> {
create_wallet_singleton_if_needed(network_info, wallet_info.clone());
let locked_wallet_singleton = WALLET_SINGLETON.lock().unwrap();
let wallet_singleton = locked_wallet_singleton.as_ref().unwrap();
wallet_singleton.create_account(wallet_info)
}
pub fn generate_address(wallet_info: WalletInfo) -> Result<String> {
let locked_wallet_singleton = WALLET_SINGLETON.lock().unwrap();
let wallet_singleton = locked_wallet_singleton.as_ref().unwrap();
wallet_singleton.generate_address(wallet_info)
}
pub fn check_balance(wallet_info: WalletInfo) -> Result<BaseCoinBalance> {
let locked_wallet_singleton = WALLET_SINGLETON.lock().unwrap();
let wallet_singleton = locked_wallet_singleton.as_ref().unwrap();
wallet_singleton.check_balance(wallet_info)
}
Generate the Dart Interface
Use this command (you need to be in the root of your project):
flutter_rust_bridge_codegen \
--rust-input rust/src/api.rs \
--dart-output ./lib/bridge_generated.dart \
--dart-decl-output ./lib/bridge_definitions.dart \
--wasm
Adjust the Dart Code
Include the Rust library
Create a file ffi.dart
next to main.dart
and paste this content. As you can see it returns the api variable.
// This file initializes the dynamic library and connects it with the stub
// generated by flutter_rust_bridge_codegen.
import 'dart:ffi';
import 'bridge_generated.dart';
import 'bridge_definitions.dart';
export 'bridge_definitions.dart';
// Re-export the bridge so it is only necessary to import this file.
export 'bridge_generated.dart';
import 'dart:io' as io;
const _base = 'rust';
// On MacOS, the dynamic library is not bundled with the binary,
// but rather directly **linked** against the binary.
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
final Rust api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
Adjust Example 1: Get Node Information
The source code for Example 1 can be found at lib/examples/example_0.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiNodeInfo()
by:
Future<void> _callFfiNodeInfo() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
String faucetUrl = Provider.of<AppProvider>(context, listen: false)
.currentNetwork
.faucetApiUrl ??
'';
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: faucetUrl);
try {
customOverlay.show(context);
final receivedText = await api.getNodeInfo(networkInfo: networkInfo);
if (mounted) {
Provider.of<AppProvider>(context, listen: false).nodeInfo =
receivedText;
setState(() => exampleSteps[1].setOutput(receivedText));
}
customOverlay.hide();
} on FfiException catch (e) {
setState(() => exampleSteps[1].setOutput(e.message));
customOverlay.hide();
}
}
Adjust Example 2: Generate Mnemonics
The source code for Example 2 can be found at lib/examples/example_1.dart
.
At the top, add:
import '../ffi.dart';
Replace the fake code of _callFfiGenerateMnemonic()
by:
Future<void> _callFfiGenerateMnemonic() async {
customOverlay.show(context);
final receivedText = await api.generateMnemonic();
if (mounted) {
setState(() {
Provider.of<AppProvider>(context, listen: false).mnemonic =
receivedText;
exampleSteps[1].setOutput(receivedText);
});
}
customOverlay.hide();
}
Adjust Example 3: Create Wallet Account
The source code for Example 3 can be found at lib/examples/example_2.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiCreateWalletAccount()
by:
Future<void> _callFfiCreateWalletAccount() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
String faucetUrl = Provider.of<AppProvider>(context, listen: false)
.currentNetwork
.faucetApiUrl ??
'';
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: faucetUrl);
final WalletInfo walletInfo = WalletInfo(
alias: exampleSteps[1].output ?? 'Account_1',
mnemonic: Provider.of<AppProvider>(context, listen: false).mnemonic,
strongholdPassword:
exampleSteps[2].output ?? 'my_super_secret_stronghold_password',
strongholdFilepath: _strongholdFilePath,
lastAddress: "",
);
try {
customOverlay.show(context);
final receivedText = await api.createWalletAccount(
networkInfo: networkInfo, walletInfo: walletInfo);
if (mounted) {
setState(() => exampleSteps[3].setOutput(receivedText));
}
customOverlay.hide();
} on FfiException catch (e) {
setState(() => exampleSteps[3].setOutput(e.message));
customOverlay.hide();
}
}
Adjust Example 4: Generate Address
The source code for Example 4 can be found at lib/examples/example_3.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiGenerateAddress()
by:
Future<void> _callFfiGenerateAddress() async {
final WalletInfo walletInfo = WalletInfo(
alias: exampleSteps[1].output ?? 'Account_1',
mnemonic: Provider.of<AppProvider>(context, listen: false).mnemonic,
strongholdPassword:
exampleSteps[2].output ?? 'my_super_secret_stronghold_password',
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedText = await api.generateAddress(walletInfo: walletInfo);
if (mounted) {
setState(() {
Provider.of<AppProvider>(context, listen: false).lastAddress =
receivedText;
exampleSteps[3].setOutput(receivedText);
});
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[3].setOutput(e.message));
}
}
Adjust Example 5: Request Funds
The source code for Example 5 can be found at lib/examples/example_4.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiRequestFunds()
by:
Future<void> _callFfiRequestFunds() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
String faucetUrl = Provider.of<AppProvider>(context, listen: false)
.currentNetwork
.faucetApiUrl ??
'';
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: faucetUrl);
final WalletInfo walletInfo = WalletInfo(
alias: "",
mnemonic: "",
strongholdPassword: "",
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedText = await api.requestFunds(
networkInfo: networkInfo, walletInfo: walletInfo);
if (mounted) {
setState(() => exampleSteps[1].setOutput(receivedText));
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[1].setOutput(e.message));
}
}
Adjust Example 6: Check Balance
The source code for Example 6 can be found at lib/examples/example_5.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import 'package:provider/provider.dart';
import '../coin_utils.dart';
import '../data/app_provider.dart';
import '../ffi.dart';
Replace the fake code of _callFfiCheckBalance()
by:
Future<void> _callFfiCheckBalance() async {
String coinCurrency =
Provider.of<AppProvider>(context, listen: false).currentNetwork.coin;
final WalletInfo walletInfo = WalletInfo(
alias: exampleSteps[0].output ?? 'Account_1',
mnemonic: "",
strongholdPassword: "",
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedBaseCoinBalance =
await api.checkBalance(walletInfo: walletInfo);
if (mounted) {
String totalString =
displayBalance(receivedBaseCoinBalance.total, coinCurrency);
String availableString =
displayBalance(receivedBaseCoinBalance.available, coinCurrency);
String result =
'Total: $totalString\nAvailable: $availableString';
setState(() {
exampleSteps[1].setOutput(result);
Provider.of<AppProvider>(context, listen: false).balanceTotal =
receivedBaseCoinBalance.total;
Provider.of<AppProvider>(context, listen: false).balanceAvailable =
receivedBaseCoinBalance.available;
});
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[1].setOutput(e.message));
}
}
Adjust Example 7: Create DID
The source code for Example 7 can be found at lib/examples/example_6.dart
.
At the top, add:
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import '../ffi.dart';
Replace the fake code of _callFfiCreateDecentralizedIdentifier()
by:
Future<void> _callFfiCreateDecentralizedIdentifier() async {
String nodeUrl =
Provider.of<AppProvider>(context, listen: false).currentNetwork.url;
final NetworkInfo networkInfo =
NetworkInfo(nodeUrl: nodeUrl, faucetUrl: '');
final WalletInfo walletInfo = WalletInfo(
alias: "",
mnemonic: "",
strongholdPassword: 'my_super_secret_stronghold_password',
strongholdFilepath: _strongholdFilePath,
lastAddress: Provider.of<AppProvider>(context, listen: false).lastAddress,
);
try {
customOverlay.show(context);
final receivedText = await api.createDecentralizedIdentifier(
networkInfo: networkInfo, walletInfo: walletInfo);
if (mounted) {
setState(() {
exampleSteps[0].setOutput(receivedText);
});
}
customOverlay.hide();
} on FfiException catch (e) {
customOverlay.hide();
setState(() => exampleSteps[0].setOutput(e.message));
}
}
Adjust my_drawer.dart
The source code can be found at lib/widgets/my_drawer.dart
.
At the top, add:
import '../ffi.dart';
Replace the fake code in line 59 receivedText = 'fake 0000 0000 0000';
by:
receivedText = await api.binToHex(val: substr, len: 1);
Update the correct libs and versions, too. You'll find the right place.
...
Container(
margin: const EdgeInsets.only(top: 20, bottom: 5),
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.topLeft,
height: 24,
child: Text(
'Included Rust Libraries',
style: Theme.of(context).textTheme.titleLarge,
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: const Row(
children: [
SizedBox(
width: 100,
child: Text(
'iota_sdk:',
),
),
Text("v1.1.3"),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: const Row(
children: [
SizedBox(
width: 100,
child: Text(
'iota_stronghold:',
),
),
Text("v2.0.0"),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: const Row(
children: [
SizedBox(
width: 100,
child: Text(
'identity_iota:',
),
),
Text("v1.0.0"),
],
),
),
...
Configure the Android setup
Install the cargo-ndk for Android
% cd rust
% cargo install cargo-ndk
% cd ..
Integrate cargo build
into the Gradle build process
If you've had NO problem with the 3rd party library libsodium, add at the bottom:
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../rust" // <-- ATTENTION: CHECK THE CORRECT FOLDER!!!
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
commandLine 'cargo', 'ndk',
// the 2 ABIs below are used by real Android devices
// '-t', 'armeabi-v7a',
'-t', 'arm64-v8a',
// the below 2 ABIs are usually used for Android simulators,
// add or remove these ABIs as needed.
// '-t', 'x86',
// '-t', 'x86_64',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
Enable the dynamic library loading
To enable dynamic library loading, you must place the libc++_shared.so
file next to the librust.so
(which is generated when you execute flutter run
). The output directory android/app/src/main/jniLibs/{abi}/ is automatically created after the first execution, but not before.
I want to address this point before I run flutter run
for the first time. Therefore, I create the directory android/app/src/main/jniLibs/{abi}/ manually and place the library libc++_shared.so
in there.
Here are some download links from Android NDK 25 on macOS:
- arm64-v8a/libc++_shared.so
- armeabi-v7a/libc++_shared.so
- x86/libc++_shared.so
- x86_64/libc++_shared.so
For example, place libc++_shared.so for abi arm64-v8a into the folder android/app/src/main/jniLibs/arm64-v8a/
Start the app
- Launch the Emulator
- Run
flutter run
Video
Building for iOS
Building The Playground App for iOS using IOTA SDK and identity.rs.
This will be a tough nut to crack once again.
I presume the functionality of the -> Android app is operational. From the iOS standpoint, this implies that you've configured the Flutter Rust Bridge and incorporated both Rust and Dart code.
I am working on a MacBook Air (Apple M1) with macOS Sonoma 14.2.1 and Xcode 15.2. It's possible that the standard workflow works smoothly in other working environments.
Unfortunately, on my system, I faced challenges working with iOS. The libsodium-sys wrapper only built successfully when the libsodium library was pre-built as a static lib and manually added. The app only ran on my iPhone, not in the simulator (but refer to the little note at the bottom). It could only be launched from the Runner.xcworkspace project, not the Runner.xcodeproj. These were quite intricate issues that were very time-consuming to resolve.
Verify your working environment
Before you start working on iOS, I suggest you check your environment by running flutter doctor
or flutter doctor -v
for detailed information.
Based on my experience, updating your macOS, Xcode, and/or iOS versions can lead to unforeseen issues. Each update tends to bring about new problems, so it requires patience to set up your workspace properly. It's essential to make it a routine to verify that everything is configured correctly.
See example "CocoaPods installed but not working" in the section "Other Issues you might encounter" at the bottom of this page.
Resolve any issues before proceeding further.
iOS Set-up
To install the cargo-xcode
command use:
cargo install cargo-xcode@1.5.0
After the installation of the command, create the Rust Xcode project. Make sure to be in the rust/ directory. From the project's root folder you may switch into the right directory:
% cd rust
% cargo xcode
% cd ..
Follow the known iOS instructions
I refer to the chapter -> iOS instructions. It contains:
- Generating the Dart Code
- Creating the subproject and configuring the projects
- Adjusting the Runner-Bridging-Header.h and the AppDelegate.swift
Specific steps to solve the Libsodium problem
Feel free to perform the ultimate act of rebellion and skip this paragraph:
- if you're not vibing with Stronghold (but in our app, we're all about that Stronghold life)
- if you want to witness firsthand how the conventional path here takes a detour into chaos.
Come on, come on, give it a shot and launch the app already!
I'm unveiling a sneaky maneuver to get the app up and running on iOS:
- Build libsodium.a manually for the iOS platform
- Include libsodium.a as static lib into a new Group "sodium" in Xcode
- Include libsodium.a into the Build Rules of the target "rust-staticlib"
This process is described in the subchapter -> Libsodium library for iOS.
Give it a try
-
Connect your iOS Device
Little note: if libsodium.a can be built for the iOS Simulator using the Extended usage, perhaps the app will also strut its stuff on the iOS Simulator... I didn't follow this way after all my attempts... I am a bit tired...
-
Options to launch the app
a) Launch with
flutter run
=> *Could fail when the compile process stops - you will get more detailed information when you launch the app from Xcode
b) Launch with
Runner.xcodeproj
=> *Could fail because "shared_preferences_foundation" is not found
c) Launch with
Runner.xcworkspace
=> *Could fail whenever libsodium-sys cannot be built; doesn't fail when you precompile libsodium.a as described in the subchapter
=> *Could fail when the Pods (dependencies) aren't yet installed
*Possible failures
Other Issues you might encounter
Error: CocoaPods installed but not working
Verifying your environment by flutter doctor -v
might log this error:
To resolve this issue, I searched online and followed the instructions outlined in the article titled How to Remove and Re-install cocoapods in Flutter. You may find other sources.
Error: Missing pod install
The first time around, the dependencies "path_provider_foundation" and "shared_preferences_foundation" are not yet installed. You will notice it in Xcode.
The straightforward method is to let Flutter handle the install. Switch to VS Code and launch the app using flutter run
. You'll notice that before the Xcode build, the command pod install
will be executed.
Error: iOS Deployment Target
Throughout the Xcode build process, you may receive an alert indicating that the current iOS Deployment target is incorrect. Example:
You should go through ALL targets (Runner, Rust, Pods -> path_provider_foundation, Pods -> shared_preferences_foundation) and adjust the default iOS Deployment Targets to your version of choice. In my situation, I often chose the latest available version 17.2 (after updating to Xcode 15.2 and installing iOS SDK 17.2), but it's up to you. It MUST be a version greater than v11.0.
Linking failed: CoreAudioTypes not found
After updating from Xcode v15.0.1 to Xcode v15.2, I encountered the following error:
Linking failed: linker command failed with exit code 1 (use -v to see invocation).
ld: warning: Could not find or use auto-linked framework 'CoreAudioTypes': framework 'CoreAudioTypes' not found.
The message indicating that the framework 'CoreAudioTypes' could not be found is highly misleading!
Potential solutions:
-
Option 1: Attempt to include the Linker Flags
-lc++
and/or-framework Flutter
(if they are not already present) in the build settings "Other Linker Flags" of the Runner target. -
Option 2: Rebuild the app from scratch. Surprisingly, this seemingly drastic step resolved the issue for me.
Error: Missing Signing Certificate
"Error (Xcode): No signing certificate 'iOS Development' found"
To proceed, a Development Account is required, and you must be logged in to it within Xcode.
Video
Github Repository
You will find the complete source code from the video in this repository:
π Β Repository - Playground App complete
Libsodium library for iOS
Please refer to this chapter to learn about a Workaround for iOS that can be used if you wish to utilize Stronghold.
The libsodium library is a widely used library for cryptography, offering a variety of functions for encryption, decryption, hashing, signatures, and more.
libsodium-sys is a Rust wrapper around the libsodium library. It provides the necessary Rust bindings and FFI (Foreign Function Interface) declarations to link and interact with the libsodium library from Rust code.
libsodium-sys is a dependency of the stronghold-runtime crate.
It's crucial to grasp that the wrapper (libsodium-sys) inherently includes a snapshot of libsodium and automatically builds it by default, which doesn't work for iOS as we have seen. HOWEVER, you have the option to substitute this bundled version with any precompiled library that you've built independently. This is the task at hand. The environment variables SODIUM_LIB_DIR and SODIUM_SHARED play a pivotal role in this context.
GitHub Repositories
π Β GitHub Repo - libsodium
π Β GitHub Repo - libsodium-sys - Extended usage
Workaround
These are the three steps:
In Step 1, create the libsodium.a
library file for the iOS target.
In Step 2, test the built library by using the environment variables SODIUM_LIB_DIR and SODIUM_SHARED.
In Step 3, include the created libsodium.a
in the Runner's build process in Xcode as static library and define SODIUM_LIB_DIR and SODIUM_SHARED in Rust's build phase in Xcode.
Step 1: Create libsodium.a
You'll need to clone the libsodium repository in a new IDE project.
git clone https://github.com/jedisct1/libsodium.git
You will need to install some tools on your macOS:
a) Homebrew
Check with brew --version
if you can skip the installation. If already installed, a version is returned. Otherwise install Homebrew with the command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
b) autoconf
Check with autoconf --version
if you can skip the installation. If already installed, a version is returned. Otherwise install autoconf with the command:
brew install autoconf
c) automake
Check with automake --version
if you can skip the installation. If already installed, a version is returned. Otherwise install automake with the command:
brew install automake
d) libtool
Install libtool with the command:
brew install libtool
If it's already installed, you'll get a message returned: "Warning: libtool 2.4.7 is already installed and up-to-date."
Once you've setup everything, you can navigate into the "libsodium" directory and execute:
./autogen.sh -s
This will create the configure
file. The next command will create the libraries for several Apple targets (iOS, macOS, tvOS, watchOS):
./dist-build/apple-xcframework.sh
To save time feel free to adjust the script in order to skip some of the targets.
The console output tells you where you will find the wanted libsodium.a
: It's located in the folder "<path/to/your/libsodium>/libsodium-apple/tmp/ios64/lib/".
Step 2: Test the built library
Navigate to the rust
folder of the Playground App you have created in the Android section.
Now, I recommend to test the cargo build in Terminal by setting the following environment variables before executing the commands. Please replace "/path/to/library" with the correct directory path to your library (do NOT append "libsodium.a" at the end of the path).
SODIUM_LIB_DIR="/path/to/library" SODIUM_SHARED=1 cargo build --target aarch64-apple-ios
e.g.
SODIUM_LIB_DIR="/Users/<yourname>/libsodium/libsodium-apple/tmp/ios64/lib" SODIUM_SHARED=1 cargo build --target aarch64-apple-ios
Step 3: Integrate libsodium.a in Xcode and in the build process
In the aforementioned test, we defined the variables SODIUM_LIB_DIR and SODIUM_SHARED within the command line. However, these definitions will not persist.
To ensure proper setup, you need to
-
Open the Runner.xcworkspace
-
Add a new group "sodium" in the Runner
-
Switch to Finder, copy the libsodium.a from
/Users/<yourname>/libsodium/libsodium-apple/tmp/ios64/lib
into the new folder/Users/<yourname>/playground_app/ios/Runner/sodium
-
Simultaneously open the Runner.xcworkspace and drag the libsodium.a library from the Finder into the newly created group "sodium"
-
Adjust the Build Rules (line 25) of the target "Rust" in order to persist the environment variables SODIUM_LIB_DIR and SODIUM_SHARED, e.g. in my case:
SODIUM_LIB_DIR="/Users/<yourname>/playground_app/ios/Runner/sodium" SODIUM_SHARED=1
Give it a try
Continue and go back to chapter -> Building for iOS.
What's the result?
A flawed "bonus". I must admit, I haven't put much effort into this app. So, it's definitely only for the very brave.
Video
That sounds like a real challenge. Yep, I'm talking about the app I used to test out MQTT chat. Clone the app from the repository (see below) if you're feeling adventurous.
I did put together a tutorial chapter, trying to give at least some information and tips how to get it running on macOS and in the iOS Simulator simultaneously. But here's the kickerβI couldn't crack the Android security issue regarding certificates. Sorry, Android fans, you're on your own with this one.
So, what's the verdict? Well, let's just say this app is tailor-made for the daredevils among us, the ones who thrive on a good challenge. If you've got a knack for troubleshooting and a touch of masochism, then by all means, dive right in. But for the rest of us mere mortals? Maybe stick to apps that don't require a crash course in programming just to send a message.
Github Repository
You will find the complete source code from the video in this repository:
π Β Repository - MQTT Chat App
Usage of the repository
Now here's a hurdle: I'm not exactly a pro at downloading repositories and making them spring to life with just one magic install command (especially when it comes to this complex amalgamation of Rust, Flutter, Flutter-Rust Bridge, Android, and Xcode). Nope, no hand-holding from me on this one. Proceed at your own risk, folks. You've been warned.
My recommendation: Build the app from scratch as you have learned in the previous chapters. Copy the most important code snippets for Flutter and Rust from the MQTT Chat App repository and paste it into your app.
When rebuilding an app from scratch, ensure that you take into account all the workarounds, tips, and pitfalls described in the previous chapters. For example:
-
A common mistake on iOS is that Pods (dependencies) need to be installed before the initial launch using the additional command "pod install". However, this is automatically done when the application is launched via flutter run.
-
On macOS, there might be issues with loading dynamic libraries or forgetting to set the key 'com.apple.security.network.client' to true.
Overview
A Small App for Sending and Receiving Messages.
Use Case
As shown in the video, the aim of the app is to allow individuals to exchange messages while they are simultaneously utilizing the same channel of a selected network. Users can choose their own names.
In the app, there are currently two network nodes selectable, one in the Shimmer Testnet and the other in the Shimmer Network. If you operate your own node, you can easily redirect the message transmission of the app through your node by simply exchanging the URLs.
The term "channel" or "tag" is synonymous with a chatroom. The choice of the channel/tag name is entirely arbitrary. For clarity, I've opted to prepend a "#" symbol before the name.
The app does not store messages broadcasted by the channel/tag.
From a Technical Perspective
The idea is for the app to register with a network node using the inx-mqtt extension. INX-MQTT extends the node endpoints to provide an Event API for listening to live changes occurring in the Tangle.
This API is by default reachable using MQTT over WebSockets.
To test this, the app must be opened simultaneously on at least two devices. For this purpose, I've chosen the iOS Simulator on one end and the macOS app on the other. Further down I'll describe how to launch both apps simultaneously.
Known Bugs
Apart from known long transmission times, which for reasons unknown to me can range from a few seconds (acceptable) to a few minutes, there are a few errors that I have not investigated.
- Messages are sometimes sent multiple times. This may be due to faulty registration or deregistration in the Rust backend.
- When switching networks, settings are not cleanly saved and updated. This is a problem on the Flutter side.
- After changing channels or networks, there are occasional error messages.
Basic Building Blocks
There are few basic building blocks worth highlighting.
Flutter Chat UI
This plugin serves as the heart of the user interface, handling a significant portion of the programming for message display and transmission functionalities. It's configurable, and I've set it up to only transmit text messages. Essentially, the programming effort boils down to registering the app with the node, enabling message publishing, and populating the message stream with messages from the Rust backend.
Shared Preferences
π Β Shared Preferences Plugin
This plugin is utilized for reading and writing simple key-value pairs in the frontend. It wraps NSUserDefaults on iOS and SharedPreferences on Android. The app stores the last chosen settings using this mechanism.
MQTT Client in Rust Backend
I wanted to encapsulate the MQTT functionalities into a separate file. Therefore, on the Rust side, there is the module mqtt
, which contains an MQTT client with functions for initializing, opening, and closing a channel, publishing a message, as well as receiving messages from the MQTT extension.
When opening a channel, it is implemented that incoming messages are further processed through log.info!
by the Rust logger within the backend. The uniqueness of the logger is described in the next section.
Logging in Rust Backend
At this point, I refer to the chapter Logging Example App. Here, a simple mechanism is described for forwarding Rust logging messages to the Flutter frontend in the form of a stream.
We leverage this approach here.
The logger
module in the MQTT Chat App is structured similarly to the one described in the mentioned chapter. With one exception. Here, I've incorporated a switch that allows differentiation based on syntax or special delimiters '@@@' in the message string, indicating whether it's a "normal" log info or a log info containing an MQTT message.
Admittedly, this is programmed rather poorly. But my focus here was on a quick solution that allows the entire process from A to Z to be tested in the simplest way possible.
Launching two apps in your working environment
Here's my approach to starting:
In VSCode, I first launch the iOS Simulator using open -a Simulator
. Then, I launch the app using flutter run
. This automatically executes "pod install" if necessary.
Next, I start the second instance of the application from Xcode. I use the Runner.xcworkspace in the macOS directory for this purpose.
Android
Usually, Android is my buddy. Unfortunately, not in this case.
Try it out
In the log file, you can see that the message was sent: "MESSAGE WAS PUBLISHED SUCCESSFULLY IN BLOCK ID ..."
And it's also traceable in the tangle explorer:
What's wrong?
A tiny little thing trips us up. It's nestled in the log file as an error message:
Android seems to have some qualms about the certificate, which is why it's not allowing the connection to the MQTT server via rustls.
I've searched for any settings in Android that could fix it. For instance, I tried to establish the server's certificate as trusted within the Android app. I also experimented with different nodes using various certificates. The number of attempts I've made is numerous, and after a long time, it's hard for me to describe them all.
At this point, I'd like to hand over the task to you guys. If you manage to find a solution for Android, I'd appreciate it if you could send me a description of your approach.
Either leave a description as a comment under the YouTube video MQTT Chat App for SHIMMER/IOTA using Flutter and Rust (iota-client.rs), or send me a message on Twitter (X) at @dj_kaiota. Thank you!