Prepare Android launch candidate and harden discovery

This commit is contained in:
SimpleTest 2026-08-01 00:47:05 +03:00
parent 040889de1a
commit d28a71bc6b
49 changed files with 919 additions and 380 deletions

View File

@ -1,5 +1,7 @@
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic
ARG X_TEXT_VERSION=v0.40.0
ENV GOTOOLCHAIN=local
WORKDIR /src
@ -7,7 +9,9 @@ WORKDIR /src
RUN go mod init who-need-help/restic-build \
&& go get github.com/restic/restic/cmd/restic@v0.19.1 \
&& go get google.golang.org/grpc@v1.82.1 \
&& go get "golang.org/x/text@${X_TEXT_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \
&& CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w" \

View File

@ -5,15 +5,21 @@ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff9
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=local
ARG CADDY_VERSION=v2.11.4
ARG GRPC_GO_VERSION=v1.82.1
ARG X_TEXT_VERSION=v0.40.0
WORKDIR /src
RUN go mod init who-need-help/caddy-build \
&& go get github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4 \
&& go get google.golang.org/grpc@v1.82.1 \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& go get "github.com/caddyserver/caddy/v2/cmd/caddy@${CADDY_VERSION}" \
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
&& go get "golang.org/x/text@${X_TEXT_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "$X_TEXT_VERSION" \
&& go build \
-trimpath \
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4-wnh-grpc1.82.1" \
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=${CADDY_VERSION}-wnh-grpc1.82.1-xtext0.40.0" \
-o /out/caddy \
github.com/caddyserver/caddy/v2/cmd/caddy

View File

@ -1,5 +1,9 @@
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS minio_builder
ARG X_TEXT_VERSION=v0.40.0
ARG X_CRYPTO_VERSION=v0.54.0
ARG X_NET_VERSION=v0.57.0
ADD --checksum=sha256:45521908307306e925c98d629e1c17d78c8b72b6ee242b1bfb1409f7d8ee5841 \
https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \
/tmp/minio.tar.gz
@ -12,10 +16,14 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \
github.com/apache/thrift@v0.23.0 \
github.com/buger/jsonparser@v1.1.2 \
github.com/prometheus/prometheus@v0.311.3 \
golang.org/x/crypto@v0.52.0 \
golang.org/x/net@v0.55.0 \
golang.org/x/crypto@${X_CRYPTO_VERSION} \
golang.org/x/net@${X_NET_VERSION} \
golang.org/x/text@${X_TEXT_VERSION} \
google.golang.org/grpc@v1.82.1 \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/crypto)" = "${X_CRYPTO_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \
&& CGO_ENABLED=0 go build \
-mod=mod \
-trimpath \
@ -30,6 +38,10 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS mc_builder
ARG X_TEXT_VERSION=v0.40.0
ARG X_CRYPTO_VERSION=v0.54.0
ARG X_NET_VERSION=v0.57.0
ADD --checksum=sha256:95cd293c7119f16921a6dc515a1fb74a2227f19fd994b9c8b770a154e802ac44 \
https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \
/tmp/mc.tar.gz
@ -40,11 +52,15 @@ RUN tar --extract --gzip --file /tmp/mc.tar.gz \
--directory . --strip-components=1 \
&& go get \
github.com/prometheus/prometheus@v0.311.3 \
golang.org/x/crypto@v0.52.0 \
golang.org/x/net@v0.55.0 \
golang.org/x/crypto@${X_CRYPTO_VERSION} \
golang.org/x/net@${X_NET_VERSION} \
golang.org/x/text@${X_TEXT_VERSION} \
google.golang.org/grpc@v1.82.1 \
&& go mod tidy \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/crypto)" = "${X_CRYPTO_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \
&& CGO_ENABLED=0 go build \
-trimpath \
-tags kqueue \

View File

@ -2,18 +2,18 @@
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
ARG TRAEFIK_VERSION=v3.7.8
ARG TRAEFIK_SOURCE_SHA256=3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9
ARG TRAEFIK_BUILD_DATE=2026-07-15_12:51:10PM
ARG TRAEFIK_VERSION=v3.7.10
ARG TRAEFIK_SOURCE_SHA256=31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6
ARG TRAEFIK_BUILD_DATE=2026-07-31_12:49:21PM
ARG GRPC_GO_VERSION=v1.82.1
ADD --checksum=sha256:3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9 \
https://github.com/traefik/traefik/releases/download/v3.7.8/traefik-v3.7.8.src.tar.gz \
ADD --checksum=sha256:31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6 \
https://github.com/traefik/traefik/releases/download/v3.7.10/traefik-v3.7.10.src.tar.gz \
/tmp/traefik.tar.gz
WORKDIR /src
RUN test "$TRAEFIK_SOURCE_SHA256" = "3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9" \
RUN test "$TRAEFIK_SOURCE_SHA256" = "31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6" \
&& tar -xzf /tmp/traefik.tar.gz --strip-components=1 \
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \

View File

@ -128,9 +128,10 @@ Compose starts Traefik, PostGIS, Mailpit, a migration runner, 2 web replicas,
and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message
broadcast from a different BEAM node. Registration emails appear in Mailpit.
The Traefik image is reproducibly built from the checksum-pinned upstream
`v3.7.8` source with `grpc-go 1.82.1`. The Caddy edge, backup image, and optional
MinIO images build upstream Caddy `v2.11.4`, restic `v0.19.1`, MinIO, and `mc`
with the same dependency pin. Those upstream dependency graphs still contain
`v3.7.10` source with `grpc-go 1.82.1`. The Caddy edge, backup image, and optional
MinIO images build upstream Caddy `v2.11.4` (with `grpc-go 1.82.1` and
`golang.org/x/text 0.40.0`), restic `v0.19.1`, MinIO, and `mc` with the same
dependency pin. Those upstream dependency graphs still contain
older `grpc-go` versions affected by
[GHSA-hrxh-6v49-42gf](https://github.com/advisories/GHSA-hrxh-6v49-42gf).

View File

@ -42,4 +42,8 @@ final class LaunchUrlResolver {
return null;
}
static boolean shouldLoadIncomingUrl(String currentUrl, String candidateUrl) {
return candidateUrl != null && !candidateUrl.equals(currentUrl);
}
}

View File

@ -259,7 +259,10 @@ public final class MainActivity extends ComponentActivity {
BuildConfig.DEBUG
);
if (candidate != null && webView != null) {
if (
webView != null &&
LaunchUrlResolver.shouldLoadIncomingUrl(webView.getUrl(), candidate)
) {
webView.loadUrl(candidate);
}
}

View File

@ -1,7 +1,9 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
@ -76,4 +78,26 @@ public final class LaunchUrlResolverTest {
)
);
}
@Test
public void incomingIntentDoesNotReloadThePageAlreadyShown() {
assertFalse(
LaunchUrlResolver.shouldLoadIncomingUrl(
"https://help.example/requests/123",
"https://help.example/requests/123"
)
);
assertTrue(
LaunchUrlResolver.shouldLoadIncomingUrl(
"https://help.example/requests/123",
"https://help.example/activities/456"
)
);
assertFalse(
LaunchUrlResolver.shouldLoadIncomingUrl(
"https://help.example/requests/123",
null
)
);
}
}

View File

