Introduction

Contigo Burrito is a project that aims to achieve real-time tracking of the UNMMS internal transport bus (or buses!) to make students' lives easier, as well as:

  • In-app notifications in the form of banners, popups and posts.
  • Real-time tracking of the bus location, status and device battery.
  • App versioning and forcing client updates.
  • Bus stops and route information, including the distance to the next stop.
  • User sessions (for analytics) and authentication (for protected resources).
  • Feature flags for both server and clients.
  • Multimedia upload integration with Cloudinary.

The following document contains all the technical high-level documentation of the project, including development setup, compilation, distribution, deployment, and other relevant information.

Support and Contact

Feel free to reach out to the team if you have any questions or need help with the project:

Overview

Here is a brief overview of the Contigo Burrito project and how its components interact with each other. For more detailed information, refer to the specific documentation of each component.

Components

The project is divided into four main components, each with its own repository:s

Repository Description Technologies
burrito-app The app that students use to check the bus status. Flutter, Riverpod, Google Maps
burrito-server REST API server responsible Rust, Rocket
burrito-driver Bus driver app that sends the location data. Flutter, Geolocator
burrito-dashboard Admin dashboard to interact with the system. Vite, React

Note that these are private repositores, so you may need to request access to them.

Architecture

The project follows a client-server architecture, where the server and driver are responsible for managing the data and the clients are responsible for displaying it.

Project architecture

Burrito App

Status: production ready

The app that students use to check the bus status. It consumes the status records from the burrito-server by constantly polling for the /status endpoint.

It is written in Flutter and builds for Android and web platforms.

Refer to the docs/ for more information.

App updating protocol

The GET /pending_updates?version=1.0.0 endpoint will return a list of application versions that are newer than the one provided in the query parameter.

// Example
{
  "must_update": true,
  "versions": [
    {
      "banner_url": "https://picsum.photos/id/866/400",
      "is_mandatory": false,
      "release_date": "2019-10-12T07:20:50.52Z",
      "release_notes": "This is a LONG summary of the changes that are introduced in the new version that can even include breaklines.\n\n- Feature 1\n- Feature 2",
      "semver": "1.1.0"
    }
  ]
}

The client MUST NOT let the user proceed with the application if some version is marked as is_mandatory. If the client decides to, it can show a dialog to the user with the changelog and the option to update, storing the acknowledgement in the local storage.

An example of the workflow would be:

Act 1: the first time

>client fetches /pending_updates?version=1.0.0
>server returns two pending version, where neither is mandatory
>two options, [Update now] and [Later] are shown to the user along with the changelogs
>user acknowledges
>client stores the highest one as "latest_acknowledged_version" in local storage
>user decides not to update

Act 2: next day, next update

>client fetches /pending_updates?version=1.0.0
>now server returns three versions, still none mandatory
>since one of them is newer than "latest_acknowledged_version", client shows the dialog
>user acknowledges
>client stores the highest one as "latest_acknowledged_version" in local storage
>user decides not to update

Act 3: the urgent update

>the client fetches /pending_updates?version=1.0.0
>now server returns four versions, where the last one (2.0.0) is mandatory
>client merges the changelogs and shows them to the user along with the [Update now] button
>user acknowledges and it only option is to update
>client stores the highest one as "latest_acknowledged_version" in local storage
>user updates

Act 4: the calm after the storm

>client fetches /pending_updates?version=2.0.0
>server returns an empty list
>client proceeds with the app

The above example describes our current implementation. We might change it in the future, but the general idea will remain the same.

Construyendo el cliente móvil

Para obtener información más detallada, consulte Los documentos oficiales de flutter.

Tenga en cuenta que esta documentación es solo para Android. Aunque Flutter es compatible con iOS, aún no lo hemos probado para este proyecto.

Hay un flujo de trabajo funcional llamado ios-compilation.yml en el directorio .github/workflows que puedes consultar.

Construyendo el APK

Para crear APK para múltiples arquitecturas (p. ej., ARM, ARM64, x86), utilice el siguiente comando. Esto generará archivos APK separados para cada ABI (interfaz binaria de aplicación), lo que permitirá a los usuarios descargar el APK apropiado para la arquitectura de su dispositivo:

flutter build apk --split-per-abi

