This README explains how to configure the wallet app. It also contains the currently supported mock scenarios, describes our conventions and project structure and finally offers information on the design decisions we made in the Architecture section.
To run the app you need to configure Flutter, Rust, the Android SDK and the iOS SDK. See the project root's README.md for instructions on how to do so.
After setting up your environment, launch an Android emulator or the iOS simulator and execute:
flutter runorfvm flutter runwhen using Flutter Version Manager (FVM)
Note that when using FVM, all Flutter commands below should be prefixed with
fvm.
You can use the ANDROID_NDK_TARGETS environment variable to limit the targets
needed for your emulator.
The easiest way to build the app locally is to use
fastlane. To install use: bundle install.
To build the iOS app use: bundle exec fastlane ios build. Note that password
for fastlane match repository will be requested while building, this repository
is not public. Alternatively you can build the app using flutter build ios,
which will rely on your own certificate.
To build the Android app use: bundle exec fastlane android build. Note that
this requires you to add a local signing key to the project:
- Get the
nl-wallet-android-local-signing-keysecrets from the secrets repository. - Move
local-keystore.jksfile into thewallet_app/android/keystorefolder. - Move the
key.propertiesfile into thewallet_app/androidfolder. - That's it! Building release builds, e.g. with
bundle exec fastlane android buildshould now work.
We are currently maintaining two 'flavours' of the app, a mock and an core version.
The mocked version is what is used to demonstrate and verify potential use cases of the app. This version is preliminary used for usability research and contains many mock (fake) scenarios that can be triggered using the QR codes or Deeplinks provided below. This version of the app does not require internet and thus does not require any server to be running.
Running the mock variant of the app is straight forward, as it's currently the
default behaviour when using flutter run. But to explicitly run a mock build
use:
flutter run --dart-define=MOCK_REPOSITORIES=trueThe 'core' version is of the app is what will (work in progress) grow into the
functioning MVP. It relies heavily on all the logic implemented in the
wallet_core, which is the component that handles
all the business logic (like storage, network and validation) needed to achieve
the app's functionalities.
A sample command to run the core version of the app is provided below. Note that the universal link needs to be configured using various environment variables, as is illustrated in this example.
UL_HOSTNAME={hostname} UNIVERSAL_LINK_BASE="https://{hostname}/path" flutter run --dart-define UL_HOSTNAME={hostname}However, since the core version relies heavily on communication with other
services, we also provide scripts to configure the complete development
environment. Please refer to scripts, and more specifically the
setup-devenv.sh and start-devenv.sh files.
The configuration for how the app can connect to the server that serves the
wallet configuration is compiled directly into the app
(wallet_core/config-server-config.json). In addition, the initial wallet
configuration (wallet_core/wallet-config.json) (the most recent version at the
time the app is built) is compiled into the app as well. These configurations
are parsed and verified at compile time.
In order to make sure the wallet configuration belongs to the environment the
app is built for, an environment variable named CONFIG_ENV is used. If, for
instance, the app is built for the demo environment, CONFIG_ENV should have
the value demo. This check is always performed, but when the --release-flag
is passed to Cargo, the CONFIG_ENV environment variable is mandatory. Without
the --release-flag the default (dev) is used.
The app performs key and app attestation when registering with the Wallet Provider (i.e. after the user first enters their PIN code). As verifying these attestations is mandatory, special care needs to be taken to make sure that attestation can be be performed for a particular environment.
For iOS key and app attestation to succeed, the following variables will need to match between the app and the Wallet Provider:
- The root CA with which the attestation is signed.
- Apple's attestation environment, which can be either development (i.e. a sandbox environment) or production.
- The app's bundle identifier.
When running the app on real hardware, the root CA is always the one provided by Apple, as the attested key is stored within the Secure Element on the device.
However, the iOS simulator is not capable of generating attested keys. For this
use case a provision has been implemented that generates faux attestations under
a self-signed CA. Generating these attestations can be enabled through the
fake_attestation cargo feature. When compiling through Xcode (or indeed
flutter run), this is done automatically when the target is the iOS simulator.
The included setup script (for a local development environment) will
automatically configure the Wallet Provider to accept both attestations signed
by Apple and these faux self-signed attestations. In short, running the app on
the iOS simulator against a local development environment should work out of the
box.
The attestation environment is set to development by default. This can be
overridden by setting the APPLE_ATTESTATION_ENVIRONMENT environment variable
during compilation. Note that this is ignored for builds that are downloaded
from TestFlight or the App Store, as (per the Apple documentation) these always
use the production environment. For this reason, the development environment
should only be used during local development, while any deployed environment
will use production.
The bundle identifier need not be changed for the local development environment. For other environments it can be changed using:
bash
flutter pub run rename setBundleId --targets ios --value <BUNDLE ID>
Any real Android device that has either a TEE as part of its SoC or a separate StrongBox should generate attested keys that pass key attestation validation. The included setup script will configure the Wallet Provider to accept keys that are signed by a CA that uses Google's published public key. The setup script will also configure the Wallet Provider to accept side loaded apps when evaluating the app integrity verdict it receives from Google, so that compiling and running the wallet app locally successfully passes app attestation.
Note that, in order to request integrity verdicts from Google, the Wallet
Provider needs to have access to a file that contains credentials for a Google
Cloud service account. This file should be located at
wallet_core/wallet_provider/google-cloud-service-account.json. Alternatively,
mock verdicts could be issued instead using the mock_android_integrity_verdict
Cargo feature, which bypasses the Google API. See the description below.
For the Android emulator to work, the following requirements will need to be met:
- The emulator should be running the Android system images with Play Store support. Some system images have fixed expired intermediate cerfiticate. These will not work as the wallet provider always require valid certificates.
- The Wallet Provider should have the
allow_android_emulator_keysCargo feature enabled, which lowers the attested key requirements to allow keys generated in software. Note that this feature should NEVER be used in any production environment. - The Wallet Provider should have the
mock_android_integrity_verdictCargo feature enabled, which prevents it from requesting integrity verdicts from Google and replaces them with fake verdicts that will pass validation. Note that this feature should NEVER be used in any production environment. - The Wallet Provider should have the
skip_android_root_key_checkCargo feature enabled, which allows all generated emulator keys as it will skip the test if the public key of the root certificate is within the configured keys. Note that this feature should NEVER be used in any production environment. - All of the above features have conveniently been combined in the
android_emulatorCargo feature for the Wallet Provider.
All files used by the project (like images, fonts, etc.), go into the assets/
directory and their appropriate sub-directories.
Note; the assets/non-free/images/ directory contains
resolution-aware images.
Copyright note: place all non free (copyrighted) assets used under the
non-free/directory inside the appropriate asset sub-directory.
Text localization is enabled; currently supporting English & Dutch, with English
set as primary ( fallback) language. Localized messages are generated based on
ARB files found in the lib/l10n directory.
To support additional languages, please visit the tutorial on Internationalizing Flutter apps.
Internally, this project uses the commercial Lokalise service to manage translations. This service is currently not accessible for external contributors. For contributors with access, please see using-lokalise.md for documentation.
Below you can find the deeplinks that can be used to trigger the supported mock
scenarios. On Android, the scenarios can be triggered from the command line by
using adb shell am start -a android.intent.action.VIEW -d "{deeplink}". On
iOS, the scenarios are triggered with the command
xcrun simctl openurl booted '{deeplink}'. Note that the deeplinks only work on
debug builds. For mock production builds you can generate a QR code from the
content and scan these using the app. Pre-generated QR codes are also available,
and can be found here.
| Scenario | Content | Deeplink |
|---|---|---|
| Driving License | {"id":"DRIVING_LICENSE","type":"issue"} | Issue driving license |
| Extended Driving License | {"id":"DRIVING_LICENSE_RENEWED","type":"issue"} | Issue extended driving license |
| Diploma | {"id":"DIPLOMA_1","type":"issue"} | Issue diploma |
| Health Insurance | {"id":"HEALTH_INSURANCE","type":"issue"} | Issue health insurance |
| VOG | {"id":"VOG","type":"issue"} | Issue VOG |
| Multiple Diplomas | {"id":"MULTI_DIPLOMA","type":"issue"} | Issue multiple diplomas |
| Scenario | Content | Deeplink |
|---|---|---|
| Job Application | {"id":"JOB_APPLICATION","type":"verify"} | Disclose for job application |
| Bar | {"id":"BAR","type":"verify"} | Disclose for bar |
| Marketplace Login | {"id":"MARKETPLACE_LOGIN","type":"verify"} | Login to marketplace |
| Car Rental | {"id":"CAR_RENTAL","type":"verify"} | Disclose for car rental |
| First Aid | {"id":"FIRST_AID","type":"verify"} | Disclose for first aid |
| Parking Permit | {"id":"PARKING_PERMIT","type":"verify"} | Disclose for parking permit |
| Open Bank Account | {"id":"OPEN_BANK_ACCOUNT","type":"verify"} | Disclose to open bank account |
| Provide Contract Details | {"id":"PROVIDE_CONTRACT_DETAILS","type":"verify"} | Disclose to provide contract details |
| Create MonkeyBike Account | {"id":"CREATE_MB_ACCOUNT","type":"verify"} | Disclose to create MB account |
| Pharmacy | {"id":"PHARMACY","type":"verify"} | Disclose for pharmacy |
| Amsterdam Login | {"id":"AMSTERDAM_LOGIN","type":"verify"} | Login to Amsterdam |
| Scenario | Content | Deeplink |
|---|---|---|
| Rental Agreement | {"id":"RENTAL_AGREEMENT","type":"sign"} | Sign rental agreement |
| Scenario | Deep dive link | Explanation |
|---|---|---|
| Skip (setup) to home | Skip setup | Use on clean app startup; to setup wallet with mock data and jump straight to the home (a.k.a. cards overview) screen |
This section specifies some of the conventions we use to format our .dart code. These are mostly enforced by the linter as well.
- Max. line length is set to 120 (Dart defaults to 80)
- Relative imports for all project files below
srcfolder; for example:import 'bloc/wallet_bloc.dart'; - Trailing commas are added by default; unless it compromises readability
- Folder naming is
singularfor folders belowsrc; for example:src/feature/wallet/widget/...
- Test file name follows the convention:
{class_name}_test.dart - Test description (ideally) follows the convention:
should {do something} when {some condition} - Tests are grouped* by the method they are testing
- Run the unit tests:
flutter test --exclude-tags=golden
** Grouping tests by method is not required, but recommended when testing a specific method.
- UI Tests are part of the normal test files
- UI Tests are grouped in
Golden Tests
Even though they run headless, UI tests are slower to run. The main goal of these tests are to:
- Verify correct accessibility behaviour on different configurations (orientation/display scaling/font scaling/theming)
- Detect unexpected UI changes
As such we aim to keep the UI tests minimal, focusing on testing the most important states for a screen. This can be done by providing a mocked bloc with the state manually configured in the test.
Note that the UI renders slightly differ per platform, causing small diffs (and
failing tests) when verifying on a different host platform (e.g. mac vs linux).
To circumvent this issue, we opted to only run UI tests on mac hosts for now.
Because of this it is vital to only generate new goldens on a mac host. This can
be done with
flutter test --update-goldens --tags=golden <optional_path_to_single_test_file>.
- To only verify goldens use
flutter test --tags=golden - To only verify other tests use
flutter test --exclude-tags=golden
To be as consistent as possible when it comes to testing widget we provide the following template. This can be used as a starting point when writing widget tests:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('goldens', () {
testGoldens('light text', (tester) async {
await tester.pumpWidgetWithAppWrapper(Text('T'));
await screenMatchesGolden('text/light');
});
});
group('widgets', () {
testWidgets('widget is visible', (tester) async {
await tester.pumpWidgetWithAppWrapper(
Text('T'),
);
// Validate that the widget exists
final widgetFinder = find.text('T');
expect(widgetFinder, findsOneWidget);
});
});
}The project uses the flutter_bloc package to handle state management.
On top of that it follows the BLoC Architecture guide, with a slightly fleshed out domain layer.
This architecture relies on three conceptual layers, namely:
Responsible for displaying the application data on screen.
In the above diagram the UI node likely represents a Widget, e.g. in the
form of a xyzScreen , that observes the Bloc using one of the flutter_bloc
provided Widgets. E.g. BlocBuilder.
When the user interacts with the UI, the UI is responsible for sending a
corresponding Event to the associated BLoC. The BLoC then processes this event
and emits an updated State to the UI, causing the UI to rebuild and render the
new state.
Encapsulate business logic to make it reusable. UseCases are likely to be used by BLoCs to interact with data, allowing the BLoCs to be concise and keep their focus on converting events into new states.
Naming convention verb in present tense + noun/what (optional) + UseCase e.g.
LogoutUserUseCase.
Exposes application data to the rest of the application. This is where we expose
the CRUD and network operations. Due to our current requirement of maintaining a
'mock' and a 'core' variant this is the layer where the distinction is made, by
injecting either a 'mock' or a 'core' version of the TypedWalletCore based on
the MOCK_REPOSITORIES compile time flag. The repositories in term rely on this
TypedWalletCore class to perform all the interactions.
The reason we opted for this BLoC layered approach with the intermediary domain layer is to optimize for: Re-usability, Testability and Readability.
Re-usable because the usecases in the domain layer are focused on a single task, making them convenient to re-use in multiple blocs when the same data or interaction is required on multiple ( ui) screens.
Testable because with the abstraction to other layers in the form of dependencies of a class, the dependencies can be easily swapped out by mock implementations, allowing us to create small, non-flaky unit tests of all individual components.
Readable because with there is a clear separation of concerns between the layers, the UI is driven by data models (not by state living in UI components) and can thus be easily inspected, there is a single source of truth for the data in the data layer and there is a unidirectional data flow in the ui layer making it easier to reason about the transitions between different states.
Finally, since while we are developing the initial Proof of Concept it is unlikely that we will be working with real datasources, this abstraction allows us to get started now, and in theory quickly migrate to a fully functional app (once the data comes online) by replacing our MockRepositories / MockDataSources with the actual implementations, without touching anything in the Domain or UI Layer.
The iOS app is distributed through our CI/CD pipeline, but one can follow the steps below in order to deliver a test version of the iOS app to users via TestFlight manually.
- Apple ID with access to App Store Connect
- App-specific password
- Fastlane Match Passphrase
Credentials and access are available within the team (ask around).
- Login to appleid.com
- Create an App-specific password
- Store the created App-specific password & fastlane username/password as environment variables:
export FASTLANE_USER="{AppleID email address}"
export FASTLANE_PASSWORD="{Fastlane Match Passphrase}"
export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD="{App-specific password}"- Run
bundle installfrom the project root folder - Run
bundle exec fastlane match appstore --readonlyto locally install App Store certificate & provisioning profile (password protected: "Fastlane Match Passphrase") - Place the latest configuration JSON files for the
ontenvironment underwallet_core/wallet/, these can be downloaded from the CI as an asset - Check latest iOS build number here:
App Store Connect - iOS Builds,
next build number needs to be
{latest_build_numer} + 1 - Build app with updated build number
CONFIG_ENV=ont UL_HOSTNAME=app.example.com UNIVERSAL_LINK_BASE="https://app.example.com/deeplink/" bundle exec fastlane ios build app_store:true build:{next_build_number} app_name:"NL Wallet (latest)" universal_link_base:app.example.com - Sign build
bundle exec fastlane ios sign - Upload to TestFlight
bundle exec fastlane ios deploy bundle_id:nl.ictu.edi.wallet.latest(login with Apple ID + password; app specific password!)
