Prepare Android launch candidate and harden discovery
|
|
@ -1,5 +1,7 @@
|
||||||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic
|
||||||
|
|
||||||
|
ARG X_TEXT_VERSION=v0.40.0
|
||||||
|
|
||||||
ENV GOTOOLCHAIN=local
|
ENV GOTOOLCHAIN=local
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
@ -7,7 +9,9 @@ WORKDIR /src
|
||||||
RUN go mod init who-need-help/restic-build \
|
RUN go mod init who-need-help/restic-build \
|
||||||
&& go get github.com/restic/restic/cmd/restic@v0.19.1 \
|
&& go get github.com/restic/restic/cmd/restic@v0.19.1 \
|
||||||
&& go get google.golang.org/grpc@v1.82.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}}' 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 \
|
&& CGO_ENABLED=0 go build \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-ldflags="-s -w" \
|
-ldflags="-s -w" \
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,21 @@ FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff9
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
ENV GOTOOLCHAIN=local
|
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
|
WORKDIR /src
|
||||||
|
|
||||||
RUN go mod init who-need-help/caddy-build \
|
RUN go mod init who-need-help/caddy-build \
|
||||||
&& go get github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4 \
|
&& go get "github.com/caddyserver/caddy/v2/cmd/caddy@${CADDY_VERSION}" \
|
||||||
&& go get google.golang.org/grpc@v1.82.1 \
|
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
|
||||||
&& test "$(go list -m -f '{{.Version}}' 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)" = "$GRPC_GO_VERSION" \
|
||||||
|
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "$X_TEXT_VERSION" \
|
||||||
&& go build \
|
&& go build \
|
||||||
-trimpath \
|
-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 \
|
-o /out/caddy \
|
||||||
github.com/caddyserver/caddy/v2/cmd/caddy
|
github.com/caddyserver/caddy/v2/cmd/caddy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS minio_builder
|
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 \
|
ADD --checksum=sha256:45521908307306e925c98d629e1c17d78c8b72b6ee242b1bfb1409f7d8ee5841 \
|
||||||
https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \
|
https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \
|
||||||
/tmp/minio.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/apache/thrift@v0.23.0 \
|
||||||
github.com/buger/jsonparser@v1.1.2 \
|
github.com/buger/jsonparser@v1.1.2 \
|
||||||
github.com/prometheus/prometheus@v0.311.3 \
|
github.com/prometheus/prometheus@v0.311.3 \
|
||||||
golang.org/x/crypto@v0.52.0 \
|
golang.org/x/crypto@${X_CRYPTO_VERSION} \
|
||||||
golang.org/x/net@v0.55.0 \
|
golang.org/x/net@${X_NET_VERSION} \
|
||||||
|
golang.org/x/text@${X_TEXT_VERSION} \
|
||||||
google.golang.org/grpc@v1.82.1 \
|
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}}' 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 \
|
&& CGO_ENABLED=0 go build \
|
||||||
-mod=mod \
|
-mod=mod \
|
||||||
-trimpath \
|
-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
|
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 \
|
ADD --checksum=sha256:95cd293c7119f16921a6dc515a1fb74a2227f19fd994b9c8b770a154e802ac44 \
|
||||||
https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \
|
https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \
|
||||||
/tmp/mc.tar.gz
|
/tmp/mc.tar.gz
|
||||||
|
|
@ -40,11 +52,15 @@ RUN tar --extract --gzip --file /tmp/mc.tar.gz \
|
||||||
--directory . --strip-components=1 \
|
--directory . --strip-components=1 \
|
||||||
&& go get \
|
&& go get \
|
||||||
github.com/prometheus/prometheus@v0.311.3 \
|
github.com/prometheus/prometheus@v0.311.3 \
|
||||||
golang.org/x/crypto@v0.52.0 \
|
golang.org/x/crypto@${X_CRYPTO_VERSION} \
|
||||||
golang.org/x/net@v0.55.0 \
|
golang.org/x/net@${X_NET_VERSION} \
|
||||||
|
golang.org/x/text@${X_TEXT_VERSION} \
|
||||||
google.golang.org/grpc@v1.82.1 \
|
google.golang.org/grpc@v1.82.1 \
|
||||||
&& go mod tidy \
|
&& 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}}' 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 \
|
&& CGO_ENABLED=0 go build \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-tags kqueue \
|
-tags kqueue \
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@
|
||||||
|
|
||||||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
|
||||||
|
|
||||||
ARG TRAEFIK_VERSION=v3.7.8
|
ARG TRAEFIK_VERSION=v3.7.10
|
||||||
ARG TRAEFIK_SOURCE_SHA256=3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9
|
ARG TRAEFIK_SOURCE_SHA256=31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6
|
||||||
ARG TRAEFIK_BUILD_DATE=2026-07-15_12:51:10PM
|
ARG TRAEFIK_BUILD_DATE=2026-07-31_12:49:21PM
|
||||||
ARG GRPC_GO_VERSION=v1.82.1
|
ARG GRPC_GO_VERSION=v1.82.1
|
||||||
|
|
||||||
ADD --checksum=sha256:3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9 \
|
ADD --checksum=sha256:31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6 \
|
||||||
https://github.com/traefik/traefik/releases/download/v3.7.8/traefik-v3.7.8.src.tar.gz \
|
https://github.com/traefik/traefik/releases/download/v3.7.10/traefik-v3.7.10.src.tar.gz \
|
||||||
/tmp/traefik.tar.gz
|
/tmp/traefik.tar.gz
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN test "$TRAEFIK_SOURCE_SHA256" = "3a725c0ead27fa512756acd57056ec4652420a9daceaa6d9c170bfbb25bf51f9" \
|
RUN test "$TRAEFIK_SOURCE_SHA256" = "31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6" \
|
||||||
&& tar -xzf /tmp/traefik.tar.gz --strip-components=1 \
|
&& tar -xzf /tmp/traefik.tar.gz --strip-components=1 \
|
||||||
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
|
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
|
||||||
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \
|
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
broadcast from a different BEAM node. Registration emails appear in Mailpit.
|
||||||
The Traefik image is reproducibly built from the checksum-pinned upstream
|
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
|
`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`, restic `v0.19.1`, MinIO, and `mc`
|
MinIO images build upstream Caddy `v2.11.4` (with `grpc-go 1.82.1` and
|
||||||
with the same dependency pin. Those upstream dependency graphs still contain
|
`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
|
older `grpc-go` versions affected by
|
||||||
[GHSA-hrxh-6v49-42gf](https://github.com/advisories/GHSA-hrxh-6v49-42gf).
|
[GHSA-hrxh-6v49-42gf](https://github.com/advisories/GHSA-hrxh-6v49-42gf).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,8 @@ final class LaunchUrlResolver {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static boolean shouldLoadIncomingUrl(String currentUrl, String candidateUrl) {
|
||||||
|
return candidateUrl != null && !candidateUrl.equals(currentUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,10 @@ public final class MainActivity extends ComponentActivity {
|
||||||
BuildConfig.DEBUG
|
BuildConfig.DEBUG
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidate != null && webView != null) {
|
if (
|
||||||
|
webView != null &&
|
||||||
|
LaunchUrlResolver.shouldLoadIncomingUrl(webView.getUrl(), candidate)
|
||||||
|
) {
|
||||||
webView.loadUrl(candidate);
|
webView.loadUrl(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package org.whoneedhelp.mobile;
|
package org.whoneedhelp.mobile;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNull;
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
import org.junit.Test;
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ current Play form immediately before submission.
|
||||||
|
|
||||||
## Form-level answers
|
## 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
|
- Is all user data encrypted in transit? **Yes.** Production app traffic uses
|
||||||
HTTPS; Firebase Cloud Messaging also uses encrypted transport.
|
HTTPS; Firebase Cloud Messaging also uses encrypted transport.
|
||||||
- Can users request deletion? **Yes.**
|
- Can users request deletion? **Yes.**
|
||||||
|
|
@ -61,13 +63,30 @@ The current implementation sends data to:
|
||||||
participant, sending a message, or starting live sharing.
|
participant, sending a message, or starting live sharing.
|
||||||
|
|
||||||
Google Play excludes some service-provider transfers and user-initiated sharing
|
Google Play excludes some service-provider transfers and user-initiated sharing
|
||||||
from the “shared” declaration. The production map-tile provider and its
|
from the “shared” declaration. The current default production configuration
|
||||||
contractual/service-provider status are not yet confirmed, so the exact
|
loads raster tiles directly from `tile.openstreetmap.org`; OSMF is an independent
|
||||||
top-level “shared” answer is currently **unknown**. Do not submit the form until
|
third party and there is no verified service-provider agreement under which it
|
||||||
the final provider, its terms/data-processing role, and the exact release
|
processes data solely on behalf of Who Need Help. OSMF's current privacy policy
|
||||||
network trace have been reviewed against the definitions shown by the current
|
says that requests to its services produce records including IP address,
|
||||||
Play form. If no exemption applies, declare the applicable device/network data
|
browser/device type, operating system, referrer, time, and requested pages.
|
||||||
as shared.
|
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
|
## Source checks before every release
|
||||||
|
|
||||||
|
|
@ -76,7 +95,8 @@ as shared.
|
||||||
2. Confirm that Analytics, Crashlytics, ads, and delivery-metrics export remain
|
2. Confirm that Analytics, Crashlytics, ads, and delivery-metrics export remain
|
||||||
absent or update the declaration.
|
absent or update the declaration.
|
||||||
3. Confirm the final map-tile provider, provider agreement, request metadata,
|
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
|
4. Compare with `/privacy`, `/account/delete`, Android manifest permissions, and
|
||||||
the live-location prominent disclosure.
|
the live-location prominent disclosure.
|
||||||
5. Confirm the external deletion URL loads without authentication and submits a
|
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
|
6. Update the worksheet if media uploads, avatars, payments, analytics, or any
|
||||||
new SDK is introduced.
|
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
|
## Official references
|
||||||
|
|
||||||
- https://support.google.com/googleplay/android-developer/answer/10787469
|
- https://support.google.com/googleplay/android-developer/answer/10787469
|
||||||
- https://support.google.com/googleplay/android-developer/answer/13327111
|
- https://support.google.com/googleplay/android-developer/answer/13327111
|
||||||
- https://firebase.google.com/docs/android/play-data-disclosure
|
- https://firebase.google.com/docs/android/play-data-disclosure
|
||||||
- https://firebase.google.com/support/privacy/
|
- https://firebase.google.com/support/privacy/
|
||||||
|
- https://operations.osmfoundation.org/policies/tiles/
|
||||||
|
- https://osmfoundation.org/wiki/Privacy_Policy
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
## External account gate
|
## External account gate
|
||||||
|
|
||||||
- [ ] Google Play developer identity verification approved.
|
- [x] Google Play developer identity verification approved.
|
||||||
- [ ] Contact phone verification completed.
|
- [x] Contact phone verification completed.
|
||||||
- [ ] Play Console enables **Create app**.
|
- [ ] Play Console enables **Create app**.
|
||||||
|
|
||||||
## App identity and signing
|
## App identity and signing
|
||||||
|
|
@ -22,12 +22,12 @@
|
||||||
|
|
||||||
## Build
|
## 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`.
|
allow-list read from the production checkout’s single `.env`.
|
||||||
- [x] Run `scripts/android-release-build.sh`.
|
- [ ] Run `scripts/android-release-build.sh`.
|
||||||
- [x] Verify source fingerprint, signing certificate, bundletool validation,
|
- [ ] Verify source fingerprint, signing certificate, bundletool validation,
|
||||||
lint, package name, version code/name, target SDK, and production origin.
|
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.
|
physical phone and run the release smoke test.
|
||||||
- [ ] Upload the source-bound AAB first to internal testing.
|
- [ ] Upload the source-bound AAB first to internal testing.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,19 @@ Suggested alt text:
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
The four files in `screenshots/phone/` were captured from the development
|
The four files in `screenshots/phone/` were captured on 2026-07-31 from the
|
||||||
Android client on a physical 1220×2712 device and cropped without stretching to
|
development Android client on the authorised physical 1220×2712 device. The
|
||||||
1080×1920. They contain only public examples or synthetic E2E data:
|
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.
|
1. `01-home.png` — public product explanation and example medicine request.
|
||||||
2. `02-discovery.png` — authenticated request discovery and filters.
|
2. `02-request-form.png` — the first step of the urgent-help request flow and
|
||||||
3. `03-private-request.png` — completed synthetic request, double-blind review,
|
its safety notice.
|
||||||
and approximate request area.
|
3. `03-location-privacy.png` — an unsubmitted approximate-area selection with
|
||||||
4. `04-activity.png` — approved synthetic group chat and exact meeting point
|
a draggable privacy circle and radius controls.
|
||||||
disclosed to an approved participant.
|
4. `04-category-proposals.png` — the community category and subcategory
|
||||||
|
proposal flow.
|
||||||
|
|
||||||
The uncropped ADB source captures are intentionally kept outside Git. No real
|
The uncropped ADB source captures are intentionally kept outside Git. No real
|
||||||
email, real user message, notification, medical detail, or exact real-user
|
email, real user message, notification, medical detail, or exact real-user
|
||||||
|
|
@ -39,9 +42,9 @@ privacy, or map behavior change.
|
||||||
Suggested English alt text:
|
Suggested English alt text:
|
||||||
|
|
||||||
1. `Who Need Help home screen explaining nearby voluntary assistance.`
|
1. `Who Need Help home screen explaining nearby voluntary assistance.`
|
||||||
2. `Nearby help request discovery with category, urgency, and area filters.`
|
2. `Urgent-help request form with clear steps and a non-emergency safety notice.`
|
||||||
3. `Completed synthetic request with double-blind review and approximate map area.`
|
3. `Approximate-area map with a movable privacy circle and selectable radius.`
|
||||||
4. `Approved synthetic activity group chat with its disclosed meeting point.`
|
4. `Community form for proposing a new help category or subcategory.`
|
||||||
|
|
||||||
Official asset requirements:
|
Official asset requirements:
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 229 KiB |
BIN
android/store-assets/screenshots/phone/02-request-form.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
android/store-assets/screenshots/phone/03-location-privacy.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
android/store-assets/screenshots/phone/04-category-proposals.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
|
|
@ -344,42 +344,6 @@ html {
|
||||||
white-space: nowrap;
|
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) {
|
@media (max-width: 56rem) {
|
||||||
.request-discovery-toolbar {
|
.request-discovery-toolbar {
|
||||||
align-items: stretch;
|
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 {
|
.maplibregl-marker.is-selected svg {
|
||||||
filter: drop-shadow(0 0 5px #d6573b) drop-shadow(0 0 8px white);
|
filter: drop-shadow(0 0 5px #d6573b) drop-shadow(0 0 8px white);
|
||||||
transform: scale(1.12);
|
transform: scale(1.12);
|
||||||
|
|
@ -449,6 +408,12 @@ html {
|
||||||
height: 2.75rem;
|
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 {
|
.request-discovery-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -472,6 +437,15 @@ html {
|
||||||
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
|
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
|
||||||
position: static;
|
position: static;
|
||||||
order: -1;
|
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,
|
.request-discovery-map,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import maplibregl from "maplibre-gl"
|
import maplibregl from "maplibre-gl"
|
||||||
import {
|
import {
|
||||||
clusterCountLabel,
|
|
||||||
clusterExpansionTarget,
|
clusterExpansionTarget,
|
||||||
|
clusterExpansionCenter,
|
||||||
|
clusterIconDescriptor,
|
||||||
pointFeatureCollection,
|
pointFeatureCollection,
|
||||||
pointSourceOptions,
|
pointSourceOptions,
|
||||||
serverClusterDescriptor
|
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 emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
|
||||||
|
|
||||||
const areaPolygon = point => {
|
const areaPolygon = point => {
|
||||||
|
|
@ -288,13 +337,12 @@ const createAidMap = element => {
|
||||||
element,
|
element,
|
||||||
map: null,
|
map: null,
|
||||||
active: true,
|
active: true,
|
||||||
viewportTimer: null,
|
pendingViewportAction: null,
|
||||||
resizeFrame: null,
|
resizeFrame: null,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
renderedSize: null,
|
renderedSize: null,
|
||||||
clusterMarkers: new Map(),
|
clusterImageIds: new Set(),
|
||||||
visibleClusterKeys: new Set(),
|
clusterImageRemovalTimers: new Map(),
|
||||||
markerElements: new Map(),
|
|
||||||
markerDataSignature: null,
|
markerDataSignature: null,
|
||||||
points: [],
|
points: [],
|
||||||
selectedItemId: null,
|
selectedItemId: null,
|
||||||
|
|
@ -308,6 +356,7 @@ const createAidMap = element => {
|
||||||
areaLineId: `${element.id || "aid-map"}-area-line`,
|
areaLineId: `${element.id || "aid-map"}-area-line`,
|
||||||
areaSelectedId: `${element.id || "aid-map"}-area-selected`,
|
areaSelectedId: `${element.id || "aid-map"}-area-selected`,
|
||||||
clusterSourceId: `${element.id || "aid-map"}-clusters`,
|
clusterSourceId: `${element.id || "aid-map"}-clusters`,
|
||||||
|
clusterIconId: `${element.id || "aid-map"}-cluster-icon`,
|
||||||
pointCircleId: `${element.id || "aid-map"}-point-circle`,
|
pointCircleId: `${element.id || "aid-map"}-point-circle`,
|
||||||
pointSelectedId: `${element.id || "aid-map"}-point-selected`,
|
pointSelectedId: `${element.id || "aid-map"}-point-selected`,
|
||||||
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
|
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
|
||||||
|
|
@ -323,14 +372,6 @@ const createAidMap = element => {
|
||||||
state.element.dataset.mapReady = "true"
|
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 = () => {
|
state.retry = () => {
|
||||||
if (!state.active) return
|
if (!state.active) return
|
||||||
|
|
||||||
|
|
@ -467,10 +508,6 @@ const createAidMap = element => {
|
||||||
state.highlightRequest = id => {
|
state.highlightRequest = id => {
|
||||||
state.selectedItemId = id || null
|
state.selectedItemId = id || null
|
||||||
|
|
||||||
state.markerElements.forEach((element, requestId) => {
|
|
||||||
element.classList.toggle("is-selected", requestId === id)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (state.map?.getLayer(state.areaSelectedId)) {
|
if (state.map?.getLayer(state.areaSelectedId)) {
|
||||||
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
|
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
|
||||||
}
|
}
|
||||||
|
|
@ -575,95 +612,45 @@ const createAidMap = element => {
|
||||||
|
|
||||||
state.clusterExpansionPending = true
|
state.clusterExpansionPending = true
|
||||||
state.map.easeTo({
|
state.map.easeTo({
|
||||||
center: [point.longitude, point.latitude],
|
center: clusterExpansionCenter(point),
|
||||||
zoom: expansionZoom,
|
zoom: expansionZoom,
|
||||||
duration: 300
|
duration: 300
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
state.syncClusterMarkers = () => {
|
state.syncClusterImages = points => {
|
||||||
if (!state.map || state.element.dataset.discoveryMap !== "true") return
|
if (!state.map || state.element.dataset.discoveryMap !== "true") return
|
||||||
|
|
||||||
const visibleKeys = new Set()
|
const descriptors = new Map()
|
||||||
|
points.forEach(point => {
|
||||||
state.points.forEach(point => {
|
if (!serverClusterDescriptor(point)) return
|
||||||
const descriptor = serverClusterDescriptor(point)
|
const descriptor = clusterIconDescriptor(point, uiLocale())
|
||||||
if (!descriptor) return
|
descriptors.set(descriptor.id, descriptor)
|
||||||
|
|
||||||
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 = {
|
descriptors.forEach((descriptor, imageId) => {
|
||||||
button,
|
const removalTimer = state.clusterImageRemovalTimers.get(imageId)
|
||||||
marker,
|
if (removalTimer !== undefined) {
|
||||||
point,
|
window.clearTimeout(removalTimer)
|
||||||
visible: false,
|
state.clusterImageRemovalTimers.delete(imageId)
|
||||||
count: null,
|
|
||||||
urgentCount: null,
|
|
||||||
label: null,
|
|
||||||
longitude: null,
|
|
||||||
latitude: null
|
|
||||||
}
|
|
||||||
state.clusterMarkers.set(key, entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.point = point
|
if (state.map.hasImage(imageId)) return
|
||||||
|
const {image, pixelRatio} = clusterIconImage(descriptor)
|
||||||
if (entry.count !== count || entry.urgentCount !== urgentCount) {
|
state.map.addImage(imageId, image, {pixelRatio})
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
state.visibleClusterKeys.forEach(key => {
|
state.clusterImageIds.forEach(imageId => {
|
||||||
if (visibleKeys.has(key)) return
|
if (descriptors.has(imageId) || state.clusterImageRemovalTimers.has(imageId)) return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
const entry = state.clusterMarkers.get(key)
|
state.clusterImageRemovalTimers.delete(imageId)
|
||||||
entry?.marker.remove()
|
if (!state.active || state.clusterImageIds.has(imageId)) return
|
||||||
state.clusterMarkers.delete(key)
|
if (state.map?.hasImage(imageId)) state.map.removeImage(imageId)
|
||||||
|
}, 100)
|
||||||
|
state.clusterImageRemovalTimers.set(imageId, timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
state.visibleClusterKeys = visibleKeys
|
state.clusterImageIds = new Set(descriptors.keys())
|
||||||
}
|
}
|
||||||
|
|
||||||
state.syncVisibleAreas = () => {
|
state.syncVisibleAreas = () => {
|
||||||
|
|
@ -742,6 +729,7 @@ const createAidMap = element => {
|
||||||
|
|
||||||
{
|
{
|
||||||
const areaSource = existingAreaSource
|
const areaSource = existingAreaSource
|
||||||
|
state.syncClusterImages(points)
|
||||||
|
|
||||||
if (areaSource) {
|
if (areaSource) {
|
||||||
areaSource.setData(areaFeatures)
|
areaSource.setData(areaFeatures)
|
||||||
|
|
@ -784,6 +772,18 @@ const createAidMap = element => {
|
||||||
pointSource.setData(pointFeatures)
|
pointSource.setData(pointFeatures)
|
||||||
} else {
|
} else {
|
||||||
state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures))
|
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({
|
state.map.addLayer({
|
||||||
id: state.pointCircleId,
|
id: state.pointCircleId,
|
||||||
type: "circle",
|
type: "circle",
|
||||||
|
|
@ -819,7 +819,7 @@ const createAidMap = element => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const interactiveLayers = [state.pointCircleId]
|
const interactiveLayers = [state.clusterIconId, state.pointCircleId]
|
||||||
|
|
||||||
interactiveLayers.forEach(layerId => {
|
interactiveLayers.forEach(layerId => {
|
||||||
state.map.on("mouseenter", 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 => {
|
state.map.on("click", state.pointCircleId, event => {
|
||||||
const feature = event.features?.[0]
|
const feature = event.features?.[0]
|
||||||
const properties = feature?.properties
|
const properties = feature?.properties
|
||||||
|
|
@ -846,7 +852,6 @@ const createAidMap = element => {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.markerDataSignature = markerDataSignature
|
state.markerDataSignature = markerDataSignature
|
||||||
state.syncClusterMarkers()
|
|
||||||
if (state.element.dataset.autoFit !== "false") state.fitMarkers()
|
if (state.element.dataset.autoFit !== "false") state.fitMarkers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -936,14 +941,11 @@ const createAidMap = element => {
|
||||||
const viewport = mapViewport(state.map)
|
const viewport = mapViewport(state.map)
|
||||||
if (!viewport) return
|
if (!viewport) return
|
||||||
|
|
||||||
if (state.clusterExpansionPending) {
|
state.pendingViewportAction = {
|
||||||
state.clusterExpansionPending = false
|
viewport,
|
||||||
window.clearTimeout(state.viewportTimer)
|
clusterExpansion: state.clusterExpansionPending
|
||||||
state.viewportTimer = null
|
|
||||||
state.onClusterExpand?.(viewport)
|
|
||||||
} else {
|
|
||||||
state.queueViewportChange(viewport)
|
|
||||||
}
|
}
|
||||||
|
state.clusterExpansionPending = false
|
||||||
}
|
}
|
||||||
|
|
||||||
state.onIdle = () => {
|
state.onIdle = () => {
|
||||||
|
|
@ -955,6 +957,15 @@ const createAidMap = element => {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.syncVisibleAreas()
|
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)
|
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
|
||||||
|
|
@ -972,16 +983,14 @@ const createAidMap = element => {
|
||||||
|
|
||||||
state.destroy = () => {
|
state.destroy = () => {
|
||||||
state.active = false
|
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)
|
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
|
||||||
state.resizeObserver?.disconnect()
|
state.resizeObserver?.disconnect()
|
||||||
state.map?.off("load", state.onLoad)
|
state.map?.off("load", state.onLoad)
|
||||||
state.map?.off("idle", state.onIdle)
|
state.map?.off("idle", state.onIdle)
|
||||||
state.map?.off("moveend", state.onMoveEnd)
|
state.map?.off("moveend", state.onMoveEnd)
|
||||||
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
|
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||||||
state.clusterMarkers.forEach(({marker}) => marker.remove())
|
|
||||||
state.clusterMarkers.clear()
|
|
||||||
state.visibleClusterKeys.clear()
|
|
||||||
state.map?.remove()
|
state.map?.remove()
|
||||||
state.map = null
|
state.map = null
|
||||||
state.locationBounds = null
|
state.locationBounds = null
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,33 @@ export const clusterCountLabel = (count, locale = "en") => {
|
||||||
}).format(value)
|
}).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) => {
|
export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = 22) => {
|
||||||
const current = finiteCount(currentZoom, 0)
|
const current = finiteCount(currentZoom, 0)
|
||||||
const requested = finiteCount(requestedZoom, current + 1)
|
const requested = finiteCount(requestedZoom, current + 1)
|
||||||
|
|
@ -32,6 +59,29 @@ export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom =
|
||||||
return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested))
|
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 => {
|
export const serverClusterDescriptor = point => {
|
||||||
if (
|
if (
|
||||||
point?.type !== "cluster" ||
|
point?.type !== "cluster" ||
|
||||||
|
|
@ -53,7 +103,11 @@ export const serverClusterDescriptor = point => {
|
||||||
|
|
||||||
export const pointFeatureCollection = (points, locale = "en") => ({
|
export const pointFeatureCollection = (points, locale = "en") => ({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: points.map(point => ({
|
features: points.map(point => {
|
||||||
|
const clusterIcon =
|
||||||
|
point.type === "cluster" ? clusterIconDescriptor(point, locale) : null
|
||||||
|
|
||||||
|
return {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
id: point.id,
|
id: point.id,
|
||||||
properties: {
|
properties: {
|
||||||
|
|
@ -69,16 +123,15 @@ export const pointFeatureCollection = (points, locale = "en") => ({
|
||||||
parent_cluster_id: point.parent_cluster_id || "",
|
parent_cluster_id: point.parent_cluster_id || "",
|
||||||
hierarchy_level: finiteCount(point.hierarchy_level, 0),
|
hierarchy_level: finiteCount(point.hierarchy_level, 0),
|
||||||
expansion_zoom: finiteCount(point.expansion_zoom, 22),
|
expansion_zoom: finiteCount(point.expansion_zoom, 22),
|
||||||
count_label:
|
count_label: clusterIcon?.label || "",
|
||||||
point.type === "cluster"
|
cluster_icon: clusterIcon?.id || ""
|
||||||
? clusterCountLabel(finiteCount(point.count, 1), locale)
|
|
||||||
: ""
|
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Point",
|
type: "Point",
|
||||||
coordinates: [Number(point.longitude), Number(point.latitude)]
|
coordinates: [Number(point.longitude), Number(point.latitude)]
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export const pointSourceOptions = data => ({
|
export const pointSourceOptions = data => ({
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,33 @@ import test from "node:test"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clusterCountLabel,
|
clusterCountLabel,
|
||||||
|
clusterExpansionCenter,
|
||||||
clusterExpansionTarget,
|
clusterExpansionTarget,
|
||||||
|
clusterIconDescriptor,
|
||||||
clusterSizeClass,
|
clusterSizeClass,
|
||||||
pointFeatureCollection,
|
pointFeatureCollection,
|
||||||
pointSourceOptions,
|
pointSourceOptions,
|
||||||
serverClusterDescriptor
|
serverClusterDescriptor
|
||||||
} from "./map_cluster_features.mjs"
|
} 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", () => {
|
test("server hierarchy metadata survives GeoJSON conversion without becoming a native cluster", () => {
|
||||||
const feature = pointFeatureCollection([
|
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.expansion_zoom, 13)
|
||||||
assert.equal(feature.properties.weight, 27)
|
assert.equal(feature.properties.weight, 27)
|
||||||
assert.equal(feature.properties.count_label, "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)
|
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(9.6, 13), 13)
|
||||||
assert.equal(clusterExpansionTarget(21.9, 24), 22)
|
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]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
image: who-need-help:traefik-v3.7.8-grpc1.82.1
|
image: who-need-help:traefik-v3.7.10-grpc1.82.1
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.traefik
|
dockerfile: Dockerfile.traefik
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| PostgreSQL | 18.4 |
|
| PostgreSQL | 18.4 |
|
||||||
| PostGIS | 3.6.4 |
|
| PostGIS | 3.6.4 |
|
||||||
| Traefik | 3.7.8 |
|
| Traefik | 3.7.10 |
|
||||||
| Mailpit | 1.30.4 |
|
| Mailpit | 1.30.4 |
|
||||||
| k6 load generator | 2.1.0 |
|
| k6 load generator | 2.1.0 |
|
||||||
| Prometheus | 3.13.1 |
|
| 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
|
`Dockerfile.minio` fetches checksum-pinned upstream source commits for MinIO
|
||||||
server and client and records every dependency override used to rebuild them.
|
server and client and records every dependency override used to rebuild them.
|
||||||
The current overrides move Apache Thrift, jsonparser, Prometheus, Go crypto/net,
|
The current overrides move Apache Thrift, jsonparser, Prometheus,
|
||||||
and gRPC dependencies to the versions checked by the image security gate.
|
`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
|
Both projects are AGPLv3; anyone distributing or operating modified builds must
|
||||||
review and satisfy the applicable license obligations. This repository keeps
|
review and satisfy the applicable license obligations. This repository keeps
|
||||||
the exact upstream source identifiers and the complete modification/build
|
the exact upstream source identifiers and the complete modification/build
|
||||||
|
|
|
||||||
75
docs/google-play-release-candidate-2026-08-01.md
Normal 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
|
||||||
|
|
@ -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);
|
expect(await updatedPage.evaluate(() => navigator.onLine)).toBe(false);
|
||||||
|
|
||||||
await context.setOffline(false);
|
await context.setOffline(false);
|
||||||
await updatedPage.reload();
|
|
||||||
await expect(updatedPage.getByRole("heading", { name: "Log in", exact: true })).toBeVisible();
|
await expect(updatedPage.getByRole("heading", { name: "Log in", exact: true })).toBeVisible();
|
||||||
expect(consoleFailures).toEqual([]);
|
expect(consoleFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,12 @@ defmodule WhoNeedHelp.Activities do
|
||||||
|
|
||||||
points =
|
points =
|
||||||
user
|
user
|
||||||
|> open_activities_query(filters, viewport)
|
|> open_activities_query(filters, nil)
|
||||||
|> where([activity], activity.location_visibility == :approximate_public)
|
|> where([activity], activity.location_visibility == :approximate_public)
|
||||||
|
|> filter_discovery_envelopes(
|
||||||
|
DiscoveryCluster.viewport_envelopes(viewport, level),
|
||||||
|
filters
|
||||||
|
)
|
||||||
|> select([activity], %{
|
|> select([activity], %{
|
||||||
id: activity.id,
|
id: activity.id,
|
||||||
title: activity.title,
|
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, nil, _filters), do: query
|
||||||
|
|
||||||
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
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 =
|
candidate_condition =
|
||||||
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
Enum.reduce(envelopes, dynamic(false), fn
|
||||||
{west, south, east, north}, condition ->
|
{west, south, east, north}, condition ->
|
||||||
dynamic(
|
dynamic(
|
||||||
[activity],
|
[activity],
|
||||||
|
|
@ -879,7 +887,7 @@ defmodule WhoNeedHelp.Activities do
|
||||||
end)
|
end)
|
||||||
|
|
||||||
exact_condition =
|
exact_condition =
|
||||||
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
Enum.reduce(envelopes, dynamic(false), fn
|
||||||
{west, south, east, north}, condition ->
|
{west, south, east, north}, condition ->
|
||||||
dynamic(
|
dynamic(
|
||||||
[activity],
|
[activity],
|
||||||
|
|
@ -1004,6 +1012,7 @@ defmodule WhoNeedHelp.Activities do
|
||||||
|
|
||||||
defp map_discovery_item(row, level) do
|
defp map_discovery_item(row, level) do
|
||||||
cluster_id = DiscoveryCluster.cluster_id(:activity, level, row.cell_x, row.cell_y)
|
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",
|
type: "cluster",
|
||||||
|
|
@ -1021,8 +1030,8 @@ defmodule WhoNeedHelp.Activities do
|
||||||
row.north_m
|
row.north_m
|
||||||
),
|
),
|
||||||
count: row.count,
|
count: row.count,
|
||||||
latitude: row.latitude,
|
latitude: latitude,
|
||||||
longitude: row.longitude,
|
longitude: longitude,
|
||||||
bounds: %{
|
bounds: %{
|
||||||
west: row.west,
|
west: row.west,
|
||||||
south: row.south,
|
south: row.south,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ defmodule WhoNeedHelp.DiscoveryQueryCache do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:fetch, key, function}, from, state) do
|
def handle_call({:fetch, key, function}, from, state) do
|
||||||
now = System.monotonic_time(:millisecond)
|
now = System.monotonic_time(:millisecond)
|
||||||
|
state = update_in(state.entries, &prune_expired(&1, now))
|
||||||
|
|
||||||
case state.entries do
|
case state.entries do
|
||||||
%{^key => {expires_at, value}} when expires_at > now ->
|
%{^key => {expires_at, value}} when expires_at > now ->
|
||||||
|
|
@ -120,4 +121,8 @@ defmodule WhoNeedHelp.DiscoveryQueryCache do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_store(state, _key, {:error, _reason}), do: state
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ defmodule WhoNeedHelp.Help do
|
||||||
[request],
|
[request],
|
||||||
not is_nil(request.location) and request.location_visibility != :hidden
|
not is_nil(request.location) and request.location_visibility != :hidden
|
||||||
)
|
)
|
||||||
|> filter_request_map_viewport(viewport)
|
|> filter_request_map_cluster_viewport(viewport, level)
|
||||||
|> select([request], %{
|
|> select([request], %{
|
||||||
id: request.id,
|
id: request.id,
|
||||||
title: request.title,
|
title: request.title,
|
||||||
|
|
@ -202,13 +202,22 @@ defmodule WhoNeedHelp.Help do
|
||||||
# Lists and map markers use the same displayed public center. A privacy
|
# Lists and map markers use the same displayed public center. A privacy
|
||||||
# radius describes uncertainty around that center; it must not make a card
|
# radius describes uncertainty around that center; it must not make a card
|
||||||
# appear in a viewport where its public marker was not loaded.
|
# appear in a viewport where its public marker was not loaded.
|
||||||
defp filter_request_map_viewport(query, %DiscoveryViewport{} = viewport) do
|
defp filter_request_map_cluster_viewport(
|
||||||
where(query, ^request_map_viewport_condition(viewport))
|
query,
|
||||||
|
%DiscoveryViewport{} = viewport,
|
||||||
|
level
|
||||||
|
) do
|
||||||
|
envelopes = DiscoveryCluster.viewport_envelopes(viewport, level)
|
||||||
|
where(query, ^request_map_envelope_condition(envelopes))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp request_map_viewport_condition(%DiscoveryViewport{} = viewport) do
|
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 =
|
spatial_condition =
|
||||||
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
Enum.reduce(envelopes, dynamic(false), fn
|
||||||
{west, south, east, north}, condition ->
|
{west, south, east, north}, condition ->
|
||||||
dynamic(
|
dynamic(
|
||||||
[request],
|
[request],
|
||||||
|
|
@ -1271,6 +1280,7 @@ defmodule WhoNeedHelp.Help do
|
||||||
|
|
||||||
defp map_discovery_item(row, level) do
|
defp map_discovery_item(row, level) do
|
||||||
cluster_id = DiscoveryCluster.cluster_id(:request, level, row.cell_x, row.cell_y)
|
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",
|
type: "cluster",
|
||||||
|
|
@ -1289,8 +1299,8 @@ defmodule WhoNeedHelp.Help do
|
||||||
),
|
),
|
||||||
count: row.count,
|
count: row.count,
|
||||||
urgent_count: row.urgent_count,
|
urgent_count: row.urgent_count,
|
||||||
latitude: row.latitude,
|
latitude: latitude,
|
||||||
longitude: row.longitude,
|
longitude: longitude,
|
||||||
bounds: %{
|
bounds: %{
|
||||||
west: row.west,
|
west: row.west,
|
||||||
south: row.south,
|
south: row.south,
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
|
||||||
MapLibre renders the resulting hierarchy nodes directly. They must not be
|
MapLibre renders the resulting hierarchy nodes directly. They must not be
|
||||||
passed through a second browser-side clustering pass, because that changes
|
passed through a second browser-side clustering pass, because that changes
|
||||||
cluster identity and center between server responses. For each server node,
|
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.
|
child cell.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
||||||
@world_half_meters 20_037_508.342_789_244
|
@world_half_meters 20_037_508.342_789_244
|
||||||
@world_width_meters @world_half_meters * 2.0
|
@world_width_meters @world_half_meters * 2.0
|
||||||
|
@max_mercator_latitude 85.051_128_78
|
||||||
@cluster_level_offset 2
|
@cluster_level_offset 2
|
||||||
@minimum_level 0
|
@minimum_level 0
|
||||||
@maximum_map_zoom 22
|
@maximum_map_zoom 22
|
||||||
|
|
@ -26,7 +29,7 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
|
||||||
|
|
||||||
def level_for_zoom(zoom) when is_number(zoom) do
|
def level_for_zoom(zoom) when is_number(zoom) do
|
||||||
zoom
|
zoom
|
||||||
|> floor()
|
|> round()
|
||||||
|> Kernel.+(@cluster_level_offset)
|
|> Kernel.+(@cluster_level_offset)
|
||||||
|> clamp(@minimum_level, @maximum_level)
|
|> clamp(@minimum_level, @maximum_level)
|
||||||
end
|
end
|
||||||
|
|
@ -37,12 +40,52 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
|
||||||
|
|
||||||
def world_half_meters, do: @world_half_meters
|
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)
|
def cluster_id(kind, level, cell_x, cell_y)
|
||||||
when kind in [:request, :activity] and is_integer(level) and is_integer(cell_x) and
|
when kind in [:request, :activity] and is_integer(level) and is_integer(cell_x) and
|
||||||
is_integer(cell_y) do
|
is_integer(cell_y) do
|
||||||
"#{kind}:#{level}:#{cell_x}:#{cell_y}"
|
"#{kind}:#{level}:#{cell_x}:#{cell_y}"
|
||||||
end
|
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, 0, _cell_x, _cell_y), do: nil
|
||||||
|
|
||||||
def parent_id(kind, level, cell_x, cell_y)
|
def parent_id(kind, level, cell_x, cell_y)
|
||||||
|
|
@ -88,6 +131,92 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
|
||||||
|> floor()
|
|> floor()
|
||||||
end
|
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
|
defp clamp_coordinate(value) do
|
||||||
value
|
value
|
||||||
|> Kernel.*(1.0)
|
|> Kernel.*(1.0)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
{gettext("A Google account is connected. You can use it to sign in.")}
|
{gettext("A Google account is connected. You can use it to sign in.")}
|
||||||
<% else %>
|
<% else %>
|
||||||
{gettext(
|
{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 %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
@view_modes ~w(list split map)
|
@view_modes ~w(list split map)
|
||||||
@map_update_modes ~w(manual auto)
|
@map_update_modes ~w(manual auto)
|
||||||
@filter_keys ~w(category_id area)
|
@filter_keys ~w(category_id area)
|
||||||
@map_update_delay_ms 450
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -26,7 +25,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
|> assign(:view_mode, "split")
|
|> assign(:view_mode, "split")
|
||||||
|> assign(:view_explicit, false)
|
|> assign(:view_explicit, false)
|
||||||
|> assign(:map_update_mode, "manual")
|
|> assign(:map_update_mode, "manual")
|
||||||
|> assign(:map_update_timer, nil)
|
|
||||||
|> assign(:viewport, nil)
|
|> assign(:viewport, nil)
|
||||||
|> assign(:pending_viewport, nil)
|
|> assign(:pending_viewport, nil)
|
||||||
|> assign(:activities, [])
|
|> assign(:activities, [])
|
||||||
|
|
@ -52,7 +50,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> cancel_map_update_timer()
|
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|> assign(:discovery_mode, discovery_mode)
|
|> assign(:discovery_mode, discovery_mode)
|
||||||
|
|
@ -130,17 +127,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
|
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||||
{:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))}
|
{:noreply, push_patch(socket, to: activity_path(socket, filters: normalize_filters(filters)))}
|
||||||
|
|
@ -183,13 +169,13 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
{:ok, viewport} ->
|
{:ok, viewport} ->
|
||||||
cond do
|
cond do
|
||||||
DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) ->
|
DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) ->
|
||||||
{:noreply,
|
{:noreply, assign(socket, :pending_viewport, nil)}
|
||||||
socket
|
|
||||||
|> cancel_map_update_timer()
|
|
||||||
|> assign(:pending_viewport, nil)}
|
|
||||||
|
|
||||||
socket.assigns.map_update_mode == "auto" ->
|
socket.assigns.map_update_mode == "auto" ->
|
||||||
{:noreply, schedule_map_update(socket, viewport)}
|
{:noreply,
|
||||||
|
push_patch(socket,
|
||||||
|
to: activity_path(socket, viewport: viewport)
|
||||||
|
)}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
{:noreply, assign(socket, :pending_viewport, viewport)}
|
{:noreply, assign(socket, :pending_viewport, viewport)}
|
||||||
|
|
@ -449,23 +435,6 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|
||||||
~p"/activities?#{params}"
|
~p"/activities?#{params}"
|
||||||
end
|
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(nil), do: ""
|
||||||
|
|
||||||
defp viewport_json(%DiscoveryViewport{} = viewport) do
|
defp viewport_json(%DiscoveryViewport{} = viewport) do
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
@view_modes ~w(list split map)
|
@view_modes ~w(list split map)
|
||||||
@map_update_modes ~w(manual auto)
|
@map_update_modes ~w(manual auto)
|
||||||
@filter_keys ~w(category_id urgency area)
|
@filter_keys ~w(category_id urgency area)
|
||||||
@map_update_delay_ms 450
|
|
||||||
@empty_reputation %{
|
@empty_reputation %{
|
||||||
completed: 0,
|
completed: 0,
|
||||||
unique_people: 0,
|
unique_people: 0,
|
||||||
|
|
@ -33,7 +32,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
|> assign(:view_mode, "split")
|
|> assign(:view_mode, "split")
|
||||||
|> assign(:view_explicit, false)
|
|> assign(:view_explicit, false)
|
||||||
|> assign(:map_update_mode, "manual")
|
|> assign(:map_update_mode, "manual")
|
||||||
|> assign(:map_update_timer, nil)
|
|
||||||
|> assign(:viewport, nil)
|
|> assign(:viewport, nil)
|
||||||
|> assign(:pending_viewport, nil)
|
|> assign(:pending_viewport, nil)
|
||||||
|> assign(:discovery_topics, [])
|
|> assign(:discovery_topics, [])
|
||||||
|
|
@ -62,7 +60,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> cancel_map_update_timer()
|
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|> assign(:discovery_mode, discovery_mode)
|
|> assign(:discovery_mode, discovery_mode)
|
||||||
|
|
@ -129,13 +126,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
{:ok, viewport} ->
|
{:ok, viewport} ->
|
||||||
cond do
|
cond do
|
||||||
DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) ->
|
DiscoveryViewport.same_area?(viewport, socket.assigns.viewport) ->
|
||||||
{:noreply,
|
{:noreply, assign(socket, :pending_viewport, nil)}
|
||||||
socket
|
|
||||||
|> cancel_map_update_timer()
|
|
||||||
|> assign(:pending_viewport, nil)}
|
|
||||||
|
|
||||||
socket.assigns.map_update_mode == "auto" ->
|
socket.assigns.map_update_mode == "auto" ->
|
||||||
{:noreply, schedule_map_update(socket, viewport)}
|
{:noreply,
|
||||||
|
push_patch(socket,
|
||||||
|
to: request_path(socket, viewport: viewport)
|
||||||
|
)}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
{:noreply, assign(socket, :pending_viewport, viewport)}
|
{:noreply, assign(socket, :pending_viewport, viewport)}
|
||||||
|
|
@ -224,17 +221,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
|
{:noreply, socket |> assign(:refresh_timer, nil) |> load_if_connected()}
|
||||||
end
|
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
|
@impl true
|
||||||
def handle_async(:load_discovery, {:ok, result}, socket) do
|
def handle_async(:load_discovery, {:ok, result}, socket) do
|
||||||
{:noreply, assign(socket, result)}
|
{:noreply, assign(socket, result)}
|
||||||
|
|
@ -454,23 +440,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
~p"/requests?#{params}"
|
~p"/requests?#{params}"
|
||||||
end
|
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("now"), do: gettext("Now")
|
defp urgency_label("now"), do: gettext("Now")
|
||||||
defp urgency_label(:today), do: gettext("Today")
|
defp urgency_label(:today), do: gettext("Today")
|
||||||
|
|
|
||||||
|
|
@ -302,8 +302,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
{:error, _reason} -> socket
|
{:error, _reason} -> socket
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply,
|
{:noreply, put_action_flash(socket, :error, gettext("Location sharing could not start."))}
|
||||||
put_action_flash(socket, :error, gettext("The browser could not share your location."))}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("stop-tracking", _, socket) do
|
def handle_event("stop-tracking", _, socket) do
|
||||||
|
|
|
||||||
|
|
@ -2686,7 +2686,7 @@ msgstr ""
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, 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 ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
||||||
|
|
|
||||||
|
|
@ -2686,8 +2686,8 @@ msgstr "Connect Google account"
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, 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 "Connect Google only after signing in here. We never merge accounts automatically by matching email addresses."
|
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
|
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
|
||||||
|
|
@ -2826,8 +2826,8 @@ msgstr "Подключить аккаунт Google"
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, 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 "Подключайте Google только после входа здесь. Мы никогда не объединяем аккаунты автоматически по совпадению email."
|
msgstr "Подключите Google для будущих входов. Аккаунт с совпадающим подтверждённым адресом Gmail или Google Workspace также может подключиться автоматически; для других почтовых доменов требуется подтверждение аккаунта."
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
|
||||||
|
|
@ -2820,8 +2820,8 @@ msgstr "Підключити обліковий запис Google"
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
#: lib/who_need_help_web/controllers/user_settings_html/edit.html.heex:34
|
||||||
#, elixir-autogen, elixir-format
|
#, 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 "Підключайте Google лише після входу тут. Ми ніколи не об’єднуємо облікові записи автоматично за збігом email."
|
msgstr "Підключіть Google для майбутніх входів. Обліковий запис із відповідною підтвердженою адресою Gmail або Google Workspace також може підключитися автоматично; для інших поштових доменів потрібне підтвердження облікового запису."
|
||||||
|
|
||||||
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
#: lib/who_need_help_web/controllers/user_registration_html/new.html.heex:99
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,12 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
background: #151618;
|
background: #151618;
|
||||||
|
|
@ -88,6 +94,14 @@
|
||||||
Reconnect to view current requests, messages, maps, and live locations.
|
Reconnect to view current requests, messages, maps, and live locations.
|
||||||
We do not save private pages on this device.
|
We do not save private pages on this device.
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
id="connection-status"
|
||||||
|
class="connection-status"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Waiting for a connection…
|
||||||
|
</p>
|
||||||
<form method="get">
|
<form method="get">
|
||||||
<button type="submit">Try again</button>
|
<button type="submit">Try again</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -95,5 +109,54 @@
|
||||||
Немає з’єднання · Нет соединения
|
Немає з’єднання · Нет соединения
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const CACHE_PREFIX = "who-need-help-static-"
|
const CACHE_PREFIX = "who-need-help-static-"
|
||||||
const CACHE = `${CACHE_PREFIX}v5`
|
const CACHE = `${CACHE_PREFIX}v6`
|
||||||
const OFFLINE_URL = "/offline.html"
|
const OFFLINE_URL = "/offline.html"
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
OFFLINE_URL,
|
OFFLINE_URL,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|
||||||
mapfile -d '' -t source_files < <(
|
mapfile -d '' -t candidate_files < <(
|
||||||
git -C "$ROOT" ls-files \
|
git -C "$ROOT" ls-files \
|
||||||
--cached \
|
--cached \
|
||||||
--others \
|
--others \
|
||||||
|
|
@ -13,6 +13,21 @@ mapfile -d '' -t source_files < <(
|
||||||
LC_ALL=C sort -z
|
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
|
if [[ "${#source_files[@]}" -eq 0 ]]; then
|
||||||
echo "No Android source files were found." >&2
|
echo "No Android source files were found." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,30 @@ cleanup() {
|
||||||
}
|
}
|
||||||
trap cleanup EXIT HUP INT TERM
|
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"
|
echo "Checking shell scripts with ShellCheck 0.11.0"
|
||||||
# Word splitting is intentional: find emits repository-controlled paths and
|
# Word splitting is intentional: find emits repository-controlled paths and
|
||||||
# ShellCheck expects each file as a separate argument.
|
# 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}}' "$socket_proxy_image")" = "haproxy"
|
||||||
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
|
test "$(docker image inspect --format '{{.Config.User}}' "$postgis_image")" = "postgres"
|
||||||
test "$(docker image inspect --format '{{.Config.User}}' "$caddy_image")" = "1000:1000"
|
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 "$caddy_image" version |
|
||||||
docker run --rm "$traefik_image" version | grep -F 'v3.7.8-wnh-grpc1.82.1' >/dev/null
|
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 '
|
docker run --rm --entrypoint sh "$postgis_image" -euc '
|
||||||
test ! -e /usr/local/bin/gosu
|
test ! -e /usr/local/bin/gosu
|
||||||
test "$(id -u)" = 70
|
test "$(id -u)" = 70
|
||||||
|
|
@ -1329,15 +1354,7 @@ for image in \
|
||||||
"$traefik_image" \
|
"$traefik_image" \
|
||||||
"$caddy_image" \
|
"$caddy_image" \
|
||||||
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
|
"axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6"; do
|
||||||
docker run --rm \
|
scan_image "$image"
|
||||||
--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"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Building the pinned quality image and cached Dialyzer PLTs"
|
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
|
grep -F 'restic 0.19.1 compiled with go1.26.5' >/dev/null
|
||||||
printf '%s\n' "$backup_versions" |
|
printf '%s\n' "$backup_versions" |
|
||||||
grep -F 'pg_dump (PostgreSQL) 18.4' >/dev/null
|
grep -F 'pg_dump (PostgreSQL) 18.4' >/dev/null
|
||||||
docker run --rm \
|
scan_image "$backup_image"
|
||||||
--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"
|
|
||||||
|
|
||||||
echo "Building and scanning the pinned non-root MinIO server and client images"
|
echo "Building and scanning the pinned non-root MinIO server and client images"
|
||||||
docker build --target server --tag "$minio_image" --file Dockerfile.minio .
|
docker build --target server --tag "$minio_image" --file Dockerfile.minio .
|
||||||
|
|
@ -1534,15 +1543,7 @@ printf '%s\n' "$mc_version" |
|
||||||
printf '%s\n' "$mc_version" |
|
printf '%s\n' "$mc_version" |
|
||||||
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
grep -F 'Runtime: go1.26.5 linux/' >/dev/null
|
||||||
for image in "$minio_image" "$mc_image"; do
|
for image in "$minio_image" "$mc_image"; do
|
||||||
docker run --rm \
|
scan_image "$image"
|
||||||
--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"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Building and scanning the pinned non-root external-boundary mock image"
|
echo "Building and scanning the pinned non-root external-boundary mock image"
|
||||||
|
|
@ -1552,26 +1553,10 @@ docker build \
|
||||||
ops/external-boundaries
|
ops/external-boundaries
|
||||||
test "$(docker image inspect --format '{{.Config.User}}' "$boundary_mock_image")" = \
|
test "$(docker image inspect --format '{{.Config.User}}' "$boundary_mock_image")" = \
|
||||||
"10001:10001"
|
"10001:10001"
|
||||||
docker run --rm \
|
scan_image "$boundary_mock_image"
|
||||||
--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"
|
|
||||||
|
|
||||||
echo "Building and scanning the production release image"
|
echo "Building and scanning the production release image"
|
||||||
docker build --target release --tag "$release_image" .
|
docker build --target release --tag "$release_image" .
|
||||||
docker run --rm \
|
scan_image "$release_image"
|
||||||
--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"
|
|
||||||
|
|
||||||
echo "All isolated quality and security gates passed."
|
echo "All isolated quality and security gates passed."
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
|
|
||||||
alias WhoNeedHelp.{Activities, Catalog, Trust}
|
alias WhoNeedHelp.{Activities, Catalog, Trust}
|
||||||
alias WhoNeedHelp.Activities.Activity
|
alias WhoNeedHelp.Activities.Activity
|
||||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport}
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Trust.AuditEvent
|
alias WhoNeedHelp.Trust.AuditEvent
|
||||||
|
|
||||||
|
|
@ -336,7 +336,7 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
|
|
||||||
expected_ids =
|
expected_ids =
|
||||||
activities
|
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)
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
actual_ids = Enum.map(first.entries ++ second.entries, & &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.cluster_id, "activity:11:")
|
||||||
assert String.starts_with?(cluster.parent_cluster_id, "activity:10:")
|
assert String.starts_with?(cluster.parent_cluster_id, "activity:10:")
|
||||||
assert cluster.expansion_zoom > 9
|
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.west, 30.52, 0.000_001
|
||||||
assert_in_delta cluster.bounds.east, 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
|
assert_in_delta cluster.bounds.south, 50.45, 0.000_001
|
||||||
|
|
@ -484,6 +490,28 @@ defmodule WhoNeedHelp.ActivitiesTest do
|
||||||
false
|
false
|
||||||
end)
|
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 =
|
world_items =
|
||||||
Activities.map_discovery_items(
|
Activities.map_discovery_items(
|
||||||
context.outsider_scope,
|
context.outsider_scope,
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,28 @@ defmodule WhoNeedHelp.DiscoveryQueryCacheTest do
|
||||||
|
|
||||||
assert DiscoveryQueryCache.fetch(key, fn -> :fresh_result end) == :fresh_result
|
assert DiscoveryQueryCache.fetch(key, fn -> :fresh_result end) == :fresh_result
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
|
defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
alias WhoNeedHelp.Help.DiscoveryCluster
|
alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport}
|
||||||
|
|
||||||
test "map zooms select stable hierarchy levels" do
|
test "map zooms select stable hierarchy levels" do
|
||||||
assert DiscoveryCluster.level_for_zoom(0) == 2
|
assert DiscoveryCluster.level_for_zoom(0) == 2
|
||||||
assert DiscoveryCluster.level_for_zoom(5.49) == 7
|
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(6) == 8
|
||||||
assert DiscoveryCluster.level_for_zoom(22) == 24
|
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
|
assert DiscoveryCluster.parent_id(:activity, 0, 0, 0) == nil
|
||||||
end
|
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
|
test "expansion zoom is the first child level that separates member bounds" do
|
||||||
level = 7
|
level = 7
|
||||||
cell = DiscoveryCluster.cell_size_meters(level)
|
cell = DiscoveryCluster.cell_size_meters(level)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
import WhoNeedHelp.AccountsFixtures
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
|
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
|
||||||
alias WhoNeedHelp.Help.Assignment
|
alias WhoNeedHelp.Help.{Assignment, DiscoveryCluster}
|
||||||
alias WhoNeedHelp.Notifications.Notification
|
alias WhoNeedHelp.Notifications.Notification
|
||||||
alias WhoNeedHelp.Push.NearbyMatchWorker
|
alias WhoNeedHelp.Push.NearbyMatchWorker
|
||||||
alias WhoNeedHelp.Help.DiscoveryViewport
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
@ -440,7 +440,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
|
|
||||||
expected_ids =
|
expected_ids =
|
||||||
requests
|
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)
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
actual_ids = Enum.map(first.entries ++ second.entries ++ third.entries, & &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
|
_request, total -> total + 1
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert map_count == length(ids)
|
assert map_count >= length(ids)
|
||||||
|
assert map_count < 3
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hidden coordinates require an explicit area-label search and never become map points",
|
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.cluster_id, "request:11:")
|
||||||
assert String.starts_with?(cluster.parent_cluster_id, "request:10:")
|
assert String.starts_with?(cluster.parent_cluster_id, "request:10:")
|
||||||
assert cluster.expansion_zoom > viewport.zoom
|
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.west, 30.5234, 0.000_001
|
||||||
assert_in_delta cluster.bounds.east, 30.5235, 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
|
assert_in_delta cluster.bounds.south, 50.4501, 0.000_001
|
||||||
|
|
@ -608,6 +615,28 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
_item -> false
|
_item -> false
|
||||||
end)
|
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 =
|
world_items =
|
||||||
Help.map_discovery_items(
|
Help.map_discovery_items(
|
||||||
context.helper_scope,
|
context.helper_scope,
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|
||||||
body = response(conn, 200)
|
body = response(conn, 200)
|
||||||
|
|
||||||
assert body =~ ~s(const CACHE_PREFIX = "who-need-help-static-")
|
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(const OFFLINE_URL = "/offline.html")
|
||||||
assert body =~ ~s(event.request.mode === "navigate")
|
assert body =~ ~s(event.request.mode === "navigate")
|
||||||
assert body =~ ~S|key.startsWith(CACHE_PREFIX) && key !== CACHE|
|
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 Enum.count(LazyHTML.query(document, "img[src='/images/logo.svg'][alt='']")) == 1
|
||||||
assert html =~ "You're offline"
|
assert html =~ "You're offline"
|
||||||
assert html =~ "Try again"
|
assert html =~ "Try again"
|
||||||
|
assert html =~ ~s(fetch("/healthz/ready")
|
||||||
|
assert html =~ "Connection restored. Reloading"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "brand raster fallbacks are served", %{conn: conn} do
|
test "brand raster fallbacks are served", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ defmodule WhoNeedHelpWeb.UserSettingsControllerTest do
|
||||||
assert response =~ ~s(autocomplete="username")
|
assert response =~ ~s(autocomplete="username")
|
||||||
assert response =~ "Google sign-in"
|
assert response =~ "Google sign-in"
|
||||||
assert response =~ "Connect Google account"
|
assert response =~ "Connect Google account"
|
||||||
|
assert response =~ "matching verified Gmail or Google Workspace address"
|
||||||
|
refute response =~ "We never merge accounts automatically"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "redirects if user is not logged in" do
|
test "redirects if user is not logged in" do
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|
|
||||||
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
|
test "authenticated LiveView honors the locale stored by the browser pipeline", %{conn: conn} do
|
||||||
conn = get(conn, ~p"/?locale=uk")
|
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 =~ "Хто потребує допомоги?"
|
||||||
assert html =~ "Створити термінову заявку"
|
assert html =~ "Створити термінову заявку"
|
||||||
|
|
@ -377,6 +378,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
view,
|
view,
|
||||||
~p"/requests?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
|
~p"/requests?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "request discovery keeps the previous list until a moved map area is applied", %{
|
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)
|
render_hook(view, "map-viewport-change", moved)
|
||||||
assert render(view) =~ "Updating results for the visible map area"
|
|
||||||
refute has_element?(view, "#search-map-area")
|
refute has_element?(view, "#search-map-area")
|
||||||
|
|
||||||
assert_patch(
|
assert_patch(
|
||||||
|
|
@ -471,6 +473,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
view,
|
view,
|
||||||
~p"/activities?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
|
~p"/activities?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "profile social-link creation uses the shared action limiter", %{conn: conn} do
|
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", %{})
|
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']")
|
assert has_element?(helper_view, "button[phx-click='start-tracking']")
|
||||||
refute has_element?(helper_view, "button[phx-click='stop-tracking']")
|
refute has_element?(helper_view, "button[phx-click='stop-tracking']")
|
||||||
|
|
||||||
|
|
|
||||||