Los APK se guardarán en build/app/outputs/flutter-apk/ directorio. En esa carpeta se encuentran los APK generados, listos para probar o distribuir.

Crea un paquete de aplicaciones para su lanzamiento

Además de crear archivos APK, también es una buena práctica generar un paquete de aplicaciones (.aab) para lanzar la aplicación en Google Play Store. El paquete de aplicaciones contiene todo lo necesario para la distribución y Google Play optimizará la aplicación para diferentes configuraciones de dispositivos de manera automática.

Para crear una versión de lanzamiento de la App Bundle, utilice el siguiente comando:

flutter build appbundle --release

Una vez que se complete la compilación, el archivo .aab estará disponible en build/app/outputs/bundle/release/ Puedes cargar este archivo en Google Play Console o en cualquier otra tienda de aplicaciones que admita App Bundles.

Publishing Your App to the Google Play Store

There are some things you want to check before submitting the app to the Google Play Store (or any other store).

  1. Make sure the server is running

    Before submitting your app to an app store, it's important to verify that the server your app relies on is live, functional, and ready to handle requests. Many app stores require apps to provide real functionality and not just placeholders, so they might review the app's responses to verify it's working as expected.

    If you don't have a driver sending the data yet, read the Using mocked routes section.

    • Check for Expected Responses: Test that the API is correctly providing the location of the burrito.
    • Monitor Uptime: Many app stores may periodically check your app's functionality during the review process. Consider using a monitoring service to alert you if your server goes down unexpectedly.
  2. Make sure the app is using the correct API and not the local host

    Check the lib/services/dio_client.dart file to ensure that the app is connected to the correct API endpoint. The baseUrl should be set to your live API URL, not localhost:

    baseUrl: 'https://yourAPI.com',
    

    Now you are ready to publish the app in the store of your choice.

Deploying the app in the web using Cloudflare Pages

Cloudflare Pages deployment is already configured as a GitHub workflow that is triggered on every push to the main branch.

For details see the .github/workflows/deploy.yml file.

Using a custom domain

By default, Cloudflare Pages are deployed to the *.pages.dev domain. To use a custom domain, follow the instructions provided by Cloudflare in the Pages dashboard.

Manually building the Web App

Build the app for web with:

flutter build web -t lib/main.dart

Burrito server

Status: Production ready

The Burrito API serves all the server data (app versions, notifications, flags, sessions) and is the communication bridge between the bus driver application and user application.

The server is written in Rust and uses the Rocket web framework.

API documentation

For the API documentation we followed a code-first approach. The documentation is generated from the source code using utoipa, which generates an OpenAPI 3.1 specification file at compile time.

This specification is then consumed and rendered by Scalar. The final document result is served by the API itself, and can be publicly accessed at /docs.

API Documentation

Live documentation is hosted in https://api.contigosanmarcos.com/docs.

Documenting API endpoints

To document a route you use the path attribute macro from utoipa. This macro takes several arguments to document the endpoint. The most important are shown in this PATCH /flag/id example:

#[utoipa::path(
    tag = docs::tags::APP_VERSIONS_TAG,
    description = "Edits an existing app version. All columns are optional.",
    request_body(content = schemas::AppVersionPatchPayload),
    responses(
        (status = 200, body = schemas::AppVersion),
        (status = 400),
        (status = 401),
    ),
    security(("staff_user_auth" = [])),
)]
#[patch("/<id>", format = "json", data = "<payload>")]
async fn patch_app_version(
    id: i32,
    _user: StaffUser,
    payload: JsonResult<'_, schemas::AppVersionPatchPayload>,
    state: &State<AppState>,
) -> ApiResponse<Json<schemas::AppVersion>> {
    // ...
}

Refer to the utoipa::path documentation for more details.

Database managment

Our database driver, sqlx, already solves the majority of the database management issues.

If you are not familiar with sqlx, this video is a good starting point:

First, make sure you have the sqlx client installed.

cargo install sqlx-cli

Creating database migrations

Database migrations live in the migrations/ directory. Create one for each change you want to make to the database schema.

For example, let's say you want to add a new profile_image column to the users table, you can create a new migration like this:

sqlx migrate add -rs add_users_profile_image

