Clarify and verify live location consent

This commit is contained in:
SimpleTest 2026-08-09 20:52:57 +03:00
parent ee0650bf32
commit cd154766a0
10 changed files with 1171 additions and 886 deletions

View File

@ -315,6 +315,13 @@ public final class AndroidClientInstrumentedTest {
cancel.click(); cancel.click();
waitForServiceState(false); waitForServiceState(false);
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(webMatches(getText(), containsString("tracking-cancelled")));
assertFalse(
"Cancelling live-location disclosure was reported as a technical error",
device.hasObject(By.text("tracking-error"))
);
} }
} }
@ -537,12 +544,19 @@ public final class AndroidClientInstrumentedTest {
} }
private ActivityScenario<MainActivity> launch(String path) { private ActivityScenario<MainActivity> launch(String path) {
Intent intent = new Intent( String targetUrl = BuildConfig.BASE_URL + path;
Intent.ACTION_VIEW, Intent intent = new Intent(context, MainActivity.class);
Uri.parse(BuildConfig.BASE_URL + path),
context, if (AppLinkRoutePolicy.allows(targetUrl)) {
MainActivity.class intent.setAction(Intent.ACTION_VIEW);
); intent.setData(Uri.parse(targetUrl));
} else {
// Instrumentation-only fixture routes are intentionally outside the
// production App Link allowlist. Debug builds expose a same-origin
// initial URL extra specifically for these isolated test pages.
intent.putExtra("initial_url", targetUrl);
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return ActivityScenario.launch(intent); return ActivityScenario.launch(intent);
} }

View File

@ -259,6 +259,12 @@ final class FixtureHttpServer implements Closeable {
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<title>Who Need Help Android fixture</title></head><body>" + "<title>Who Need Help Android fixture</title></head><body>"
+ "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>" + "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>"
+ "<script>"
+ "window.addEventListener('wnh:native-tracking-cancelled',()=>{"
+ "document.getElementById('marker').textContent='tracking-cancelled'});"
+ "window.addEventListener('wnh:native-tracking-error',()=>{"
+ "document.getElementById('marker').textContent='tracking-error'});"
+ "</script>"
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition(" + "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>{document.getElementById('marker').textContent='location-granted';" + "()=>{document.getElementById('marker').textContent='location-granted';"
+ "fetch('/geolocation-granted')}," + "fetch('/geolocation-granted')},"

View File

@ -130,7 +130,7 @@ public final class MainActivity extends ComponentActivity {
startPendingNativeTracking(); startPendingNativeTracking();
} else { } else {
pendingNativeTracking = null; pendingNativeTracking = null;
dispatchNativeTrackingError(); dispatchNativeTrackingCancelled();
} }
} }
); );
@ -533,7 +533,7 @@ public final class MainActivity extends ComponentActivity {
private void cancelPendingNativeTracking() { private void cancelPendingNativeTracking() {
pendingNativeTracking = null; pendingNativeTracking = null;
dispatchNativeTrackingError(); dispatchNativeTrackingCancelled();
} }
private void dismissTrackingDisclosure() { private void dismissTrackingDisclosure() {
@ -1044,6 +1044,17 @@ public final class MainActivity extends ComponentActivity {
); );
} }
private void dispatchNativeTrackingCancelled() {
if (webView == null) {
return;
}
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('wnh:native-tracking-cancelled'))",
null
);
}
private static boolean validAssignmentId(String value) { private static boolean validAssignmentId(String value) {
try { try {
UUID.fromString(value); UUID.fromString(value);

View File

@ -2556,7 +2556,11 @@ export const Hooks = {
this.nativeError = () => { this.nativeError = () => {
this.pushEvent("location-error", {}) this.pushEvent("location-error", {})
} }
this.nativeCancelled = () => {
this.pushEvent("location-cancelled", {})
}
window.addEventListener("wnh:native-tracking-error", this.nativeError) window.addEventListener("wnh:native-tracking-error", this.nativeError)
window.addEventListener("wnh:native-tracking-cancelled", this.nativeCancelled)
return return
} }
@ -2589,6 +2593,9 @@ export const Hooks = {
if (this.nativeError) { if (this.nativeError) {
window.removeEventListener("wnh:native-tracking-error", this.nativeError) window.removeEventListener("wnh:native-tracking-error", this.nativeError)
} }
if (this.nativeCancelled) {
window.removeEventListener("wnh:native-tracking-cancelled", this.nativeCancelled)
}
if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId) if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId)
} }
} }

