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, push notifications, analytics, and the database it’s deployed on.
The Initializr’s ride-hailing template generates the project under your own name and package. Paths in this chapter are relative to the project’s root.
Running it
The project needs JDK 17 or newer and Maven, and nothing else installed. Start the server on the development profile:
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 [email protected] with the password wayline-demo. Start a
second simulator and sign in as [email protected] 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 runs the server on the JVM for development. For a
deployment it builds a native binary with one command:
backend/server.sh build --native
backend/server.sh run --native --prebuilt --port 8080
The modules
| Module | What it holds |
|---|---|
| The contract: the records 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,
push notifications and the WebSocket. The entity classes are in the |
There are no platform modules: common builds the app for the simulator, the
desktop, Android, iOS and the browser.
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);
@GET("/api/rides/history")
void history(OnComplete<Response<List<RideDto>>> callback);
@GET("/api/rides/{id}")
void ride(@Path("id") String id, OnComplete<Response<RideDto>> callback);
@POST("/api/rides/{id}/cancel")
void cancel(@Path("id") String id, OnComplete<Response<RideDto>> callback);
@POST("/api/rides/{id}/rate")
void rate(@Path("id") String id, @Body RatingDto rating, OnComplete<Response<RideDto>> callback);
/// The free drivers around a point, for the cars on the map.
@GET("/api/rides/nearby")
void nearby(@Query("lat") double lat, @Query("lng") double lng,
OnComplete<Response<List<NearbyDriverDto>>> callback);
/// Tells the server which Live Activity this phone shows the ride in, so
/// that it can keep it up to date while the app is in the background.
@POST("/api/rides/{id}/activity")
void activity(@Path("id") String id, @Body RideActivityDto activity,
OnComplete<Response<RideActivityDto>> callback);
}
The data that travels is a record marked @Mapped. This is RideRequestDto:
/// Where a ride starts and ends, and how the rider wants it, for a quote and for
/// the request itself.
///
/// @param distanceMeters The length of the route the app drew, when it has one. The server prices
/// from this and [#durationSeconds()] only while they are plausible for the two points;
/// otherwise it prices from its own estimate.
/// @param durationSeconds How long the app's route takes, when it has one.
/// @param paymentMethodId A saved method's id, or `cash`. Empty means the rider's default method,
/// and cash when there is none.
/// @param product `standard`, `comfort` or `xl`; empty reads as standard.
/// @param driverGender `any` or `women`; empty reads as any.
/// @param quietRide Whether the rider would rather not talk.
/// @param accessibleVehicle Whether the car has to take a wheelchair.
/// @param petFriendly Whether a pet is coming: only drivers who take pets are offered the ride.
/// @param note A few words for the driver: which door, what to look for.
@Mapped
public record RideRequestDto(
double pickupLat,
double pickupLng,
String pickupAddress,
double dropoffLat,
double dropoffLng,
String dropoffAddress,
double distanceMeters,
double durationSeconds,
String paymentMethodId,
String product,
String driverGender,
boolean quietRide,
boolean accessibleVehicle,
boolean petFriendly,
String note) {
}
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. This is the start of
the class that does:
/// The server's half of `RiderApi`. Every method acts as the signed-in user and
/// on that user's rides only: the rider is never a parameter.
@Component
public class RiderEndpoint implements RiderApiServer {
private final Rides rides;
private final RidePushes pushes;
public RiderEndpoint(Rides rides, RidePushes pushes) {
this.rides = rides;
this.pushes = pushes;
}
@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);
}
A method added to the contract and not implemented on the server is a compile error, and so is a component the app reads that the record 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 private fields behind getters and setters:
|
Repositories | The | The queries, and nothing else. Each is an interface, and the build writes its implementation. |
Services |
| The rules. Each is a |
An entity gets no generated key: every id is text the server makes in Ids.
It names neither its table nor its columns. They’re the names of the class and
its fields in snake case, so Ride is stored in ride and pickupLat in
pickup_lat, and a field whose type is an enum, such as state, holds the
constant’s name. This is the start of Ride:
/// A ride, from the request to its end.
///
/// `rider` and `driver` are account names and not references: a ride outlives the
/// accounts that took it, and `driver` is empty until one is offered it.
@Entity
public class Ride {
@Id(autoIncrement = false)
@Column(nullable = false)
private String id = "";
@Column(nullable = false)
private RideState state = RideState.REQUESTED;
@Column(nullable = false)
private String rider = "";
@Column(nullable = false)
private String driver = "";
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 an interface marked @Repository that extends
CrudRepository, which brings findById, insert and the other operations
on one entity:
@Repository(entity = Ride.class, key = String.class)
public interface RideRepository extends CrudRepository<Ride, String> {
/// The ride a rider has in one of `states`, or null.
Ride findFirstByRiderAndStateIn(String rider, RideState[] states);
/// The ride a driver has in one of `states`, or null.
Ride findFirstByDriverAndStateIn(String driver, RideState[] states);
A query that its method’s name can say is derived from the name, as these two
are. The rest carry a @Query over the entities, which names Java fields and
not columns, as DriverStateRepository.freeInBox does to find the free
drivers inside a bounding box. A query that changes rows is also marked
@Modifying and answers how many it changed. Generated repositories
covers both kinds.
A service method that’s marked @Transactional runs in one transaction, and
every repository it calls works inside it, as The transaction’s session
describes. A change made to an entity the transaction read is written when the
method returns, with no call to save it: PhoneVerification.store sets the
fields of 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.findFirstByRiderAndStateIn(rider, ACTIVE);
return ride == null ? none() : toDto(ride, rider, new HashMap<String, Profile>());
}
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 = attempt(() -> 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, RIDER, rider);
move(id, RideState.CANCELLED_BY_RIDER, rider, RIDER, null, null);
return new Change(id, RideState.CANCELLED_BY_RIDER, rider, before.getDriver(), "",
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.
The public method runs the transaction through attempt. Two transactions
that change the same ride are meant to be settled by the update itself, as
Two answers at the same moment describes: the one that comes second changes no rows.
MariaDB, as it’s configured out of the box, refuses that second update
instead, because the ride it read has changed since, and ends its
transaction. attempt runs the transaction again in that case, up to three
times. The second run reads the ride as the first one left it, and its update
changes no rows, which is the answer the other databases give.
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. After the rate limits it declares, its rules are the whole of
the access policy:
.authorizeHttpRequests(auth -> auth
.requestMatchers(AntPathRequestMatcher.antMatcher("POST",
"/api/account/register")).permitAll()
.requestMatchers("/api/driver/**").hasRole(Roles.DRIVER)
.requestMatchers("/api/admin/**").hasRole(Roles.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. For that reasonAccounts.setRolesanswers 409 when asked to withdraw the driver role from a driver who is on a ride: without the role they couldn’t finish or cancel it. The admin cancels the ride first, or waits.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 ride, with a state. The states are the
RideState enum in shared, so the app and the server name them the same
way, and RideStateMachine on the server 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 in RideRepository, 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 statement for that kind of move: the
rider’s, the driver’s, a driver taking an offer, a driver giving one back, an
offer being made, or a move the server makes itself:
private boolean tryMove(String id, RideState to, String actor, String ownerField,
String newDriver, Long newOfferExpiresAt) throws IOException {
RideState[] sources = RideStateMachine.sources(to);
if (sources.length == 0) {
return false;
}
long now = System.currentTimeMillis();
int moved;
if (newOfferExpiresAt != null) {
moved = rides.offer(id, sources, to, now, newDriver, newOfferExpiresAt.longValue());
} else if (RIDER.equals(ownerField)) {
moved = rides.moveAsRider(id, sources, to, now, actor);
} else if (!DRIVER.equals(ownerField)) {
moved = rides.moveByServer(id, sources, to, now);
} else if (newDriver != null) {
moved = rides.giveBack(id, sources, to, now, actor, newDriver);
} else if (RideState.ACCEPTED == to) {
// An offer that has lapsed cannot be taken, even in the moment
// before the sweep takes it back.
moved = rides.takeOffer(id, sources, to, now, actor);
} else {
moved = rides.moveAsDriver(id, sources, to, now, actor);
}
if (moved != 1) {
return false;
}
event(id, to, actor);
return true;
}
Each kind is a fixed statement of its own in the repository. This is the one for a driver accepting an offer:
/// Takes an offer made to `driver`: refused once the offer has lapsed, as
/// of `now`, even in the moment before the sweep takes it back.
@Modifying(clearAutomatically = true)
@Query("update Ride r set r.state = :to, r.updatedAt = :now "
+ "where r.id = :id and r.state in :from and r.driver = :driver "
+ "and r.offerExpiresAt >= :now")
int takeOffer(@Param("id") String id, @Param("from") RideState[] from,
@Param("to") RideState to, @Param("now") long now, @Param("driver") String driver);
It names the states the move may start from, the driver the ride was offered
to, and the time the offer lapses. It 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.takeGuess has the database add one to the count of
wrong codes only while the count is under the limit, so of any number of
guesses sent together only as many as are left are looked at.
RideRepository.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. Both claims update the ride’s row, so a
refund that arrives while a tip is still being charged sees the tip and answers
409 until the charge has been recorded or given up. A server that stops after
claiming a refund leaves the ride marked refunded with no refund recorded.
The next request for that ride finds the payment row unchanged and asks the
provider again. That’s safe because a refund carries a key made from the
charge it returns, so the provider answers a repeat with the refund it made.
The same rule lets a second refund through while the first is still at the
provider. Whichever of the two fails gives its claim back only while the
payment row is still unrefunded, and whichever succeeds writes the ride’s
status along with the payment’s. Both write the ride’s row first, so they take
turns, and the ride ends up agreeing with its payment in either order. A
refunded fare can’t come back as earnings for the driver to cash out.
Accounts.setRoles and Accounts.suspend hold two rows: the profile of the
admin who asks and the profile of the account being changed, in the order of
their names. The admin’s own role is then read again inside the transaction.
Without that, two admins could remove or block each other in the same moment
and leave the service with nobody to administer it.
Neither blocks an account in the middle of a ride, nor withdraws the driver
role from one: every request a blocked account sends is refused, so nobody
could end that ride. The admin cancels the ride first, or waits for it to end.
A driver taking an offer holds the driver’s profile too and then checks that
the account is still allowed, so a block and an accepted offer can’t both
land.
A live connection that was opening while the change committed reads the
account again once it’s registered, so it can’t keep what it read before.
A conditional update settles a race over one row. Two races here are over a
row that doesn’t exist yet: a rider asking for two rides at once, and two rides
choosing the same free driver. Each transaction reads, finds nothing in its
way, and inserts or updates a row of its own. Rides.open and Rides.offer
therefore start by writing the rider’s profile row, or the driver’s state row,
to the value it already has (ProfileRepository.hold, DriverStateRepository.hold).
The database keeps a written row for its writer until the transaction commits,
so the second transaction waits there and then reads what the first one left.
That write must come first in the transaction: a transaction that reads
first may go on seeing the database as it was at that read. That’s why choosing
a driver and offering them the ride are two transactions.
The same hold serializes everything else that reads an account and then writes for it. Closing an account, removing a card and choosing the default card hold the profile, so each takes turns with a ride being asked for. Sending a phone code holds it too, so two requests can’t both pass the wait between codes. Changing a password holds it before the old password is checked, so of two changes made together the second is checked against the password the first one set. Approving a driving application holds the applicant’s profile, the row blocking the account holds as well: granting the role rewrites the whole sign-in row, and an approval that read the account before a block would otherwise write it back unblocked. A driver’s position report holds the driver’s state row, so two reports sent together don’t count the same online minutes twice. Cashing out holds the driver’s payout account, so two requests sent together pay the balance once and the second is told there’s nothing left. Saving the payout details holds the driver’s profile, because the first save has no payout row to hold yet. Saving a driving application or uploading one of its documents holds the application, so neither can land on one that was handed in a moment earlier.
Changing a password ends the account’s other sign-ins. A refresh token asks
for no password, so account.Grants removes every grant in the authorization
server’s table except the one the request came with, and closing an account
removes them all. An access token already issued isn’t recalled: it’s checked
by its signature and runs out on its own.
A ride’s two parties see each other’s display name, and each other’s phone
number while they’re meeting. The e-mail address an account signs in with is
its username, and Rides.toDto gives it only to its owner and to an admin. A
fare counts toward a driver’s balance once the ride is paid for, in cash or by
a card whose charge came back, and a tip paid by card once its charge is
recorded. A charge that failed, was refunded or is still pending isn’t money
the driver can cash out, and a refund takes the tip back with the fare. The
earnings screen’s totals for the day, week and month count the same money as
the balance, so a refunded or unpaid ride counts as a trip and earns nothing.
A statement that changes rows in bulk leaves the session unchanged, so an
entity read before it keeps the values it had. Every move of a ride is marked
@Modifying(clearAutomatically = true), which empties the session after the
statement, so a Ride read before a 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.
A phone whose app isn’t open hears of the same changes through push notifications, which Push notifications covers.
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. |
| Registers for push notifications and tells the server which phone the signed-in user is on. See Push notifications. |
| The ride on the lock screen while a driver is on the way. See The ride on the lock screen. |
| Keeps an online driver’s position going to the server with the app in the background. See Driving in the background. |
| 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
mvn -pl shared,backend -Dcodename1.platform=backend test # (1)
mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # (2)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, the push notifications each step of a ride sends and the browser sign-in.
The same tests again with the server compiled to a native binary, which is how it ships.
Backend testing covers the server test support.
Your project’s names
The Initializr generates the project under the name and package you choose:
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.
This chapter uses the name Wayline. 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 the ones in force until an admin saves
the pricing screen, which writes them to the database. 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 inpricing,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.product.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 DriverStateRepository.freeInBox, which finds the free, approved
drivers inside a box, Matcher.nearby, which keeps those within the radius
who fit the ride and 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.
DriverStateRepository.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
driver_application and components 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
A development server prints verification codes on its console. Any other server sends them through Twilio, Vonage or MessageBird, and needs an account with one of them. The settings, the limits and how to add another provider are in Text messages.
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.
An account with a text-message provider. See Text messages.
A keyed digest for phone codes.
PhoneVerificationstores a plain SHA-256 of the account and the code. Somebody who can read thephone_codetable can try all million codes within the five minutes a code lasts. Use an HMAC under a server secret that isn’t kept in the database.Sturdier push cleanup. A sign-out that can’t reach the server leaves the phone registered to that account until somebody signs in on it again. A rider who signs out during a ride keeps its Live Activity until the system removes it. One Live Activity is kept per ride, so a rider following a ride on two devices sees the second one updated, and the first one not.
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 A dispatcher shows.
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 that
Who may do what shows. Above the rule for /api/driver/, add one that
admits the dispatcher and the admin to the dispatcher’s paths:
.requestMatchers("/api/dispatch/").hasAnyRole(Roles.DISPATCHER, Roles.ADMIN).
If only staff create accounts, the rule for registration changes too, from
permitAll() to hasRole(Roles.ADMIN).
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. This is the transaction in Rides that makes it for the matcher:
/// Offers a ride to the driver chosen for it, unless they were given
/// another in the meantime. Returns what it changed, or null for nothing,
/// in which case [#tick] looks again.
///
/// The driver is held before anything is read, so two rides that chose
/// the same driver come through here one after the other, and the second
/// reads the offer the first one committed. The update of the ride cannot
/// do this on its own: it is conditional on its own ride's row, and each
/// of the two has a row of its own.
///
/// Everything that made the driver the choice is asked again once they
/// are held: a driver who went off line, drove out of reach, lost their
/// approval or was blocked since [#choose] read them would otherwise get
/// the offer and keep the rider waiting for it to lapse. Not private, so
/// that a test can make the offer to a driver who changed in between.
@Transactional
Change offer(String id, String driver) throws IOException {
if (drivers.hold(driver) == 0
|| rides.findFirstByDriverAndStateIn(driver, WITH_DRIVER) != null) {
return null;
}
Ride ride = find(id);
if (ride == null || RideState.REQUESTED != ride.getState()) {
return null;
}
long now = System.currentTimeMillis();
if (!matcher.fits(ride, driver, now - ride.getRequestedAt())) {
return null;
}
return tryMove(id, RideState.OFFERED, "", null, driver, Long.valueOf(now + offerMillis))
? new Change(id, RideState.OFFERED, ride.getRider(), driver, "", null) : null;
}
An assign method for the dispatcher is a pair, the way the other moves are.
Its private @Transactional half does what offer does with the driver the
dispatcher picked, holding the driver first and calling tryMove with
RideState.OFFERED, the driver and the time the offer lapses. Where offer
answers null, assign answers 409, so a dispatcher who picked a busy driver,
or a ride that somebody else assigned first or that the rider cancelled, is
told. The public half runs it through attempt and passes the Change it
returns to tell, as cancelByRider does. tryMove records the event in the
ride’s history, and tell has the driver’s screen show the offer at once. 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 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 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:
public interface PaymentProvider {
/// `simulated` or `stripe`: 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;
/// Deletes the processor's record of a rider, and the cards saved to it
/// with it. A record that is already gone is deleted: this is asked again
/// when the first answer was lost.
void forgetCustomer(String customer) throws IOException;
/// Begins saving a card to `customer`. `setupId` is the server's own id
/// for the attempt, for the processor to hand back.
Setup startCardSetup(String customer, String setupId) throws IOException;
/// Finishes a setup and returns the card it saved.
///
/// @param reference what [#startCardSetup] returned as the setup's reference
/// @param typed the card as typed into the app, for a processor whose setup
/// is not hosted; ignored by one whose setup is
/// - `PaymentDeclinedException`: when the card was refused or the setup was
/// not finished
Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException;
/// Takes a saved card off `customer`, so that it can't be charged again.
/// A card that is already off, or gone, is detached: this is asked again
/// when the first answer was lost.
///
/// @param token what [#finishCardSetup] returned as the card's token
void detachCard(String customer, String token) throws IOException;
/// Charges a saved card with its owner absent.
///
/// @param key names this charge and no other. A processor asked twice with
/// the same key charges once, so a request repeated after a timeout
/// cannot take the money twice.
/// @return the processor's reference for the charge
/// - `PaymentDeclinedException`: when the card was refused
/// - `IOException`: when the processor could not be asked
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.
///
/// A processor may take the refund and only later say whether the bank
/// returned the money: such a refund comes back [Refund#pending], and is
/// asked about with [#refundStatus] until it has an answer.
///
/// @return the refund as the processor took it
/// - `IOException`: when the processor could not be asked, or refused the
/// refund outright
Refund refund(String chargeReference, long amountCents) throws IOException;
/// What became of a refund that was [Refund#pending]: [#REFUND_SUCCEEDED],
/// [#REFUND_PENDING] while the processor still does not know, or
/// [#REFUND_FAILED] when the money did not go back.
String refundStatus(String refundReference) throws IOException;
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.
A ride completes in one transaction and its payment is written in another,
with the charge between them. A server that stops between the two leaves a completed
ride whose payment is still pending. Rides.settleLeftBehind runs every minute,
finds such rides once they’re two minutes old, and settles them. Because the
charge is keyed by the ride, a card that was already charged isn’t charged
again.
A charge can also fail without saying whether the card was charged: the
provider wasn’t reached, or its answer was lost. Only a PaymentDeclinedException
is recorded as a failed payment. Any other failure leaves the ride pending, and
the same pass asks again every minute for 23 hours, which is inside the day a
provider keeps a charge’s key. After that the ride stays pending for a person
to look up. A tip works the same way: a declined card takes the tip off the
ride, and any other failure leaves it there with no charge recorded, so the
rider can send the same tip again under the same key. The ride can’t be
refunded until then.
A refund can also be taken without being finished. Stripe answers some
refunds pending and only later says whether the bank returned the money, and
a pending refund can still end failed or canceled. PaymentProvider.refund
reports such a refund as pending, and the payment and its ride are then
REFUND_PENDING, not REFUNDED: the receipt says the refund is pending, and the
ride can’t be refunded again. Payments.settleRefunds runs every minute and asks
the provider about each one through PaymentProvider.refundStatus. Once the
fare’s refund and the tip’s have both succeeded the ride is REFUNDED. If either
fails, the ride is REFUND_FAILED and isn’t tried again: part of the money may
have gone back, so it takes a person with the provider’s records to settle it.
A ride in either state earns its driver nothing.
While a ride’s charge is being asked about, the card it was sent to can’t be
removed and the account can’t be closed: Payments.remove and
Payments.forget answer 409. The retry looks the card up by its token, so
with the card gone it couldn’t ask.
Closing an account also deletes the rider’s record at the provider, through
PaymentProvider.forgetCustomer, in the same transaction that deletes the
rest. A provider that can’t be reached fails the closure and leaves the
account as it was. A record that’s already gone counts as deleted, so closing
again finishes a closure that was interrupted.
Removing a card works the same way for that one card. Payments.remove asks
PaymentProvider.detachCard to take the card off the rider’s record before it
deletes the row, in the same transaction. A provider that can’t be reached
leaves the card saved, and a card that’s already detached counts as detached.
Each card setup is a session made at the provider. An account can have five
open at once, and a sixth is refused with 429 until one is finished or an hour
has passed. Payments.sweepSetups runs every ten minutes and deletes the
finished setups and the ones abandoned more than two hours ago.
The limit of eight cards is checked again when a setup finishes, because
several setups can start while there’s room for one. By then the provider has
already attached the card, so when the limit refuses it, Payments detaches
it with PaymentProvider.detachCard after the refused transaction has rolled
back. If that detach fails, the server logs the card’s token and still answers
409.
Two implementations come with the sample, and the setting
wayline.payments.provider chooses between them:
simulatedisSimulatedPayments. 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.stripeisStripePayments, a starting point over Stripe’s REST API that hasn’t been run against a Stripe account. Turning on Stripe says what it does and what to check.
Without the setting, a development profile (dev, test or local) gets
simulated and any other profile gets stripe. A provider that can’t be set
up isn’t replaced with anything. With stripe chosen and its key missing, or
with a name that’s neither, the server starts, logs a configuration error that
names what to set, and logs it again on every payment call, each of which
fails. The rider is told payments are unavailable, and no ride is charged to a
card nobody can reach. A production server never falls back to simulated
cards.
Turning on Stripe
Choose 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.provider=stripe
wayline.payments.stripe.secret=${WAYLINE_PAYMENTS_STRIPE_SECRET}
wayline.public.url=https://rides.example.com
The first line is what a production profile chooses anyway, and a development
profile needs it to use Stripe at all. The second reads the key from the
environment variable WAYLINE_PAYMENTS_STRIPE_SECRET. The server reads that
variable for this setting without the line as well, since every setting is
read from the environment under its name in upper case with an underscore for
each dot. The third 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 or both addresses missing, every payment call fails
with the configuration error described above.
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. One that Stripe answers
pendingorrequires_actionis read again withGET /v1/refunds/{id}until it succeeds, fails or is canceled.Removing a card detaches its PaymentMethod with
POST /v1/payment_methods/{id}/detach. If Stripe refuses, the server reads the PaymentMethod, and one that’s no longer attached to the rider counts as detached.
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
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
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.
StripePayments shows what a provider with a page of its own does where the
simulated one takes a typed card. Its setup creates a session on the
provider’s side and hands back the address of the provider’s page:
@Override
public Setup startCardSetup(String customer, String setupId) throws IOException {
Map session = ask("POST", "/v1/checkout/sessions", form("mode", "setup",
"customer", customer, "payment_method_types[0]", "card",
"client_reference_id", setupId,
"success_url", returnUrl + "?result=done",
"cancel_url", returnUrl + "?result=cancelled"), "");
Setup setup = new Setup();
setup.hosted = true;
setup.reference = id(session);
setup.url = text(session, "url");
if (setup.url.length() == 0) {
throw new IOException("The payment processor returned no page to open");
}
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
wayline.payments.provider, and a new provider is one more name there:
/// `wayline.payments.provider` is one of:
///
/// - `stripe`: [StripePayments], an untested stub over Stripe's REST API.
/// It needs `wayline.payments.stripe.secret`.
/// - `simulated`: test card numbers, and no money moves.
///
/// Unset, it is `simulated` on a development profile and `stripe` otherwise.
///
/// With Stripe a card is typed into Stripe's own page, which then sends the
/// browser back to `/pay/return` on this server. That address has to be the
/// one a phone can reach, so it is `wayline.public.url` when that is set
/// and the issuer -- the public address of this server, which a deployment
/// sets anyway -- when it is not.
@Bean
public PaymentProvider paymentProvider(Config config,
@Value("${wayline.payments.provider:}") String chosen,
@Value("${wayline.payments.stripe.secret:}") String secret,
@Value("${wayline.public.url:}") String publicUrl,
@Value("${" + SecurityConfig.ISSUER_KEY + ":}") String issuer) {
String kind = chosen.trim().length() > 0 ? chosen.trim()
: config.isDevelopmentProfile() ? "simulated" : "stripe";
if ("simulated".equalsIgnoreCase(kind)) {
return new SimulatedPayments();
}
if (!"stripe".equalsIgnoreCase(kind)) {
return new MisconfiguredPayments(kind, "wayline.payments.provider is '" + kind
+ "', which is neither stripe nor simulated");
}
String address = publicUrl.length() > 0 ? publicUrl : issuer;
String missing = missing("", new String[] {"wayline.payments.stripe.secret", secret,
"wayline.public.url (or " + SecurityConfig.ISSUER_KEY + ")", address});
if (missing.length() > 0) {
return new MisconfiguredPayments("stripe", unset("payments", "stripe", missing));
}
while (address.endsWith("/")) {
address = address.substring(0, address.length() - 1);
}
return new StripePayments(secret, address + "/pay/return");
}
The name a provider returns from name() is what the app is told, and is how
it knows whether to show a card form or open a page.
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 setting wayline.sms.provider chooses the implementation, and
Services.smsSender builds it:
| Value | Class | Settings it needs |
|---|---|---|
|
| None. It writes |
|
|
|
|
|
|
|
|
|
Without the setting, a development profile gets console and any other
profile gets twilio. The development settings say so outright:
wayline.sms.provider=console
wayline.payments.provider=simulated
wayline.push.provider=console
Turning on a provider
Name the provider and give it its settings, with the secrets read from the environment:
wayline.sms.provider=vonage
wayline.sms.vonage.key=${WAYLINE_SMS_VONAGE_KEY}
wayline.sms.vonage.secret=${WAYLINE_SMS_VONAGE_SECRET}
wayline.sms.vonage.from=Wayline
The server reads every setting from the environment as well, under its name in
upper case with an underscore for each dot, so wayline.sms.vonage.secret is
also WAYLINE_SMS_VONAGE_SECRET. Keep keys, tokens and secrets there and not
in a committed file. Nothing in the app changes when the provider does.
A provider that can’t be set up isn’t replaced with the console. With a
setting missing, with console asked for on a production profile, or with a
name that’s none of the four, the server uses MisconfiguredSmsSender. It
logs a [config] line naming what to set when the server starts and again on
every message, and every message fails, which the verification answers with
503. A server whose provider was forgotten verifies nobody, rather than
everybody.
On a development profile with the console sender, the server also returns the code to the app, which shows it on the verification screen so a demo needs no phone. With any other sender, 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.takeGuess, which also applies the limit, so guesses sent together can’t get past it.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
phone_code. A new code doesn’t void the one before it, which stays good until it would have expired: people ask again when a message is slow, and text messages aren’t delivered in the order they were sent, so the first code can arrive last. At most two codes are good at once, and entering either one uses up both. The earlier code keeps the guesses it had left rather than getting five more.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 code sent before it, while still good, remains the one to enter.
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 console sender used in
/// development, 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();
}
Another provider is another class, with delivers() answering true. The
three that come with the sample have one shape: a form posted to the
provider’s API. VonageSmsSender is the shortest:
/// Sends through Vonage's (formerly Nexmo's) SMS API: a form POST to
/// `https://rest.nexmo.com/sms/json` carrying `api_key`, `api_secret`, `to`,
/// `from` and `text`.
///
/// Vonage answers 200 even for a message it refused; the outcome is the
/// `status` of each entry of `messages`, where `"0"` is success and anything
/// else is an error code. A long text is split into parts and each part has an
/// entry, so every one of them has to be `"0"`.
///
/// Settings: `wayline.sms.vonage.key`, `wayline.sms.vonage.secret` and
/// `wayline.sms.vonage.from` (a virtual number or an alphanumeric sender id).
/// The number is sent without its leading `+`, as Vonage asks.
public class VonageSmsSender implements SmsSender {
private static final String URL = "https://rest.nexmo.com/sms/json";
private final String apiKey;
private final String apiSecret;
private final String from;
public VonageSmsSender(String apiKey, String apiSecret, String from) {
this.apiKey = apiKey;
this.apiSecret = apiSecret;
this.from = from;
}
@Override
public void send(String phone, String text) throws IOException {
String to = phone.startsWith("+") ? phone.substring(1) : phone;
List headers = new ArrayList();
headers.add("Content-Type: application/x-www-form-urlencoded");
Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("api_key", apiKey,
"api_secret", apiSecret, "to", to, "from", from, "text", text));
if (sent.getStatus() != 200) {
throw new IOException("Vonage answered " + sent.getStatus());
}
Object messages = Json.parseObject(sent.getBodyAsString()).get("messages");
if (!(messages instanceof List) || ((List) messages).isEmpty()) {
throw new IOException("Vonage's answer lists no message");
}
for (Object entry : (List) messages) {
Object status = entry instanceof Map ? ((Map) entry).get("status") : null;
if (!"0".equals(status)) {
// The code only; `error-text` can echo the number back.
throw new IOException("Vonage refused the message with status " + status);
}
}
}
@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 needs
one more name for wayline.sms.provider, built the way the Vonage one is:
if ("vonage".equalsIgnoreCase(kind)) {
String missing = missing("wayline.sms.vonage.", new String[] {"key", vonageKey,
"secret", vonageSecret, "from", vonageFrom});
return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "vonage",
missing)) : new VonageSmsSender(vonageKey, vonageSecret, vonageFrom);
}
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.
Push notifications
The live channel tells an app that’s open. A push notification is for the phone
in a pocket: the rider whose driver has arrived, the driver who has been
offered a ride. The server sends them through the Codename One push service,
which delivers to Firebase, APNs and Huawei, and the app receives them with
PushClient. Codename One Push Notifications covers the push service and the
Console, and Sending push notifications the server’s side of the API.
The app registers
Each phone registers with its platform’s push service when the app comes to the
foreground, and hands the token it gets to the server for whoever is signed in.
Signing out takes it back first, so the next person to use the phone isn’t told
about the last one’s rides. That’s Notifications in the notify package:
/// Asks the platform for a push token. Called every time the app comes to
/// the foreground; registering is idempotent, and the platform answers
/// with the token it already gave when nothing has changed.
public static void start() {
if (!available()) {
return;
}
if (client == null) {
client = PushClient.builder(AppConfig.PUSH_APP_KEY)
.listener(new Listener())
.build();
}
client.register();
}
/// Someone has signed in: the server learns where to reach them.
public static void signedIn() {
PushDeviceDto device = device();
if (device != null && Session.user() != null) {
Api.account().registerPush(device, Net.to(saved -> { }, QUIET));
}
}
/// Someone is signing out: the server forgets this phone for them, and
/// then `then` runs. It runs whether or not the server could be reached;
/// signing out never waits on a notification.
public static void signingOut(Runnable then) {
PushDeviceDto device = device();
if (device == null || Session.user() == null) {
then.run();
return;
}
// A failure is not kept for retrying. The row it would remove names
// this phone's token, and the next sign-in on the phone takes that row
// over for whoever signs in, as the server keeps a phone on the
// account that signed in on it last. What is lost to an outage is
// the window until then, on the phone of the account that signed out
// -- noted in the implementor's guide among the hardening a
// production deployment adds, not worth a durable queue in a sample.
Api.account().unregisterPush(device, Net.to(gone -> then.run(),
(status, message) -> then.run()));
}
The two calls are part of AccountApi:
/// Records a device the signed-in account is to be sent notifications on.
/// Registering the same token again moves it to this account: a phone
/// belongs to whoever signed in on it last.
@POST("/api/account/push")
void registerPush(@Body PushDeviceDto device, OnComplete<Response<PushDeviceDto>> callback);
/// Forgets a device, on signing out: the next person to use the phone is
/// not to be told about this account's rides.
@POST("/api/account/push/remove")
void unregisterPush(@Body PushDeviceDto device, OnComplete<Response<PushDeviceDto>> callback);
A token is the provider’s own, so the server stores it with the provider’s name.
PushDevices keeps one row per phone: a phone that changes hands moves to the
account that signed in on it last, and an account keeps its eight most recent
phones. Closing an account removes them all.
/// The devices each account is sent notifications on.
///
/// The app registers its token when somebody signs in and removes it when they
/// sign out. A token registered by a second account moves to it: the phone
/// belongs to whoever signed in on it last, and the one before is not to be
/// told about the new owner's rides.
@Component
public class PushDevices {
/// How many devices one account keeps. A new one past this replaces the
/// one heard from longest ago: a token the platform has replaced is never
/// removed by its app, and would otherwise be sent to for good.
static final int MAX_PER_ACCOUNT = 8;
private final PushDeviceRepository devices;
private final ProfileRepository profiles;
public PushDevices(PushDeviceRepository devices, ProfileRepository profiles) {
this.devices = devices;
this.profiles = profiles;
}
@Transactional
public PushDeviceDto register(String username, PushDeviceDto device) throws IOException {
PushTarget target = target(device);
String id = key(target);
long now = System.currentTimeMillis();
// Before anything is read, the account's profile row is held: a no-op
// update that answers 0 when the profile is gone and otherwise keeps
// the row locked until this commits. It settles two races.
//
// Against closing the account: Accounts.erase holds the same row
// first and deletes it before it commits, so a registration either
// runs first (and erase's cleanup then deletes the device with the
// rest) or waits, finds no profile and is refused. Without it, a
// registration authorized just before the closure could insert its
// device after erase removed the account's, leaving a device of an
// account that no longer exists and that nobody can unregister.
//
// Against itself: two devices registering at once for an account one
// short of the cap would both count the same rows in makeRoom(), both
// skip the eviction and both insert, one past the cap. Behind the hold
// the second counts after the first committed, and evicts.
if (profiles.hold(username) == 0) {
throw new ResponseStatusException(404, "No such account");
}
// The device row itself is not locked. Two accounts registering one
// token at the same moment -- a registration of the account that
// signed out still in flight as the next one signs in -- can leave it
// with the earlier account, but not for long: the app registers again
// each time it comes to the foreground, for whoever is signed in then.
PushDevice row = devices.findById(id);
if (row == null || !username.equals(row.getUsername())) {
// A device new to this account, whether its token is new or moves
// here from another account, takes a place under the cap.
makeRoom(username);
}
if (row == null) {
row = new PushDevice();
row.setId(id);
row.setProvider(target.getProvider());
row.setToken(target.getToken());
fill(row, username, device, now);
devices.insert(row);
} else {
fill(row, username, device, now);
}
return new PushDeviceDto(target.getProvider(), target.getToken(), row.getPlatform());
}
/// Removes `username`'s devices heard from longest ago until one more fits
/// under [#MAX_PER_ACCOUNT].
private void makeRoom(String username) {
List<PushDevice> held = new ArrayList<PushDevice>(devices.findByUsername(username));
while (held.size() >= MAX_PER_ACCOUNT) {
PushDevice oldest = held.get(0);
for (PushDevice each : held) {
if (each.getUpdatedAt() < oldest.getUpdatedAt()) {
oldest = each;
}
}
devices.deleteById(oldest.getId());
held.remove(oldest);
}
}
/// Forgets a device, if it is this account's. Somebody else's is left alone.
@Transactional
public PushDeviceDto unregister(String username, PushDeviceDto device) throws IOException {
PushTarget target = target(device);
devices.deleteByIdAndUsername(key(target), username);
return new PushDeviceDto(target.getProvider(), target.getToken(), "");
}
/// Where to reach `username`: every device they registered.
@Transactional(readOnly = true)
public List<PushTarget> targets(String username) {
return targetsOf(username);
}
/// [#targets] for a caller already inside a transaction.
List<PushTarget> targetsOf(String username) {
List<PushTarget> out = new ArrayList<PushTarget>();
if (username == null || username.length() == 0) {
return out;
}
for (PushDevice each : devices.findByUsername(username)) {
out.add(PushTarget.of(each.getProvider(), each.getToken()));
}
return out;
}
A tapped notification opens the app on the screen that shows the ride, which then reads the ride from the server as it does when it’s opened any other way:
/// Called on the event thread. A message is about a ride: the app opens
/// the screen that shows it, which reads the ride from the server. The
/// message's own words are for the notification shade, not for the app.
private static final class Listener implements PushListener {
@Override
public void onRegistration(PushSubscription registered) {
subscription = registered;
signedIn();
}
@Override
public void onMessage(PushMessage message) {
Object ride = message.getData().get("rideId");
Object role = message.getData().get("role");
// A message with no title is a silent one: the Live Activity
// moving on, which the push client has already applied.
String title = message.getTitle();
if (ride != null && title != null && title.length() > 0
&& Session.user() != null) {
Nav.showRide(role == null ? Nav.RIDER : String.valueOf(role));
}
}
@Override
public void onError(PushError error) {
// Nothing to tell the user: the app works without notifications,
// and asks again the next time it comes to the foreground.
System.out.println("Push registration failed: " + error.getCode() + " "
+ error.getMessage());
}
}
The app registers only on Android and iOS devices, and only with its push key set. The simulator has no push service to register with, and a page in a browser hears everything over the live channel while it’s open.
/// The app's push key, from the Push section of the Codename One console.
/// Empty, the app registers for no notifications: everything still works,
/// and a phone in a pocket simply hears nothing until it is opened.
public static final String PUSH_APP_KEY = "";
What the server sends
Rides tells RidePushes of every change to a ride once the change is
committed, at the same moment it tells the live channel:
/// Tells the people concerned about a change to a ride that has been
/// committed. Called after the transaction, so what it reads is the ride as
/// it now is, and a phone that opens the app at once finds it so.
public void rideChanged(String id, RideState state, String rider, String driver) {
try {
send(compose(id, state, rider, driver));
} catch (IOException | RuntimeException err) {
System.err.println("[push] nothing was sent for ride " + id + " (" + state + "): "
+ err);
}
}
A notification is a courtesy on top of what the app reads from the server, so
nothing that goes wrong sending one fails the change it’s about. RidePushes
decides who hears what:
switch (state) {
case OFFERED:
toDriver = message(id, state, DRIVER, "New ride request",
"Pickup at " + ride.getPickupAddress() + ". Open Wayline to accept.")
.ttlSeconds(offerSeconds);
break;
case ACCEPTED:
toRider = message(id, state, RIDER, "Your driver is on the way",
name + " is coming" + (car.length() > 0 ? " in a " + car : "") + ". About "
+ eta(ride, driver, false) + " min.");
break;
case ARRIVED:
toRider = message(id, state, RIDER, "Your driver is here",
car.length() > 0 ? "Look for " + car + "." : name + " is waiting for you.");
surface = update(activityId, state(state, name, who, 0));
break;
case IN_PROGRESS:
toRider = message(id, state, RIDER, "Your trip has started",
"Arriving at " + ride.getDropoffAddress() + " in about "
+ eta(ride, driver, true) + " min.");
surface = update(activityId, state(state, name, who, eta(ride, driver, true)));
break;
case COMPLETED:
toRider = message(id, state, RIDER, "You've arrived",
"Thanks for riding with Wayline. Your receipt is in the app.");
surface = endOf(activityId, state, name, who);
break;
case CANCELLED_BY_DRIVER:
toRider = message(id, state, RIDER, "Your driver canceled",
"Sorry about that. Request again to find another driver.");
surface = endOf(activityId, state, name, who);
break;
case CANCELLED_BY_ADMIN:
toRider = message(id, state, RIDER, "Your ride was canceled",
"Wayline support canceled this ride.");
toDriver = driver == null || driver.length() == 0 ? null
: message(id, state, DRIVER, "Ride canceled",
"Wayline support canceled this ride.");
surface = endOf(activityId, state, name, who);
break;
case CANCELLED_BY_RIDER:
toDriver = driver == null || driver.length() == 0 ? null
: message(id, state, DRIVER, "Ride canceled",
"The rider canceled. You're available for new requests.");
surface = endOf(activityId, state, name, who);
break;
case NO_DRIVERS:
toRider = message(id, state, RIDER, "No drivers available",
"Nobody could take your ride right now. Try again in a few minutes.");
break;
default:
break;
}
Every message carries the ride’s id, its state and whether it’s for the rider or the driver, so the app knows which screen to open. Each ride and role has its own collapse key, so a phone that was out of reach shows the latest step and not every one. An offer to a driver lives only as long as the offer, fifteen seconds by default: one delivered later would offer a ride that’s gone.
The switches under Notifications in the app’s settings decide what’s said. A switch that’s off silences the notification and nothing else, so the lock screen still follows the ride:
/// Whether `username` asked to be told: of receipts when `receipt`, of
/// ride updates otherwise. An account that never saved its settings gets
/// both, as the settings screen shows it.
private boolean wants(String username, boolean receipt) throws IOException {
Preference row = preferences.findById(username);
if (row == null) {
return true;
}
return receipt ? row.isNotifyReceipts() : row.isNotifyRides();
}
Turning on the push service
wayline.push.provider chooses how notifications leave the server, and
Services.pushSender builds the sender:
| Value | Class | What it does |
|---|---|---|
|
| Sends through the Codename One push service. It needs |
|
| Writes |
|
| Keeps every message for the tests to read. Only a development profile may use it, and the test profile does. |
Without the setting, the test profile gets recording, the other development
profiles get console and any other profile gets cloud:
/// `wayline.push.provider` is one of:
///
/// - `cloud`: the Codename One push service. It needs `wayline.push.app`,
/// the app's push id, and `wayline.push.key`, a server key with the push
/// scope; both are in the Push section of the Codename One console.
/// - `console`: prints each notification, on a development profile only.
/// - `recording`: keeps each notification for the tests to read, on a
/// development profile only.
///
/// Unset, it is `recording` on the `test` profile, `console` on the other
/// development profiles and `cloud` otherwise. A server whose push settings
/// are missing still starts and still serves rides: it logs a configuration
/// error at start-up and again for every notification it could not send.
@Bean
public PushSender pushSender(Config config,
@Value("${wayline.push.provider:}") String chosen,
@Value("${wayline.push.app:}") String app,
@Value("${wayline.push.key:}") String key,
@Value("${wayline.push.url:}") String url) {
String kind = chosen.trim().length() > 0 ? chosen.trim()
: "test".equals(config.getProfile()) ? "recording"
: config.isDevelopmentProfile() ? "console" : "cloud";
if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) {
if (!config.isDevelopmentProfile()) {
return new MisconfiguredPushSender("wayline.push.provider=" + kind
+ " sends nothing and is refused on the '" + config.getProfile()
+ "' profile; set it to cloud");
}
return "console".equalsIgnoreCase(kind) ? new ConsolePushSender()
: new RecordingPushSender();
}
if (!"cloud".equalsIgnoreCase(kind)) {
return new MisconfiguredPushSender("wayline.push.provider is '" + kind
+ "', which is none of cloud, console or recording");
}
String missing = missing("wayline.push.", new String[] {"app", app, "key", key});
if (missing.length() > 0) {
return new MisconfiguredPushSender(unset("push", "cloud", missing));
}
try {
CloudPush push = new CloudPush(app.trim(), key.trim());
return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim())
: push);
} catch (IllegalArgumentException err) {
return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: "
+ err.getMessage());
}
}
To turn it on, give the server the two values, with the key read from the environment:
wayline.push.app=3f0c2a4e-8b1d-4c7a-9e55-2d6b1f0a7c19
wayline.push.key=${WAYLINE_PUSH_KEY}
Then put the push key the Console shows for the app in AppConfig.PUSH_APP_KEY,
and configure each platform’s credentials in the Console as
Codename One Push Notifications describes.
PushClient needs google-services.json from Firebase,
agconnect-services.json from Huawei, or both, in the Android native files
as Codename One Push Notifications describes, and the build stops with an
error without them. Until then codenameone_settings.properties carries
codename1.arg.android.messagingService=none, which builds the app without
push. Add the file and delete that line, and Android gets notifications too.A server that’s missing a setting
still starts and serves rides; the sender it gets instead, MisconfiguredPushSender,
logs a [config] line naming the setting when the server starts and again for
every notification it can’t send. CloudPushSender sends on a thread of its own,
so a ride’s request never waits on the push service, and it logs a refusal
rather than throwing it:
/// Sends through the Codename One push service, which delivers to Firebase,
/// Apple, Huawei and the browsers on the app's behalf.
///
/// The call to the service is made on a thread of its own, so the request that
/// changed a ride answers without waiting for it, and a refusal is logged and
/// not thrown: the rider is still told over the live channel and on their next
/// look at the ride.
public class CloudPushSender implements PushSender {
private final CloudPush push;
/// @param push the client for the service, holding the app's id and the
/// server key
public CloudPushSender(CloudPush push) {
this.push = push;
}
@Override
public void send(List<PushTarget> targets, final PushNotification message,
final Runnable delivered) {
if (targets == null || targets.isEmpty()) {
return;
}
final List<PushTarget> to = new ArrayList<PushTarget>(targets);
Tasks.platform(() -> deliver(to, message, delivered));
}
private void deliver(List<PushTarget> to, PushNotification message, Runnable delivered) {
boolean accepted = false;
try {
PushResult result = push.send(message, to);
accepted = result.isAccepted();
if (!accepted) {
System.err.println("[push] the push service refused a notification: "
+ result.getStatus() + " " + result.getError());
}
} catch (IOException | RuntimeException err) {
System.err.println("[push] a notification was not sent: " + err);
}
if (accepted && delivered != null) {
try {
delivered.run();
} catch (RuntimeException err) {
System.err.println("[push] a notification was sent, and what follows it failed: "
+ err);
}
}
}
}
To send through another service, implement PushSender, one method, and return
it from Services.pushSender:
/// Sends push notifications to devices.
///
/// [com.codenameone.examples.wayline.Services] chooses the implementation from
/// `wayline.push.provider`. A notification is a courtesy on top of what the
/// app reads from the server, so sending one never fails the ride it is about:
/// an implementation logs what went wrong and returns.
public interface PushSender {
/// Sends `message` to every one of `targets`. Returns at once; the sending
/// itself may happen later, on another thread.
///
/// @param delivered run once the push service has taken the message, on
/// whatever thread learnt it; never when it was refused or could not be
/// sent. Null when nobody needs to know.
void send(List<PushTarget> targets, PushNotification message, Runnable delivered);
}
The ride on the lock screen
While a driver is on the way, the rider’s ride shows outside the app: a Live Activity on the lock screen and on the Dynamic Island of an iPhone, an ongoing notification on Android, and a small window on the desktop and in the simulator. A browser shows none. External Surfaces: Widgets and Live Activities covers the API.
RideCard in the rider package starts it when a driver accepts, moves it on
with every change the rider screen sees and ends it with the ride. A finished
ride stays on the lock screen for a while with its last state; a canceled one
goes at once. Starting one is a round trip to the system, so it runs off the
event thread, and the activity’s id goes to the server:
/// The rider screen has `ride` as it now is. Called on the event thread.
static void follow(final RideDto ride) {
if (ride == null || busy) {
return;
}
boolean meeting = RideState.ACCEPTED == ride.state() || RideState.ARRIVED == ride.state()
|| RideState.IN_PROGRESS == ride.state();
if (activity != null && !ride.id().equals(rideId)) {
// A ride this screen no longer shows: its activity goes.
final LiveActivity stale = activity;
activity = null;
rideId = null;
shown = "";
CN.scheduleBackgroundTask(() -> stale.end(null, true));
}
final boolean ours = activity != null;
if (!meeting) {
if (ours) {
final LiveActivity ending = activity;
final boolean done = RideState.COMPLETED == ride.state();
activity = null;
rideId = null;
shown = "";
// A finished ride stays on the lock screen a little while, as
// the platform decides; a cancelled one goes at once.
CN.scheduleBackgroundTask(() -> ending.end(done ? state(ride) : null, !done));
}
return;
}
final Map<String, Object> now = state(ride);
String key = ride.id() + ":" + now;
if (ours ? key.equals(shown) : ride.id().equals(tried)) {
return;
}
if (!ours) {
tried = ride.id();
}
shown = key;
busy = true;
final LiveActivity current = ours ? activity : null;
// Off the event thread: on a phone starting and updating an activity
// is a round trip to the system.
CN.scheduleBackgroundTask(() -> {
if (current != null) {
current.update(now);
CN.callSerially(() -> busy = false);
return;
}
final LiveActivity started = LiveActivity.start(descriptor(), now);
CN.callSerially(() -> {
busy = false;
if (!started.isActive()) {
return;
}
activity = started;
rideId = ride.id();
Api.rider().activity(ride.id(), new RideActivityDto(started.getId()),
Net.to(saved -> { }, QUIET));
});
});
}
The layout is data. The app sends it once when the activity starts, with
${key} placeholders the platform fills from the state, so an update is a few
short strings and a number. The keys are constants in RideActivityKeys in
shared, because the server writes the same state:
/// The layout, filled in from the state on the device: an update sends the
/// state alone.
private static LiveActivityDescriptor descriptor() {
return new LiveActivityDescriptor(RideActivityKeys.KIND)
.setContent(content())
.setCompactLeading(new SurfaceText("${driver}").setFontSize(13)
.setMaxLines(1))
.setCompactTrailing(new SurfaceText("${eta}").setFontSize(13)
.setColor(SurfaceColor.ACCENT))
.setMinimal(new SurfaceText("${eta}").setFontSize(11))
.setExpandedCenter(new SurfaceText("${status}").setFontSize(16)
.setFontWeight(SurfaceFontWeight.SEMIBOLD))
.setExpandedBottom(content())
.setTint(SurfaceColor.rgb(ACCENT));
}
private static SurfaceNode content() {
return new SurfaceColumn().setSpacing(6).setPadding(12)
.add(new SurfaceRow().setSpacing(10)
.add(new SurfaceColumn().setSpacing(2).setWeight(1)
.add(new SurfaceText("${status}").setFontSize(15)
.setFontWeight(SurfaceFontWeight.SEMIBOLD)
.setMaxLines(1))
.add(new SurfaceText("${driver} - ${car}").setFontSize(13)
.setColor(SurfaceColor.SECONDARY_LABEL)
.setMaxLines(1))
.add(new SurfaceText("${plate}").setFontSize(13)
.setFontWeight(SurfaceFontWeight.BOLD)
.setMaxLines(1)))
.add(new SurfaceText("${eta}").setFontSize(22)
.setFontWeight(SurfaceFontWeight.BOLD)
.setColor(SurfaceColor.ACCENT)))
.add(new SurfaceProgress(SurfaceProgress.STYLE_LINEAR)
.setValueState(RideActivityKeys.PROGRESS)
.setColor(SurfaceColor.rgb(ACCENT)));
}
Once the app is in the background the server keeps the activity current. It records the activity’s id against the ride, but only the rider’s own ride and only while a driver is bringing or carrying them:
/// Records the Live Activity the rider's phone shows `rideId` in. Only for
/// the ride's rider, and only while a driver is bringing or carrying them:
/// that is when the activity is on screen.
@Transactional
public RideActivityDto attach(String rider, String rideId, RideActivityDto activity)
throws IOException {
String activityId = Text.required(activity == null ? null : activity.activityId(), 190,
"the activity");
Ride ride = rides.findById(rideId);
if (ride == null || !ride.getRider().equals(rider)) {
throw new ResponseStatusException(404, "No such ride");
}
// The state check and the insert have to be one step against the ride
// ending: checked from the ride read above, a driver completing it or
// an admin cancelling it could commit in between, its compose() would
// find no activity to end, and this one would be inserted after it and
// stay on the lock screen. holdWhile checks the state in the database
// and keeps the ride's row locked until this commits, so an ending
// either happened first (409 here) or waits and then finds the row.
if (rides.holdWhile(rideId, rider, MEETING) == 0) {
throw new ResponseStatusException(409, "That ride has no driver on the way");
}
// One activity per ride, the last one attached. A rider following
// their own ride on two phones at once is not a case the sample
// serves: the earlier phone's activity stops moving and the system
// removes it. A row per device is listed in the implementor's guide
// among what a product adds.
RideActivity row = activities.findById(rideId);
if (row == null) {
row = new RideActivity();
row.setRideId(rideId);
row.setUsername(rider);
row.setActivityId(activityId);
row.setSentAt(System.currentTimeMillis());
activities.insert(row);
} else {
row.setActivityId(activityId);
row.setSentAt(System.currentTimeMillis());
}
return new RideActivityDto(activityId);
}
Each change of state then carries the activity’s new state in the same
notification that announces it, and the end of the ride ends the activity. In
between, the driver’s position reports move the minutes on in a silent message,
no more often than wayline.push.activity.seconds, 45 by default. A conditional
update of the ride’s row decides whether one is due, so two servers receiving
reports at once send one update between them.
The app’s push client applies such a message when the Codename One runtime receives it, before the listener runs. Where the platform doesn’t start the app for a push in the background, the message waits and is applied when the app next starts or resumes. Apple’s own route for a server to update a Live Activity, the ActivityKit push token, isn’t used, so on an iPhone the activity of an app iOS has suspended is only as current as the last message the app received. While the app is open, the rider screen keeps it exact itself.
The app declares the Live Activity in surfaces.json, in src/main/resources
of common:
{
"liveActivities": true
}
ios.surfaces.frequentUpdates among the build hints below asks iOS for the
frequent updates a moving car needs.
Driving in the background
A driver who switches to a navigation app or locks the phone is still driving,
and the server stops offering rides to a driver it hasn’t heard from for a
minute. While a driver is online, Shift in the driver package keeps the
position going to the server with the app in the background:
/// A driver's time on line, as far as the phone is concerned: the position
/// keeps going to the server while the app is in the background.
///
/// In the foreground the driver screen reports every few seconds itself. Put
/// away -- the driver switched to a navigation app, or locked the phone -- that
/// screen stops, and this takes over: it follows the position in the
/// background and reports it when the car has moved [#MIN_METERS], and at
/// least every [#KEEP_ALIVE_MILLIS] even when it has not, because a driver who
/// goes quiet for a minute stops being offered rides.
///
/// - On iOS the `location` background mode keeps the app running while it
/// tracks, and each new position is the moment to report.
/// - On Android a foreground service keeps it running, with the notification
/// the system requires, and wakes it for the reports a parked car would not
/// send.
/// - The simulator and the browser stand still and report nothing here.
public final class Shift {
/// How far the car moves before the server hears of it again.
static final double MIN_METERS = 25d;
/// The longest the server goes without hearing, well inside the minute
/// after which it stops offering this driver rides.
static final long KEEP_ALIVE_MILLIS = 20000L;
private static final String CHANNEL = "wayline-driving";
private static final Net.Failed QUIET = (status, message) -> { };
private static boolean on;
private static boolean background;
private static long sentAt;
private static LatLng sentFrom;
private static ForegroundService service;
/// Read by the service's own thread, so not a plain field.
private static final AtomicBoolean RUNNING = new AtomicBoolean();
private static final Locator.Watcher WATCHER = (state, where) -> {
if (state == Locator.FOUND && background) {
report(where);
}
};
private Shift() {
}
/// The driver went on line.
public static void begin() {
if (on) {
return;
}
on = true;
sentFrom = null;
Locator.track(true);
Locator.watch(WATCHER);
if (ForegroundService.isSupported() && "and".equals(CN.getPlatformName())
&& !CN.isSimulator()) {
RUNNING.set(true);
service = ForegroundService.start(CHANNEL, "You're online",
"Wayline shares your location with riders while you drive.", null,
Shift::keepAlive);
}
}
/// The driver went off line, signed out or left the driver's side of the
/// app. Nothing goes on following where the phone is.
public static void end() {
if (!on) {
return;
}
on = false;
Locator.unwatch(WATCHER);
Locator.track(false);
RUNNING.set(false);
if (service != null) {
service.stop();
service = null;
}
}
/// Leaving the driver's side of the app, with the session still there:
/// the server is told this driver is off line before the phone stops
/// reporting. Otherwise it would go on offering rides to a phone that is
/// no longer listening until the reports went stale, a minute, and each
/// rider would wait out an offer nobody sees. One request, not waited
/// for; if it is lost the minute is the fallback.
public static void leave() {
if (on) {
LatLng where = Locator.here();
Api.driver().status(new DriverStatusDto(false, where.getLatitude(),
where.getLongitude(), Locator.heading()), Net.to(me -> { }, QUIET));
}
end();
}
public static boolean isOn() {
return on;
}
/// The app went to the background, and the reports are this class's.
public static void paused() {
background = true;
}
/// The app is back, and the driver screen reports again.
public static void resumed() {
background = false;
}
DriverForm begins the shift when the server says the driver is online and ends
it when they go offline; signing out and switching to the rider’s side end it
too. Reports go through the same call the driver screen makes in the foreground,
so the server needs nothing new. They’re spaced by distance and time, 25 meters
or twenty seconds, which is what keeps the battery cost down: the car’s position
changes far more often than the server needs to hear of it.
Locator asks the platform for the precise, frequent fixes a driver needs only
while a shift is on, and lets go of location altogether when the app is in the
background without one:
/// Asks for the position precisely, often, and on in the background, or
/// goes back to the ordinary search. A driver on line tracks: riders follow
/// the car on a map, and the server offers rides by distance.
///
/// The platform decides what "in the background" allows. With the
/// `location` background mode an iPhone goes on reporting after the app is
/// put away; Android does while the app runs a foreground service, which is
/// what [com.codenameone.examples.wayline.driver.Shift] starts.
public static void track(boolean on) {
if (tracking == on) {
return;
}
tracking = on;
if (started && !simulated()) {
LocationManager.getLocationManager().setLocationListener(null);
start(true);
}
}
/// The app went to the background. Unless a driver is tracking, the
/// platform is told to stop looking: an app with a background location
/// mode is otherwise kept running, and draining the battery, for a map
/// nobody is looking at.
public static void paused() {
if (!started || tracking || simulated()) {
return;
}
paused = true;
search++;
LocationManager.getLocationManager().setLocationListener(null);
}
/// The app is back: whatever [#paused] put down is taken up again.
public static void resumed() {
if (!paused) {
return;
}
paused = false;
if (started && !simulated()) {
start(true);
}
}
| Platform | How it keeps running |
|---|---|
iOS | The |
Android | A foreground service of the |
The simulator, the desktop and a browser | None. Reports stop while the app isn’t in front. |
These are the build hints the three features need. The project already has
them in common/codenameone_settings.properties:
codename1.arg.ios.background_modes=location
codename1.arg.ios.locationUsageDescription=Your location is where a ride picks you up, and where riders find you while you drive.
codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription=While you are online as a driver, Wayline keeps sharing your location with riders and dispatch, even with the app in the background.
codename1.arg.android.foregroundServiceType=location
codename1.arg.ios.includePush=true
codename1.arg.ios.surfaces.frequentUpdates=true
Both app stores review background location. Apple asks what the location is used for while the app is in the background, and Google Play asks for a declaration and a video of the feature in use. A driver app has a good answer, and the usage description should give it in the driver’s terms.
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
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 through their repositories the wayDemoRidesdoes, or a migration that inserts intoride,ride_eventandpayment. 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:[email protected]/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, the moderation history and the hashed addresses of closed accounts. |
| 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 one row of prices, marked as not yet set, so that saving prices always updates it and two admins saving at once can’t both try to create it. |
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.
Five of the six 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 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. The
option to bring a pet is the column of ride the child seat would sit beside,
and in Ride it’s one private field:
@Column(nullable = false)
private boolean pets;
The child seat is a childSeat field written the same way, with an
isChildSeat and a setChildSeat beside the getter and setter pets has.
The field’s name in snake case is the column’s, so childSeat reads
child_seat with nothing more said.
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 ride, in V4rides.sql,
and of 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 private
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, Matcher.pick passes Ride.isPets to Matcher.nearby, which drops
every driver whose application doesn’t take pets.
The contract. The value is a component of the record in shared that
carries it, petFriendly in RideRequestDto, and of RideDto so the driver
sees it. A child seat is a boolean childSeat component beside it in each,
with a line for it in the record’s comment. Every place that builds one of
those records then passes the new value, and the compiler lists each place
that doesn’t.
A component needs nothing else. Both builds regenerate the JSON code for the record.
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, profile
and ProfileDto, about a driver’s car through DriverApplication,
driver_application and DriverApplicationDto, and about the service’s
prices through Pricing, 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. |
| The payment provider. |
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=[email protected]
wayline.admin.password=${WAYLINE_ADMIN_PASSWORD}
wayline.sms.provider=twilio
wayline.sms.twilio.sid=${WAYLINE_SMS_TWILIO_SID}
wayline.sms.twilio.token=${WAYLINE_SMS_TWILIO_TOKEN}
wayline.sms.twilio.from=+15555550100
wayline.payments.provider=stripe
wayline.payments.stripe.secret=${WAYLINE_PAYMENTS_STRIPE_SECRET}
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.
What’s rate limited. SecurityConfig limits registration, the phone codes,
the password change, the document uploads, the card setups and the address
search under /api/geo. Each search is a request the server makes of the geocoder you
configured, so the limit protects that service’s allowance and not only this
server.
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.