Two files will be created in the migrations/ directory:

  • migrations/000x_add_users_profile_image.up.sql
  • migrations/000x_add_users_profile_image.down.sql

Edit the up.sql file to add the new column:

ALTER TABLE users
ADD COLUMN profile_image TEXT;

Edit the down.sql file to remove the column:

ALTER TABLE users
DROP COLUMN profile_image;

The idea is that the down.sql file should revert the changes made in the up.sql, letting the database in exactly the same state as before the migration.

Then you can run or revert the migrations with:

sqlx migrate run
sqlx migrate revert

Or completely reset the database with:

sqlx database reset --force

Once you commit your migrations, you do not need to do anything more to apply them to production, because they are always checked and executed on each server start.

Compiling the queries for offline mode

The idea of sqlx is that plain SQL queries are checked at compile time and translated into Rust primitives. However, this requires a connection to the database to check the queries.

This is why offline mode compilation exists. The only thing you need to make sure is to always run the following before committing your changes:

cargo sqlx prepare

The compiled queries will be saved in the .sqlx/ directory and should be committed to the repository. For more details, check the Offline mode docs.

Environment variables

The .env.example shows an up-to-date list of all environment variables that the server uses. Start by copying the example file to .env:

cp .env.example .env

All the variables defined here directly map an static, global Rust variable defined in the src/env.rs file, so you can use them in your code like this:

fn main() {
  let port: u16 = *crate::env::PORT;
  println!("Server running on port {}", port);
}

Examples

A ready-to-go .env file for development would look like this:

IS_MOCKED=false

ROOT_SECRET="root_secret"
AUTH_DRIVER_PASSPHRASE="driver_pass"
AUTH_WHATSAPP_ACCESS_TOKEN="none"

POSTGRES_PASSWORD="dontship"
DATABASE_URL="postgres://admin:${POSTGRES_PASSWORD}@localhost/burrito_app"

CLOUDINARY_API_KEY="438453545385499"
CLOUDINARY_CLOUD_NAME="sea1jk51z"
CLOUDINARY_API_SECRET="mJd3bbkWa5mPVKuNBgCLxjY5FSM"

A .env file for production would look like this:

IS_MOCKED=false

ROOT_SECRET="burrito_prod_6z3g5z2t5z2g5Z2t5g3X"
AUTH_DRIVER_PASSPHRASE="burrito_prod_K4ZVf3g1zS6x2TcjdyDztkbvh4CQHrF6"

# Leave it empty ("") if you don't plan to use the WhatsApp API
AUTH_WHATSAPP_ACCESS_TOKEN="EAAjnKUIiz4ABOzMXloXZCVvifdfFHJGHvlFFWENYE1zFyfg0Ikh0ExDWnkTO1q9CllVXQgKZBvrD3XUucr6Bxk9RIZAITIvzAxWZB2KbZApppIbSwsk2Ozu54emMqb6QlpBRrUM7WAvrRWa8ZApj5p4ZBY9ROIcHKI6CXujoAg1Q1jnv7pJCnVeLDUblAND97J7Q5LliGPZCdiZAHKI16boABdPHo6p2mm8lFCIYZD"

POSTGRES_PASSWORD="MM3ky4RhgpFSbfoXmUh42r0REZzCYXyu"
DATABASE_URL="postgres://admin:${POSTGRES_PASSWORD}@burrito_db/burrito_app"

CLOUDINARY_API_KEY="438453545385499"
CLOUDINARY_CLOUD_NAME="sea1jk51z"
CLOUDINARY_API_SECRET="mJd3bbkWa5mPVKuNBgCLxjY5FSM"
The above files does not contain any valid credentials and just demonstrate how a real .env file would look like in both cases.

Uploading the production variables to GitHub

If you are using the GitHub CI pipeline, make sure to set the variables in the GitHub repository settings. You can find them in the Settings tab, under Secrets and Variables > Actions section.

Secrets description:

  • ENV_FILE: An exact copy of the production .env file content.
  • SSH_KEY: The private SSH key that will be used to connect to the server. This assumes the public key is already in the server's authorized_keys file.

Variables description:

  • SSH_HOST: The host where the server is running.
  • SSH_USERNAME: The user that will be used to connect to the server.
  • SSH_REPO_PATH: The path where the repository is already cloned in the server.

