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

ModuleWhat it holds

shared

The contract: the records both sides exchange and the @RestClient interfaces that name every call. It’s plain Java 17 with no Codename One build step of its own.

common

The app: sign-in, the rider, driver and admin screens, the map, the wallet, the settings and the live channel. The styling is in src/main/css/theme.css and the translations in src/main/l10n.

backend

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 domain package, the queries in repository, the schema in src/main/resources/db/migration, and the settings in application.properties beside the module’s pom.xml.

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.

LayerWhereWhat it does

Entities

The domain package

One class for each table, with private fields behind getters and setters: Ride, RideEvent, RidePassed, Profile, Preference, PhoneCode, DriverState, DriverApplication, DriverDocument, Payment, PaymentMethod, Payout, Pricing and the rest. They’re never sent to the app. The records in shared are what travels.

Repositories

The repository package: RideRepository, DriverStateRepository, ProfileRepository, PaymentRepository, DriverApplicationRepository and one for each other entity

The queries, and nothing else. Each is an interface, and the build writes its implementation.

Services

Rides, Matcher, Fares, Payments, Earnings, Applications, Accounts, Preferences, PhoneVerification, Statistics

The rules. Each is a @Component whose constructor takes the repositories it uses, and whose methods are @Transactional.

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.complete is three steps: finish moves the ride to COMPLETED and commits, Payments.settle charges the card with no transaction open, and settled records the result in a second transaction. PhoneVerification.start stores 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 Rides come in pairs for that reason: a private @Transactional method makes the change and returns a Change, and the public method that called it passes that to tell, 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, DRIVER and ADMIN, in Roles. 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 reason Accounts.setRoles answers 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:

StateHow a ride gets there

REQUESTED

A rider asked for it, or the driver it was offered to declined or didn’t answer.

OFFERED

The server offered it to a driver.

ACCEPTED, ARRIVED, IN_PROGRESS, COMPLETED

The driver accepted, reached the pickup, started the trip and finished it.

CANCELLED_BY_RIDER

The rider cancelled before the trip started.

CANCELLED_BY_DRIVER

The driver cancelled after accepting and before the trip started.

CANCELLED_BY_ADMIN

An admin cancelled a ride that hadn’t finished.

NO_DRIVERS

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.

ClassWhat it does

Nav

Chooses the first screen from the roles /api/me returns: AdminForm, DriverForm, ApplyForm for someone who has applied to drive, or RiderForm. It remembers the mode a user with several roles was last in.

net.Session

Signs in and out and keeps the tokens in secure storage. net.Api holds the generated clients, and net.Net adapts their callbacks.

AppConfig

The server’s address. It’s the page’s own origin in a browser, the address saved from the welcome screen otherwise, and http://localhost:8080 when neither is there.

map.MapStage

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.

map.Maps

Creates the map and draws what goes on it: the pins, the car and the route.

notify.Notifications

Registers for push notifications and tells the server which phone the signed-in user is on. See Push notifications.

rider.RideCard

The ride on the lock screen while a driver is on the way. See The ride on the lock screen.

driver.Shift

Keeps an online driver’s position going to the server with the app in the background. See Driving in the background.

ui.Look, ui.Lang, ui.Layouts

The theme, the language and the layout for the width of the window.

admin.Console

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)
  1. 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.

  2. 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.

  • wayline in artifact ids and configuration keys becomes your project’s name in lower case. wayline.fare.baseCents is myapp.fare.baseCents in a project called MyApp.

  • WAYLINE in 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 in Fares.price.

  • Rides.PRODUCT_TEXT, the name, description and seats the rider is shown.

  • The comfort and xl fields of the Pricing entity and their columns in pricing, PricingDto and PricingForm, if the new kind has a multiplier an admin can set.

  • The picker in ApplicationForm, where a driver says which kind their car is, and Ui.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. PhoneVerification stores a plain SHA-256 of the account and the code. Somebody who can read the phone_code table 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. Matcher filters 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_attr from codenameone_settings.properties once 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 from RegisterForm, and ApplyForm from Nav.

  • Decide whether the public may register. If only staff create accounts, take Create an account off WelcomeForm and 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

FeatureWhere

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 Fares.PRODUCTS and Rides.PRODUCT_TEXT.

Surge

Leave the multiplier at 1, and remove its field from PricingForm.

Tips through the app

Remove the tip row from the end-of-ride sheet in RiderForm and from ReceiptForm.

Driving applications

