Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions docs/clean-air-forum-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Clean Air Forum selfie feature — follow-ups for other teams

This feature (mobile selfie filter/sticker + `/selfies` conference wall
display) was built as a **temporary, fully client-side-and-mock-API
implementation** so it could be demoed end-to-end before any backend or
infra work existed for it. Everything below is scaffolding, explicitly
marked `TEMPORARY` in code, and needs real ownership before/soon after the
event it launches with.

Related PR: [#3753](https://github.com/airqo-platform/AirQo-frontend/pull/3753)

## 1. Backend team — replace the mock submissions API

**What exists today**: `src/website/src/services/cleanAirForumSelfiesStore.ts`
is a plain in-memory array behind `src/website/src/app/api/clean-air-forum/selfies/`
(`GET`/`POST`) and `.../selfies/[id]/` (`PATCH` to hide). It:

- Resets on every redeploy and isn't shared across replicas — a submission
can vanish mid-event if the website pod restarts or scales out.
- Has no real auth beyond a shared secret/PIN (see §3) — fine for a
short-lived event, not something to keep long-term.

**What's needed**: equivalent endpoints on the real AirQo backend, with
persistent storage (even a simple table is enough — submissions are
small: image URL + AQI metadata + timestamp + hidden flag). The
request/response shapes in the mock were deliberately kept simple so they
can carry over almost unchanged:

- `POST /selfies` — body `{ eventId, imageUrl, locationName?, pm25Value?, aqiCategory?, displayName? }` → `201 { submission }`
- `GET /selfies?eventId=...` — → `200 { submissions: [...] }`, newest first, hidden ones excluded
- `PATCH /selfies/:id` — hides (soft-deletes) a submission, used by wall moderation

Once these exist, the two Next.js routes above should just proxy to them
(or be deleted in favor of calling the real API directly from
`SelfiesWallPage.tsx` and the mobile app).

## 2. Cloudinary / whoever owns that account — harden the upload preset

Selfies are uploaded directly from the mobile app to Cloudinary using an
**unsigned upload preset** (`NEXT_PUBLIC_CLOUDINARY_NAME` /
`NEXT_PUBLIC_CLOUDINARY_PRESET`, read in
`src/mobile/lib/src/app/shared/services/clean_air_forum_submission_service.dart`).
Unsigned presets are bundled in the app binary by design (that's how
unsigned uploads work), so anyone who decompiles the app can extract the
preset name and cloud name and upload directly to this Cloudinary account.

Please apply, at minimum, on the `clean_air_forum_selfies` preset:

- **Folder lock** — restrict the preset to only write into
`clean_air_forum_selfies/` (prevents overwriting/polluting other
folders in the account).
- **File size / format limits** — cap to a few MB, images only.
- **Moderation** — consider enabling Cloudinary's built-in moderation
add-on (or at least manual review capability) given this feeds a public
venue display.
- Longer-term: move to a **signed upload** flow (mobile app calls our own
backend for a signature, backend calls Cloudinary) once the real backend
from §1 exists — this closes the preset-exposure issue completely
instead of just mitigating it.

The website route already validates that submitted `imageUrl`s are
`https://res.cloudinary.com/.../clean_air_forum_selfies/...` before
accepting them (defense in depth), but that alone doesn't stop someone
with the preset from uploading directly to Cloudinary — it only stops them
from getting arbitrary *other* URLs onto the wall via our API.

## 3. DevOps / whoever manages GitHub Actions secrets — wire up new env vars

Two new secrets need to be created and referenced in the deploy workflows
(`.github/workflows/deploy-frontends-to-staging.yml`,
`deploy-frontends-to-production.yml`, the Azure equivalents, and
`deploy-frontend-pr-previews.yml` if selfie testing on PR previews matters).
These are **server-only** vars (no `NEXT_PUBLIC_` prefix), so they only need
adding to each workflow's `.env.yaml` generation step (same pattern as
`SLACK_WEBHOOK_URL`) — no Docker `--build-arg` needed.

| Env var | Where it's used | What it does |
|---|---|---|
| `CLEAN_AIR_FORUM_WALL_PIN` | `src/website/src/app/api/clean-air-forum/selfies/[id]/route.ts` | Staff PIN required to remove a photo from the wall via double-tap/long-press |
| `CLEAN_AIR_FORUM_SUBMISSION_SECRET` | `src/website/src/app/api/clean-air-forum/selfies/route.ts` | Shared secret the mobile app sends (`x-clean-air-forum-secret` header) so only the app can post to the wall |

**Important**: both checks now **fail closed in production** if the
corresponding env var is missing (they only fail open in
development/preview, for local testing convenience) — see
`src/website/src/services/cleanAirForumAuth.ts`. If these aren't
configured before a production deploy, moderation and/or submissions will
be rejected outright (with a `console.warn` in the server logs), not
silently left open. **Please set both before the forum goes live in
production.**

The mobile app also needs a matching secret in its own env
(`CLEAN_AIR_FORUM_API_SECRET` in `src/mobile/.env.prod` /
`.env.dev` — not committed to the repo) equal to whatever
`CLEAN_AIR_FORUM_SUBMISSION_SECRET` is set to on the website side.

## 4. Rate limiting — needs a real (distributed) solution

`route.ts` currently has a best-effort **in-memory, per-server-instance**
rate limit (5 submissions/IP/minute) as a stopgap — it resets per instance
and doesn't hold across replicas, so it only blunts casual spam, not a
determined abuser hitting a load-balanced deployment. If abuse becomes a
real concern for the event, this needs a proper distributed rate limiter
(e.g. Cloudflare in front of the site, or a Redis/Upstash-backed limiter)
— that's an infra decision beyond what this PR can set up.