Creating a new app user

Currently there is no way to create a new user from the API, you'll need direct database access to the internal schema.

There is a DB procedure internal.create_user exactly for this purpose.

\df internal.create_user

Its (current) definition is as follows:

CREATE OR REPLACE PROCEDURE internal.create_user(
    p_username citext,
    p_display_name varchar(255),
    p_password text,
    p_is_active boolean,
    p_is_staff boolean
)

For example, in the local dev container you would do the following:

$ psql 'postgres://admin:dontship@localhost/burrito_app'

burrito_app=# CALL internal.create_user('username', 'Display Name', 'pass123', true, true);

Changing the user password

There is a DB procedure internal.change_password for changing a user's password.

\df internal.change_password

For example, to change the password of the user created in the previous step:

$ psql 'postgres://admin:dontship@localhost/burrito_app'

burrito_app=# CALL internal.change_password('username', 'newpass123');

Verifying the user

Similarly, there is a DB function internal.get_auth_user for querying a user by its username and password.

\df internal.get_auth_user

For example, to verify the user created in the previous step:

$ psql 'postgres://admin:dontship@localhost/burrito_app'

burrito_app=# SELECT * FROM internal.get_auth_user('username', 'pass123');

This functions is used under the hood on app routes that require authentication.

Why this even exists?

So Postgres is responsible of hashing the password and storing it securely. The server is not responsible for hashing or verifying anything.

Using a mocked bus route

No bus driver? No problem! You can start the app with a mocked route by setting IS_MOCKED=true in the env file.

