Wayline: A ride-hailing app from three sides shows Wayline from the outside. This chapter is for the developer who wants to build on it. It covers how to run the project, how it’s put together, what to change for two common kinds of service, and the parts a sample leaves for you to finish: taking real money, map data at production volume, native maps, sign-in through another identity provider, real text messages, analytics, and the database it’s deployed on.
The project is in scripts/wayline in the Codename One repository. The
Initializr generates the same project under your own name and package, and
that’s the copy to build a product on. Paths in this chapter are relative to the
project’s root.
Running it
The server needs JDK 17 and Maven, and nothing else installed. Start it on the development profile:
cd scripts/wayline
CN1_PROFILE=dev backend/server.sh run
On that profile the database is in memory, verification codes are written to the log and shown in the app in place of a text message, cards are simulated, and the server creates the demo accounts listed in Wayline: A ride-hailing app from three sides. It listens on port 8080.
Then run the app in the simulator, from another terminal in the same directory:
mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase
Sign in as rider@wayline.example with the password wayline-demo. Start a
second simulator and sign in as driver@wayline.example to follow a ride from
both ends. A phone on the same network reaches the server through Server
address on the welcome screen.
To use the app in a browser, have the server host it. The first command builds the browser version, which takes about a minute and is needed again only when the app changes:
backend/server.sh web
CN1_PROFILE=dev backend/server.sh run
Then open http://localhost:8080/. Hosting the app in the browser describes what
the server does with the staged files and how to deploy them.
backend/server.sh wraps the Maven goals. On the JVM it starts in a few
seconds, which suits development. A deployment runs the server compiled to a
native binary, which needs clang and the development headers of OpenSSL,
libcurl and nghttp2:
backend/server.sh build --native
backend/server.sh run --native --prebuilt --port 8080
The modules
| Module | What it holds |
|---|---|
| The contract: the data classes both sides exchange and the |
| The app: sign-in, the rider, driver and admin screens, the map, the wallet,
the settings and the live channel. The styling is in
|
| The server: accounts and roles, phone verification, driving applications,
moderation, fares, matching, the ride state machine, payments, statistics and
the WebSocket. The entity classes are in the |
| The platforms the app is built for. |
The contract both sides share
Every call the app makes is a method of an interface in shared. This is part
of RiderApi:
/// Asking for a ride and following it.
@RestClient
public interface RiderApi {
@POST("/api/rides/quote")
void quote(@Body RideRequestDto request, OnComplete<Response<FareQuoteDto>> callback);
@POST("/api/rides")
void request(@Body RideRequestDto request, OnComplete<Response<RideDto>> callback);
/// The ride this rider has under way, or one whose state is NONE.
@GET("/api/rides/active")
void active(OnComplete<Response<RideDto>> callback);
@POST("/api/rides/{id}/cancel")
void cancel(@Path("id") String id, OnComplete<Response<RideDto>> callback);
}
The data that travels is a class with public fields, marked @Mapped. This is
RideRequestDto with some of its fields left out:
/// Where a ride starts and ends, and how the rider wants it.
@Mapped
public class RideRequestDto {
public double pickupLat;
public double pickupLng;
public String pickupAddress;
public double dropoffLat;
public double dropoffLng;
public String dropoffAddress;
/// A saved method's id, or `cash`.
public String paymentMethodId;
/// `standard`, `comfort` or `xl`; empty reads as standard.
public String product;
/// Whether a pet is coming: only drivers who take pets are offered the ride.
public boolean petFriendly;
public RideRequestDto() {
}
}
Two builds read these files. The app’s build writes a client, RiderApiImpl,
which the app reaches through Api.rider() in common. The server’s build
writes an interface, RiderApiServer, whose methods take the same parameters
without the callback and return the value, and a controller that routes each
path to it. The server then implements that interface:
/// The server's half of `RiderApi`. Every method acts as the signed-in user:
/// the rider is never a parameter.
@Component
public class RiderEndpoint implements RiderApiServer {
private final Rides rides;
public RiderEndpoint(Rides rides) {
this.rides = rides;
}
@Override
public FareQuoteDto quote(RideRequestDto request) throws Exception {
return rides.quote(request);
}
@Override
public RideDto request(RideRequestDto request) throws Exception {
return rides.request(Caller.name(), request);
}
@Override
public RideDto active() throws Exception {
return rides.activeForRider(Caller.name());
}
@Override
public RideDto cancel(String id) throws Exception {
return rides.cancelByRider(Caller.name(), id);
}
}
A method added to the contract and not implemented on the server is a compile error, and so is a field the app reads that the server’s class no longer has. Backend controllers, contracts and WebSockets covers the mechanism.
The caller is never a parameter. Caller.name() reads the signed-in user from
the request’s security context, so an endpoint acts on the rides of whoever
holds the token and can’t be asked to act on someone else’s.
The interfaces are AccountApi, RiderApi, DrivingApi for the application
to drive, DriverApi, PaymentApi, GeoApi and AdminApi. Each has one
endpoint class on the server that does nothing but call a service: Rides,
Payments, Applications, Accounts and the rest. The services hold the rules.
How the server is put together
Under the endpoints the server has three layers, and a change usually touches one class in each.
| Layer | Where | What it does |
|---|---|---|
Entities | The | One class for each table, with public fields: |
Repositories | Beside the service that uses them: | The queries, and nothing else. Each is a |
Services |
| The rules. Each is a |
An entity names its table and its columns, and gets no generated key: every id
is text the server makes in Ids. This is the start of Ride:
/// A ride, from the request to its end.
@Entity(table = "wl_ride")
public class Ride {
@Id(autoIncrement = false)
@Column(name = "id", nullable = false)
public String id = "";
@Column(name = "state", nullable = false)
public String state = "";
@Column(name = "rider", nullable = false)
public String rider = "";
@Column(name = "driver", nullable = false)
public String driver = "";
@Column(name = "fare_cents", nullable = false)
public long fareCents;
@Column(name = "pets", nullable = false)
public boolean pets;
The annotations are the ones Backend data access and transactions describes. The entities don’t create the tables. The migrations do, as The schema explains, so a field and its column are two edits that go together.
A repository is a @Component over the injected Session:
/// Where rides are read from and written to. It holds the queries and
/// nothing else.
@Component
public class RideRepository {
public static final String RIDER = "rider";
public static final String DRIVER = "driver";
private static final String[] ACTIVE = {RideStates.REQUESTED, RideStates.OFFERED,
RideStates.ACCEPTED, RideStates.ARRIVED, RideStates.IN_PROGRESS};
private final Session session;
public RideRepository(Session session) {
this.session = session;
}
/// The ride of this id, or null when there is none.
public Ride find(String id) {
return session.find(Ride.class, id);
}
public void add(Ride ride) {
session.persist(ride);
}
/// The ride a rider has under way, or null.
public Ride activeForRider(String rider) {
return session.query(Ride.class).eq("rider", rider).in("state", (Object[]) ACTIVE).first();
}
Most queries are written with the session’s builder, which names Java fields
and not columns. Where the builder can’t say it, a repository passes a
query over the entities to session.createQuery, as
DriverRepository.freeInBox does to find the free drivers inside a bounding
box.
A service method that’s marked @Transactional runs in one transaction, and
the injected Session is that transaction’s session, as
The transaction’s session describes. A change made to an entity the
session loaded is written when the method returns, with no call to save it:
PhoneVerification.store sets four fields on the PhoneCode it read and
returns. Methods that only read are marked @Transactional(readOnly = true),
which PostgreSQL and MySQL enforce:
@Transactional(readOnly = true)
public RideDto activeForRider(String rider) throws IOException {
Ride ride = rides.activeForRider(rider);
return ride == null ? none() : toDto(ride);
}
Two rules shape the services, and both are about what a transaction must not contain:
A call that leaves the server isn’t made inside a transaction.
Rides.completeis three steps:finishmoves the ride toCOMPLETEDand commits,Payments.settlecharges the card with no transaction open, andsettledrecords the result in a second transaction.PhoneVerification.startstores the code, commits, and only then sends the text message. A charge can take seconds, and a transaction held across it would hold a database connection for as long.The app is told of a change only once it’s committed. The public methods of
Ridescome in pairs for that reason: a private@Transactionalmethod makes the change and returns aChange, and the public method that called it passes that totell, which publishes it on the live channel. An app that’s told a ride changed asks for the ride at once, on another connection, and must find it changed.
A rider cancelling a ride is one such pair:
public RideDto cancelByRider(String rider, String id) throws IOException {
Change change = cancelAsRider(rider, id);
// The driver it was offered to or who had taken it is told as well.
return tell(change);
}
@Transactional
private Change cancelAsRider(String rider, String id) throws IOException {
Ride before = owned(id, RideRepository.RIDER, rider);
move(id, RideStates.CANCELLED_BY_RIDER, rider, RideRepository.RIDER, null, null);
return new Change(id, RideStates.CANCELLED_BY_RIDER, rider, before.driver, "",
view(id, rider));
}
A private method can carry @Transactional here and be called through this.
The build writes the transaction into the method itself, so there’s no proxy
for the call to bypass. Backend data access and transactions covers the rules.
Who may do what
The server is its own OAuth 2.0 authorization server. SecurityConfig registers
one public client for the app, which signs in with the authorization-code flow
and PKCE and holds no secret. Accounts, the registered client, tokens and
signing keys are kept in the database by the tables the security layer creates.
The authorization server documents the pieces.
SecurityConfig declares two filter chains. The first covers the sign-in
paths: /login, /oauth2/, /.well-known/, /userinfo and /logout. It keeps a session for
the few requests a sign-in takes, answers the sign-in form with a status and
JSON, and limits attempts for each client address.
The second chain covers /api/**, has no session, and requires a bearer token
on every request. Its rules are the whole of the access policy:
http.securityMatcher("/api/**")
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(AntPathRequestMatcher.antMatcher("POST",
"/api/account/register")).permitAll()
.requestMatchers("/api/driver/**").hasRole("DRIVER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated());
Three things about that chain are worth knowing before changing it:
The roles are
RIDER,DRIVERandADMIN, inRoles. A token says who the caller is and not what they may do. The chain reads the account’s roles from the database on every request, so a role an admin withdraws and a block an admin places take effect on the next request, not when the token expires.The application to drive is under
/api/driving, not/api/driver, on purpose: someone who isn’t a driver yet has to reach it.The app hides the screens a role can’t use. That’s a courtesy. The chain is what refuses the request.
The same sign-in works from a browser with one difference. An installed app
receives the authorization code on a loopback address. A page can’t, so the
server registers a second redirect address on its own origin,
/signin/code, which SignInCodePage answers with the code as JSON. Because a
browser sends a session cookie on its own, the sign-in chain refuses a request
that changes something and names another site in its Origin header.
Hosting the app in the browser explains the reasoning.
A ride from request to receipt
A ride is a Ride entity, a row in wl_ride, with a state. RideStateMachine
lists the moves that are allowed:
| State | How a ride gets there |
|---|---|
| A rider asked for it, or the driver it was offered to declined or didn’t answer. |
| The server offered it to a driver. |
| The driver accepted, reached the pickup, started the trip and finished it. |
| The rider cancelled before the trip started. |
| The driver cancelled after accepting and before the trip started. |
| An admin cancelled a ride that hadn’t finished. |
| Nobody took the ride within the time allowed. |
Two answers at the same moment
Two drivers can’t both accept a ride, a rider can’t cancel a ride in the instant
a driver accepts it, and an offer can’t be accepted while the server is taking
it back. None of it takes a lock. Every change of state is one
conditional update, RideRepository.move, and the number of rows it changed is
the whole answer.
Rides.tryMove asks RideStateMachine.sources which states the new one may be
reached from and passes them to the repository:
private boolean tryMove(String id, String to, String actor, String ownerField,
String newDriver, Long newOfferExpiresAt) throws IOException {
String[] sources = RideStateMachine.sources(to);
if (sources.length == 0) {
return false;
}
// An offer that has lapsed cannot be taken, even in the moment before
// the sweep takes it back.
boolean offerOpen = RideStates.ACCEPTED.equals(to);
if (rides.move(id, sources, to, System.currentTimeMillis(), ownerField, actor, newDriver,
newOfferExpiresAt, offerOpen) != 1) {
return false;
}
event(id, to, actor);
return true;
}
The repository builds one statement from what it’s given:
/// Moves a ride to `to` if it is in one of the states `from`, and answers
/// how many rows that changed: 1 for the caller that made the move, 0 for
/// every caller that came second.
public int move(String id, String[] from, String to, long now, String ownerField,
String owner, String newDriver, Long newOfferExpiresAt, boolean offerOpen) {
StringBuilder text = new StringBuilder("update Ride r set r.state = :to, "
+ "r.updatedAt = :now");
if (newDriver != null) {
text.append(", r.driver = :newDriver");
}
if (newOfferExpiresAt != null) {
text.append(", r.offerExpiresAt = :newExpiry");
}
text.append(" where r.id = :id and r.state in :from");
boolean owned = RIDER.equals(ownerField) || DRIVER.equals(ownerField);
if (owned) {
text.append(RIDER.equals(ownerField) ? " and r.rider = :owner"
: " and r.driver = :owner");
}
if (offerOpen) {
text.append(" and r.offerExpiresAt >= :now");
}
JpqlQuery<Object> update = session.createQuery(text.toString())
.setParameter("to", to).setParameter("now", Long.valueOf(now))
.setParameter("id", id).setParameter("from", from);
if (newDriver != null) {
update.setParameter("newDriver", newDriver);
}
if (newOfferExpiresAt != null) {
update.setParameter("newExpiry", newOfferExpiresAt);
}
if (owned) {
update.setParameter("owner", owner);
}
return update.executeUpdate();
}
For a driver accepting an offer, the statement is this one:
update Ride r set r.state = :to, r.updatedAt = :now where r.id = :id and r.state in :from and r.driver = :owner and r.offerExpiresAt >= :now
It names the states the move may start from, the driver the ride was offered
to, and the time the offer lapses. executeUpdate answers 1 for the request
that made the move and 0 for every other, because by the time the database
runs the second statement the row is no longer in a state the statement
names. tryMove records a RideEvent only for the 1, in the same
transaction, and Rides.move turns a 0 into a 409 that says where the ride is
now.
The service never reads the state and then writes it. Reading first and
writing second would let two transactions both read OFFERED and both write
ACCEPTED. One statement can’t be interleaved that way on any of the three
engines, and nothing is held in the server’s memory, so the rule holds with
several server processes on one database.
RideRaceTest is the test that would notice the difference. It calls
Rides.accept for one offer from eight threads released together, and
requires that exactly one call returns the ride as ACCEPTED, that the other
seven are answered 409, and that the ride’s history holds one ACCEPTED
event. Two more tests run the acceptances against Rides.tick while it takes
back an offer that has lapsed, and require that nobody gets the ride.
Three other places use the same pattern. RideRepository.rate gives a ride
its stars only where it has none, so a ride rated twice is rated once.
PhoneCodeRepository.countGuess has the database add one to the count of
wrong codes with session.increment, so two guesses sent together each use
one up. PaymentRepository.claimTip and claimRefund claim a tip and a refund
the same way before the payment provider is asked for anything, so of two
requests only one charges or refunds. A payout has no single row to claim, so
Earnings.cashOut commits the payout, reads the balance again, and takes its
own row back when the balance has gone below zero.
A statement that changes rows in bulk empties the session, so a Ride read
before move is read again after it. The services do that wherever they go on
to describe the ride.
Fares, matching and the sweep
Rides.request fixes the fare when the ride is created, from the server’s own
prices in Fares. The distance the app sends is believed only while it’s
plausible for the two points. The driver’s share is fixed at the same moment.
Matcher picks the driver. It selects drivers who are online, reported a
position in the last minute, are approved, aren’t blocked, have no ride in hand,
drive the kind of car asked for and meet the ride’s options. It takes the
nearest one who hasn’t already passed on this ride. The search covers 3 km for
the first twenty seconds and 8 km after that.
Rides.tick runs every two seconds as a scheduled job. It takes back offers
whose time ran out and searches again for every ride still waiting. Each ride
is dealt with in a transaction of its own, so one ride that fails doesn’t cost
the others their turn. The job carries a lock name, so with several server
processes only one runs it at a time. Backend scheduling and background work covers scheduled
jobs.
When a driver completes a ride, Payments.settle charges it.
Live updates
LiveEndpoint is a WebSocket at /ws/live. The server pushes a short JSON
frame when a ride changes state, when the driver of a ride moves, and, to
admins, when any car online moves. LiveChannel in the app receives them.
A WebSocket request from a browser can’t carry a bearer token in a header. The
app therefore first asks POST /api/live/ticket, which is a normal call that
carries the bearer token, for a ticket: a random value that works once, for thirty seconds. It then
opens the socket with the ticket in the address. The server stores only a hash
of each ticket.
The screens don’t depend on the socket. Each also asks the server for the current state every few seconds, so a dropped connection delays an update and doesn’t lose it.
LiveHub keeps the open connections in the memory of one process. With
several server processes, a change made on one isn’t pushed to the sockets held
by another, and those clients catch up on their next poll. Pushing across
processes needs a channel between them, such as PostgreSQL’s LISTEN and
NOTIFY or a message broker. The sample has none.The app
Wayline is the app’s main class. It applies the look and the language, starts
the session and hands over to Nav.
| Class | What it does |
|---|---|
| Chooses the first screen from the roles |
| Signs in and out and keeps the tokens in secure storage. |
| The server’s address. It’s the page’s own origin in a browser, the address
saved from the welcome screen otherwise, and |
| A map filling the screen with the app’s controls floating over it: a bar at the top, a chip for one short fact, round buttons and a bottom sheet. The rider’s, the driver’s and the live-map screens are all built on it. |
| Creates the map and draws what goes on it: the pins, the car and the route. |
| The theme, the language and the layout for the width of the window. |
| The admin screens side by side, for a wide window. |
The colors and the corner radius are CSS variables in the first block of
theme.css, each with a dark twin, and every style in the file is written in
terms of them. The style names all start with Wl.
The text on every screen is English in the source, and the English text is the
key of the translation. src/main/l10n holds one Bundle_<code>.properties
for each language.
Layouts.wide() is true in a window at least 165 mm wide. AdminForm shows
the phone console below that width and Console above it, and switches when a
window is resized across it. Screens that have no wide layout are kept to a
column 150 mm wide. On the desktop the nativeTheme=native build hint gives
the app the operating system’s own look.
Tests and continuous integration
mvn -pl shared,backend -Dcodename1.platform=backend test # (1)
mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # (2)
./run-e2e.sh # (3)
./check-template.sh # (4)
./run-web-e2e.sh # (5)The server’s tests on the JVM. They cover access by role, accounts, driving applications, matching, the ride state machine, a whole ride, payments against the simulated provider and against stand-in answers for Stripe, pricing, statistics, moderation, the live socket and the browser sign-in.
The same tests again with the server compiled to a native binary, which is how it ships.
Starts the server on the
testprofile and runs the app’s tests against it in the simulator. They register, apply to drive and are approved, request a ride, drive it, pay for it and administer it, and compare screens with the pictures inscreenshots/. On Linux a second pass runs the simulator as a desktop window for the wide console.Generates a project from the Initializr template under another name and package, runs its server tests and builds its app.
Builds the browser version, starts the server hosting it, and drives it in headless Chromium with Playwright: sign in as the rider, reach the home screen and the live channel, sign out, sign in as the admin and reach the console.
The app’s tests draw the map from 24 tiles bundled with the tests and take
straight lines for routes, so a run asks no outside service for anything. The
test profile also swaps the place search for one that knows ten places.
.github/workflows/wayline.yml runs all five and builds the app for Android
and iOS. Backend testing covers the server test support.
The Initializr template
The Initializr’s ride-hailing template is this project. A script in the
repository, scripts/sync-initializr-wayline.py, derives the template from
scripts/wayline and fails the build when the two differ, so the template is
always the project that the tests above ran against.
A generated project differs from the sample in its names:
The Java package becomes yours.
waylinein artifact ids and configuration keys becomes your project’s name in lower case.wayline.fare.baseCentsismyapp.fare.baseCentsin a project calledMyApp.WAYLINEin environment variable names becomes the name in upper case.The main class takes the project’s name.
The server’s tests come with the generated project. The app’s simulator tests, their reference pictures and the browser test stay behind.
This chapter uses the sample’s names. In your project read wayline as your
own.
Making it yours: A ride-sharing service
Most of a ride-sharing product is already here. What changes is the identity, the prices and the rules.
Name, icon and colors
The name and the package are chosen in the Initializr. After that they’re in
common/codenameone_settings.properties, as codename1.displayName and
codename1.packageName, and the icon is common/icon.png. Text that names the
service on screen, such as the title of the welcome screen, is ordinary
translated text: search the bundles in src/main/l10n for it.
The colors are the variables at the top of theme.css. Changing the brand
color and its dark twin recolors every button, link and selection:
#Constants {
--brand: #0f766e;
--brand-pressed: #115e59;
--brand-contrast: #ffffff;
--brand-dark: #5eead4;
--brand-pressed-dark: #2dd4bf;
--brand-contrast-dark: #042f2e;
--radius: 1.5mm;
}
The map’s pins, car and route take their colors from the WlMarkerPickup,
WlMarkerDropoff, WlMarkerCar and WlRoute styles in the same file.
Products and prices
The prices start as settings and are in the database from the first time an
admin saves the pricing screen. To start a deployment with your own, set them
in backend/application.properties. The amounts are in the currency’s smallest
unit:
wayline.currency=EUR
wayline.fare.baseCents=350
wayline.fare.perKmCents=180
wayline.fare.perMinuteCents=40
wayline.fare.minimumCents=700
wayline.fare.serviceFeePercent=0
wayline.fare.commissionPercent=0
The currency is a setting only. Rides already taken are recorded in it, and changing it doesn’t convert them.
A fare is the base fare plus the distance and time charges, multiplied for the
kind of ride and for surge, and never less than the minimum. The service fee is
a percentage added on top for the rider. The commission is a percentage of the
fare kept from the driver. Fares.price is the arithmetic, and it’s the place
for a different pricing model, such as fixed prices between zones or a price
for each stop.
Surge is one multiplier an admin sets by hand. Nothing raises it when demand is high.
The three kinds of ride are named in several places, and adding or removing one touches each:
Fares.PRODUCTS, and the multiplier for each inFares.price.Rides.PRODUCT_TEXT, the name, description and seats the rider is shown.The
comfortandxlfields of thePricingentity and their columns inwl_pricing,PricingDtoandPricingForm, if the new kind has a multiplier an admin can set.The picker in
ApplicationForm, where a driver says which kind their car is, andUi.productName.The icon chosen in
RiderForm.
Matching rules
How long a driver has to answer and how long the search goes on are settings:
wayline.offer.seconds=30
wayline.search.seconds=300
The search distances and the minute after which a silent driver counts as gone
are constants at the top of Matcher. The rule itself is in three places: the
query in DriverRepository.freeInBox, which finds the drivers who qualify,
Matcher.nearby, which orders them by distance, and Matcher.pick, which
takes the nearest who hasn’t passed on the ride. A different rule, such as the
driver who has waited longest or the best rated within reach, replaces the
ordering there. Nothing else in the server knows how a driver was chosen.
DriverRepository.freeInBox compares latitudes and longitudes in a bounding
box. That’s right for one city on any of the three databases. A service with
tens of thousands of drivers online would move that query to a spatial index.
What a driver has to provide
The five documents are the KINDS array in Applications on the server and
the list of cards in ApplicationForm in the app. Add a kind to both, such as a
vehicle inspection or a background check, and the review screen shows it. The
fields of the form are fields of the DriverApplication entity, columns of
wl_driver_application and fields of DriverApplicationDto.
Changing the schema later walks through adding one.
The server checks that an uploaded file is a JPEG or a PNG and under 1.5 MB.
It doesn’t read the documents. Whether a license is real is the admin’s
judgment, or a verification service you call from Applications.submit.
Text messages
Verification codes go to the log until the server has the credentials of a text-message provider. Text messages covers the settings, the limits and how to use a provider other than Twilio.
Languages
To add a language, add Bundle_<code>.properties to src/main/l10n, add the
code to Lang.CODES in the app, and add it to the LANGUAGES array in
Preferences on the server, which refuses a language it doesn’t know. A
language written right to left sets the @rtl key in its bundle, as the Hebrew
one does.
Before it’s a product
These are things the sample doesn’t have, and a ride-sharing product needs:
Real payments, payouts to drivers, and tax. See Billing.
Real text messages. See Text messages.
Push notifications. The switches in the settings are stored and nothing sends a notification. A rider with the app closed isn’t told the driver arrived.
Position reports while the driver’s app is in the background.
A cancellation fee, scheduled rides, shared rides and promotions.
A check that the rider asking for a woman driver is one.
Matcherfilters the drivers by the gender in their profile and doesn’t look at the rider’s.Statistics by local time. The dashboard’s day and its busy hours are in UTC.
TLS. The Android build allows clear-text traffic so that a phone can reach a development server. Remove
android.xapplication_attrfromcodenameone_settings.propertiesonce the server has a certificate.
Making it yours: A dispatch service for a taxi station
A station with twelve cars and a dispatcher is a smaller service with different rules. The drivers are known, bookings arrive by phone as well as from the app, a person decides who takes which ride, and most rides are paid in cash or put on a company’s account. The sample covers the riders' app, the drivers' app, the fares, the receipts and the console. The rest is described here, with the class each change belongs in.
A fixed fleet
Nobody applies to drive. An admin makes an account a driver directly: the
Driver switch on an account in People calls Accounts.setRoles, which
gives the role and records an approved application for it. A driver made this
way is of the standard kind and has no car details. To record each car, fill in
the application’s vehicle fields from an admin screen of your own.
To close the open door:
Remove Drive with Wayline from the rider’s menu in
RiderForm, the Drive choice fromRegisterForm, andApplyFormfromNav.Decide whether the public may register. If only staff create accounts, take Create an account off
WelcomeFormand require the admin role for the registration path, as in the rules below.
A dispatcher
A dispatcher needs more than a driver and less than an admin, which is a fourth
role. Add it to Roles, to the role switches in Accounts.setRoles and
UsersForm, and give its paths a rule of their own in the API chain:
http.securityMatcher("/api/**")
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/account/register").hasRole("ADMIN")
.requestMatchers("/api/dispatch/**").hasAnyRole("DISPATCHER", "ADMIN")
.requestMatchers("/api/driver/**").hasRole("DRIVER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated());
Then declare the dispatcher’s calls in a new @RestClient interface in
shared, such as DispatchApi, and implement the interface the server’s build
generates from it.
Assigning a ride by hand
Automatic matching is two calls to Rides.search: one at the end of
Rides.request and one for each waiting ride in Rides.tick. Remove both and a
requested ride waits in REQUESTED until somebody assigns it. Keep the part of
tick that takes back unanswered offers.
Assigning is the same move the matcher makes, with the driver chosen by a
person. Add it to Rides:
/// Offers a waiting ride to the driver a dispatcher picked. The driver
/// still accepts or declines it on their own screen, and an offer nobody
/// answers goes back to waiting like any other.
public RideDto assign(String dispatcher, String id, String driver) throws IOException {
return tell(assignTo(dispatcher, id, driver));
}
@Transactional
private Change assignTo(String dispatcher, String id, String driver) throws IOException {
Ride ride = rides.find(id);
if (ride == null) {
throw new ResponseStatusException(404, "No such ride");
}
Long lapses = Long.valueOf(System.currentTimeMillis() + offerMillis);
if (!tryMove(id, RideStates.OFFERED, dispatcher, null, driver, lapses)) {
throw new ResponseStatusException(409, "That ride is no longer waiting");
}
return new Change(id, RideStates.OFFERED, ride.rider, driver, "", view(id, dispatcher));
}
It’s a pair, the way the other moves are. The private @Transactional method
calls tryMove with RideStates.OFFERED, the driver and the time the offer
lapses, which is the move Rides.offer makes for the matcher. The public
method passes the Change it returns to tell. tryMove also records the
event in the ride’s history, and tell has the driver’s screen show the offer
at once. A ride that somebody else assigned first, or that the rider cancelled,
is answered 409. The driver’s app needs no change: an
offer is an offer whoever made it.
Two settings matter once a person does the matching. wayline.search.seconds
is how long a ride may wait before it becomes NO_DRIVERS, and ninety seconds
is too short for a dispatcher on the phone. wayline.offer.seconds is how long
the driver has to answer.
The dispatcher’s screen is a list of waiting rides beside the live map. Both
exist: AdminRidesForm lists rides, FleetForm shows the cars, and Console
shows how a list and a detail sit side by side in a wide window.
Bookings taken by phone
Rides.request takes the rider’s account name as its first parameter, and the
rider’s endpoint passes the caller. A dispatcher’s endpoint passes the customer
instead, so a booking entered by staff is the same ride with the same fare
rules. Two checks in that method are written for riders booking for themselves
and need a decision:
The rider’s phone number must be verified. A customer the dispatcher is speaking to has shown they hold the number, so staff-created accounts can be marked verified.
A rider may have one ride under way. That’s right for a customer account and wrong for one shared account that stands for everyone who phones in. Give each caller an account keyed by their phone number, or lift the check for bookings made by staff.
The pickup and the destination come from the same place search the rider’s app
uses, GeoApi, so the booking form can reuse PlaceSearchForm.
Cash and accounts
Cash is already a payment method. It’s the default for an account with no saved
card, the driver’s screen says how much to collect, and the ride is recorded as
paid. A station that takes no cards removes Add a card from WalletForm and
never configures a payment provider.
Riding on account, where a company is invoiced at the end of the month, is a
method the sample doesn’t have. Cash shows the pattern: Payments.CASH is a
method id that Payments.settle records as paid without asking the provider.
An account method does the same and records which customer to invoice, which
needs a table for the customers and a column on the ride. They go in a new
migration, numbered after the last one the project has, with an entity for the
table and a field on Ride for the column, as Changing the schema later
describes:
-- Companies that ride on account and are invoiced at the end of the month.
CREATE TABLE wl_account_customer (
id VARCHAR(40) NOT NULL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
billing_email VARCHAR(190) NOT NULL,
active SMALLINT NOT NULL,
created_at BIGINT NOT NULL
);
ALTER TABLE wl_ride ADD COLUMN account_customer VARCHAR(40) NOT NULL DEFAULT '';
Producing the invoice from those rows is yours to write.
Earnings needs a decision too. It credits a driver with the fare less
commission for every completed ride, cash rides included, as if the service had
collected the money. For drivers who keep the cash they collect, subtract cash
fares from the balance, or the ledger says the station owes money it never
held. For drivers on a wage, remove EarningsForm from the driver’s menu.
What to switch off
| Feature | Where |
|---|---|
The service fee and commission | Set both percentages to zero, in the settings or on the pricing screen. |
The larger kinds of ride | Leave one entry in |
Surge | Leave the multiplier at 1, and remove its field from |
Tips through the app | Remove the tip row from the end-of-ride sheet in |
Driving applications | Remove |
Ratings | Remove the stars from the end-of-ride sheet in |
Billing
What the sample does
Every payment goes through one interface on the server, PaymentProvider:
/// A payment processor. The one place the server touches one.
public interface PaymentProvider {
/// What the app is told, so it knows how to add a card.
String name();
/// Opens the processor's record of a rider and returns its reference.
String createCustomer(String username, String displayName) throws IOException;
/// Begins saving a card to `customer`.
Setup startCardSetup(String customer, String setupId) throws IOException;
/// Finishes a setup and returns the card it saved.
Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException;
/// Charges a saved card with its owner absent. A processor asked twice
/// with the same `key` charges once.
String charge(String key, String customer, String token, long amountCents, String currency,
String description) throws IOException;
/// Returns `amountCents` of a charge to the card it came from.
String refund(String chargeReference, long amountCents) throws IOException;
/// A setup under way.
final class Setup {
/// Whether the card is typed into the processor's own page, at `url`.
public boolean hosted;
public String url = "";
public String reference = "";
}
/// A saved card: what may be shown of it, and the token it is charged by.
final class Card {
public String brand = "";
public String last4 = "";
public int expMonth;
public int expYear;
public String token = "";
}
}
The shape is the one card processors share. A rider is a customer of the processor. A card is saved to that customer once and comes back as a token. From then on the server charges the token with nobody present, which is what lets a ride be paid for as it ends. The server stores the token, the brand and the last four digits.
Payments is the service that uses the interface. It keeps the saved methods,
charges the fare and the service fee when a ride completes, charges a tip once,
refunds, and builds receipts. Every charge carries a key made from the ride’s
id, so a request repeated after a timeout can’t charge twice.
Two implementations come with the sample:
SimulatedPaymentsis used when no key is configured. It accepts any card number that passes the usual checksum, and treats the test numbers listed in Wayline: A ride-hailing app from three sides specially. The card is typed into the app. No money moves.StripePaymentsis used when a Stripe secret key is configured.
Turning on Stripe
Set the key in the server’s environment, not in a file in the repository, and tell the server the address it’s reached at:
wayline.payments.stripe.secret=${WAYLINE_PAYMENTS_STRIPE_SECRET}
wayline.public.url=https://rides.example.com
The first line reads the key from the environment variable
WAYLINE_PAYMENTS_STRIPE_SECRET. The server reads that variable for this
setting without the line as well. The second line is the address Stripe sends
the browser back to. It’s needed only when that address differs from the
issuer, which the server uses otherwise. With the key set and neither address
configured the server refuses to start.
StripePayments calls Stripe’s REST API directly and uses no SDK:
Adding a card creates a Checkout Session in setup mode. The app opens the session’s page in the device’s browser, so the card number is typed into Stripe’s page and never reaches the app or your server.
Stripe sends the browser back to
/pay/returnon your server, a plain page fromPayReturnPagethat tells the user to return to the app. When the app comes back to the foreground, it asks the server to finish the setup, and the server reads the saved card from the session.A ride is charged with a PaymentIntent that’s confirmed at once and marked off-session, with the ride’s key as the idempotency key.
A refund is a Stripe refund of that PaymentIntent.
What’s left to do
The sample stops well short of a billing system. In rough order of need:
- Webhooks
The server learns the result of a charge from the response to its own request and listens for nothing. A dispute, a refund made in Stripe’s dashboard, or a payment that settles later is never seen. Add a controller for Stripe’s events, verify each event’s signature, and update
wl_paymentfrom it.- Cards that need the owner’s approval
A bank may require authentication for an off-session charge.
StripePaymentstreats that answer as a decline. A product brings the rider back into the app to approve the payment, and tries again.- Failed payments
When the charge at the end of a ride fails, the ride is marked as failed to pay and the failure is logged. Nothing retries it, asks for another card, or stops that rider from asking for the next ride. Decide the policy and put it in
Payments.settleandRides.request.- Holding the fare first
The sample charges once, at the end. Placing a hold on the card when the ride is requested, and capturing it at the end, catches a card with no funds before the driver has driven.
- Paying drivers
Earnings and payouts are a ledger. Cash out writes a row that says the balance was paid, and no money leaves. Paying drivers needs a product built for it, such as Stripe Connect, where each driver is a connected account that passes the processor’s identity checks and receives transfers. The alternative is a payment file for your bank produced from
wl_payout. The bank details the sample stores are a holder’s name, a bank’s name and four digits, which is enough to show on a screen and not enough to pay anyone.- Tax and invoices
A receipt lists the fare’s parts and the total. There’s no tax line, no invoice number and no document to download. What a receipt must show depends on where you operate.
Another payment provider
A second processor is a second class that implements PaymentProvider. Two of
its methods show the difference between a provider with a page of its own and
the simulated one:
@Override
public String name() {
// A name of its own. The app opens the processor's page for any
// provider whose setup says it is hosted, so it needs no change.
return "hosted";
}
@Override
public Setup startCardSetup(String customer, String setupId) throws IOException {
Setup setup = new Setup();
setup.hosted = true;
setup.url = openCardPage(customer, returnUrl + "?result=done");
setup.reference = setupId;
return setup;
}
The app opens the provider’s page for any setup that says it’s hosted, so it
needs no change. Services is where the server picks an implementation from
its settings:
@Bean
public PaymentProvider paymentProvider(Config config) throws IOException {
String address = config.get("wayline.public.url", "");
String hosted = config.get("wayline.payments.hosted.secret", "");
if (hosted.length() > 0) {
return new HostedPayments(hosted, address + "/pay/return");
}
return new SimulatedPayments();
}
A provider that takes the card inside the app through its own mobile SDK is a bigger change. The setup then happens in native code in the app, and the server is sent a token, not a typed card.
Text messages
A phone number is verified with a six-digit code sent by text message.
PhoneVerification makes the code and checks it, and hands the sending to an
SmsSender, an interface of two methods: send(phone, text) and delivers().
The server has two implementations, and Services.smsSender picks one from
the settings:
| Class | When it’s used |
|---|---|
| When no provider is configured. It writes |
| When all three Twilio settings are present. It posts the message to Twilio’s Messages API. |
Turning on Twilio
| Property | Environment variable | What it holds |
|---|---|---|
|
| The account SID. |
|
| The auth token. Keep it in the environment and not in a committed file. |
|
| The number the messages are sent from. |
With any of the three missing, the server logs the codes. Nothing in the app changes when the provider does.
On a development profile with the logging sender, the server also returns the code to the app, which shows it on the verification screen so a demo needs no phone. Once the sender delivers, or on any other profile, the code is never returned.
What limits a code
These are in PhoneVerification and SecurityConfig:
A code is good for five minutes,
CODE_SECONDS.Five wrong entries,
MAX_ATTEMPTS, spend the code, and the user asks for another. The count is one statement in the database,PhoneCodeRepository.countGuess, so guesses sent together are each counted.Another code for the same account within 30 seconds,
RESEND_SECONDS, is answered 429.SecurityConfigallows/api/account/phone/**ten requests in ten minutes for each signed-in user.Only a hash of the code is stored, in
wl_phone_code, and a new code replaces the one before it.The code is stored and committed before the message is sent. When the provider refuses the message, the code is discarded and the request is answered 503.
The ten-in-ten-minutes limit uses an InMemoryRateLimiter, so it’s counted in
each server process. The other limits are in the database and hold across
instances.
Another provider
The interface is all a provider has to implement:
/// Sends a text message. The one place the server touches an SMS provider, so
/// changing provider is one class.
public interface SmsSender {
/// - `IOException`: when the message could not be handed to the provider
void send(String phone, String text) throws IOException;
/// Whether a message really reaches a phone. False for the sender that only
/// logs, which is what lets a development server hand the code back to the
/// app in place of the text message nobody is going to receive.
boolean delivers();
}
A second provider is a second class, with delivers() answering true. This
one posts a form to a gateway, which is the shape of TwilioSmsSender too:
/// Sends through an SMS gateway that takes a form post and a bearer key.
public class GatewaySmsSender implements SmsSender {
private final String url;
private final String key;
private final String from;
public GatewaySmsSender(String url, String key, String from) {
this.url = url;
this.key = key;
this.from = from;
}
@Override
public void send(String phone, String text) throws IOException {
List headers = new ArrayList();
headers.add("Authorization: Bearer " + key);
headers.add("Content-Type: application/x-www-form-urlencoded");
String form = "to=" + UrlText.encode(phone) + "&from=" + UrlText.encode(from)
+ "&text=" + UrlText.encode(text);
Web.Result sent = Web.request("POST", url, headers, form.getBytes("UTF-8"));
if (!sent.isSuccess()) {
// The status only: the body can echo the number back.
throw new IOException("The SMS provider answered " + sent.getStatus());
}
}
@Override
public boolean delivers() {
return true;
}
}
send throws an IOException when the provider refuses the message, and
PhoneVerification turns that into the 503. Services.smsSender then returns
the new class when its settings are present. Add the branch beside the one
that returns TwilioSmsSender:
@Bean
public SmsSender smsSender(@Value("${wayline.sms.gateway.url:}") String url,
@Value("${wayline.sms.gateway.key:}") String key,
@Value("${wayline.sms.gateway.from:}") String from) throws IOException {
if (url.length() > 0 && key.length() > 0 && from.length() > 0) {
return new GatewaySmsSender(url, key, from);
}
return new LoggingSmsSender();
}
A message costs money and a number can be anyone’s, so before going live
decide which countries you send to and add that check to Accounts.phone,
which accepts any number of seven to fifteen digits.
Signing in through another identity provider
Out of the box the server is its own authorization server: accounts and passwords are in its database, and the tokens the API accepts are the ones it signs. A product often wants people to sign in with an account they already have, at Google, Microsoft Entra ID, Auth0 or a Keycloak of its own. There are two ways to do it, and they differ in who issues the token the API sees.
| Beside the built-in server | Instead of it | |
|---|---|---|
Who signs the user in | The provider, or Wayline’s own password form | The provider only |
Whose token the API accepts | Wayline’s, as now | The provider’s |
Where the roles are | Wayline’s database, as now | Wayline’s database, or a claim in the token |
What changes | The sign-in chain, and how the app starts a sign-in | The API chain, the app’s sign-in, registration and the password screens |
The first is the smaller change and keeps password accounts working. Choose the second when the organization already manages its people, and their roles, in the provider.
Beside the built-in server
The server keeps issuing its own tokens and leaves it to the provider to
identify the user. On the server that’s oauth2Login on the sign-in chain, with the
provider’s client id and secret in the configuration:
cn1.security.oauth2.client.registration.google.client-id=your-google-client-id
cn1.security.oauth2.client.registration.google.client-secret=your-google-client-secret
cn1.security.oauth2.client.registration.github.client-id=your-github-client-id
cn1.security.oauth2.client.registration.github.client-secret=your-github-client-secret
cn1.security.oauth2.client.registration.acme.client-id=web
cn1.security.oauth2.client.registration.acme.client-secret=your-acme-client-secret
cn1.security.oauth2.client.registration.acme.scope=openid,profile,email
cn1.security.oauth2.client.registration.acme.provider=acme-id
cn1.security.oauth2.client.provider.acme-id.issuer-uri=https://id.example.com
A registration called google or microsoft needs only the id and the
secret. Auth0 and Keycloak are described by a provider block with their
issuer-uri, as the acme registration is. The secret belongs in the
environment.
Wayline’s accounts are named by e-mail address, which is what
LinkingOAuth2UserService matches on: a person whose provider vouches for
the address of an existing account signs in as that account, with its roles.
@Bean
FederatedIdentityRepository identities(DataSource dataSource) {
return new JdbcFederatedIdentityRepository(dataSource);
}
@Bean
SecurityFilterChain web(HttpSecurity http, FederatedIdentityRepository identities,
UserDetailsService users) {
LinkingOAuth2UserService linking = new LinkingOAuth2UserService(identities, users);
linking.setCreateUsers(true);
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.userService(linking)
.oidcUserService(linking.oidc()));
return http.build();
}
In Wayline the oauth2Login call goes on the signIn chain in
SecurityConfig, beside formLogin. Signing in through another provider covers
the registration keys and the linking rules. Two things are particular to
Wayline:
An account has a row in
wl_profileas well as a sign-in, andAccounts.registerwrites both. WithsetCreateUsers(true)the linking service creates the sign-in alone. Either leave itfalseand send new people through the app’s registration first, or create the profile for a user who arrives without one.The redirect address to register with the provider is the server’s:
/login/oauth2/code/and the registration’s name, on the issuer’shttpsaddress. It’s one address for every platform, because the provider only ever redirects to the server.
In the app, Session.authenticate posts the e-mail address and password to
/login and then asks for the code itself, with no browser. A provider’s
sign-in page has to be shown, so that path changes to the browser flow of
OidcClient. Session already builds the client with OidcClient.create.
The sign-in becomes a call to authorize():
OidcClient.discover("https://api.example.com").ready(discovered -> {
client = discovered
.setClientId("notes-app")
.setRedirectUri("com.example.notes:/oauth2redirect")
.setScopes("openid", "profile", "notes.read")
.setTokenStore(new SecureStorageTokenStore());
client.authorize()
.ready(tokens -> showNotes())
.except(err -> showSignInFailed(err));
});
The redirect address of that flow is the app’s, and it differs by platform:
| Where the app runs | Redirect address |
|---|---|
iOS and Android | An address in a scheme of the app’s own, such as
|
The hosted browser app |
|
The simulator and the desktop app | An |
Each address the app uses has to be on the registered client. Add the scheme
address with another redirectUri call where SecurityConfig.clients builds
the RegisteredClient, and return it from AppConfig.redirectUri() on a
device. The client is saved only when the database has none with that id, so a
database that already holds the registration keeps the old list until the
stored client is updated.
Instead of the built-in server
With this approach the provider issues the tokens and the server only validates them.
The app. Build the client from the provider’s metadata instead of from Wayline’s endpoints, with the client id the provider issued:
OidcClient.discover("https://accounts.google.com").ready(new SuccessCallback<OidcClient>() {
public void onSucess(OidcClient client) {
client.setClientId("YOUR_CLIENT_ID")
.setRedirectUri("com.example.app:/oauth2redirect")
.setScopes("openid", "email", "profile");
client.authorize().ready(new SuccessCallback<OidcTokens>() {
public void onSucess(OidcTokens tokens) {
// tokens.getAccessToken() -- bearer for API calls
// tokens.getIdToken() -- JWT with user identity claims
// tokens.getEmail() -- convenience accessor
}
}).except(new SuccessCallback<Throwable>() {
public void onSucess(Throwable err) {
System.out.println("Sign-in failed: " + err.getMessage());
}
});
}
});
OidcClient.discover works with any provider that publishes OpenID Connect
metadata. Keep the OidcRequestAuthorizer that Session installs on the
API’s address. It attaches the token and refreshes it whatever the issuer is.
The redirect addresses are the ones in the table above, registered with the
provider this time: the scheme address for the installed apps and the https
address of the hosted app. Register the app as a public client, with no
secret.
The server. The API chain stops building a decoder from its own keys and validates the provider’s tokens:
cn1.security.oauth2.resourceserver.jwt.issuer-uri=https://id.example.com
cn1.security.oauth2.resourceserver.jwt.audiences=orders-api
Set audiences to the identifier the provider puts in the access token’s
aud for your API. In SecurityConfig.api, drop the DefaultJwtDecoder
built from the signing keys and the decoder call, and the chain uses the
one these properties describe. The signIn chain, the clients bean and
SignInCodePage are no longer used. A JWT resource server
covers the keys, what’s verified and a server that trusts several issuers.
The API needs an access token that’s a JWT the server can verify with the provider’s published keys. Check what your provider issues for an API of your own before choosing this approach. A provider whose access tokens are opaque, or meant only for its own APIs, fits the first approach.
What goes with it. Registration, the password change and the password check before deleting an account all work on Wayline’s own user store. With a provider in charge of the accounts, a person’s first request has to create their profile, and the password screens go.
From claims to roles
The API rules ask for RIDER, DRIVER and ADMIN. Where those come from is
the JwtAuthenticationConverter on the API chain, and there are two choices.
Keep the roles in Wayline’s database. The converter in SecurityConfig
ignores the token’s claims and loads the account named by the token’s subject
on every request. A provider’s subject is an opaque id and not an e-mail
address, so tell the converter which claim names the account with
setPrincipalClaimName, and use a claim the provider has verified. Driver
approval, an admin’s grant and a block all keep working as they do now, and
they take effect on the next request.
Read the roles from the token. Have the provider put the roles in a claim and map the claim:
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles"); // ["ADMIN", "USER"]
roles.setAuthorityPrefix("ROLE_"); // so hasRole("ADMIN") matches
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
converter.setPrincipalClaimName("preferred_username");
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(converter)));
With the ROLE_ prefix, a claim holding RIDER, DRIVER or ADMIN matches
the rules unchanged. JwtGrantedAuthoritiesConverter reads a claim holding a
flat list of names. Each provider has its own way to add such a claim to a
token and its own name for it, so configure the provider to send one and
check a token from your own tenant.
The cost of this choice is timing. A role withdrawn at the provider is still
in every token already issued and holds until the token expires, and the same
is true of a blocked account. Wayline’s flows also grant roles themselves:
approving a driving application makes a driver, and the admin screens grant
and withdraw admin. With roles in the provider, those two steps have to change
the roles there, or the driver role has to stay in the database. A reasonable
split is ADMIN from the provider and RIDER and DRIVER from the database,
which a converter of your own can combine.
Analytics
The app reports how it’s used through the framework’s analytics API, which
Analytics documents. Everything it does is in one class, Telemetry, so
that’s the file to read and the file to change.
Consent
Nothing is reported until the user agrees. After the first sign-in,
ConsentForm asks once, with Share usage statistics and Not now.
The answer is kept on the device, not with the account, and the Usage
statistics switch under Privacy and safety in the settings changes it
at any time. Telemetry passes the answer to Analytics.setConsent with
analytics and crash reporting set to the answer and personalization and
advertising storage always off. Consent and privacy describes what each of
those gates.
Deleting the account calls Telemetry.forget, which resets the anonymous id
the reports carry.
What’s reported
Every screen reports a screen view. Beyond that the app reports these events:
| Area | Event | Parameters |
|---|---|---|
Account |
| The method, whether the person meant to ride or drive, and why a session ended: the user, expiry or deletion |
Rides |
| The product, the payment kind, a distance bracket, a fare bracket, who cancelled and in which state, the number of stars |
Payments |
| Where the tip was added and its share of the fare |
Driving |
| Whether an application is a resubmission, and an amount bracket |
Administration |
| Which screen a refund was made from |
Settings |
| The language and the theme |
Two user properties go with them, the role and the language. An uncaught
error is reported with Analytics.crash and carries the exception’s class
name alone.
No event carries a name, an e-mail address, a phone number, a street address,
a position, a card number or a ride’s id. Money and distance are reported as
brackets and never as amounts. Keep to that when you add an event: add a
method to Telemetry and call it from the screen, so the list of what leaves
the device stays in one file.
Where the reports go
Telemetry.start registers LoggingAnalyticsProvider in the simulator and
CodenameOneAnalyticsProvider everywhere else. A run in the simulator
writes its events to the console and sends nothing. The Codename One provider
identifies the app from properties the build puts into it, which a local run
doesn’t have. A built app reports to Codename One, and the reports are shown
in the developer console.
To send the events somewhere else, register another provider in
Telemetry.start. Analytics lists the ones the framework has and how to
write one.
Maps, search and routes
What the sample uses
| What | Where it comes from | Called by |
|---|---|---|
The map | Vector tiles of OpenStreetMap data from OpenFreeMap, drawn by | The app, directly. |
Routes | The OSRM project’s public demo server, through the | The app, directly. |
Place search | A public Photon server, which searches OpenStreetMap data. | The server, at |
Place search goes through your server so that the app never sends what a user types to a third party directly, and so that you can change the service without shipping a new app.
When a route can’t be fetched, the app draws a straight line between the two points and estimates the time from the distance. The server prices from its own estimate whenever the app’s figures aren’t plausible, so a failed route changes what the rider sees and not what they pay.
What these services are good for
All three are run by other people at no charge. They’re the right choice for development, a demo and a pilot, and they need a decision before a launch:
The OSRM demo server has no service guarantee and limits how often it can be asked. It exists for trying OSRM out.
The public Photon server is shared by everyone who uses it and is meant for light use.
OpenFreeMap asks for no key. Read its terms of use, and plan for what your app does when a service you don’t control is slow or gone.
None of them knows about traffic. The time the app shows and the time part of the fare come from distances and typical speeds.
Pointing at your own
Tiles and routes are chosen in the app. Maps.create builds the map, and
Routing.setService replaces the route service for the whole app:
/// Tiles from a server of your own, in place of the keyless default.
public static MapView create(LatLng center, int zoom) {
MapView map = new MapView(
new MvtTileSource("https://tiles.example.com/planet/{z}/{x}/{y}.pbf", 0, 14),
dark ? MapStyle.dark() : MapStyle.light());
map.setName("map");
map.moveCamera(center, zoom);
return map;
}
/// Routes from your own OSRM instance. Call it once, as the app starts.
public static void useOwnRouting() {
Routing.setService(new OsrmRouteService("https://osrm.example.com"));
}
A keyed tile provider takes its key with setApiKey, and a provider that isn’t
OSRM-compatible is an implementation of RouteService.
The Maps chapter covers both.
Place search is chosen on the server:
wayline.geocoder=photon
wayline.geocoder.url=https://photon.rides.example.com
That points PhotonGeocoder at a Photon server you run. A different search
service is another implementation of the Geocoder interface, chosen in
Services.
Where the app starts
A device that reports its position opens the map there. Until it does, and in a
browser that won’t say, the map opens on AppConfig.DEFAULT_LAT and
DEFAULT_LNG, which is San Francisco’s northern waterfront. Change them to
your city.
Demo data
Everything a development server shows on first start is made by two classes.
DemoAccounts creates the accounts when the server starts. On a development
profile, which is dev, test or local, it creates the riders, the drivers,
the applicant and the admin, gives the drivers approved applications with cars
and placeholder documents, and saves a simulated card for some riders. On any
other profile it creates one account and no more: the first admin, from
wayline.admin.email and wayline.admin.password.
DemoRides stores two weeks of rides as entities, already finished: three to six a
day between ten places in San Francisco, about three quarters of them
completed, with their events, payments and ratings, and a payout of the first
week’s earnings. The
sequence is fixed, so every start produces the same fortnight ending today.
That’s what the dashboard’s charts draw on a fresh server.
The cars a rider sees are real drivers who are online. Nothing on the server invents traffic. To see a car move, sign in as a driver in a second simulator and go online. In the simulator the app has no position, so it stands at the default one, and the driver’s screen drives the car along the route itself at 25 meters a second.
To use your own data:
For your own city, change the ten places in
DemoRidesand inStubGeocoder, and the default position inAppConfig.For a demo with your own cast, edit the list in
DemoAccounts.For a production database, do nothing. Neither class writes demo data outside a development profile.
To load real history, such as rides from a system you’re replacing, write a one-off job that stores
Ride,RideEventandPaymententities throughRideRepositorythe wayDemoRidesdoes, or a migration that inserts intowl_ride,wl_ride_eventandwl_payment. The statistics read those tables and don’t care who wrote the rows.
The app’s tests use 24 vector tiles of the same waterfront, stored in
common/src/test/resources. They aren’t part of the app. Maps.useTiles is the
hook that swaps them in.
Native maps
The sample draws its map with MapView, the vector map that Codename One
renders itself. It looks the same on every platform, the app’s bottom sheet and
buttons float over it, and its light and dark styles follow the app.
NativeMap shows the platform’s own map instead: Apple MapKit on iOS, and
Google Maps or Huawei Map Kit on Android. It has the same MapSurface
operations as MapView, and it falls back to a MapView where no provider is
available, which includes the simulator, the desktop and the browser.
Native maps and providers documents it.
Three changes move Wayline to it.
First, select a provider for each platform with build hints in
common/codenameone_settings.properties:
codename1.arg.ios.maps.provider=apple
codename1.arg.android.maps.provider=google
Apple MapKit needs no key. Google Maps needs an API key for each platform, supplied through the build hints the Maps chapter lists.
Second, create the map as a NativeMap in Maps.create. The tile source and
style passed to it are what the fallback uses:
/// The platform's own map where the build selected a provider, and the
/// vector map everywhere else.
public static NativeMap createNative(LatLng center, int zoom) {
NativeMap map = new NativeMap(center, zoom, MvtTileSource.openFreeMap(),
dark ? MapStyle.dark() : MapStyle.light());
map.setName("map");
return map;
}
MapStage.map, the CarMarker constructor and the helper methods of Maps
are declared with the type MapView. Change them to MapSurface, which has
the camera, marker and line methods they call. The few calls that treat the map
as a component, such as repaint and getHeight, go through asComponent().
Third, change how the car moves. CarMarker glides a marker by setting its
position twenty times a second. A native provider is told about a marker when
it’s added and isn’t told when its position changes, so on a native map the
marker has to be taken off and put back:
/// Moves a marker on a map that may be native. A native provider is told
/// about a marker when it is added and not again, so a marker that moved
/// is taken off and put back.
static Marker move(MapSurface map, Marker marker, EncodedImage icon, LatLng position) {
if (!(map instanceof NativeMap) || !((NativeMap) map).isNativeMap()) {
marker.setPosition(position);
return marker;
}
map.removeMarker(marker);
return map.addMarker(new MarkerOptions(position).icon(icon).anchor(0.5f, 0.5f));
}
Do that a few times a second, not twenty: every move is a call into the platform’s map.
The sample hasn’t been built with a native provider, so treat this as the route to take and test it on devices. What you gain and give up:
MapView, as shipped | NativeMap with a provider | |
|---|---|---|
Keys and accounts | None. | None for Apple MapKit. A key and a billing account for Google Maps, and Huawei’s onboarding for Map Kit. |
Look | One style on every platform, in the app’s light and dark colors, with the
pins and car drawn from | The platform’s map, which users know. Its colors are the provider’s, and a dark app doesn’t make the map dark. |
Data | OpenStreetMap, as good as its coverage of your city. | The provider’s data, which often includes more businesses and addresses. |
Layering | An ordinary component. The bottom sheet, the chip and the buttons are drawn over it. | A native view embedded in the screen. Test every screen that floats controls over the map, on both platforms. |
Offline and tests | Tiles can be bundled with the app, which is how the tests run. | The provider decides what’s cached. In the simulator and in tests you see the fallback, not the native map. |
The database
Engines and where the address comes from
The server runs on SQLite, PostgreSQL, MySQL and MariaDB with the same code. It
reads one setting, cn1.datasource.url:
| Value | What it opens |
|---|---|
| A SQLite database that lives as long as the process. It’s what
|
A file path, such as | A SQLite database in that file. |
| PostgreSQL. |
| MySQL, or MariaDB. Which of the two answered is read from the server and not from the URL. |
The setting is also read from the environment, as CN1_DATASOURCE_URL and as
DATABASE_URL, which is the name a hosting platform usually sets for a
database it attaches. backend/application.properties sets the key to
${DATABASE_URL}, so a deployment supplies the address in its environment and
no address is committed.
To keep data between restarts while developing, give the dev profile a file:
cn1.datasource.url=./target/app.db
To run against PostgreSQL or MySQL, give the server a URL. Nothing else changes, and there’s no driver to add:
DATABASE_URL=postgres://wayline:secret@db.internal/wayline backend/server.sh run
The server holds a pool of connections. cn1.datasource.pool.size sets how
many: eight for PostgreSQL and MySQL unless set, four for a SQLite file and one
for an in-memory database. A request that finds no free connection waits
cn1.datasource.pool.borrowTimeoutMillis, ten seconds unless set, and then
fails. Every server process has a pool of its own, so the processes times the
pool size has to stay under the number of connections the database allows.
Backend data access and transactions covers the pool, and the table of settings in
Configuration and profiles lists these keys with the rest.
The sample’s own tests run on the test profile, which is an in-memory SQLite
database. Run the server tests against the engine you deploy on before you
rely on it there.
The schema
The tables are created by the migration scripts in
backend/src/main/resources/db/migration, which run in order when the server
starts. Schema migrations describes the rules.
| Script | What it creates |
|---|---|
| The profile, the preferences, the pending phone codes, the live-channel tickets and the moderation history. |
| A driver’s state and position, the driving applications and the hours driven. |
| The photographs behind a driving application. |
| The rides, their events, and the drivers who passed on each. |
| Payments, the provider’s customers, saved methods and setups, payout accounts, payouts and the prices. |
The sign-in tables are separate. The security layer creates them because
cn1.security.schema.enabled is set, as Keeping it in the database describes.
Four of the five scripts are written once, in SQL that all three engines
accept. They use no generated keys and no boolean or timestamp types: an id is
text the server makes, a yes or no is a SMALLINT of 0 or 1, and a time is
milliseconds in a BIGINT. That’s also how the ORM stores a boolean and a
long field, so the entities and the columns agree on every engine.
V3__driver_documents.sql is the exception, and it shows what the per-engine
directories are for. A document is stored as text, and it outgrows the 64 KB of
MySQL’s TEXT. MySQL needs LONGTEXT, a type the other two don’t have. For that reason
the script isn’t in db/migration itself. It exists three times under the same
name, in db/migration/sqlite, db/migration/postgresql and
db/migration/mysql, and the server runs the one for the engine it’s connected
to. MariaDB reads the mysql directory. A version is either one common script
or one script for each engine, never both.
Changing the schema later
A change to the schema is a new script with the next version number. The next
one is V6, and the two underscores before the description are part of the
name. A child seat a rider can ask for is V6__child_seat.sql:
-- V6__child_seat.sql
-- A rider may ask for a child seat. No ride has one until someone asks.
ALTER TABLE wl_ride ADD COLUMN child_seat SMALLINT NOT NULL DEFAULT 0;
A column added to a table that already has rows needs a default, as
child_seat has here. ADD COLUMN with a NOT NULL default is accepted by
all three engines, so a script that sticks to it stays a common script. When the SQL has to differ, put the script in the
three engine directories under one name, as V3 does, and leave it out of
db/migration itself.
Add the field to the entity in the same change. The migrations create the
columns and the entities read them, and neither is derived from the other.
Here Ride gets one field:
/// Whether the rider asked for a child seat.
@Column(name = "child_seat", nullable = false)
public boolean childSeat;
A new table gets an entity class of its own in domain as well, as the
customers of Cash and accounts do. The server tests start from
an empty database and run every migration, so RideFlowTest and the others
fail on an entity and a script that disagree.
What happens when the new build starts:
Before it accepts a request, the server compares the scripts in the build with the history the database keeps and applies the ones the database hasn’t had, in order.
On SQLite and PostgreSQL each script runs in a transaction with the row that records it, so a script that fails leaves nothing behind and the server doesn’t start. MySQL and MariaDB commit each schema change as it runs. A script that fails there is recorded as failed, and the server refuses to migrate until what it left has been removed and
repairhas been called.Several server processes starting together take a lock in the database, so the script runs once.
A database that’s already at the newest version isn’t touched.
Never edit a script that has been applied. The server checks the applied
scripts against the ones in the build before it migrates, and refuses to start
on a difference. A mistake in V6 that has reached a database is corrected in
V7. Before a script has left your own machine, delete the development
database and start again. An in-memory database starts from nothing every
time.
A script that removes a column should ship one release after the build that stopped reading it. While a deployment replaces its instances one at a time, the old build runs against the new schema.
A column, end to end
The script and the entity field above are the first two of the places a piece of data touches. The option to bring a pet is already in the sample and shows every one, so follow it for the child seat, a flight number or a cost center.
The schema. pets is a SMALLINT column of wl_ride, in V4rides.sql,
and of wl_driver_application, in V2drivers.sql. For new data it’s a new
script, as V6__child_seat.sql is.
The entities. Ride.pets and DriverApplication.pets are boolean fields
with a @Column, as childSeat is.
The services. Rides.open, the transaction of Rides.request, copies
petFriendly from the request to Ride.pets, and Rides.toDto copies it into the
ride the app is sent. Because the pet option also decides who is offered the
ride, Rides.offer passes it to Matcher.pick, and
DriverRepository.freeInBox adds a condition on the driver’s application.
The contract. The field goes on the class in shared that carries it,
RideRequestDto here, and on RideDto so the driver sees it:
/// Whether a pet is coming: only drivers who take pets are offered the ride.
public boolean petFriendly;
A field needs nothing else. Both builds regenerate the JSON code for the class.
The endpoint. A new field on an existing call changes no endpoint. A new call
is a method on the @RestClient interface and its implementation on the
server, and the server’s build fails until the two match.
The app. SettingsForm has the switch, Prefs keeps its value, RiderForm
copies it into the request, and DriverForm shows it on the offer. The label is
a key in each of the five bundles in src/main/l10n.
The tests. MatchingTest on the server checks that a ride with a pet is
offered only to a driver who takes pets.
More data about a person follows the same path through Profile, wl_profile
and ProfileDto, about a driver’s car through DriverApplication,
wl_driver_application and DriverApplicationDto, and about the service’s
prices through Pricing, wl_pricing and PricingDto. A new kind of record
gets a table, an entity, a repository, a class in shared, and a service.
Hosting it: A managed database and one binary
A deployment that suits a new service is a managed PostgreSQL and the server’s native binary in a small container or virtual machine, behind the platform’s HTTPS proxy. The steps are the same on any platform that runs a container or a process and attaches a database to it.
Build. backend/server.sh build --native --web runs cn1:backend-package,
which writes the binary to backend/target/wayline-server, and
cn1:backend-webapp, which stages the browser app in backend/target/webapp.
The binary is built for the machine the build runs on, so build on the
architecture you deploy to, or use the cross-compiled targets
Backend performance, deployment and limits describes.
Lay it out. The server reads its settings and looks for the browser app in
its working directory. Put the binary, application.properties and the
webapp directory side by side, as Hosting the app in the browser shows, and
start the binary from that directory. Leave webapp out and the server is the
API alone.
Create the database. Create an empty PostgreSQL database and a user that owns it. The server creates every table at its first start, which takes a user that may create tables. Don’t create anything by hand: a database that has tables and no migration history is refused.
Set the environment. Nothing secret belongs in application.properties,
which is committed. A setting is read from the environment under its name in
upper case with an underscore for each dot.
| Variable | What it holds |
|---|---|
| The |
| The port to listen on. A platform that sets it wins over |
| The public |
| The files holding the key that signs the tokens. Mount them as secret files. They’re required outside a development profile. The authorization server covers the keys and their rotation. |
| The first administrator, created at the first start. That account grants the admin role to the rest. |
| The text-message provider. See Text messages. |
| The payment provider. See Billing. |
The same settings as properties, for a platform that mounts a file instead:
cn1.datasource.url=${DATABASE_URL}
cn1.security.authorizationserver.issuer=https://rides.example.com
wayline.admin.email=owner@rides.example.com
wayline.admin.password=${WAYLINE_ADMIN_PASSWORD}
wayline.public.url=https://rides.example.com
Leave TLS to the proxy. The platform’s proxy holds the certificate and
forwards plain HTTP to the port. Two things follow. The issuer is the address
users reach, which is the proxy’s https address and not the container’s. And
the server sees every connection coming from the proxy, so the limit on
sign-in attempts for each client address would count all users as one. Set
cn1.server.forwardHeaders to true and list the proxy’s addresses in
cn1.server.trustedProxies, as Client addresses behind a proxy describes. A
server with no proxy in front terminates TLS itself with
cn1.server.tls.certificate and cn1.server.tls.key.
Give the platform a health check. GET /manage/health answers without a
token, with 503 while the server is starting or draining. The management
endpoints are linked into a packaged server only when cn1.management.enabled
is true in backend/application.properties at build time, and outside a
development profile they also need cn1.management.token set.
Backend tracing, metrics and management covers both.
Start it. The first start applies the five migrations, creates the sign-in tables, registers the app as a client and creates the first administrator. No demo accounts and no demo rides are written outside a development profile.
Point the apps at it. The hosted browser app uses the page’s own origin and
needs nothing. An installed app takes the address from Server address on
the welcome screen. To build an app that needs no such step, change the
address AppConfig falls back to, and remove the Android clear-text
attribute listed in Before it’s a product.
Before a second instance. A change of ride state, the matching job and the
migrations are each safe with several processes on one database.
Two things are held in one process’s memory: the open sockets in LiveHub,
and the counters of the InMemoryRateLimiter that SecurityConfig gives each
rate limit, so every instance allows the full number of attempts.
A second database
A server has one configured database. It’s the one the Session of every
repository works on, and the one injected wherever a bean asks for a
DataSource. A second one, such as an archive or a database
another system owns, is a pool you open yourself with DataSource.open and
publish as a bean. Wrap it in a class of your own so that the two can’t be
confused:
/// A second database, opened beside the server's own. Wrapped in a class
/// of its own so that a bean asking for a `DataSource` still gets the
/// server's.
public static final class Archive {
public final DataSource db;
Archive(DataSource db) {
this.db = db;
}
}
@Bean
public Archive archive(Config config) throws IOException {
return new Archive(DataSource.open(config.get("wayline.archive.url", ""), 4));
}
A service then asks for both:
public Trips(DataSource db, Services.Archive archive) {
this.db = db;
this.archive = archive;
}
public List olderThan(long millis) throws IOException {
return archive.db.query("SELECT id, rider, fare_cents FROM archived_ride "
+ "WHERE requested_at < ?", new Object[] {Long.valueOf(millis)});
}
The sample has one database, so this is a pattern and not something its tests exercise. Its limits come from the runtime:
Migrations run against the configured database only, and so do the entities. The schema of the second one is yours to manage, and its rows are read with SQL through its
DataSource.A transaction is on one database: the first pool the method touches. Statements sent to another pool inside the same
@Transactionalmethod run outside it and commit on their own. Nothing spans the two.The first connection is opened when the pool is, so a wrong address stops the server at start-up and not on the first request.
The engines can differ. The portable SQL described in Backend data access and transactions is what lets one statement run on both.