## 5. Content moderation policy

Right now, moderation is manual only: a staff member double-taps or
long-presses a bad photo on the wall display itself and enters a shared
PIN. There's no automated image moderation (nudity/violence detection,
etc.). Given this is a public-facing conference wall, it may be worth
asking whoever owns Cloudinary to enable an automated moderation add-on
(see §2) as a first line of defense, with manual removal as the backstop.

## Real event details still needed

`src/mobile/lib/src/app/dashboard/utils/clean_air_forum_branding.dart`
has `edition` and `dateRange` set to `'Pretoria 2026'` and
`'13TH-16TH JULY'` based on the Figma design — please confirm these are
the actual confirmed host city/year and dates before the forum, or update
them.
4 changes: 2 additions & 2 deletions src/mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ if (releaseKeystorePropertiesFile.exists()) {
android {
namespace "com.airqo.app"
compileSdk 36
ndkVersion flutter.ndkVersion
ndkVersion "27.0.12077973"

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_9
Expand All @@ -98,7 +98,7 @@ android {
applicationId "com.airqo.app"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion flutter.minSdkVersion
minSdkVersion 24
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
Expand Down
13 changes: 13 additions & 0 deletions src/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
<application
android:label="AirQo"
android:name="${applicationName}"
Expand Down Expand Up @@ -43,6 +46,16 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Required by pasteboard to write images to the Android clipboard. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
Expand Down
13 changes: 13 additions & 0 deletions src/mobile/android/app/src/main/res/xml/provider_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- pasteboard's writeImage writes to context.cacheDir, which needs a
cache-path entry — the external-path alone (from pasteboard's own
README) doesn't cover it, causing FileProvider to throw "failed to
find configured root" for that file. -->
<cache-path
name="cache"
path="." />
<external-path
name="external_files"
path="." />
</paths>
4 changes: 4 additions & 0 deletions src/mobile/assets/icons/camera.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/check-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/copy.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/icons/gallery.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/mobile/assets/images/shared/airqo_icon_mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
94 changes: 94 additions & 0 deletions src/mobile/lib/src/app/dashboard/utils/air_quality_card_utils.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'dart:convert';

import 'package:airqo/src/app/dashboard/models/airquality_response.dart';
import 'package:airqo/src/meta/utils/colors.dart';
import 'package:flutter/material.dart';

/// Shared rendering helpers for the air-quality share/filter/sticker cards so
/// they all present identical values (color, category label, sanitized
/// text) without duplicating logic in every widget.

/// Fixes mojibake that occasionally shows up in API text fields.
String sanitizeCardText(String value) {
if (!value.contains('Ã') && !value.contains('Â') && !value.contains('â')) {
return value;
}

try {
return utf8.decode(latin1.encode(value));
} catch (_) {
return value;
}
}

/// Resolves the AQI accent color for a measurement, preferring the color
/// returned by the API and falling back to a category lookup.
Color getMeasurementAqiColor(Measurement measurement) {
if (measurement.aqiColor != null) {
try {
final colorString = measurement.aqiColor!.replaceAll('#', '');
return Color(int.parse('0xFF$colorString'));
} catch (_) {}
}

switch (measurement.aqiCategory?.toLowerCase() ?? '') {
case 'good':
return const Color(0xFF179D5B);
case 'moderate':
return const Color(0xFFC79000);
case 'unhealthy for sensitive groups':
case 'u4sg':
return const Color(0xFFE17827);
case 'unhealthy':
return const Color(0xFFD63A45);
case 'very unhealthy':
return const Color(0xFF7540B5);
case 'hazardous':
return const Color(0xFF6D4C41);
default:
return AppColors.primaryColor;
}
}

/// Shortens verbose AQI category labels so they fit on compact card pills.
String aqiCategoryLabel(String category) {
switch (category.toLowerCase()) {
case 'unhealthy for sensitive groups':
return 'Sensitive Groups';
case 'very unhealthy':
return 'Very Unhealthy';
default:
return category;
}
}

/// Light pastel background for an AQI status pill, derived by blending the
/// category color into white — matches the Figma "Label/Status/Good"
/// component style (solid pale bg + solid saturated text) rather than a
/// translucent overlay, which reads as muddy over photos.
Color aqiPillBackground(Color categoryColor) {
return Color.alphaBlend(categoryColor.withValues(alpha: 0.18), Colors.white);
}

/// Resolves the circular AQI "wheel" icon (ring + face) used on the share
/// card, filter, and sticker templates — same asset set used across the
/// dashboard and map so the icon always matches the category color.
String getMeasurementAqiIconAsset(Measurement measurement) {
switch (measurement.aqiCategory?.toLowerCase() ?? '') {
case 'good':
return 'assets/images/shared/airquality_indicators/good.svg';
case 'moderate':
return 'assets/images/shared/airquality_indicators/moderate.svg';
case 'unhealthy for sensitive groups':
case 'u4sg':
return 'assets/images/shared/airquality_indicators/unhealthy-sensitive.svg';
case 'unhealthy':
return 'assets/images/shared/airquality_indicators/unhealthy.svg';
case 'very unhealthy':
return 'assets/images/shared/airquality_indicators/very-unhealthy.svg';
case 'hazardous':
return 'assets/images/shared/airquality_indicators/hazardous.svg';
default:
return 'assets/images/shared/airquality_indicators/unavailable.svg';
}
}
Loading
Loading