@ -6,7 +6,9 @@ current Play form immediately before submission.
## Form-level answers
- Does the app collect or share required user data types? **Yes, collects.**
- Does the app collect or share required user data types? **Yes, collects and
shares.** The conservative sharing declaration is required by the current
direct OpenStreetMap tile integration described below.
- Is all user data encrypted in transit? **Yes.** Production app traffic uses
HTTPS; Firebase Cloud Messaging also uses encrypted transport.
- Can users request deletion? **Yes.**
@ -61,13 +63,30 @@ The current implementation sends data to:
participant, sending a message, or starting live sharing.
Google Play excludes some service-provider transfers and user-initiated sharing
from the “shared” declaration. The production map-tile provider and its
contractual/service-provider status are not yet confirmed, so the exact
top-level “shared” answer is currently **unknown**. Do not submit the form until
the final provider, its terms/data-processing role, and the exact release
network trace have been reviewed against the definitions shown by the current
Play form. If no exemption applies, declare the applicable device/network data
as shared.
from the “shared” declaration. The current default production configuration
loads raster tiles directly from `tile.openstreetmap.org`; OSMF is an independent
third party and there is no verified service-provider agreement under which it
processes data solely on behalf of Who Need Help. OSMF's current privacy policy
says that requests to its services produce records including IP address,
browser/device type, operating system, referrer, time, and requested pages.
At detailed zoom levels, requested tile coordinates can also describe an area
smaller than 3 km².
Until the release uses a separately verified provider relationship, answer the
top-level sharing question **Yes** and conservatively declare these current
direct tile transfers:
- **Approximate location — shared, optional, app functionality.**
- **Precise location — shared, optional, app functionality.** This applies when
a user opens a detailed map around an exact or live point.
- **Device or other IDs — shared, required while maps are used, app
functionality.** This is the conservative classification for the network and
browser/application identifiers recorded by OSMF.
This is a disclosure choice, not permission to send private request text,
messages, email, handover codes, or raw live-location API payloads to the tile
provider; the current tile requests must remain limited to standard tile
coordinates and ordinary HTTP request metadata.
## Source checks before every release
@ -76,7 +95,8 @@ as shared.
2. Confirm that Analytics, Crashlytics, ads, and delivery-metrics export remain
absent or update the declaration.
3. Confirm the final map-tile provider, provider agreement, request metadata,
and Play sharing classification.
and Play sharing classification. If the direct OSMF integration remains,
keep the conservative sharing declarations above.
4. Compare with `/privacy`, `/account/delete`, Android manifest permissions, and
the live-location prominent disclosure.
5. Confirm the external deletion URL loads without authentication and submits a
@ -84,9 +104,29 @@ as shared.
6. Update the worksheet if media uploads, avatars, payments, analytics, or any
new SDK is introduced.
## 2026-07-31 release-candidate verification
- `releaseRuntimeClasspath` contains Firebase Cloud Messaging 25.1.1 and its
Firebase Installations dependency. It does not contain the Firebase
Analytics, Crashlytics, Performance Monitoring, or advertising SDKs.
- The manifest keeps FCM auto-initialization and Firebase Analytics collection
disabled. Push registration is enabled only after the user requests it in the
product UI.
- No call enabling BigQuery message-delivery export was found in the Android
source.
- Firebase's current Android disclosure reference says FCM automatically
collects the app version and Firebase user agent, while Firebase
Installations generates and collects a per-installation FID. The device-ID
row above conservatively accounts for the installation identifier.
- The exact dependency report is generated locally during release validation
and intentionally is not treated as a permanent substitute for re-checking
the final AAB and current Google Play form.
## Official references
- https://support.google.com/googleplay/android-developer/answer/10787469
- https://support.google.com/googleplay/android-developer/answer/13327111
- https://firebase.google.com/docs/android/play-data-disclosure
- https://firebase.google.com/support/privacy/
- https://operations.osmfoundation.org/policies/tiles/
- https://osmfoundation.org/wiki/Privacy_Policy

View File

@ -2,8 +2,8 @@
## External account gate
- [ ] Google Play developer identity verification approved.
- [ ] Contact phone verification completed.
- [x] Google Play developer identity verification approved.
- [x] Contact phone verification completed.
- [ ] Play Console enables **Create app**.
## App identity and signing
@ -22,12 +22,12 @@
## Build
- [x] Build from the exact committed candidate with the validated Android
- [ ] Build from the exact committed candidate with the validated Android
allow-list read from the production checkout’s single `.env`.
- [x] Run `scripts/android-release-build.sh`.
- [x] Verify source fingerprint, signing certificate, bundletool validation,
- [ ] Run `scripts/android-release-build.sh`.
- [ ] Verify source fingerprint, signing certificate, bundletool validation,
lint, package name, version code/name, target SDK, and production origin.
- [x] Install the universal APK generated from the same AAB on the authorized
- [ ] Install the universal APK generated from the same AAB on the authorized
physical phone and run the release smoke test.
- [ ] Upload the source-bound AAB first to internal testing.

View File

