Scouting App: Add ability to submit data with QR Codes

This patch adds the option for a scout to generate a QR Code
storing the collected data instead of submitting it. Next,
another user can submit the data by scanning the QR Code using the
new 'Scan' tab.

Since flatbuffers are pretty inefficient in terms of space usage, we
have trouble fitting all the data into a single QR code. The app
allows the user to split the data into multiple QR codes which have to
be scanned.

I tried a bunch of upstream QR code scanning libraries, but they're
all either unmaintained, not open source, or just don't work well. I
ended up settling on OpenCV because it was the most reliable library I
could find.

Co-Authored-By: Filip Kujawa <filip.j.kujawa@gmail.com>
Signed-off-by: Filip Kujawa <filip.j.kujawa@gmail.com>
Signed-off-by: Philipp Schrader <philipp.schrader+971@gmail.com>
Change-Id: I794b54bf7e8389200aa2abe8d05f622a987bca9c
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 98b457b..48732f9 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -10,6 +10,10 @@
     ],
     deps = [
         ":node_modules/@angular/forms",
+        "//:node_modules/@angular/platform-browser",
+        "//:node_modules/@types/pako",
+        "//:node_modules/angularx-qrcode",
+        "//:node_modules/pako",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_2024_actions_ts_fbs",
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index cd208b9..e646a25 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -28,3 +28,43 @@
   width: 90vw;
   justify-content: space-between;
 }
+
+.qr-container {
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  padding: 0px;
+}
+
+.qrcode-buttons > li.page-item {
+  padding: 0px;
+}
+
+/* Using deprecated ::ng-deep here, but couldn't find a better way to do it.
+ * The qrcode container generates a canvas without any style markers or
+ * classes. Angular's view encapsulation prevents this style from applying.
+ * Maybe https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted ? */
+:host ::ng-deep .qr-container canvas {
+  /* Make the QR code take up as much space as possible. Can't take up more
+   * than the screen size, however, because you can't scan a QR code while
+   * scrolling. It needs to be fully visibile. */
+  width: 100% !important;
+  height: 100% !important;
+  aspect-ratio: 1 / 1;
+}
+
+/* Make the UI a little more compact. The QR code itself already has a good
+ * amount of margin. */
+
+.qrcode-nav {
+  padding: 0px;
+}
+
+.qrcode-buttons {
+  padding: 0px;
+  margin: 0px;
+}
+
+.qrcode {
+  padding: 0px;
+}
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 6da8be8..09b1a8b 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -26,6 +26,7 @@
 } from '../../webserver/requests/messages/submit_2024_actions_generated';
 import {Match} from '../../webserver/requests/messages/request_all_matches_response_generated';
 import {MatchListRequestor} from '../rpc';
+import * as pako from 'pako';
 
 type Section =
   | 'Team Selection'
@@ -35,6 +36,7 @@
   | 'Endgame'
   | 'Dead'
   | 'Review and Submit'
+  | 'QR Code'
   | 'Success';
 
 // TODO(phil): Deduplicate with match_list.component.ts.
@@ -50,6 +52,13 @@
   f: 'Finals',
 };
 
+// The maximum number of bytes per QR code. The user can adjust this value to
+// make the QR code contain less information, but easier to scan.
+const QR_CODE_PIECE_SIZES = [150, 300, 450, 600, 750, 900];
+
+// The default index into QR_CODE_PIECE_SIZES.
+const DEFAULT_QR_CODE_PIECE_SIZE_INDEX = QR_CODE_PIECE_SIZES.indexOf(750);
+
 type ActionT =
   | {
       type: 'startMatchAction';
@@ -112,6 +121,7 @@
   // of radio buttons.
   readonly COMP_LEVELS = COMP_LEVELS;
   readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
+  readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
   readonly ScoreType = ScoreType;
   readonly StageType = StageType;
 
@@ -136,6 +146,16 @@
 
   teamSelectionIsValid = false;
 
+  // When the user chooses to generate QR codes, we convert the flatbuffer into
+  // a long string. Since we frequently have more data than we can display in a
+  // single QR code, we break the data into multiple QR codes. The data for
+  // each QR code ("pieces") is stored in the `qrCodeValuePieces` list below.
+  // The `qrCodeValueIndex` keeps track of which QR code we're currently
+  // displaying.
+  qrCodeValuePieceSize = QR_CODE_PIECE_SIZES[DEFAULT_QR_CODE_PIECE_SIZE_INDEX];
+  qrCodeValuePieces: string[] = [];
+  qrCodeValueIndex: number = 0;
+
   constructor(private readonly matchListRequestor: MatchListRequestor) {}
 
   ngOnInit() {
@@ -219,7 +239,8 @@
     }
 
     if (action.type == 'endMatchAction') {
-      // endMatchAction occurs at the same time as penaltyAction so add to its timestamp to make it unique.
+      // endMatchAction occurs at the same time as penaltyAction so add to its
+      // timestamp to make it unique.
       action.timestamp += 1;
     }
 
@@ -282,6 +303,11 @@
     this.errorMessage = '';
     this.progressMessage = '';
 
+    // For the QR code screen, we need to make the value to encode available.
+    if (target == 'QR Code') {
+      this.updateQrCodeValuePieceSize();
+    }
+
     this.section = target;
   }
 
@@ -291,7 +317,7 @@
     this.header.nativeElement.scrollIntoView();
   }
 