View File

@ -20,6 +20,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:subscribed_assignment_id, nil) |> assign(:subscribed_assignment_id, nil)
|> assign(:tracking_presence_key, nil) |> assign(:tracking_presence_key, nil)
|> assign(:tracking_session_id, nil) |> assign(:tracking_session_id, nil)
|> assign(:tracking_pending, false)
|> assign(:accept_confirmation_open?, false) |> assign(:accept_confirmation_open?, false)
|> assign(:native_client, native_client) |> assign(:native_client, native_client)
|> assign( |> assign(
@ -116,10 +117,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_info({:position_updated, user_id, position, evidence}, socket) do def handle_info({:position_updated, user_id, position, evidence}, socket) do
positions = Map.put(socket.assigns.positions, user_id, position) positions = Map.put(socket.assigns.positions, user_id, position)
{:noreply, socket =
socket socket
|> assign_positions(positions) |> assign_positions(positions)
|> assign_assignment_evidence(evidence)} |> assign_assignment_evidence(evidence)
|> then(fn socket ->
if user_id == socket.assigns.current_scope.user.id do
assign(socket, :tracking_pending, false)
else
socket
end
end)
{:noreply, socket}
end end
def handle_info({:tracking_stopped, user_id}, socket) do def handle_info({:tracking_stopped, user_id}, socket) do
@ -132,6 +142,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
if user_id == socket.assigns.current_scope.user.id do if user_id == socket.assigns.current_scope.user.id do
socket socket
|> assign(:tracking_active, false) |> assign(:tracking_active, false)
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil) |> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence() |> maybe_untrack_browser_presence()
else else
@ -289,6 +300,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:noreply, {:noreply,
socket socket
|> assign(:tracking_active, true) |> assign(:tracking_active, true)
|> assign(:tracking_pending, true)
|> assign(:tracking_session_id, tracking_session.id) |> assign(:tracking_session_id, tracking_session.id)
|> maybe_track_browser_presence() |> maybe_track_browser_presence()
|> maybe_start_native_tracking()} |> maybe_start_native_tracking()}
@ -300,11 +312,21 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("location-update", params, socket) do def handle_event("location-update", params, socket) do
case Tracking.update_position(socket.assigns.current_scope, socket.assigns.assignment, params) do case Tracking.update_position(socket.assigns.current_scope, socket.assigns.assignment, params) do
{:ok, _} -> {:noreply, socket} {:ok, _} -> {:noreply, assign(socket, :tracking_pending, false)}
{:error, _} -> {:noreply, socket} {:error, _} -> {:noreply, socket}
end end
end end
def handle_event("location-cancelled", _, socket) do
case stop_tracking(socket) do
{:ok, stopped_socket} ->
{:noreply, stopped_socket}
{:error, reason} ->
{:noreply, put_action_flash(socket, :error, message(reason))}
end
end
def handle_event("location-error", _, socket) do def handle_event("location-error", _, socket) do
socket = socket =
case stop_tracking(socket) do case stop_tracking(socket) do
@ -410,6 +432,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:messages_cursor, nil) |> assign(:messages_cursor, nil)
|> assign(:positions, %{}) |> assign(:positions, %{})
|> assign(:tracking_active, false) |> assign(:tracking_active, false)
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil) |> assign(:tracking_session_id, nil)
|> refresh_request_coordinates() |> refresh_request_coordinates()
|> put_action_flash( |> put_action_flash(
@ -502,6 +525,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:ok, {:ok,
socket socket
|> assign(:tracking_active, false) |> assign(:tracking_active, false)
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil) |> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence() |> maybe_untrack_browser_presence()
|> maybe_stop_native_tracking()} |> maybe_stop_native_tracking()}
@ -702,6 +726,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
do: Tracking.list_current_positions(socket.assigns.current_scope, assignment), do: Tracking.list_current_positions(socket.assigns.current_scope, assignment),
else: %{} else: %{}
tracking_pending = tracking_active && not Map.has_key?(positions, current_user_id)
reputations = reputations =
[request.requester_id, assignment && assignment.helper_id] [request.requester_id, assignment && assignment.helper_id]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@ -744,6 +770,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:positions, positions) |> assign(:positions, positions)
|> assign(:request_coordinates, request_coordinates) |> assign(:request_coordinates, request_coordinates)
|> assign(:tracking_active, tracking_active) |> assign(:tracking_active, tracking_active)
|> assign(:tracking_pending, tracking_pending)
|> assign(:message_form, to_form(%{"body" => ""}, as: :message)) |> assign(:message_form, to_form(%{"body" => ""}, as: :message))
|> assign(:handover_form, to_form(%{"code" => ""}, as: :handover)) |> assign(:handover_form, to_form(%{"code" => ""}, as: :handover))
|> assign( |> assign(
@ -820,6 +847,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
if tracking_was_active and not tracking_active do if tracking_was_active and not tracking_active do
socket socket
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil) |> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence() |> maybe_untrack_browser_presence()
|> maybe_stop_native_tracking() |> maybe_stop_native_tracking()
@ -1789,8 +1817,42 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
> >
{gettext("Share location")} {gettext("Share location")}
</button> </button>
<div
:if={@tracking_pending}
id="pending-location-status"
role="status"
aria-live="polite"
class="alert alert-info mt-4 items-start text-sm text-base-content"
>
<span class="loading loading-spinner loading-sm mt-0.5 shrink-0" aria-hidden="true"></span>
<div>
<p class="font-semibold">{gettext("Waiting for location permission…")}</p>
<p class="mt-1 text-xs opacity-75">
{gettext(
"Complete the location permission prompt. If you cancel, no location will be shared."
)}
</p>
</div>
</div>
<div
:if={@tracking_active && !@tracking_pending}
id="active-location-status"
role="status"
aria-live="polite"
class="alert alert-success mt-4 items-start text-sm text-base-content"
>
<.icon name="hero-map-pin" class="mt-0.5 size-5 shrink-0" />
<div>
<p class="font-semibold">{gettext("Live location is being shared")}</p>
<p class="mt-1 text-xs opacity-75">
{gettext(
"Only your matched participant can see your current position. Stop sharing to remove the stored coordinates."
)}
</p>
</div>
</div>
<button <button
:if={@tracking_active} :if={@tracking_active && !@tracking_pending}
id="stop-location-button" id="stop-location-button"
phx-click="stop-tracking" phx-click="stop-tracking"
class="btn btn-error btn-sm mt-4 w-full" class="btn btn-error btn-sm mt-4 w-full"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import WhoNeedHelp.AccountsFixtures import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts, Activities, Catalog, Help, Messaging, Notifications, Trust} alias WhoNeedHelp.{
Accounts,
Activities,
Catalog,
Help,
Messaging,
Notifications,
Tracking,
Trust
}
alias WhoNeedHelp.Repo alias WhoNeedHelp.Repo
alias WhoNeedHelp.Trust.AbuseSignal alias WhoNeedHelp.Trust.AbuseSignal
@ -1464,6 +1474,19 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert_push_event(helper_view, "native-tracking-start", %{assignment_id: assignment_id}) assert_push_event(helper_view, "native-tracking-start", %{assignment_id: assignment_id})
assert assignment_id == assignment.id assert assignment_id == assignment.id
assert has_element?(helper_view, "#pending-location-status[role='status']")
refute has_element?(helper_view, "#active-location-status")
refute has_element?(helper_view, "#stop-location-button")
render_hook(helper_view, "location-update", %{
"latitude" => 50.4501,
"longitude" => 30.5234,
"accuracy_meters" => 12
})
refute has_element?(helper_view, "#pending-location-status")
assert has_element?(helper_view, "#active-location-status[role='status']")
assert has_element?(helper_view, "#stop-location-button")
assert {:ok, _cancelled} = assert {:ok, _cancelled} =
Help.cancel_request(user_scope_fixture(requester), request.id) Help.cancel_request(user_scope_fixture(requester), request.id)
@ -1494,6 +1517,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> element("button[phx-click='start-tracking']") |> element("button[phx-click='start-tracking']")
|> render_click() |> render_click()
assert has_element?(helper_view, "#pending-location-status[role='status']")
refute has_element?(helper_view, "#active-location-status")
assert Repo.get_by!(WhoNeedHelp.Tracking.TrackingSession, assert Repo.get_by!(WhoNeedHelp.Tracking.TrackingSession,
assignment_id: assignment.id, assignment_id: assignment.id,
user_id: helper.id, user_id: helper.id,
@ -1528,6 +1554,85 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
stop_live_view(helper_view) stop_live_view(helper_view)
end end
test "Android disclosure cancellation quietly stops the empty tracking session", _context do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Location requester")
helper = user_fixture(display_name: "Location helper")
{:ok, request} =
Help.create_request(user_scope_fixture(requester), request_attrs(category))
{:ok, assignment} = Help.accept_request(user_scope_fixture(helper), request.id)
helper_conn =
build_conn()
|> log_in_user(helper)
|> put_connect_params(%{"client_type" => "android"})
{:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}")
helper_view
|> element("#share-location-button")
|> render_click()
assert_push_event(helper_view, "native-tracking-start", %{assignment_id: assignment_id})
assert assignment_id == assignment.id
assert has_element?(helper_view, "#pending-location-status[role='status']")
refute has_element?(helper_view, "#stop-location-button")
html = render_hook(helper_view, "location-cancelled", %{})
refute html =~ "Location sharing could not start."
assert has_element?(helper_view, "#share-location-button")
refute has_element?(helper_view, "#pending-location-status")
refute has_element?(helper_view, "#active-location-status")
refute has_element?(helper_view, "#stop-location-button")
refute Repo.get_by(WhoNeedHelp.Tracking.TrackingSession,
assignment_id: assignment.id,
user_id: helper.id,
active: true
)
stopped_session =
Repo.get_by!(WhoNeedHelp.Tracking.TrackingSession,
assignment_id: assignment.id,
user_id: helper.id
)
assert stopped_session.ended_at
assert stopped_session.sample_count == 0
stop_live_view(helper_view)
end
test "Android reconnect keeps an empty active session in the permission-pending state",
_context do
category = Catalog.seed_defaults()
requester = user_fixture(display_name: "Location requester")
helper = user_fixture(display_name: "Location helper")
{:ok, request} =
Help.create_request(user_scope_fixture(requester), request_attrs(category))
{:ok, assignment} = Help.accept_request(user_scope_fixture(helper), request.id)
{:ok, _session} = Tracking.start_session(user_scope_fixture(helper), assignment)
helper_conn =
build_conn()
|> log_in_user(helper)
|> put_connect_params(%{"client_type" => "android"})
{:ok, helper_view, _html} = live(helper_conn, ~p"/requests/#{request.id}")
assert_push_event(helper_view, "native-tracking-start", %{assignment_id: assignment_id})
assert assignment_id == assignment.id
assert has_element?(helper_view, "#pending-location-status[role='status']")
refute has_element?(helper_view, "#stop-location-button")
stop_live_view(helper_view)
end
test "regular users cannot enter moderation", %{conn: conn} do test "regular users cannot enter moderation", %{conn: conn} do
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation") assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
end end