Remove ApplyForm, ApplicationForm and the Applications section of the console. DrivingEndpoint can stay unused or go with them.

Ratings

Remove the stars from the end-of-ride sheet in RiderForm.

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:

  • simulated is SimulatedPayments. 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.

  • stripe is StripePayments, 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/return on your server, a plain page from PayReturnPage that 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 pending or requires_action is read again with GET /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.

The tests cover this path with stand-in answers in place of Stripe’s. Nothing in the project has run against a Stripe account. Use a test-mode key and Stripe’s test cards, and follow one card from adding it to a charge, a tip and a refund, before a live key goes near it.

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 payment from it.

Cards that need the owner’s approval

A bank may require authentication for an off-session charge. StripePayments treats 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.settle and Rides.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:

ValueClassSettings it needs

console

ConsoleSmsSender

None. It writes [sms] to, the number and the text to the server’s output, and answers false from delivers(). Only a development profile (dev, test or local) may use it.

twilio

TwilioSmsSender

wayline.sms.twilio.sid, .token and .from: the account SID, the auth token and the number the messages come from.

vonage

VonageSmsSender

wayline.sms.vonage.key, .secret and .from: the API key, the API secret and the sender name or number.

messagebird

MessageBirdSmsSender

wayline.sms.messagebird.key and .originator: the access key and the sender name or number.

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.

  • SecurityConfig allows /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:

ValueClassWhat it does

cloud

CloudPushSender

Sends through the Codename One push service. It needs wayline.push.app, the application UUID of the push application, and wayline.push.key, a server API key with the push scope. Both come from the Push section of the Console.

console

ConsolePushSender

Writes [push], the number of devices and the message to the server’s output. Only a development profile may use it.

recording

RecordingPushSender

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.

Push on Android starts switched off, so the generated project builds for Android before you have a Firebase project. An app that uses 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);
    }
}
PlatformHow it keeps running

iOS

The location background mode. iOS asks the driver for permission to use the location while the app isn’t in use, with the text of the NSLocationAlwaysAndWhenInUseUsageDescription build hint.

Android

A foreground service of the location type, with the You’re online notification Android requires while it runs.

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 serverInstead 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 profile as well as a sign-in, and Accounts.register writes both. With setCreateUsers(true) the linking service creates the sign-in alone. Either leave it false and 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’s https address. 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 runsRedirect address

iOS and Android

An address in a scheme of the app’s own, such as com.example.rides:/oauth2redirect. Declare the scheme with the ios.urlScheme and android.xintent_filter build hints, as Signing in against a Codename One backend shows.

The hosted browser app

/signin/code on the server’s own origin, AppConfig.WEB_REDIRECT_PATH, which the server already registers.

The simulator and the desktop app

An https address. The browser window closes when it reaches it.

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.

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:

AreaEventParameters

Account

login, sign_up, logout, phone_verified

The method, whether the person meant to ride or drive, and why a session ended: the user, expiry or deletion

Rides

ride_quoted, ride_requested, ride_cancelled, ride_completed, rating_given

The product, the payment kind, a distance bracket, a fare bracket, who cancelled and in which state, the number of stars

Payments

tip_added, card_added, card_removed

Where the tip was added and its share of the fare

Driving

driver_application_started, driver_application_submitted, driver_online, driver_offline, offer_accepted, offer_declined, payout_requested

Whether an application is a resubmission, and an amount bracket

Administration

admin_application_approved, admin_application_rejected, admin_user_flagged, admin_user_unflagged, admin_user_blocked, admin_user_unblocked, admin_ride_refunded, admin_pricing_changed

Which screen a refund was made from

Settings

language_changed, theme_changed

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

WhatWhere it comes fromCalled by

The map

Vector tiles of OpenStreetMap data from OpenFreeMap, drawn by MapView. No key and no account.

The app, directly.

Routes

The OSRM project’s public demo server, through the Routing class.

The app, directly.

Place search

A public Photon server, which searches OpenStreetMap data.

The server, at /api/geo/search and /api/geo/reverse.

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 DemoRides and in StubGeocoder, and the default position in AppConfig.

  • 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, RideEvent and Payment entities through their repositories the way DemoRides does, or a migration that inserts into ride, ride_event and payment. The statistics read those tables and don’t care who wrote the rows.

The app’s tests use 24 vector tiles of the same waterfront, stored in common/src/test/resources. They aren’t part of the app. Maps.useTiles is the hook that swaps them in.

Native maps