The mocked route will be read from static/mocks/*.json. See mock.rs for more details.

Once you have set IS_MOCKED=true, you can start the server as usual. Mocking works by sending POST /driver requests to ourselves, iterating over the mocked route records.

Why would I use this?

This feature is useful for showcasing the app without having to rely on a real bus driver, or testing purposes and MUST NOT be used in production.

Server deployment

The server repository contains all the necessary components to easily set up continuous integration and continuous deployment with just a VPS and GitHub Actions.

Setting up the VPS

The initial VPS setup should be straightforward to follow. Assuming you are in a Debian bookworm server with root privileges:

Dependencies

SSH authentication

  • Create a new user that you would use to deploy the server. Name it however you like

    # adduser burrito
    # usermod -aG sudo burrito
    
  • Generate a SSH key pair (or upload an existing one) for the new user

    # su burrito
    $ ssh-keygen -t ed25519 -C "admin@burrito" -f ~/.ssh/id_ed25519
    
  • Copy the public key to the server's authorized_keys file

    $ cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
    
  • Test the connection to the server

    $ ssh burrito@localhost
    

Repository setup

  • Now clone the server repository

    $ sudo mkdir -p /opt/burrito/repo
    $ git clone git@github.com:burrito/server.git /opt/burrito/repo
    
  • If you are using the GitHub CI pipeline, set the variables in the GitHub repository settings and you are ready to go. See Uploading the production variables to GitHub.

    Otherwise, manually populate an .env file.

TLS certificates

  • Carefully read the docker/prod/nginx/nginx.conf. It contains the server configuration and the paths to the TLS certificates.

  • Follow the instructions of your CA (Let's Encrypt, for example) to generate the certificates. You'll probably need to create a new DNS record pointing to the server's IP address or a challenge TXT record.

  • Generate a pair of TLS certificates using Let's Encrypt or any other CA. For example, using Certbot:

    # certbot certonly --standalone -d api.contigosanmarcos.com
    
  • Wherever domain you have used, make sure to update the DOMAIN_NAME variable in the docker-compose.prod.yml file.

  • If you want to save the certs in a custom path (such as /opt/burrito/certs) make sure to update the docker/prod/nginx/nginx.conf file.

  • Use a DNS propagation checker to check if your domain is correctly pointing to the server's IP address.

Starting the container

  • Start the server using Docker Compose

    $ cd /opt/burrito/repo
    $ docker compose -f docker-compose.prod.yml up --build
    
  • Every time you want to update the server, just pull the changes and restart the server

    $ cd /opt/burrito/repo
    $ git pull origin main
    $ docker compose -f docker-compose.prod.yml up --build
    

    Altough, it is strongly recommended to use GitHub Actions pipeline instead.

Production checks

Before deploying to production, make sure you have the following checks in place:

Bus Driver Application

Status: Production ready

A Flutter application that send the location, battery and status of the UNMSM bus transport in regular intervals to be consumed by application clients.

Construcción de la aplicación del conductor de bus

Para información más detallada, consulta la documentación oficial de Flutter.

Ten en cuenta que esta documentación es solo para Android. Aunque Flutter soporta completamente iOS, aún no se ha probado con este proyecto.

Hay un flujo de trabajo funcional llamado ios-compilation.yml en el directorio .github/workflows que puedes consultar.

Construcción del APK

Para construir APKs para múltiples arquitecturas (por ejemplo, ARM, ARM64, x86), utiliza el siguiente comando. Esto generará archivos APK separados para cada ABI (Interfaz Binaria de Aplicación), permitiendo a los usuarios descargar el APK adecuado para la arquitectura de su dispositivo:

flutter build apk --split-per-abi

Los APKs se guardarán en el directorio build/app/outputs/flutter-apk/. Puedes encontrar los APKs generados en esa carpeta, listos para pruebas o distribución.

Construir un paquete de la aplicación para la liberación

Además de construir los APKs, también es una buena práctica generar un Paquete de la Aplicación (.aab) para liberar la aplicación en la Google Play Store. El Paquete de la Aplicación contiene todo lo necesario para la distribución, y Google Play optimizará automáticamente la aplicación para diferentes configuraciones de dispositivos.

Para construir una versión de liberación del Paquete de la Aplicación, utiliza el siguiente comando:

flutter build appbundle --release

Una vez que la construcción esté completa, el archivo .aab estará disponible en el directorio build/app/outputs/bundle/release/. Puedes subir este archivo a la Google Play Console o cualquier otra tienda de aplicaciones que soporte Paquetes de Aplicación.

How the driver application works

Background location service

This app uses a background location service to continuously track the position of the bus, even when the app is in the background or when the screen is off. This service runs silently, ensuring that the bus-s location is sent to the server without requiring the user to have the app open.

Sending the location to the API

The app periodically sends the bus's GPS coordinates to a server. This is done through an API endpoint, which the app hits in the background.

Sending the battery of the phone

At the same time the app sends a coordinate to the server, it also sends the battery of the phone.

Simple button to start or end the service

The app includes a simple button that allows users to start or stop the background location service. When the service is started, the app will begin sending location updates to the server. When stopped, the app will no longer track or send location data, effectively ending the location tracking for the bus.

Change the state of the burrito

The app allows changing the state of the bus, referred to as the "burrito," between different operational statuses. These statuses represent the current condition of the bus:

  • En ruta ("In route"): The bus is on its way to its destination.
  • Fuera de servicio ("Out of service"): The bus is not operational or unavailable.
  • En descanso ("At rest"): The bus is temporarily stopped or idle.
  • Accidente ("Accident"): The bus has been involved in an accident.
  • Error ("Error"): There is an issue with the bus or its location data.
  • Cargando ("Loading"): The bus is being loaded or is in preparation.
  • Desconocido ("Unknown"): The bus status is not known.

Burrito Admin Dashboard

Status: production ready, but partially finished

Dashboard for staff/admin users to manage protected resources.

Features

  • Notifications: Add and delete app banners. No popup or posts are currently supported.
  • Battery: Quick view of the remaining app battery.

Written with React and Vite.

Dashboard deployment

The dashboard is a simple React Vite application so it should be straightforward to deploy.

Cloudflare Pages

With the help of GitHub Actions, the deployment is mostly automated. Ensure you have the following in place:

  1. Set up GitHub Secrets: Add your CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in your GitHub repository secrets.

  2. Verify the GitHub Actions Workflow: Ensure the workflow file is properly configured to build and deploy the application. The configuration is in .github/workflows/cloudflare-deploy.yml

Deployed at https://burrito-dashboard.pages.dev/

Additional notes

Before deploying the dashboard, make sure to:

  • Ensure that a user account with the appropriate permissions is created in the database. This allows access control and ensures that each user can interact with the dashboard as expected.

  • Verify that the backend server is running and accessible. The dashboard relies on the server for data retrieval and updates, so a live connection is essential for full functionality.