-  async submit2024Actions() {
+  createActionsBuffer() {
     const builder = new Builder();
     const actionOffsets: number[] = [];
 
@@ -419,10 +445,44 @@
     Submit2024Actions.addPreScouting(builder, this.preScouting);
     builder.finish(Submit2024Actions.endSubmit2024Actions(builder));
 
-    const buffer = builder.asUint8Array();
+    return builder.asUint8Array();
+  }
+
+  // Same as createActionsBuffer, but encoded as Base64. It's also split into
+  // a number of pieces so that each piece is roughly limited to
+  // `qrCodeValuePieceSize` bytes.
+  createBase64ActionsBuffers(): string[] {
+    const originalBuffer = this.createActionsBuffer();
+    const deflatedData = pako.deflate(originalBuffer, {level: 9});
+
+    const pieceSize = this.qrCodeValuePieceSize;
+    const fullValue = btoa(String.fromCharCode(...deflatedData));
+    const numPieces = Math.ceil(fullValue.length / pieceSize);
+
+    let splitData: string[] = [];
+    for (let i = 0; i < numPieces; i++) {
+      const splitPiece = fullValue.slice(i * pieceSize, (i + 1) * pieceSize);
+      splitData.push(`${i}_${numPieces}_${pieceSize}_${splitPiece}`);
+    }
+    return splitData;
+  }
+
+  setQrCodeValueIndex(index: number) {
+    this.qrCodeValueIndex = Math.max(
+      0,
+      Math.min(index, this.qrCodeValuePieces.length - 1)
+    );
+  }
+
+  updateQrCodeValuePieceSize() {
+    this.qrCodeValuePieces = this.createBase64ActionsBuffers();
+    this.qrCodeValueIndex = 0;
+  }
+
+  async submit2024Actions() {
     const res = await fetch('/requests/submit/submit_2024_actions', {
       method: 'POST',
-      body: buffer,
+      body: this.createActionsBuffer(),
     });
 
     if (res.ok) {
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index c74d4d0..bcba4ee 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -2,10 +2,11 @@
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
 import {EntryComponent} from './entry.component';
+import {QRCodeModule} from 'angularx-qrcode';
 
 @NgModule({
   declarations: [EntryComponent],
   exports: [EntryComponent],
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, QRCodeModule],
 })
 export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 22d2d27..139268f 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -476,6 +476,9 @@
     </div>
     <div class="d-grid gap-5">
       <button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
+      <button class="btn btn-info" (click)="changeSectionTo('QR Code');">
+        Create QR Code
+      </button>
       <button class="btn btn-warning" (click)="submit2024Actions();">
         Submit
       </button>
@@ -484,6 +487,75 @@
   <div *ngSwitchCase="'Success'" id="Success" class="container-fluid">
     <h2>Successfully submitted data.</h2>
   </div>
+  <div *ngSwitchCase="'QR Code'" id="QR Code" class="container-fluid">
+    <span>Density:</span>
+    <select
+      [(ngModel)]="qrCodeValuePieceSize"
+      (ngModelChange)="updateQrCodeValuePieceSize()"
+      type="number"
+      id="qr_code_piece_size"
+    >
+      <option
+        *ngFor="let pieceSize of QR_CODE_PIECE_SIZES"
+        [ngValue]="pieceSize"
+      >
+        {{pieceSize}}
+      </option>
+    </select>
+    <div class="qr-container">
+      <qrcode
+        [qrdata]="qrCodeValuePieces[qrCodeValueIndex]"
+        [width]="1000"
+        [errorCorrectionLevel]="'M'"
+        [margin]="6"
+        class="qrcode"
+      ></qrcode>
+    </div>
+    <nav class="qrcode-nav">
+      <ul
+        class="qrcode-buttons pagination pagination-lg justify-content-center"
+      >
+        <li class="page-item">
+          <a
+            class="page-link"
+            href="#"
+            aria-label="Previous"
+            (click)="setQrCodeValueIndex(qrCodeValueIndex - 1)"
+          >
+            <span aria-hidden="true">&laquo;</span>
+            <span class="visually-hidden">Previous</span>
+          </a>
+        </li>
+        <li *ngFor="let _ of qrCodeValuePieces; index as i" class="page-item">
+          <a
+            class="page-link"
+            href="#"
+            (click)="setQrCodeValueIndex(i)"
+            [class.active]="qrCodeValueIndex == i"
+          >
+            {{i + 1}}
+          </a>
+        </li>
+        <li class="page-item">
+          <a
+            class="page-link"
+            href="#"
+            aria-label="Next"
+            (click)="setQrCodeValueIndex(qrCodeValueIndex + 1)"
+          >
+            <span aria-hidden="true">&raquo;</span>
+            <span class="visually-hidden">Next</span>
+          </a>
+        </li>
+      </ul>
+    </nav>
+    <button
+      class="btn btn-secondary"
+      (click)="changeSectionTo('Review and Submit')"
+    >
+      BACK
+    </button>
+  </div>
 
   <span class="progress_message" role="alert">{{ progressMessage }}</span>
   <span class="error_message" role="alert">{{ errorMessage }}</span>
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index d37ce10..a02c0a6 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -2,6 +2,8 @@
     "name": "@org_frc971/scouting/www/entry",
     "private": true,
     "dependencies": {
+        "pako": "2.1.0",
+        "@types/pako": "2.0.3",
         "@org_frc971/scouting/www/counter_button": "workspace:*",
         "@angular/forms": "v16-lts"
     }