@ -17,16 +17,19 @@ Suggested alt text:
## Screenshots
The four files in `screenshots/phone/` were captured from the development
Android client on a physical 1220×2712 device and cropped without stretching to
1080×1920. They contain only public examples or synthetic E2E data:
The four files in `screenshots/phone/` were captured on 2026-07-31 from the
development Android client on the authorised physical 1220×2712 device. The
captures were cropped to a 9:16 frame and proportionally scaled to 1080×1920.
They are 24-bit RGB PNG files without alpha and contain only public example
content or an unsubmitted empty form:
1. `01-home.png` — public product explanation and example medicine request.
2. `02-discovery.png` — authenticated request discovery and filters.
3. `03-private-request.png` — completed synthetic request, double-blind review,
and approximate request area.
4. `04-activity.png` — approved synthetic group chat and exact meeting point
disclosed to an approved participant.
2. `02-request-form.png` — the first step of the urgent-help request flow and
its safety notice.
3. `03-location-privacy.png` — an unsubmitted approximate-area selection with
a draggable privacy circle and radius controls.
4. `04-category-proposals.png` — the community category and subcategory
proposal flow.
The uncropped ADB source captures are intentionally kept outside Git. No real
email, real user message, notification, medical detail, or exact real-user
@ -39,9 +42,9 @@ privacy, or map behavior change.
Suggested English alt text:
1. `Who Need Help home screen explaining nearby voluntary assistance.`
2. `Nearby help request discovery with category, urgency, and area filters.`
3. `Completed synthetic request with double-blind review and approximate map area.`
4. `Approved synthetic activity group chat with its disclosed meeting point.`
2. `Urgent-help request form with clear steps and a non-emergency safety notice.`
3. `Approximate-area map with a movable privacy circle and selectable radius.`
4. `Community form for proposing a new help category or subcategory.`
Official asset requirements:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@ -344,42 +344,6 @@ html {
white-space: nowrap;
}
.request-map-cluster {
display: grid;
width: 2.75rem;
height: 2.75rem;
padding: 0.2rem;
place-items: center;
color: white;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
border: 3px solid white;
border-radius: 9999px;
background: #16725b;
box-shadow: 0 3px 12px rgb(0 0 0 / 24%);
cursor: pointer;
}
.request-map-cluster.is-lg {
width: 3.25rem;
height: 3.25rem;
}
.request-map-cluster.is-xl {
width: 3.5rem;
height: 3.5rem;
}
.request-map-cluster.is-xxl {
width: 3.75rem;
height: 3.75rem;
}
.request-map-cluster.is-urgent {
background: #b63b21;
}
@media (max-width: 56rem) {
.request-discovery-toolbar {
align-items: stretch;
@ -411,11 +375,6 @@ html {
}
}
.request-map-cluster:hover,
.request-map-cluster:focus-visible {
transform: scale(1.08);
}
.maplibregl-marker.is-selected svg {
filter: drop-shadow(0 0 5px #d6573b) drop-shadow(0 0 8px white);
transform: scale(1.12);
@ -449,6 +408,12 @@ html {
height: 2.75rem;
}
.aid-map .maplibregl-ctrl-attrib-button {
background-position: center;
background-repeat: no-repeat;
background-size: 1.5rem 1.5rem;
}
.request-discovery-toolbar {
align-items: stretch;
flex-direction: column;
@ -472,6 +437,15 @@ html {
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
position: static;
order: -1;
width: 100%;
align-self: stretch;
flex: 0 0 auto;
}
.request-discovery[data-view-mode="split"] .request-results-list,
.request-discovery[data-view-mode="split"] .request-discovery-map-shell {
width: 100%;
min-width: 0;
}
.request-discovery-map,

View File

@ -1,7 +1,8 @@
import maplibregl from "maplibre-gl"
import {
clusterCountLabel,
clusterExpansionTarget,
clusterExpansionCenter,
clusterIconDescriptor,
pointFeatureCollection,
pointSourceOptions,
serverClusterDescriptor
@ -117,6 +118,54 @@ const markerPoints = element => {
}
}
const clusterIconImage = descriptor => {
const pixelRatio = 2
const shadowPadding = 7
const logicalSize = descriptor.diameter + shadowPadding * 2
const canvas = document.createElement("canvas")
canvas.width = logicalSize * pixelRatio
canvas.height = logicalSize * pixelRatio
const context = canvas.getContext("2d")
context.scale(pixelRatio, pixelRatio)
const center = logicalSize / 2
const radius = descriptor.diameter / 2 - 2
context.save()
context.shadowColor = "rgba(0, 0, 0, 0.24)"
context.shadowBlur = 6
context.shadowOffsetY = 3
context.beginPath()
context.arc(center, center, radius, 0, Math.PI * 2)
context.fillStyle = descriptor.urgent ? "#b63b21" : "#16725b"
context.fill()
context.restore()
context.beginPath()
context.arc(center, center, radius, 0, Math.PI * 2)
context.strokeStyle = "#ffffff"
context.lineWidth = 3
context.stroke()
let fontSize = descriptor.label.length >= 4 ? 11 : 12
context.font = `800 ${fontSize}px system-ui, sans-serif`
while (context.measureText(descriptor.label).width > descriptor.diameter - 12 && fontSize > 8) {
fontSize -= 1
context.font = `800 ${fontSize}px system-ui, sans-serif`
}
context.fillStyle = "#ffffff"
context.textAlign = "center"
context.textBaseline = "middle"
context.fillText(descriptor.label, center, center + 0.5)
return {
image: context.getImageData(0, 0, canvas.width, canvas.height),
pixelRatio
}
}
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
const areaPolygon = point => {
@ -288,13 +337,12 @@ const createAidMap = element => {
element,
map: null,
active: true,
viewportTimer: null,
pendingViewportAction: null,
resizeFrame: null,
resizeObserver: null,
renderedSize: null,
clusterMarkers: new Map(),
visibleClusterKeys: new Set(),
markerElements: new Map(),
clusterImageIds: new Set(),
clusterImageRemovalTimers: new Map(),
markerDataSignature: null,
points: [],
selectedItemId: null,
@ -308,6 +356,7 @@ const createAidMap = element => {
areaLineId: `${element.id || "aid-map"}-area-line`,
areaSelectedId: `${element.id || "aid-map"}-area-selected`,
clusterSourceId: `${element.id || "aid-map"}-clusters`,
clusterIconId: `${element.id || "aid-map"}-cluster-icon`,
pointCircleId: `${element.id || "aid-map"}-point-circle`,
pointSelectedId: `${element.id || "aid-map"}-point-selected`,
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
@ -323,14 +372,6 @@ const createAidMap = element => {
state.element.dataset.mapReady = "true"
}
state.queueViewportChange = viewport => {
window.clearTimeout(state.viewportTimer)
state.viewportTimer = window.setTimeout(() => {
state.viewportTimer = null
if (state.active) state.onViewportChange?.(viewport)
}, 300)
}
state.retry = () => {
if (!state.active) return
@ -467,10 +508,6 @@ const createAidMap = element => {
state.highlightRequest = id => {
state.selectedItemId = id || null
state.markerElements.forEach((element, requestId) => {
element.classList.toggle("is-selected", requestId === id)
})
if (state.map?.getLayer(state.areaSelectedId)) {
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
}
@ -575,95 +612,45 @@ const createAidMap = element => {
state.clusterExpansionPending = true
state.map.easeTo({
center: [point.longitude, point.latitude],
center: clusterExpansionCenter(point),
zoom: expansionZoom,
duration: 300
})
}
state.syncClusterMarkers = () => {
state.syncClusterImages = points => {
if (!state.map || state.element.dataset.discoveryMap !== "true") return
const visibleKeys = new Set()
state.points.forEach(point => {
const descriptor = serverClusterDescriptor(point)
if (!descriptor) return
const {key, count, urgentCount, longitude, latitude} = descriptor
visibleKeys.add(key)
const formattedCount = new Intl.NumberFormat(uiLocale()).format(count)
const label = (state.element.dataset.clusterLabel || "%{count} items")
.replace("%{count}", formattedCount)
let entry = state.clusterMarkers.get(key)
if (!entry) {
const button = document.createElement("button")
const marker = new maplibregl.Marker({element: button})
.setSubpixelPositioning(true)
button.type = "button"
button.className = "request-map-cluster"
button.addEventListener("click", event => {
event.stopPropagation()
state.expandServerCluster(entry.point)
})
entry = {
button,
marker,
point,
visible: false,
count: null,
urgentCount: null,
label: null,
longitude: null,
latitude: null
}
state.clusterMarkers.set(key, entry)
}
entry.point = point
if (entry.count !== count || entry.urgentCount !== urgentCount) {
entry.count = count
entry.urgentCount = urgentCount
entry.button.dataset.clusterCount = String(count)
entry.button.classList.toggle("is-lg", count >= 1_000 && count < 10_000)
entry.button.classList.toggle("is-xl", count >= 10_000 && count < 100_000)
entry.button.classList.toggle("is-xxl", count >= 100_000)
entry.button.classList.toggle("is-urgent", urgentCount > 0)
entry.button.textContent = clusterCountLabel(count, uiLocale())
}
if (entry.label !== label) {
entry.label = label
entry.button.setAttribute("aria-label", label)
entry.button.title = label
}
if (entry.longitude !== longitude || entry.latitude !== latitude) {
entry.longitude = longitude
entry.latitude = latitude
entry.marker.setLngLat([longitude, latitude])
}
if (!entry.visible) {
entry.marker.addTo(state.map)
entry.visible = true
}
const descriptors = new Map()
points.forEach(point => {
if (!serverClusterDescriptor(point)) return
const descriptor = clusterIconDescriptor(point, uiLocale())
descriptors.set(descriptor.id, descriptor)
})
state.visibleClusterKeys.forEach(key => {
if (visibleKeys.has(key)) return
descriptors.forEach((descriptor, imageId) => {
const removalTimer = state.clusterImageRemovalTimers.get(imageId)
if (removalTimer !== undefined) {
window.clearTimeout(removalTimer)
state.clusterImageRemovalTimers.delete(imageId)
}
const entry = state.clusterMarkers.get(key)
entry?.marker.remove()
state.clusterMarkers.delete(key)
if (state.map.hasImage(imageId)) return
const {image, pixelRatio} = clusterIconImage(descriptor)
state.map.addImage(imageId, image, {pixelRatio})
})
state.visibleClusterKeys = visibleKeys
state.clusterImageIds.forEach(imageId => {
if (descriptors.has(imageId) || state.clusterImageRemovalTimers.has(imageId)) return
const timer = window.setTimeout(() => {
state.clusterImageRemovalTimers.delete(imageId)
if (!state.active || state.clusterImageIds.has(imageId)) return
if (state.map?.hasImage(imageId)) state.map.removeImage(imageId)
}, 100)
state.clusterImageRemovalTimers.set(imageId, timer)
})
state.clusterImageIds = new Set(descriptors.keys())
}
state.syncVisibleAreas = () => {
@ -742,6 +729,7 @@ const createAidMap = element => {
{
const areaSource = existingAreaSource
state.syncClusterImages(points)
if (areaSource) {
areaSource.setData(areaFeatures)
@ -784,6 +772,18 @@ const createAidMap = element => {
pointSource.setData(pointFeatures)
} else {
state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures))
state.map.addLayer({
id: state.clusterIconId,
type: "symbol",
source: state.clusterSourceId,
filter: ["==", ["get", "item_type"], "cluster"],
layout: {
"icon-image": ["get", "cluster_icon"],
"icon-anchor": "center",
"icon-allow-overlap": true,
"icon-ignore-placement": true
}
})
state.map.addLayer({
id: state.pointCircleId,
type: "circle",
@ -819,7 +819,7 @@ const createAidMap = element => {
}
})
const interactiveLayers = [state.pointCircleId]
const interactiveLayers = [state.clusterIconId, state.pointCircleId]
interactiveLayers.forEach(layerId => {
state.map.on("mouseenter", layerId, () => {
@ -830,6 +830,12 @@ const createAidMap = element => {
})
})
state.map.on("click", state.clusterIconId, event => {
const clusterId = event.features?.[0]?.properties?.server_cluster_id
const point = state.points.find(candidate => candidate.cluster_id === clusterId)
if (point) state.expandServerCluster(point)
})
state.map.on("click", state.pointCircleId, event => {
const feature = event.features?.[0]
const properties = feature?.properties
@ -846,7 +852,6 @@ const createAidMap = element => {
}
state.markerDataSignature = markerDataSignature
state.syncClusterMarkers()
if (state.element.dataset.autoFit !== "false") state.fitMarkers()
}
@ -936,14 +941,11 @@ const createAidMap = element => {
const viewport = mapViewport(state.map)
if (!viewport) return
if (state.clusterExpansionPending) {
state.clusterExpansionPending = false
window.clearTimeout(state.viewportTimer)
state.viewportTimer = null
state.onClusterExpand?.(viewport)
} else {
state.queueViewportChange(viewport)
state.pendingViewportAction = {
viewport,
clusterExpansion: state.clusterExpansionPending
}
state.clusterExpansionPending = false
}
state.onIdle = () => {
@ -955,6 +957,15 @@ const createAidMap = element => {
}
state.syncVisibleAreas()
const action = state.pendingViewportAction
state.pendingViewportAction = null
if (action?.clusterExpansion) {
state.onClusterExpand?.(action.viewport)
} else if (action) {
state.onViewportChange?.(action.viewport)
}
}
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
@ -972,16 +983,14 @@ const createAidMap = element => {
state.destroy = () => {
state.active = false
window.clearTimeout(state.viewportTimer)
state.clusterImageRemovalTimers.forEach(timer => window.clearTimeout(timer))
state.clusterImageRemovalTimers.clear()
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeObserver?.disconnect()
state.map?.off("load", state.onLoad)
state.map?.off("idle", state.onIdle)
state.map?.off("moveend", state.onMoveEnd)
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
state.clusterMarkers.forEach(({marker}) => marker.remove())
state.clusterMarkers.clear()
state.visibleClusterKeys.clear()
state.map?.remove()
state.map = null
state.locationBounds = null

View File

@ -21,6 +21,33 @@ export const clusterCountLabel = (count, locale = "en") => {
}).format(value)
}
const clusterIconDiameter = count => {
if (count >= 100_000) return 60
if (count >= 10_000) return 56
if (count >= 1_000) return 52
return 44
}
const imageKeyPart = value =>
Array.from(value)
.map(character => character.codePointAt(0).toString(16))
.join("-")
export const clusterIconDescriptor = (point, locale = "en") => {
const count = finiteCount(point?.count, 1)
const urgent = finiteCount(point?.urgent_count, 0) > 0
const label = clusterCountLabel(count, locale)
const diameter = clusterIconDiameter(count)
return {
id: `wnh-cluster-${urgent ? "urgent" : "normal"}-${diameter}-${imageKeyPart(label)}`,
count,
urgent,
label,
diameter
}
}
export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = 22) => {
const current = finiteCount(currentZoom, 0)
const requested = finiteCount(requestedZoom, current + 1)
@ -32,6 +59,29 @@ export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom =
return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested))
}
const normalizedLongitude = longitude => {
const wrapped = ((longitude + 180) % 360 + 360) % 360 - 180
return wrapped === -180 && longitude > 0 ? 180 : wrapped
}
export const clusterExpansionCenter = point => {
const fallback = [Number(point?.longitude), Number(point?.latitude)]
const bounds = point?.bounds
const west = Number(bounds?.west)
const south = Number(bounds?.south)
const east = Number(bounds?.east)
const north = Number(bounds?.north)
if (![west, south, east, north].every(Number.isFinite)) return fallback
const longitude =
west <= east
? (west + east) / 2
: normalizedLongitude((west + east + 360) / 2)
return [longitude, (south + north) / 2]
}
export const serverClusterDescriptor = point => {
if (
point?.type !== "cluster" ||
@ -53,32 +103,35 @@ export const serverClusterDescriptor = point => {
export const pointFeatureCollection = (points, locale = "en") => ({
type: "FeatureCollection",
features: points.map(point => ({
type: "Feature",
id: point.id,
properties: {
features: points.map(point => {
const clusterIcon =
point.type === "cluster" ? clusterIconDescriptor(point, locale) : null
return {
type: "Feature",
id: point.id,
item_type: point.type || "request",
title: point.title || "",
location: point.location || "",
exact: point.exact === true,
radius_meters: Number.isFinite(point.radius_meters) ? point.radius_meters : 0,
weight: finiteCount(point.count, 1),
urgent_weight: finiteCount(point.urgent_count, 0),
server_cluster_id: point.cluster_id || "",
parent_cluster_id: point.parent_cluster_id || "",
hierarchy_level: finiteCount(point.hierarchy_level, 0),
expansion_zoom: finiteCount(point.expansion_zoom, 22),
count_label:
point.type === "cluster"
? clusterCountLabel(finiteCount(point.count, 1), locale)
: ""
},
geometry: {
type: "Point",
coordinates: [Number(point.longitude), Number(point.latitude)]
properties: {
id: point.id,
item_type: point.type || "request",
title: point.title || "",
location: point.location || "",
exact: point.exact === true,
radius_meters: Number.isFinite(point.radius_meters) ? point.radius_meters : 0,
weight: finiteCount(point.count, 1),
urgent_weight: finiteCount(point.urgent_count, 0),
server_cluster_id: point.cluster_id || "",
parent_cluster_id: point.parent_cluster_id || "",
hierarchy_level: finiteCount(point.hierarchy_level, 0),
expansion_zoom: finiteCount(point.expansion_zoom, 22),
count_label: clusterIcon?.label || "",
cluster_icon: clusterIcon?.id || ""
},
geometry: {
type: "Point",
coordinates: [Number(point.longitude), Number(point.latitude)]
}
}
}))
})
})
export const pointSourceOptions = data => ({

View File

@ -3,13 +3,33 @@ import test from "node:test"
import {
clusterCountLabel,
clusterExpansionCenter,
clusterExpansionTarget,
clusterIconDescriptor,
clusterSizeClass,
pointFeatureCollection,
pointSourceOptions,
serverClusterDescriptor
} from "./map_cluster_features.mjs"
test("cluster icon descriptors are stable and encode visual state", () => {
assert.deepEqual(clusterIconDescriptor({count: 999, urgent_count: 0}, "en"), {
id: "wnh-cluster-normal-44-39-39-39",
count: 999,
urgent: false,
label: "999",
diameter: 44
})
assert.deepEqual(clusterIconDescriptor({count: 12_500, urgent_count: 2}, "en"), {
id: "wnh-cluster-urgent-56-31-33-4b",
count: 12_500,
urgent: true,
label: "13K",
diameter: 56
})
})
test("server hierarchy metadata survives GeoJSON conversion without becoming a native cluster", () => {
const feature = pointFeatureCollection([
{
@ -31,6 +51,7 @@ test("server hierarchy metadata survives GeoJSON conversion without becoming a n
assert.equal(feature.properties.expansion_zoom, 13)
assert.equal(feature.properties.weight, 27)
assert.equal(feature.properties.count_label, "27")
assert.equal(feature.properties.cluster_icon, "wnh-cluster-normal-44-32-37")
assert.equal("point_count" in feature.properties, false)
})
@ -115,3 +136,30 @@ test("cluster expansion always advances at least one integer zoom level", () =>
assert.equal(clusterExpansionTarget(9.6, 13), 13)
assert.equal(clusterExpansionTarget(21.9, 24), 22)
})
test("cluster expansion centers on actual member bounds instead of the display cell", () => {
const [longitude, latitude] = clusterExpansionCenter({
longitude: 30.4,
latitude: 50.6,
bounds: {west: 30.51, south: 50.44, east: 30.53, north: 50.46}
})
assert.ok(Math.abs(longitude - 30.52) <= Number.EPSILON * 32)
assert.ok(Math.abs(latitude - 50.45) <= Number.EPSILON * 32)
assert.deepEqual(
clusterExpansionCenter({
longitude: 179,
latitude: 10,
bounds: {west: 179.5, south: 9, east: -179.5, north: 11}
}),
[180, 10]
)
})
test("cluster expansion falls back to the display point without valid bounds", () => {
assert.deepEqual(
clusterExpansionCenter({longitude: 30.52, latitude: 50.45}),
[30.52, 50.45]
)
})

View File

@ -92,7 +92,7 @@ services:
restart: unless-stopped
proxy:
image: who-need-help:traefik-v3.7.8-grpc1.82.1
image: who-need-help:traefik-v3.7.10-grpc1.82.1
build:
context: .
dockerfile: Dockerfile.traefik

View File

@ -35,7 +35,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
| --- | --- |
| PostgreSQL | 18.4 |
| PostGIS | 3.6.4 |
| Traefik | 3.7.8 |
| Traefik | 3.7.10 |
| Mailpit | 1.30.4 |
| k6 load generator | 2.1.0 |
| Prometheus | 3.13.1 |
@ -55,8 +55,10 @@ release feeds reported those same versions as current during verification.
`Dockerfile.minio` fetches checksum-pinned upstream source commits for MinIO
server and client and records every dependency override used to rebuild them.
The current overrides move Apache Thrift, jsonparser, Prometheus, Go crypto/net,
and gRPC dependencies to the versions checked by the image security gate.
The current overrides move Apache Thrift, jsonparser, Prometheus,
`golang.org/x/crypto` 0.54.0, `golang.org/x/net` 0.57.0,
`golang.org/x/text` 0.40.0, and gRPC dependencies to the versions checked by
the image security gate.
Both projects are AGPLv3; anyone distributing or operating modified builds must
review and satisfy the applicable license obligations. This repository keeps
the exact upstream source identifiers and the complete modification/build

View File

@ -0,0 +1,75 @@
# Google Play release candidate — 2026-08-01
This document identifies the exact locally validated artifact intended for the
first Google Play upload. It contains no credentials or private signing-key
material.
## Upload artifact
- File: `android/dist-release-20260801-final/who-need-help-release.aab`
- SHA-256: `55f05f4b7394be40f2740529eb8597279f9af25269b97c4f58fa4446b431bb14`
- Package: `org.whoneedhelp.mobile`
- Version code: `1`
- Version name: `0.1.0`
- Minimum SDK: `24`
- Target SDK: `37`
- Source fingerprint:
`8339812414061c6090b91f0abfe3562633926b4e72159885f57e7bd4000d2555`
The source fingerprint stored next to the artifact matched a fresh local
fingerprint after the build.
## Upload certificate
- SHA-256:
`A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB`
- SHA-1:
`8C:84:D5:CA:2F:B7:EA:2B:7E:08:2D:D1:CD:E8:AC:60:56:AA:1B:3C`
This upload certificate is not the Google Play App Signing certificate. After
the first upload, record the Play-generated certificate separately and add its
fingerprints to production Google/Firebase configuration and the production
App Links association.
## Validation evidence
- `bundletool` validation passed.
- Release unit tests and Android lint passed.
- R8 release build completed successfully.
- APK and AAB signing verification passed.
- Universal APK generated from this AAB:
`android/dist-release-20260801-final/who-need-help-release-universal.apk`
- Universal APK SHA-256:
`cf8bf8001b02447fb63ee73b3d8dd980485c38113cc7e0f67705690afa64f79c`
- The universal APK was installed on the authorised physical Android 16 / API
36 device and cold-started successfully.
- The production origin rendered correctly on the device.
- `https://whoneedhelp.com/safety` opened in the installed production app.
- No application crash or TLS/SSL/WebView load failure was observed in the
release smoke-test log.
- The complete repository quality run passed 414 tests plus compiler, format,
xref, Credo, Sobelow, Dialyzer, dependency audit, container, Compose, Helm,
migration, rollback, observability, and image-security gates.
## First Play Console session
1. Create **Who Need Help** as an app (not a game), free, default language
English (United States), support email `contact@whoneedhelp.com`.
2. Accept the policy, export-law, and Play App Signing declarations.
3. Complete the prepared store listing and App content sections using
`android/play-store/` and `android/store-assets/`.
4. Upload only the AAB identified above to an internal-testing release first.
5. Install the Play-delivered build from the internal-test opt-in link and
repeat the production-origin, sign-in, notification, location, and App Link
smoke tests.
6. Record the Play App Signing SHA-1 and SHA-256 before starting the closed
test.
7. Start a closed test with at least 12 continuously opted-in testers for at
least 14 days before requesting production access.
Official references:
- https://support.google.com/googleplay/android-developer/answer/9859152
- https://support.google.com/googleplay/android-developer/answer/9842756
- https://support.google.com/googleplay/android-developer/answer/9845334
- https://support.google.com/googleplay/android-developer/answer/14151465

View File

@ -172,7 +172,6 @@ test("public HTTPS PWA installs, updates its cache, and falls back safely offlin
expect(await updatedPage.evaluate(() => navigator.onLine)).toBe(false);
await context.setOffline(false);
await updatedPage.reload();
await expect(updatedPage.getByRole("heading", { name: "Log in", exact: true })).toBeVisible();
expect(consoleFailures).toEqual([]);
});

View File

@ -67,8 +67,12 @@ defmodule WhoNeedHelp.Activities do
points =
user
|> open_activities_query(filters, viewport)
|> open_activities_query(filters, nil)
|> where([activity], activity.location_visibility == :approximate_public)
|> filter_discovery_envelopes(
DiscoveryCluster.viewport_envelopes(viewport, level),
filters
)
|> select([activity], %{
id: activity.id,
title: activity.title,
@ -857,8 +861,12 @@ defmodule WhoNeedHelp.Activities do
defp filter_discovery_viewport(query, nil, _filters), do: query
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
filter_discovery_envelopes(query, DiscoveryViewport.envelopes(viewport), filters)
end
defp filter_discovery_envelopes(query, envelopes, filters) when is_list(envelopes) do
candidate_condition =
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
Enum.reduce(envelopes, dynamic(false), fn
{west, south, east, north}, condition ->
dynamic(
[activity],
@ -879,7 +887,7 @@ defmodule WhoNeedHelp.Activities do
end)
exact_condition =
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
Enum.reduce(envelopes, dynamic(false), fn
{west, south, east, north}, condition ->
dynamic(
[activity],
@ -1004,6 +1012,7 @@ defmodule WhoNeedHelp.Activities do
defp map_discovery_item(row, level) do
cluster_id = DiscoveryCluster.cluster_id(:activity, level, row.cell_x, row.cell_y)
{longitude, latitude} = DiscoveryCluster.cell_center(level, row.cell_x, row.cell_y)
%{
type: "cluster",
@ -1021,8 +1030,8 @@ defmodule WhoNeedHelp.Activities do
row.north_m
),
count: row.count,
latitude: row.latitude,
longitude: row.longitude,
latitude: latitude,
longitude: longitude,
bounds: %{
west: row.west,
south: row.south,

View File

@ -38,6 +38,7 @@ defmodule WhoNeedHelp.DiscoveryQueryCache do
@impl true
def handle_call({:fetch, key, function}, from, state) do
now = System.monotonic_time(:millisecond)
state = update_in(state.entries, &prune_expired(&1, now))
case state.entries do
%{^key => {expires_at, value}} when expires_at > now ->
@ -120,4 +121,8 @@ defmodule WhoNeedHelp.DiscoveryQueryCache do
end
defp maybe_store(state, _key, {:error, _reason}), do: state
defp prune_expired(entries, now) do
Map.reject(entries, fn {_key, {expires_at, _value}} -> expires_at <= now end)
end
end

View File

@ -83,7 +83,7 @@ defmodule WhoNeedHelp.Help do
[request],
not is_nil(request.location) and request.location_visibility != :hidden
)
|> filter_request_map_viewport(viewport)
|> filter_request_map_cluster_viewport(viewport, level)
|> select([request], %{
id: request.id,
title: request.title,
@ -202,13 +202,22 @@ defmodule WhoNeedHelp.Help do
# Lists and map markers use the same displayed public center. A privacy
# radius describes uncertainty around that center; it must not make a card
# appear in a viewport where its public marker was not loaded.
defp filter_request_map_viewport(query, %DiscoveryViewport{} = viewport) do
where(query, ^request_map_viewport_condition(viewport))
defp filter_request_map_cluster_viewport(
query,
%DiscoveryViewport{} = viewport,
level
) do
envelopes = DiscoveryCluster.viewport_envelopes(viewport, level)
where(query, ^request_map_envelope_condition(envelopes))
end
defp request_map_viewport_condition(%DiscoveryViewport{} = viewport) do
request_map_envelope_condition(DiscoveryViewport.envelopes(viewport))
end
defp request_map_envelope_condition(envelopes) when is_list(envelopes) do
spatial_condition =
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
Enum.reduce(envelopes, dynamic(false), fn
{west, south, east, north}, condition ->
dynamic(
[request],
@ -1271,6 +1280,7 @@ defmodule WhoNeedHelp.Help do
defp map_discovery_item(row, level) do
cluster_id = DiscoveryCluster.cluster_id(:request, level, row.cell_x, row.cell_y)
{longitude, latitude} = DiscoveryCluster.cell_center(level, row.cell_x, row.cell_y)
%{
type: "cluster",
@ -1289,8 +1299,8 @@ defmodule WhoNeedHelp.Help do
),
count: row.count,
urgent_count: row.urgent_count,
latitude: row.latitude,
longitude: row.longitude,
latitude: latitude,
longitude: longitude,
bounds: %{
west: row.west,
south: row.south,

View File

@ -11,12 +11,15 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
MapLibre renders the resulting hierarchy nodes directly. They must not be
passed through a second browser-side clustering pass, because that changes
cluster identity and center between server responses. For each server node,
`expansion_zoom/5` identifies the first map zoom where it has more than one
`expansion_zoom` identifies the first map zoom where it has more than one
child cell.
"""
alias WhoNeedHelp.Help.DiscoveryViewport
@world_half_meters 20_037_508.342_789_244
@world_width_meters @world_half_meters * 2.0
@max_mercator_latitude 85.051_128_78
@cluster_level_offset 2
@minimum_level 0
@maximum_map_zoom 22
@ -26,7 +29,7 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
def level_for_zoom(zoom) when is_number(zoom) do
zoom
|> floor()
|> round()
|> Kernel.+(@cluster_level_offset)
|> clamp(@minimum_level, @maximum_level)
end
@ -37,12 +40,52 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
def world_half_meters, do: @world_half_meters
@doc """
Expands a viewport to complete hierarchy cells at the selected level.
With MapLibre's 512-pixel world tiles and the current level offset, a cell is
roughly 128 screen pixels wide at its integer map zoom. Returning
complete cells plus one neighboring cell gives edge clusters stable
membership and a viewport buffer comparable to the 60-pixel padding used by
Google's viewport Supercluster algorithm. Panning inside a cell therefore
cannot change that cell's count or centroid.
"""
def viewport_envelopes(%DiscoveryViewport{} = viewport, level)
when is_integer(level) and level in 0..@maximum_level do
viewport
|> DiscoveryViewport.envelopes()
|> Enum.map(&align_envelope_to_cells(&1, level))
|> Enum.uniq()
end
def cluster_id(kind, level, cell_x, cell_y)
when kind in [:request, :activity] and is_integer(level) and is_integer(cell_x) and
is_integer(cell_y) do
"#{kind}:#{level}:#{cell_x}:#{cell_y}"
end
@doc """
Returns the stable geographic center of one hierarchy cell.
Cluster symbols use this center rather than the moving average of their
members. Adjacent cells therefore keep their symbols separated while the
map is panned and while a refreshed viewport returns the same hierarchy
level. Individual, unclustered points still use their real public location.
"""
def cell_center(level, cell_x, cell_y)
when is_integer(level) and level in 0..@maximum_level and is_integer(cell_x) and
is_integer(cell_y) do
maximum_index = Integer.pow(2, level) - 1
cell_x = clamp(cell_x, 0, maximum_index)
cell_y = clamp(cell_y, 0, maximum_index)
half_cell = cell_size_meters(level) / 2.0
{
cell_x |> cell_start(level) |> Kernel.+(half_cell) |> mercator_x_to_longitude(),
cell_y |> cell_start(level) |> Kernel.+(half_cell) |> mercator_y_to_latitude()
}
end
def parent_id(_kind, 0, _cell_x, _cell_y), do: nil
def parent_id(kind, level, cell_x, cell_y)
@ -88,6 +131,92 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
|> floor()
end
defp align_envelope_to_cells({west, south, east, north}, level) do
maximum_index = Integer.pow(2, level) - 1
west_index =
west
|> longitude_to_mercator_x()
|> cell_index(level)
|> Kernel.-(1)
|> clamp(0, maximum_index)
east_index =
east
|> longitude_to_mercator_x()
|> cell_index(level)
|> Kernel.+(1)
|> clamp(0, maximum_index)
south_index =
south
|> latitude_to_mercator_y()
|> cell_index(level)
|> Kernel.-(1)
|> clamp(0, maximum_index)
north_index =
north
|> latitude_to_mercator_y()
|> cell_index(level)
|> Kernel.+(1)
|> clamp(0, maximum_index)
{
west_index |> cell_start(level) |> mercator_x_to_longitude(),
south_index |> cell_start(level) |> mercator_y_to_latitude(),
east_index |> cell_end(level) |> mercator_x_to_longitude(),
north_index |> cell_end(level) |> mercator_y_to_latitude()
}
end
defp cell_start(index, level) do
-@world_half_meters + index * cell_size_meters(level)
end
defp cell_end(index, level) do
min(@world_half_meters, cell_start(index + 1, level))
end
defp longitude_to_mercator_x(longitude) do
longitude
|> clamp(-180.0, 180.0)
|> Kernel.*(@world_half_meters / 180.0)
|> clamp_coordinate()
end
defp latitude_to_mercator_y(latitude) do
latitude =
latitude
|> clamp(-@max_mercator_latitude, @max_mercator_latitude)
|> degrees_to_radians()
@world_half_meters / :math.pi() *
:math.log(:math.tan(:math.pi() / 4.0 + latitude / 2.0))
end
defp mercator_x_to_longitude(x) do
x
|> clamp(-@world_half_meters, @world_half_meters)
|> Kernel.*(180.0 / @world_half_meters)
|> clamp(-180.0, 180.0)
end
defp mercator_y_to_latitude(y) do
y
|> clamp(-@world_half_meters, @world_half_meters)
|> Kernel.*(:math.pi() / @world_half_meters)
|> :math.exp()
|> :math.atan()
|> Kernel.*(2.0)
|> Kernel.-(:math.pi() / 2.0)
|> radians_to_degrees()
|> clamp(-@max_mercator_latitude, @max_mercator_latitude)
end
defp degrees_to_radians(value), do: value * :math.pi() / 180.0
defp radians_to_degrees(value), do: value * 180.0 / :math.pi()
defp clamp_coordinate(value) do
value
|> Kernel.*(1.0)

View File

@ -32,7 +32,7 @@
{gettext("A Google account is connected. You can use it to sign in.")}
<% else %>
{gettext(
"Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
"Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation."
)}
<% end %>
</p>

View File

@ -10,7 +10,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
@view_modes ~w(list split map)
@map_update_modes ~w(manual auto)
@filter_keys ~w(category_id area)
@map_update_delay_ms 450
@impl true
def mount(_params, _session, socket) do
@ -26,7 +25,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|> assign(:view_mode, "split")
|> assign(:view_explicit, false)
|> assign(:map_update_mode, "manual")
|> assign(:map_update_timer, nil)
|> assign(:viewport, nil)
|> assign(:pending_viewport, nil)
|> assign(:activities, [])
@ -52,7 +50,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
{:noreply,
socket
|> cancel_map_update_timer()
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:discovery_mode, discovery_mode)
@ -130,17 +127,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
end
def handle_info({:apply_map_viewport, %DiscoveryViewport{} = viewport}, socket) do
socket = assign(socket, :map_update_timer, nil)
if socket.assigns.map_update_mode == "auto" and
DiscoveryViewport.same_area?(viewport, socket.assigns.pending_viewport) do
{:noreply, push_patch(socket, to: activity_path(socket, viewport: viewport))}
else
{:noreply, socket}
end
end
@impl true
def handle_event("filter", %{"filters" => filters}, socket) do
{:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))}
@ -183,13 +169,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
{:ok, viewport} ->
cond do
DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) ->
{:noreply,
socket
|> cancel_map_update_timer()
|> assign(:pending_viewport, nil)}
{:noreply, assign(socket, :pending_viewport, nil)}
socket.assigns.map_update_mode == "auto" ->
{:noreply, schedule_map_update(socket, viewport)}
{:noreply,
push_patch(socket,
to: activity_path(socket, viewport: viewport)
)}
true ->
{:noreply, assign(socket, :pending_viewport, viewport)}
@ -449,23 +435,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
~p"/activities?#{params}"
end
defp schedule_map_update(socket, viewport) do
socket = cancel_map_update_timer(socket)
timer = Process.send_after(self(), {:apply_map_viewport, viewport}, @map_update_delay_ms)
socket
|> assign(:pending_viewport, viewport)
|> assign(:map_update_timer, timer)
end
defp cancel_map_update_timer(socket) do
if socket.assigns.map_update_timer do
Process.cancel_timer(socket.assigns.map_update_timer)
end
assign(socket, :map_update_timer, nil)
end
defp viewport_json(nil), do: ""
defp viewport_json(%DiscoveryViewport{} = viewport) do

View File

@ -10,7 +10,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
@view_modes ~w(list split map)
@map_update_modes ~w(manual auto)
@filter_keys ~w(category_id urgency area)
@map_update_delay_ms 450
@empty_reputation %{
completed: 0,
unique_people: 0,
@ -33,7 +32,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|> assign(:view_mode, "split")
|> assign(:view_explicit, false)
|> assign(:map_update_mode, "manual")
|> assign(:map_update_timer, nil)
|> assign(:viewport, nil)
|> assign(:pending_viewport, nil)
|> assign(:discovery_topics, [])
@ -62,7 +60,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
socket =
socket
|> cancel_map_update_timer()
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filters))
|> assign(:discovery_mode, discovery_mode)
@ -129,13 +126,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{:ok, viewport} ->
cond do
DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) ->
{:noreply,
socket
|> cancel_map_update_timer()
|> assign(:pending_viewport, nil)}
{:noreply, assign(socket, :pending_viewport, nil)}
socket.assigns.map_update_mode == "auto" ->
{:noreply, schedule_map_update(socket, viewport)}
{:noreply,
push_patch(socket,
to: request_path(socket, viewport: viewport)
)}
true ->
{:noreply, assign(socket, :pending_viewport, viewport)}
@ -224,17 +221,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
end
def handle_info({:apply_map_viewport, %DiscoveryViewport{} = viewport}, socket) do
socket = assign(socket, :map_update_timer, nil)
if socket.assigns.map_update_mode == "auto" and
DiscoveryViewport.same_area?(viewport, socket.assigns.pending_viewport) do
{:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))}
else
{:noreply, socket}
end
end
@impl true
def handle_async(:load_discovery, {:ok, result}, socket) do
{:noreply, assign(socket, result)}
@ -454,23 +440,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
~p"/requests?#{params}"
end
defp schedule_map_update(socket, viewport) do
socket = cancel_map_update_timer(socket)
timer = Process.send_after(self(), {:apply_map_viewport, viewport}, @map_update_delay_ms)
socket
|> assign(:pending_viewport, viewport)
|> assign(:map_update_timer, timer)
end
defp cancel_map_update_timer(socket) do
if socket.assigns.map_update_timer do
Process.cancel_timer(socket.assigns.map_update_timer)
end
assign(socket, :map_update_timer, nil)
end
defp urgency_label(:now), do: gettext("Now")
defp urgency_label("now"), do: gettext("Now")
defp urgency_label(:today), do: gettext("Today")

View File

@ -302,8 +302,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:error, _reason} -> socket
end
{:noreply,
put_action_flash(socket, :error, gettext("The browser could not share your location."))}
{:noreply, put_action_flash(socket, :error, gettext("Location sharing could not start."))}
end
def handle_event("stop-tracking", _, socket) do

View File

@ -2686,7 +2686,7 @@ msgstr ""
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
#, elixir-autogen, elixir-format
msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation."
msgstr ""
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99

View File

@ -2686,8 +2686,8 @@ msgstr "Connect Google account"
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
#, elixir-autogen, elixir-format
msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
msgstr "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation."
msgstr "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation."
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
#, elixir-autogen, elixir-format

View File

@ -2826,8 +2826,8 @@ msgstr "Подключить аккаунт Google"
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
#, elixir-autogen, elixir-format
msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
msgstr "Подключайте Google только после входа здесь. Мы никогда не объединяем аккаунты автоматически по совпадению email."
msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation."
msgstr "Подключите Google для будущих входов. Аккаунт с совпадающим подтверждённым адресом Gmail или Google Workspace также может подключиться автоматически; для других почтовых доменов требуется подтверждение аккаунта."
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
#, elixir-autogen, elixir-format

View File

@ -2820,8 +2820,8 @@ msgstr "Підключити обліковий запис Google"
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
#, elixir-autogen, elixir-format
msgid "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
msgstr "Підключайте Google лише після входу тут. Ми ніколи не об’єднуємо облікові записи автоматично за збігом email."
msgid "Connect Google to use it for future sign-ins. A matching verified Gmail or Google Workspace address can also connect automatically; other email domains require account confirmation."
msgstr "Підключіть Google для майбутніх входів. Обліковий запис із відповідною підтвердженою адресою Gmail або Google Workspace також може підключитися автоматично; для інших поштових доменів потрібне підтвердження облікового запису."
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
#, elixir-autogen, elixir-format

View File

@ -68,6 +68,12 @@
font-size: 0.875rem;
}
.connection-status {
min-height: 1.5rem;
margin-top: 1rem;
font-size: 0.875rem;
}
@media (prefers-color-scheme: dark) {
:root {
background: #151618;
@ -88,6 +94,14 @@
Reconnect to view current requests, messages, maps, and live locations.
We do not save private pages on this device.
</p>
<p
id="connection-status"
class="connection-status"
role="status"
aria-live="polite"
>
Waiting for a connection…
</p>
<form method="get">
<button type="submit">Try again</button>
</form>
@ -95,5 +109,54 @@
Немає з’єднання · Нет соединения
</p>
</main>
<script>
(() => {
const status = document.getElementById("connection-status")
let retryTimer
let checking = false
const scheduleRetry = delay => {
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(checkConnection, delay)
}
const setStatus = message => {
if (status) status.textContent = message
}
const checkConnection = async () => {
if (checking) return
checking = true
setStatus("Checking the connection…")
try {
const response = await fetch("/healthz/ready", {
cache: "no-store",
headers: {accept: "application/json"}
})
if (response.ok) {
setStatus("Connection restored. Reloading…")
window.location.reload()
return
}
} catch (_error) {
// The next scheduled attempt handles temporary network failures.
}
checking = false
setStatus("Still offline. We will keep trying.")
scheduleRetry(3_000)
}
window.addEventListener("online", checkConnection)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") checkConnection()
})
scheduleRetry(navigator.onLine ? 0 : 3_000)
})()
</script>
</body>
</html>

View File

@ -1,5 +1,5 @@
const CACHE_PREFIX = "who-need-help-static-"
const CACHE = `${CACHE_PREFIX}v5`
const CACHE = `${CACHE_PREFIX}v6`
const OFFLINE_URL = "/offline.html"
const SHELL = [
OFFLINE_URL,

View File

@ -3,7 +3,7 @@ set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
mapfile -d '' -t source_files < <(
mapfile -d '' -t candidate_files < <(
git -C "$ROOT" ls-files \
--cached \
--others \
@ -13,6 +13,21 @@ mapfile -d '' -t source_files < <(
LC_ALL=C sort -z
)
source_files=()
for relative_path in "${candidate_files[@]}"; do
absolute_path="$ROOT/$relative_path"
# `git ls-files --cached` also reports tracked paths deleted in the working
# tree. They are not inputs to the artifact being built, so exclude them
# from the working-tree fingerprint instead of treating a valid deletion as
# a broken source tree.
if [[ ! -e "$absolute_path" && ! -L "$absolute_path" ]]; then
continue
fi
source_files+=("$relative_path")
done
if [[ "${#source_files[@]}" -eq 0 ]]; then
echo "No Android source files were found." >&2
exit 1

View File

@ -55,6 +55,30 @@ cleanup() {
}
trap cleanup EXIT HUP INT TERM
scan_image() {
image=$1
scan_image_sequence=$((scan_image_sequence + 1))
image_archive="$scan_dir/trivy-image-$scan_image_sequence.tar"
if ! docker image inspect "$image" >/dev/null 2>&1; then
docker pull "$image" >/dev/null
fi
docker image save --output "$image_archive" "$image"
docker run --rm \
--volume "$scan_dir:/scan:ro" \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--input "/scan/$(basename "$image_archive")" \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1
rm -f "$image_archive"
}
scan_image_sequence=0
echo "Checking shell scripts with ShellCheck 0.11.0"
# Word splitting is intentional: find emits repository-controlled paths and
# ShellCheck expects each file as a separate argument.
@ -1271,8 +1295,9 @@ docker build --tag "$traefik_image" --file Dockerfile.traefik .
test "$(docker image inspect --format '{{.Config.User}}' "$socket_proxy_image")" = "haproxy"
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
test "$(docker image inspect --format '{{.Config.User}}' "$caddy_image")" = "1000:1000"
docker run --rm "$caddy_image" version | grep -F 'v2.11.4' >/dev/null
docker run --rm "$traefik_image" version | grep -F 'v3.7.8-wnh-grpc1.82.1' >/dev/null
docker run --rm "$caddy_image" version |
grep -F 'v2.11.4-wnh-grpc1.82.1-xtext0.40.0' >/dev/null
docker run --rm "$traefik_image" version | grep -F 'v3.7.10-wnh-grpc1.82.1' >/dev/null
docker run --rm --entrypoint sh "$postgis_image" -euc '
test ! -e /usr/local/bin/gosu
test "$(id -u)" = 70
@ -1329,15 +1354,7 @@ for image in \
"$traefik_image" \
"$caddy_image" \
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$image"
scan_image "$image"
done
echo "Building the pinned quality image and cached Dialyzer PLTs"
@ -1490,15 +1507,7 @@ printf '%s\n' "$backup_versions" |
grep -F 'restic 0.19.1 compiled with go1.26.5' >/dev/null
printf '%s\n' "$backup_versions" |
grep -F 'pg_dump (PostgreSQL) 18.4' >/dev/null
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$backup_image"
scan_image "$backup_image"
echo "Building and scanning the pinned non-root MinIO server and client images"
docker build --target server --tag "$minio_image" --file Dockerfile.minio .
@ -1534,15 +1543,7 @@ printf '%s\n' "$mc_version" |
printf '%s\n' "$mc_version" |
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
for image in "$minio_image" "$mc_image"; do
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$image"
scan_image "$image"
done
echo "Building and scanning the pinned non-root external-boundary mock image"
@ -1552,26 +1553,10 @@ docker build \
ops/external-boundaries
test "$(docker image inspect --format '{{.Config.User}}' "$boundary_mock_image")" = \
"10001:10001"
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$boundary_mock_image"
scan_image "$boundary_mock_image"
echo "Building and scanning the production release image"
docker build --target release --tag "$release_image" .
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$ROOT/.tools/trivy-cache:/root/.cache/trivy" \
"$TRIVY_IMAGE" image \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
"$release_image"
scan_image "$release_image"
echo "All isolated quality and security gates passed."

View File

@ -5,7 +5,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
alias WhoNeedHelp.{Activities, Catalog, Trust}
alias WhoNeedHelp.Activities.Activity
alias WhoNeedHelp.Help.DiscoveryViewport
alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport}
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.AuditEvent
@ -336,7 +336,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
expected_ids =
activities
|> Enum.sort_by(&{&1.starts_at, &1.id})
|> Enum.sort_by(&{DateTime.to_unix(&1.starts_at, :microsecond), &1.id})
|> Enum.map(& &1.id)
actual_ids = Enum.map(first.entries ++ second.entries, & &1.id)
@ -464,8 +464,14 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert String.starts_with?(cluster.cluster_id, "activity:11:")
assert String.starts_with?(cluster.parent_cluster_id, "activity:10:")
assert cluster.expansion_zoom > 9
assert_in_delta cluster.latitude, 50.45, 0.000_001
assert_in_delta cluster.longitude, 30.52, 0.000_001
["activity", "11", cell_x, cell_y] = String.split(cluster.cluster_id, ":")
{expected_longitude, expected_latitude} =
DiscoveryCluster.cell_center(11, String.to_integer(cell_x), String.to_integer(cell_y))
assert_in_delta cluster.latitude, expected_latitude, 0.000_001
assert_in_delta cluster.longitude, expected_longitude, 0.000_001
assert_in_delta cluster.bounds.west, 30.52, 0.000_001
assert_in_delta cluster.bounds.east, 30.52, 0.000_001
assert_in_delta cluster.bounds.south, 50.45, 0.000_001
@ -484,6 +490,28 @@ defmodule WhoNeedHelp.ActivitiesTest do
false
end)
left_edge_cluster =
context.outsider_scope
|> Activities.map_discovery_items(
%{},
viewport!(30.519, 50.449, 30.521, 50.451, 15)
)
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
right_edge_cluster =
context.outsider_scope
|> Activities.map_discovery_items(
%{},
viewport!(30.521, 50.449, 30.523, 50.451, 15)
)
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
assert left_edge_cluster
assert right_edge_cluster
assert left_edge_cluster.cluster_id == right_edge_cluster.cluster_id
assert left_edge_cluster.latitude == right_edge_cluster.latitude
assert left_edge_cluster.longitude == right_edge_cluster.longitude
world_items =
Activities.map_discovery_items(
context.outsider_scope,

View File

@ -33,4 +33,28 @@ defmodule WhoNeedHelp.DiscoveryQueryCacheTest do
assert DiscoveryQueryCache.fetch(key, fn -> :fresh_result end) == :fresh_result
end
test "prunes expired entries created by other viewport keys" do
expired_key = {:expired, make_ref()}
live_key = {:live, make_ref()}
now = System.monotonic_time(:millisecond)
:sys.replace_state(DiscoveryQueryCache, fn state ->
put_in(
state.entries,
Map.merge(state.entries, %{
expired_key => {now - 1, :expired},
live_key => {now + 60_000, :live}
})
)
end)
assert DiscoveryQueryCache.fetch({:new, make_ref()}, fn -> :new end) == :new
%{entries: entries} = :sys.get_state(DiscoveryQueryCache)
refute Map.has_key?(entries, expired_key)
assert Map.has_key?(entries, live_key)
assert :ok = DiscoveryQueryCache.invalidate(live_key)
end
end

View File

@ -1,12 +1,13 @@
defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
use ExUnit.Case, async: true
alias WhoNeedHelp.Help.DiscoveryCluster
alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport}
test "map zooms select stable hierarchy levels" do
assert DiscoveryCluster.level_for_zoom(0) == 2
assert DiscoveryCluster.level_for_zoom(5.49) == 7
assert DiscoveryCluster.level_for_zoom(5.99) == 7
assert DiscoveryCluster.level_for_zoom(5.5) == 8
assert DiscoveryCluster.level_for_zoom(5.99) == 8
assert DiscoveryCluster.level_for_zoom(6) == 8
assert DiscoveryCluster.level_for_zoom(22) == 24
@ -23,6 +24,41 @@ defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
assert DiscoveryCluster.parent_id(:activity, 0, 0, 0) == nil
end
test "cell centers are stable geographic points separated from adjacent cells" do
{longitude, latitude} = DiscoveryCluster.cell_center(2, 2, 2)
{east_longitude, east_latitude} = DiscoveryCluster.cell_center(2, 3, 2)
assert_in_delta longitude, 45.0, 1.0e-9
assert_in_delta latitude, 40.979_898, 1.0e-6
assert_in_delta east_longitude, 135.0, 1.0e-9
assert_in_delta east_latitude, latitude, 1.0e-9
end
test "viewport envelopes cover complete stable hierarchy cells" do
viewport = %DiscoveryViewport{
west: 30.50,
south: 50.40,
east: 30.60,
north: 50.50,
zoom: 10.2,
width: 900,
height: 700
}
level = DiscoveryCluster.level_for_zoom(viewport.zoom)
[{west, south, east, north}] = DiscoveryCluster.viewport_envelopes(viewport, level)
assert west <= viewport.west
assert south <= viewport.south
assert east >= viewport.east
assert north >= viewport.north
shifted = %{viewport | west: 30.5001, east: 30.5999, south: 50.4001, north: 50.4999}
assert DiscoveryCluster.viewport_envelopes(shifted, level) ==
DiscoveryCluster.viewport_envelopes(viewport, level)
end
test "expansion zoom is the first child level that separates member bounds" do
level = 7
cell = DiscoveryCluster.cell_size_meters(level)

View File

@ -5,7 +5,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
alias WhoNeedHelp.Help.Assignment
alias WhoNeedHelp.Help.{Assignment, DiscoveryCluster}
alias WhoNeedHelp.Notifications.Notification
alias WhoNeedHelp.Push.NearbyMatchWorker
alias WhoNeedHelp.Help.DiscoveryViewport
@ -440,7 +440,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
expected_ids =
requests
|> Enum.sort_by(&{&1.expires_at, &1.id})
|> Enum.sort_by(&{DateTime.to_unix(&1.expires_at, :microsecond), &1.id})
|> Enum.map(& &1.id)
actual_ids = Enum.map(first.entries ++ second.entries ++ third.entries, & &1.id)
@ -502,7 +502,8 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
_request, total -> total + 1
end)
assert map_count == length(ids)
assert map_count >= length(ids)
assert map_count < 3
end
test "hidden coordinates require an explicit area-label search and never become map points",
@ -580,8 +581,14 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert String.starts_with?(cluster.cluster_id, "request:11:")
assert String.starts_with?(cluster.parent_cluster_id, "request:10:")
assert cluster.expansion_zoom > viewport.zoom
assert_in_delta cluster.latitude, 50.4501, 0.000_001
assert_in_delta cluster.longitude, 30.52345, 0.000_001
["request", "11", cell_x, cell_y] = String.split(cluster.cluster_id, ":")
{expected_longitude, expected_latitude} =
DiscoveryCluster.cell_center(11, String.to_integer(cell_x), String.to_integer(cell_y))
assert_in_delta cluster.latitude, expected_latitude, 0.000_001
assert_in_delta cluster.longitude, expected_longitude, 0.000_001
assert_in_delta cluster.bounds.west, 30.5234, 0.000_001
assert_in_delta cluster.bounds.east, 30.5235, 0.000_001
assert_in_delta cluster.bounds.south, 50.4501, 0.000_001
@ -608,6 +615,28 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
_item -> false
end)
left_edge_cluster =
context.helper_scope
|> Help.map_discovery_items(
%{},
viewport!(30.52339, 50.45005, 30.52344, 50.45015, 15)
)
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
right_edge_cluster =
context.helper_scope
|> Help.map_discovery_items(
%{},
viewport!(30.52346, 50.45005, 30.52351, 50.45015, 15)
)
|> Enum.find(&match?(%{type: "cluster", count: 3}, &1))
assert left_edge_cluster
assert right_edge_cluster
assert left_edge_cluster.cluster_id == right_edge_cluster.cluster_id
assert left_edge_cluster.latitude == right_edge_cluster.latitude
assert left_edge_cluster.longitude == right_edge_cluster.longitude
world_items =
Help.map_discovery_items(
context.helper_scope,

View File

@ -241,7 +241,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
body = response(conn, 200)
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-")
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v5`)
assert body =~ ~s(const CACHE = `${CACHE_PREFIX}v6`)
assert body =~ ~s(const OFFLINE_URL = "/offline.html")
assert body =~ ~s(event.request.mode === "navigate")
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|
@ -258,6 +258,8 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
assert Enum.count(LazyHTML.query(document, "img[src='/images/logo.svg'][alt='']")) == 1
assert html =~ "You're offline"
assert html =~ "Try again"
assert html =~ ~s(fetch("/healthz/ready")
assert html =~ "Connection restored. Reloading"
end
test "brand raster fallbacks are served", %{conn: conn} do

View File

@ -17,6 +17,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
assert response =~ ~s(autocomplete="username")
assert response =~ "Google sign-in"
assert response =~ "Connect Google account"
assert response =~ "matching verified Gmail or Google Workspace address"
refute response =~ "We never merge accounts automatically"
end
test "redirects if user is not logged in" do

View File

@ -229,7 +229,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
conn = get(conn, ~p"/?locale=uk")
{:ok, _view, html} = live(conn, ~p"/requests")
{:ok, view, html} = live(conn, ~p"/requests")
render_async(view)
assert html =~ "Хто потребує допомоги?"
assert html =~ "Створити термінову заявку"
@ -377,6 +378,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
view,
~p"/requests?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
)
render_async(view)
end
test "request discovery keeps the previous list until a moved map area is applied", %{
@ -440,7 +443,6 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
}
render_hook(view, "map-viewport-change", moved)
assert render(view) =~ "Updating results for the visible map area"
refute has_element?(view, "#search-map-area")
assert_patch(
@ -471,6 +473,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
view,
~p"/activities?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
)
render_async(view)
end
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
@ -1468,7 +1472,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
html = render_hook(helper_view, "location-error", %{})
assert html =~ "The browser could not share your location."
assert html =~ "Location sharing could not start."
assert has_element?(helper_view, "button[phx-click='start-tracking']")
refute has_element?(helper_view, "button[phx-click='stop-tracking']")