The sample draws its map with MapView, the vector map that Codename One renders itself. It looks the same on every platform, the app’s bottom sheet and buttons float over it, and its light and dark styles follow the app.

NativeMap shows the platform’s own map instead: Apple MapKit on iOS, and Google Maps or Huawei Map Kit on Android. It has the same MapSurface operations as MapView, and it falls back to a MapView where no provider is available, which includes the simulator, the desktop and the browser. Native maps and providers documents it.

Three changes move Wayline to it.

First, select a provider for each platform with build hints in common/codenameone_settings.properties:

codename1.arg.ios.maps.provider=apple
codename1.arg.android.maps.provider=google

Apple MapKit needs no key. Google Maps needs an API key for each platform, supplied through the build hints the Maps chapter lists.

Second, create the map as a NativeMap in Maps.create. The tile source and style passed to it are what the fallback uses:

/// The platform's own map where the build selected a provider, and the
/// vector map everywhere else.
public static NativeMap createNative(LatLng center, int zoom) {
    NativeMap map = new NativeMap(center, zoom, MvtTileSource.openFreeMap(),
            dark ? MapStyle.dark() : MapStyle.light());
    map.setName("map");
    return map;
}

MapStage.map, the CarMarker constructor and the helper methods of Maps are declared with the type MapView. Change them to MapSurface, which has the camera, marker and line methods they call. The few calls that treat the map as a component, such as repaint and getHeight, go through asComponent().

Third, change how the car moves. CarMarker glides a marker by setting its position twenty times a second. A native provider is told about a marker when it’s added and isn’t told when its position changes, so on a native map the marker has to be taken off and put back:

/// Moves a marker on a map that may be native. A native provider is told
/// about a marker when it is added and not again, so a marker that moved
/// is taken off and put back.
static Marker move(MapSurface map, Marker marker, EncodedImage icon, LatLng position) {
    if (!(map instanceof NativeMap) || !((NativeMap) map).isNativeMap()) {
        marker.setPosition(position);
        return marker;
    }
    map.removeMarker(marker);
    return map.addMarker(new MarkerOptions(position).icon(icon).anchor(0.5f, 0.5f));
}

Do that a few times a second, not twenty: every move is a call into the platform’s map.

The sample hasn’t been built with a native provider, so treat this as the route to take and test it on devices. What you gain and give up:

MapView, as shippedNativeMap 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 theme.css.

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:

ValueWhat it opens

:memory:

A SQLite database that lives as long as the process. It’s what backend/application-dev.properties and application-test.properties set.

A file path, such as ./target/app.db

A SQLite database in that file.

postgres://user:password@host/database

PostgreSQL.

mysql://user:password@host/database

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.

ScriptWhat it creates

V1__accounts.sql

The profile, the preferences, the pending phone codes, the live-channel tickets, the moderation history and the hashed addresses of closed accounts.

V2__drivers.sql

A driver’s state and position, the driving applications and the hours driven.

V3__driver_documents.sql

The photographs behind a driving application.

V4__rides.sql

The rides, their events, and the drivers who passed on each.

V5__payments.sql

Payments, the provider’s customers, saved methods and setups, payout accounts, payouts and the prices.

V6__pricing_row.sql

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 repair has 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.

VariableWhat it holds

DATABASE_URL

The postgres:// address of the database. Many platforms set it themselves when a database is attached.

PORT

The port to listen on. A platform that sets it wins over cn1.server.port.

CN1_SECURITY_AUTHORIZATIONSERVER_ISSUER

The public https address of the server, without a trailing slash. It goes into every token, and it’s the only origin the browser app may sign in from.

CN1_SECURITY_AUTHORIZATIONSERVER_JWK_KEYS

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.

WAYLINE_ADMIN_EMAIL, WAYLINE_ADMIN_PASSWORD

The first administrator, created at the first start. That account grants the admin role to the rest.

WAYLINE_SMS_PROVIDER, then WAYLINE_SMS_TWILIO_SID, WAYLINE_SMS_TWILIO_TOKEN, WAYLINE_SMS_TWILIO_FROM or the settings of another provider

The text-message provider. twilio when unset. See Text messages.

WAYLINE_PAYMENTS_PROVIDER, WAYLINE_PAYMENTS_STRIPE_SECRET

The payment provider. stripe when unset. See Billing.

The same settings as properties, for a platform that mounts a file instead:

cn1.datasource.url=${DATABASE_URL}
cn1.security.authorizationserver.issuer=https://rides.example.com
wayline.admin.email=[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.