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();
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) {
Intent intent = new Intent(
Intent.ACTION_VIEW,
Uri.parse(BuildConfig.BASE_URL + path),
context,
MainActivity.class
);
String targetUrl = BuildConfig.BASE_URL + path;
Intent intent = new Intent(context, MainActivity.class);
if (AppLinkRoutePolicy.allows(targetUrl)) {
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);
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\">"
+ "<title>Who Need Help Android fixture</title></head><body>"
+ "<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("
+ "()=>{document.getElementById('marker').textContent='location-granted';"
+ "fetch('/geolocation-granted')},"

View File

@ -130,7 +130,7 @@ public final class MainActivity extends ComponentActivity {
startPendingNativeTracking();
} else {
pendingNativeTracking = null;
dispatchNativeTrackingError();
dispatchNativeTrackingCancelled();
}
}
);
@ -533,7 +533,7 @@ public final class MainActivity extends ComponentActivity {
private void cancelPendingNativeTracking() {
pendingNativeTracking = null;
dispatchNativeTrackingError();
dispatchNativeTrackingCancelled();
}
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) {
try {
UUID.fromString(value);

View File

@ -2556,7 +2556,11 @@ export const Hooks = {
this.nativeError = () => {
this.pushEvent("location-error", {})
}
this.nativeCancelled = () => {
this.pushEvent("location-cancelled", {})
}
window.addEventListener("wnh:native-tracking-error", this.nativeError)
window.addEventListener("wnh:native-tracking-cancelled", this.nativeCancelled)
return
}
@ -2589,6 +2593,9 @@ export const Hooks = {
if (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)
}
}

View File

@ -20,6 +20,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:subscribed_assignment_id, nil)
|> assign(:tracking_presence_key, nil)
|> assign(:tracking_session_id, nil)
|> assign(:tracking_pending, false)
|> assign(:accept_confirmation_open?, false)
|> assign(:native_client, native_client)
|> assign(
@ -116,10 +117,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_info({:position_updated, user_id, position, evidence}, socket) do
positions = Map.put(socket.assigns.positions, user_id, position)
{:noreply,
socket
|> assign_positions(positions)
|> assign_assignment_evidence(evidence)}
socket =
socket
|> assign_positions(positions)
|> 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
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
socket
|> assign(:tracking_active, false)
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence()
else
@ -289,6 +300,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:noreply,
socket
|> assign(:tracking_active, true)
|> assign(:tracking_pending, true)
|> assign(:tracking_session_id, tracking_session.id)
|> maybe_track_browser_presence()
|> maybe_start_native_tracking()}
@ -300,11 +312,21 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("location-update", params, socket) 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}
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
socket =
case stop_tracking(socket) do
@ -410,6 +432,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:messages_cursor, nil)
|> assign(:positions, %{})
|> assign(:tracking_active, false)
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil)
|> refresh_request_coordinates()
|> put_action_flash(
@ -502,6 +525,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
{:ok,
socket
|> assign(:tracking_active, false)
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence()
|> maybe_stop_native_tracking()}
@ -702,6 +726,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
do: Tracking.list_current_positions(socket.assigns.current_scope, assignment),
else: %{}
tracking_pending = tracking_active && not Map.has_key?(positions, current_user_id)
reputations =
[request.requester_id, assignment && assignment.helper_id]
|> Enum.reject(&is_nil/1)
@ -744,6 +770,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:positions, positions)
|> assign(:request_coordinates, request_coordinates)
|> assign(:tracking_active, tracking_active)
|> assign(:tracking_pending, tracking_pending)
|> assign(:message_form, to_form(%{"body" => ""}, as: :message))
|> assign(:handover_form, to_form(%{"code" => ""}, as: :handover))
|> assign(
@ -820,6 +847,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
if tracking_was_active and not tracking_active do
socket
|> assign(:tracking_pending, false)
|> assign(:tracking_session_id, nil)
|> maybe_untrack_browser_presence()
|> maybe_stop_native_tracking()
@ -1789,8 +1817,42 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
>
{gettext("Share location")}
</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
:if={@tracking_active}
:if={@tracking_active && !@tracking_pending}
id="stop-location-button"
phx-click="stop-tracking"
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 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.Trust.AbuseSignal
@ -1464,6 +1474,19 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
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, "#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} =
Help.cancel_request(user_scope_fixture(requester), request.id)
@ -1494,6 +1517,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|> element("button[phx-click='start-tracking']")
|> 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,
assignment_id: assignment.id,
user_id: helper.id,
@ -1528,6 +1554,85 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
stop_live_view(helper_view)
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
assert {:error, {:redirect, %{to: "/requests"}}} = live(conn, ~p"/moderation")
end