feat: keep Android location sharing while minimized

This commit is contained in:
SimpleTest 2026-07-18 17:56:49 +03:00
parent 30bfeef1fb
commit 68faa4795c
24 changed files with 1024 additions and 58 deletions

View File

@ -10,6 +10,11 @@ PHX_URL_PORT=4010
WNH_DEBUG_BASE_URL=http://localhost:4010
# Release builds require a public HTTPS origin.
WNH_BASE_URL=https://whoneedhelp.imalto.site
# These local values preserve the existing five-second client freshness window
# and fifteen-second request timeout. They are build inputs, not measured
# production capacity recommendations.
WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000
# Public raster tile template used by MapLibre. Use a provider whose policy and
# capacity match the deployment before a public launch.
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png

View File

@ -17,7 +17,7 @@ thank-you link; money goes directly between users outside the platform.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
PubSub/Presence, and optional foreground-only location sharing.
PubSub/Presence, and optional consent-driven live location sharing.
- Handover code plus both-party confirmation before verified completion.
- Double-blind reviews, public trust summaries, and a helper leaderboard that
prioritizes unique location-supported and handover-verified counterparts
@ -32,9 +32,11 @@ thank-you link; money goes directly between users outside the platform.
- Public manual social links, always marked unverified in this MVP.
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
- Native Android WebView client with the same authenticated LiveView, map,
private chat, and foreground geolocation flows. The reproducible Docker
target currently exports a debug APK; production signing and store
publication are not configured.
private chat, and a user-started location foreground service. Its persistent
notification exposes Stop, it continues while the Activity is minimized, and
it retains only the current point. The reproducible Docker target currently
exports a debug APK; production signing and store publication are not
configured.
- Local, advisory Codex category review through the user's ChatGPT-authenticated
Codex CLI. It receives a PII-free export and never writes to the database.
- One immutable release image with `web`, `worker`, and one-shot `migrate`
@ -43,9 +45,10 @@ thank-you link; money goes directly between users outside the platform.
replicas by default.
OAuth social verification, public coffee/cinema/hiking activities, background
tracking, platform payments, production Android signing/store publication,
iOS, automatic punitive fraud decisions, and jurisdiction-specific
public-launch policies are deliberately not claimed as complete.
PWA or unattended location tracking, platform payments, production Android
signing/store publication, iOS, automatic punitive fraud decisions, and
jurisdiction-specific public-launch policies are deliberately not claimed as
complete.
## Fast start with Docker Compose

View File

@ -37,11 +37,15 @@ WORKDIR /workspace/android
COPY --chown=gradle:gradle . .
ARG WNH_DEBUG_BASE_URL
ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
gradle --no-daemon \
"-PWNH_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \
"-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
testDebugUnitTest lintDebug assembleDebug
FROM android-base AS emulator

View File

@ -1,8 +1,11 @@
# Who Need Help for Android
This module is a native Android WebView shell for the Phoenix application. It
keeps authentication cookies, LiveView WebSockets, MapLibre, chat, and
foreground geolocation in the same trusted origin.
keeps authentication cookies, LiveView WebSockets, MapLibre, and chat in the
same trusted origin. Live location is sent by a user-started native foreground
service with a persistent notification and Stop action, so it can continue
while the Activity is minimized without requesting Android's
background-location permission.
## Verified build configuration
@ -19,11 +22,18 @@ local configuration uses loopback together with `adb reverse`; debug builds
allow cleartext traffic, while the WebView still restricts in-app navigation to
that one configured origin.
The same ignored file supplies `WNH_TRACKING_MIN_TIME_MS` and
`WNH_TRACKING_HTTP_TIMEOUT_MS`. The checked-in example preserves the original
local client freshness and timeout behavior; these values are not claimed as
measured production capacity settings.
Release builds do not have a default server. Supply the real HTTPS deployment:
```sh
./gradlew :app:assembleRelease \
-PWNH_BASE_URL=https://help.your-domain.example
-PWNH_BASE_URL=https://help.your-domain.example \
-PWNH_TRACKING_MIN_TIME_MS=5000 \
-PWNH_TRACKING_HTTP_TIMEOUT_MS=15000
```
The build rejects a missing, HTTP, credentialed, query-bearing, or
@ -53,6 +63,8 @@ set -a
set +a
docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
--target emulator \
-t who-need-help-android:emulator \
android

View File

@ -7,6 +7,9 @@ plugins {
val releaseBaseUrl = providers.gradleProperty("WNH_BASE_URL").orElse("")
val debugBaseUrl = providers.gradleProperty("WNH_DEBUG_BASE_URL").orElse("")
val trackingMinTimeMs = providers.gradleProperty("WNH_TRACKING_MIN_TIME_MS").orElse("0")
val trackingHttpTimeoutMs =
providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0")
android {
namespace = "org.whoneedhelp.mobile"
@ -19,6 +22,12 @@ android {
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
buildConfigField("long", "TRACKING_MIN_TIME_MS", "${trackingMinTimeMs.get()}L")
buildConfigField(
"long",
"TRACKING_HTTP_TIMEOUT_MS",
"${trackingHttpTimeoutMs.get()}L"
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -82,6 +91,8 @@ tasks.matching { it.name == "preReleaseBuild" }.configureEach {
"Release builds require -PWNH_BASE_URL=https://your-real-deployment.example"
)
}
validateTrackingConfiguration()
}
}
@ -102,6 +113,25 @@ tasks.matching { it.name == "preDebugBuild" }.configureEach {
"Debug builds require -PWNH_DEBUG_BASE_URL=http(s)://your-development-host"
)
}
validateTrackingConfiguration()
}
}
fun validateTrackingConfiguration() {
val minTime = trackingMinTimeMs.orNull?.toLongOrNull()
val httpTimeout = trackingHttpTimeoutMs.orNull?.toLongOrNull()
if (minTime == null || minTime <= 0) {
throw GradleException(
"Builds require -PWNH_TRACKING_MIN_TIME_MS=POSITIVE_MILLISECONDS"
)
}
if (httpTimeout == null || httpTimeout <= 0 || httpTimeout > Int.MAX_VALUE) {
throw GradleException(
"Builds require -PWNH_TRACKING_HTTP_TIMEOUT_MS=POSITIVE_INT_MILLISECONDS"
)
}
}

View File

@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application
android:allowBackup="false"
@ -23,5 +26,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TrackingService"
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false" />
</application>
</manifest>

View File

@ -8,11 +8,13 @@ import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.net.Uri;
import android.net.http.SslError;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.ViewGroup;
import android.webkit.CookieManager;
import android.webkit.GeolocationPermissions;
import android.webkit.JavascriptInterface;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
@ -27,6 +29,9 @@ import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import java.util.ArrayList;
import java.util.UUID;
public final class MainActivity extends ComponentActivity {
private static final String LOG_TAG = "WhoNeedHelpWebView";
private WebView webView;
@ -34,6 +39,8 @@ public final class MainActivity extends ComponentActivity {
private GeolocationPermissions.Callback pendingLocationCallback;
private String pendingLocationOrigin;
private ActivityResultLauncher<String[]> locationPermissionLauncher;
private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher;
private PendingNativeTracking pendingNativeTracking;
@Override
@SuppressLint("SetJavaScriptEnabled")
@ -52,6 +59,24 @@ public final class MainActivity extends ComponentActivity {
completeLocationPermission(granted);
}
);
nativeTrackingPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
result -> {
boolean locationGranted =
Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))
|| Boolean.TRUE.equals(
result.get(Manifest.permission.ACCESS_COARSE_LOCATION)
)
|| hasLocationPermission();
if (locationGranted) {
startPendingNativeTracking();
} else {
pendingNativeTracking = null;
dispatchNativeTrackingError();
}
}
);
webView = new WebView(this);
webView.setLayoutParams(
new ViewGroup.LayoutParams(
@ -77,6 +102,7 @@ public final class MainActivity extends ComponentActivity {
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, false);
webView.addJavascriptInterface(new NativeTrackingBridge(), "WhoNeedHelpAndroid");
webView.setWebViewClient(new TrustedWebViewClient());
webView.setWebChromeClient(new LocationWebChromeClient());
getOnBackPressedDispatcher().addCallback(
@ -147,6 +173,7 @@ public final class MainActivity extends ComponentActivity {
if (webView != null) {
webView.stopLoading();
webView.removeJavascriptInterface("WhoNeedHelpAndroid");
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
webView.destroy();
@ -187,6 +214,101 @@ public final class MainActivity extends ComponentActivity {
);
}
private boolean hasLocationPermission() {
return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
|| checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED;
}
private void prepareNativeTracking(String assignmentId, String csrfToken) {
if (
!validAssignmentId(assignmentId)
|| csrfToken == null
|| csrfToken.trim().isEmpty()
) {
dispatchNativeTrackingError();
return;
}
pendingNativeTracking = new PendingNativeTracking(assignmentId, csrfToken);
boolean notificationGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|| checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
if (hasLocationPermission() && notificationGranted) {
startPendingNativeTracking();
return;
}
ArrayList<String> permissions = new ArrayList<>();
if (!hasLocationPermission()) {
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION);
permissions.add(Manifest.permission.ACCESS_COARSE_LOCATION);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !notificationGranted) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS);
}
nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0]));
}
private void startPendingNativeTracking() {
PendingNativeTracking pending = pendingNativeTracking;
pendingNativeTracking = null;
if (pending == null) {
return;
}
String cookie = CookieManager.getInstance().getCookie(BuildConfig.BASE_URL);
if (cookie == null || cookie.trim().isEmpty()) {
dispatchNativeTrackingError();
return;
}
Intent intent = TrackingService.startIntent(
this,
pending.assignmentId,
pending.csrfToken,
cookie
);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent);
} else {
startService(intent);
}
} catch (RuntimeException exception) {
dispatchNativeTrackingError();
}
}
private void dispatchNativeTrackingError() {
if (webView == null) {
return;
}
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('wnh:native-tracking-error'))",
null
);
}
private static boolean validAssignmentId(String value) {
try {
UUID.fromString(value);
return true;
} catch (IllegalArgumentException | NullPointerException exception) {
return false;
}
}
private void completeLocationPermission(boolean granted) {
if (pendingLocationCallback != null && pendingLocationOrigin != null) {
pendingLocationCallback.invoke(pendingLocationOrigin, granted, false);
@ -327,4 +449,28 @@ public final class MainActivity extends ComponentActivity {
denyPendingLocation();
}
}
private final class NativeTrackingBridge {
@JavascriptInterface
public void startTracking(String assignmentId, String csrfToken) {
runOnUiThread(() -> prepareNativeTracking(assignmentId, csrfToken));
}
@JavascriptInterface
public void stopTracking() {
runOnUiThread(() ->
stopService(new Intent(MainActivity.this, TrackingService.class))
);
}
}
private static final class PendingNativeTracking {
private final String assignmentId;
private final String csrfToken;
private PendingNativeTracking(String assignmentId, String csrfToken) {
this.assignmentId = assignmentId;
this.csrfToken = csrfToken;
}
}
}

View File

@ -0,0 +1,436 @@
package org.whoneedhelp.mobile;
import android.Manifest;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ServiceInfo;
import android.graphics.drawable.Icon;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.webkit.CookieManager;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public final class TrackingService extends Service implements LocationListener {
private static final String ACTION_START =
"org.whoneedhelp.mobile.action.START_TRACKING";
private static final String ACTION_STOP_REMOTE =
"org.whoneedhelp.mobile.action.STOP_TRACKING_REMOTE";
private static final String EXTRA_ASSIGNMENT_ID = "assignment_id";
private static final String EXTRA_CSRF_TOKEN = "csrf_token";
private static final String EXTRA_COOKIE = "cookie";
private static final String CHANNEL_ID = "active_help_tracking";
private static final int NOTIFICATION_ID = 4101;
private final Object pendingLocationLock = new Object();
private final AtomicBoolean uploadRunning = new AtomicBoolean(false);
private final AtomicBoolean stopRequestRunning = new AtomicBoolean(false);
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private ExecutorService networkExecutor;
private LocationManager locationManager;
private Location pendingLocation;
private String assignmentId;
private String csrfToken;
private String cookie;
public static Intent startIntent(
android.content.Context context,
String assignmentId,
String csrfToken,
String cookie
) {
return new Intent(context, TrackingService.class)
.setAction(ACTION_START)
.putExtra(EXTRA_ASSIGNMENT_ID, assignmentId)
.putExtra(EXTRA_CSRF_TOKEN, csrfToken)
.putExtra(EXTRA_COOKIE, cookie);
}
@Override
public void onCreate() {
super.onCreate();
networkExecutor = Executors.newSingleThreadExecutor();
locationManager = getSystemService(LocationManager.class);
createNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent == null ? null : intent.getAction();
if (ACTION_STOP_REMOTE.equals(action)) {
if (
!validCredential(assignmentId)
|| !validCredential(csrfToken)
|| !validCredential(cookie)
) {
stopTrackingLocally();
return START_NOT_STICKY;
}
pauseLocationUpdates();
showNotification(
getString(R.string.tracking_stopping),
getString(R.string.tracking_stopping_detail)
);
requestRemoteStop();
return START_NOT_STICKY;
}
if (!ACTION_START.equals(action)) {
stopSelf();
return START_NOT_STICKY;
}
assignmentId = intent.getStringExtra(EXTRA_ASSIGNMENT_ID);
csrfToken = intent.getStringExtra(EXTRA_CSRF_TOKEN);
cookie = intent.getStringExtra(EXTRA_COOKIE);
if (!validCredential(assignmentId) || !validCredential(csrfToken) || !validCredential(cookie)) {
stopSelf();
return START_NOT_STICKY;
}
try {
promoteToForeground();
requestLocationUpdates();
} catch (SecurityException exception) {
stopTrackingLocally();
}
return START_NOT_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onLocationChanged(Location location) {
synchronized (pendingLocationLock) {
pendingLocation = new Location(location);
}
dispatchPendingLocation();
}
@Override
public void onProviderEnabled(String provider) {
// The active provider will deliver a new point without a route history.
}
@Override
public void onProviderDisabled(String provider) {
// Other enabled providers, if any, remain registered.
}
@SuppressWarnings("deprecation")
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
// Required by LocationListener on supported older Android releases.
}
@Override
public void onDestroy() {
pauseLocationUpdates();
networkExecutor.shutdownNow();
super.onDestroy();
}
private void promoteToForeground() {
Notification notification = trackingNotification(
getString(R.string.tracking_active),
getString(R.string.tracking_active_detail)
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
);
} else {
startForeground(NOTIFICATION_ID, notification);
}
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
getString(R.string.tracking_channel_name),
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription(getString(R.string.tracking_channel_description));
getSystemService(NotificationManager.class).createNotificationChannel(channel);
}
@SuppressWarnings("deprecation")
private Notification trackingNotification(String title, String text) {
Intent openIntent = new Intent(this, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent openPendingIntent = PendingIntent.getActivity(
this,
0,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
Intent stopIntent = new Intent(this, TrackingService.class)
.setAction(ACTION_STOP_REMOTE);
PendingIntent stopPendingIntent = PendingIntent.getService(
this,
1,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
Notification.Action stopAction = new Notification.Action.Builder(
Icon.createWithResource(this, R.drawable.ic_launcher),
getString(R.string.tracking_stop_action),
stopPendingIntent
).build();
Notification.Builder builder =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? new Notification.Builder(this, CHANNEL_ID)
: new Notification.Builder(this);
return builder
.setSmallIcon(R.drawable.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(openPendingIntent)
.setCategory(Notification.CATEGORY_SERVICE)
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(stopAction)
.build();
}
private void showNotification(String title, String text) {
getSystemService(NotificationManager.class)
.notify(NOTIFICATION_ID, trackingNotification(title, text));
}
private void requestLocationUpdates() {
boolean locationGranted =
checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
|| checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED;
if (!locationGranted) {
throw new SecurityException("Location permission is unavailable.");
}
boolean registered = false;
registered |= requestProviderIfEnabled(LocationManager.GPS_PROVIDER);
registered |= requestProviderIfEnabled(LocationManager.NETWORK_PROVIDER);
if (!registered) {
showNotification(
getString(R.string.tracking_location_unavailable),
getString(R.string.tracking_location_unavailable_detail)
);
return;
}
}
@SuppressWarnings("MissingPermission")
private boolean requestProviderIfEnabled(String provider) {
if (!locationManager.isProviderEnabled(provider)) {
return false;
}
locationManager.requestLocationUpdates(
provider,
BuildConfig.TRACKING_MIN_TIME_MS,
0.0f,
this,
Looper.getMainLooper()
);
return true;
}
private void pauseLocationUpdates() {
if (locationManager != null) {
try {
locationManager.removeUpdates(this);
} catch (SecurityException ignored) {
// Permissions may have been revoked while sharing was active.
}
}
synchronized (pendingLocationLock) {
pendingLocation = null;
}
}
private void dispatchPendingLocation() {
if (!uploadRunning.compareAndSet(false, true)) {
return;
}
networkExecutor.execute(() -> {
try {
while (!Thread.currentThread().isInterrupted()) {
Location location;
synchronized (pendingLocationLock) {
location = pendingLocation;
pendingLocation = null;
}
if (location == null) {
return;
}
int status = sendPosition(location);
if (status == HttpURLConnection.HTTP_UNAUTHORIZED
|| status == HttpURLConnection.HTTP_FORBIDDEN
|| status == HttpURLConnection.HTTP_NOT_FOUND
|| status == HttpURLConnection.HTTP_CONFLICT) {
mainHandler.post(this::stopTrackingLocally);
return;
}
}
} finally {
uploadRunning.set(false);
synchronized (pendingLocationLock) {
if (pendingLocation != null) {
dispatchPendingLocation();
}
}
}
});
}
private int sendPosition(Location location) {
String body = String.format(
Locale.US,
"{\"latitude\":%.8f,\"longitude\":%.8f,\"accuracy_meters\":%.2f}",
location.getLatitude(),
location.getLongitude(),
location.getAccuracy()
);
return post(
"/mobile/tracking/" + assignmentId + "/position",
body.getBytes(StandardCharsets.UTF_8)
);
}
private void requestRemoteStop() {
if (!stopRequestRunning.compareAndSet(false, true)) {
return;
}
networkExecutor.execute(() -> {
try {
int status = post(
"/mobile/tracking/" + assignmentId + "/stop",
"{}".getBytes(StandardCharsets.UTF_8)
);
if (status == HttpURLConnection.HTTP_NO_CONTENT
|| status == HttpURLConnection.HTTP_NOT_FOUND
|| status == HttpURLConnection.HTTP_CONFLICT) {
mainHandler.post(this::stopTrackingLocally);
} else {
mainHandler.post(() ->
showNotification(
getString(R.string.tracking_stop_failed),
getString(R.string.tracking_stop_failed_detail)
)
);
}
} finally {
stopRequestRunning.set(false);
}
});
}
private int post(String path, byte[] body) {
HttpURLConnection connection = null;
try {
URL url = new URI(BuildConfig.BASE_URL).resolve(path).toURL();
connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
connection.setConnectTimeout((int) BuildConfig.TRACKING_HTTP_TIMEOUT_MS);
connection.setReadTimeout((int) BuildConfig.TRACKING_HTTP_TIMEOUT_MS);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("X-CSRF-Token", csrfToken);
connection.setRequestProperty("Cookie", cookie);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(body.length);
try (OutputStream output = connection.getOutputStream()) {
output.write(body);
}
int status = connection.getResponseCode();
for (java.util.Map.Entry<String, java.util.List<String>> header
: connection.getHeaderFields().entrySet()) {
if (header.getKey() != null && header.getKey().equalsIgnoreCase("Set-Cookie")) {
for (String setCookie : header.getValue()) {
mainHandler.post(() ->
CookieManager.getInstance().setCookie(BuildConfig.BASE_URL, setCookie)
);
}
}
}
return status;
} catch (Exception exception) {
return -1;
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private void stopTrackingLocally() {
pauseLocationUpdates();
stopForeground(STOP_FOREGROUND_REMOVE);
stopSelf();
}
private static boolean validCredential(String value) {
return value != null
&& !value.trim().isEmpty()
&& value.indexOf('\r') < 0
&& value.indexOf('\n') < 0;
}
}

View File

@ -28,7 +28,7 @@ final class TrustedOrigin {
if (
!acceptedScheme ||
uri.getHost() == null ||
uri.getHost().isBlank() ||
uri.getHost().trim().isEmpty() ||
uri.getUserInfo() != null ||
uri.getQuery() != null ||
uri.getFragment() != null

View File

@ -5,4 +5,15 @@
<string name="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string>
<string name="secure_connection_failed">The secure connection was rejected.</string>
<string name="unsupported_link">This link type is not supported.</string>
<string name="tracking_channel_name">Active help location</string>
<string name="tracking_channel_description">Visible location sharing for an active matched request.</string>
<string name="tracking_active">Sharing live location</string>
<string name="tracking_active_detail">Who Need Help can continue sharing while minimized.</string>
<string name="tracking_stop_action">Stop sharing</string>
<string name="tracking_stopping">Stopping location sharing</string>
<string name="tracking_stopping_detail">Waiting for the server to delete the current position.</string>
<string name="tracking_stop_failed">Could not confirm Stop</string>
<string name="tracking_stop_failed_detail">New updates are paused. Tap Stop sharing to retry deleting the current position.</string>
<string name="tracking_location_unavailable">Location is unavailable</string>
<string name="tracking_location_unavailable_detail">Enable device location, then return to the request and try again.</string>
</resources>

View File

@ -29,7 +29,10 @@ import {Hooks} from "./hooks"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
params: {
_csrf_token: csrfToken,
client_type: window.WhoNeedHelpAndroid ? "android" : "browser"
},
hooks: {...colocatedHooks, ...Hooks},
})
@ -41,6 +44,12 @@ window.addEventListener("phx:reset-message-form", ({detail}) => {
const form = document.getElementById(detail.id)
if (form instanceof HTMLFormElement) form.reset()
})
window.addEventListener("phx:native-tracking-start", ({detail}) => {
window.WhoNeedHelpAndroid?.startTracking(detail.assignment_id, csrfToken)
})
window.addEventListener("phx:native-tracking-stop", () => {
window.WhoNeedHelpAndroid?.stopTracking()
})
// connect if there are any LiveViews on the page
liveSocket.connect()

View File

@ -82,6 +82,16 @@ export const Hooks = {
LiveTracking: {
mounted() {
this.destroying = false
if (window.WhoNeedHelpAndroid) {
this.nativeError = () => {
this.pushEvent("location-error", {})
this.pushEvent("stop-tracking", {})
}
window.addEventListener("wnh:native-tracking-error", this.nativeError)
return
}
this.watchId = navigator.geolocation.watchPosition(
position => this.pushEvent("location-update", {
latitude: position.coords.latitude,
@ -96,6 +106,9 @@ export const Hooks = {
},
destroyed() {
this.destroying = true
if (this.nativeError) {
window.removeEventListener("wnh:native-tracking-error", this.nativeError)
}
if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId)
}
}

View File

@ -32,13 +32,15 @@ Traefik / Kubernetes Service
web and worker nodes form a BEAM cluster for distributed PubSub/Presence
```
The Android module is a thin same-origin WebView shell rather than a separate
API client. Its debug origin is supplied at build time from the repository's
ignored `.env`; release builds require an explicit HTTPS origin. Authentication
cookies, LiveView WebSockets, MapLibre, private chat, and foreground
geolocation therefore use the same Phoenix application paths and authorization
rules as the browser. Production signing and distribution are separate
operational work and are not represented as complete.
The Android module is a thin same-origin WebView shell with one native
foreground location service. Its debug origin is supplied at build time from
the repository's ignored `.env`; release builds require an explicit HTTPS
origin. Authentication cookies, LiveView WebSockets, MapLibre, and private chat
use the same Phoenix application as the browser. A JavaScript bridge starts the
native service only from the visible Activity; the service posts the current
point through CSRF-protected, participant-authorized same-origin routes and
shows a persistent notification with Stop. Production signing and distribution
are separate operational work and are not represented as complete.
## Application boundaries
@ -49,8 +51,8 @@ operational work and are not represented as complete.
- `Help`: requests, assignments, state transitions, handover codes, and
completion evidence.
- `Messaging`: match chat and delivery receipts.
- `Tracking`: foreground sharing sessions, current positions, and derived
proximity signals.
- `Tracking`: consent-driven sharing sessions, ephemeral current positions,
and derived proximity signals.
- `Trust`: reviews, reports, blocks, leaderboard/reputation projections,
abuse signals, moderator audit events, and shared rate-limit policies.
@ -79,12 +81,15 @@ leadership, so no Redis dependency is introduced.
Locations are stored as WGS84 coordinates through the PostGIS Ecto extension.
Current movement and proximity evidence is derived in the application from two
current points and their browser-reported accuracy envelopes; no route is
retained. MapLibre GL JS renders the map through a small JavaScript hook. The
tile/style URL and attribution are configuration, not hard-coded provider
assumptions. Production operators must use a tile service whose policy and
capacity fit the traffic; the public OpenStreetMap tile service is best-effort
and has a usage policy, not an application backend.
current points and their client-reported accuracy envelopes; no route is
retained. Browser updates stop with the page. Android updates may continue
while minimized only through the user-started foreground service and its
visible notification; the app does not request `ACCESS_BACKGROUND_LOCATION`.
MapLibre GL JS renders the map through a small JavaScript hook. The tile/style
URL and attribution are configuration, not hard-coded provider assumptions.
Production operators must use a tile service whose policy and capacity fit the
traffic; the public OpenStreetMap tile service is best-effort and has a usage
policy, not an application backend.
## Data durability and retention

View File

@ -19,7 +19,10 @@ or handle controlled substances.
public location, expiry time, and medicine-pickup category.
3. Nearby helpers see the request on a list and map.
4. One helper accepts it; requester and helper receive a private matched chat.
5. Either party may share foreground location for the active match.
5. Either party may explicitly start live location sharing for the active
match. A browser shares only while its page remains open; Android may
continue through a visible foreground-service notification after the app is
minimized.
6. The requester gives the helper a one-time handover code after receiving help.
7. Both sides confirm completion and may leave a double-blind review.
8. The requester may send a voluntary thank-you through a helper-provided
@ -39,7 +42,7 @@ helper per request. Every state transition is authorized and recorded.
- Medicine pickup/delivery requests.
- Hierarchical, data-driven categories with validated structured request fields.
- Request list, filters, details, and a MapLibre map.
- Matching, presence, chat, and foreground location updates.
- Matching, presence, chat, and consent-driven live location updates.
- Configurable location privacy.
- One-time handover confirmation.
- Double-blind reviews and a reputation summary.
@ -52,8 +55,9 @@ helper per request. Every state transition is authorized and recorded.
handover-verified counterpart counts.
- Configurable PostgreSQL-backed action limits shared by every web replica.
- PWA installation and foreground geolocation.
- Native Android WebView client for the same-origin authenticated map, matched
chat, and foreground geolocation flows; reproducible debug APK build.
- Native Android WebView client for the same-origin authenticated map and
matched chat, plus a user-started native location foreground service with a
persistent Stop notification; reproducible debug APK build.
- Admin-only local Codex moderation batch for category proposals.
## Explicitly outside the MVP
@ -61,7 +65,7 @@ helper per request. Every state transition is authorized and recorded.
- Medical advice, prescriptions, pharmacy integrations, medication sale, and
controlled-substance delivery.
- Platform payments, escrow, fees, or compulsory compensation.
- Background PWA location tracking.
- Background PWA or unattended location tracking.
- Multiple simultaneous helpers on one request.
- Public activities such as coffee, cinema, or hiking. These will be a separate
future `Activity` mode, not an urgent-help category.

View File

@ -11,17 +11,17 @@ results from product limits and unknown production properties.
| Extensible categories | Implemented and tested | Categories and validated text/select/boolean fields are stored in PostgreSQL. Proposal, vote, approve, reject, and merge paths have automated tests. | Coffee, cinema, hiking, and other social activities are intentionally a future `Activity` mode. |
| Map and discovery | Implemented and browser-verified | A headed Chrome session rendered the MapLibre request map, marker, controls, attribution, and configured OpenStreetMap raster tiles. Tile requests returned HTTP 200 during the check. | A production operator must configure a tile provider appropriate for its policy and traffic. |
| Private matched chat | Implemented and cross-client verified | A message sent from the helper browser appeared in the requester's browser without reload. An earlier Android emulator run also sent a message that appeared in the requester browser in real time. | There is no unsolicited general-purpose inbox. |
| Foreground tracking | Implemented and cross-client verified | Android emulator coordinates created an active tracking session and appeared for the matched requester. Stopping sharing made the session inactive and removed raw current positions. | No background tracking or route history is implemented. |
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
| Social profiles | Implemented as manual links | Public links can be attached and are visibly labelled unverified. | OAuth/social-network verification is not implemented. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
| Android client | Debug client implemented and emulator-verified | The native package `org.whoneedhelp.mobile.debug` launches the same authenticated LiveView app. Login persistence, map, chat, and foreground geolocation were exercised on API 37. The final Docker emulator image separately passed boot/install/launch smoke verification. | Production signing, a public production origin, Play Store publication, background tracking, and iOS are not implemented. |
| Android client | Debug client implemented and emulator-verified | The native package `org.whoneedhelp.mobile.debug` launches the same authenticated LiveView app. Login, map, chat, permission prompts, minimized foreground-service location updates, notification Stop, and server cleanup were exercised on API 37. | Production signing, a public production origin, Play Store publication, unattended/background-permission tracking, and iOS are not implemented. |
| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
## Reproducible checks
- `./scripts/test.sh`: 121 tests, 0 failures in the final run.
- `./scripts/test.sh`: 124 tests, 0 failures in the final pre-upgrade run.
- `mix format --check-formatted`: passed in the final run.
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
`assembleDebug` passed; the final lint report contains no errors or warnings.
@ -29,20 +29,22 @@ results from product limits and unknown production properties.
intended; release configuration accepts only an explicit HTTPS origin.
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness
checks, and cross-node PubSub verification passed in the local kind cluster.
- Browser verification used headed Chrome. Fresh requester and helper pages
reported no console errors or warnings after the final rollout.
- Browser verification used headed Chrome. The authenticated matched-request
page rendered its chat, MapLibre marker, and live-location controls with no
console errors or warnings after the foreground-service rollout.
Local generated evidence (ignored by Git):
- `output/playwright/final-request-map-chat.png`
- `output/playwright/final-privacy-profile.png`
- `output/android/final-image-smoke.png`
- `output/android/foreground-notification.png`
Android artifact:
- `android/dist/who-need-help-debug.apk`
- SHA-256:
`4df66ec90e056c70f08841db2f0aa6178ba9cb64d4882478cd2cba88f5731003`
- SHA-256 before the dependency-upgrade pass:
`063f3d8d877009ee229a403692e4b16517244dd9e7dddab4c2b3c202c5def4b8`
- Observed manifest values: version `0.1.0-debug`, minimum SDK 24, target and
compile SDK 37.

View File

@ -101,6 +101,18 @@ defmodule WhoNeedHelp.Help do
end
end
def get_assignment_for_participant(%Scope{} = scope, id) do
with {:ok, id} <- Ecto.UUID.cast(id),
%Assignment{} = assignment <- Repo.get(Assignment, id),
true <- participant?(scope, assignment) do
{:ok, assignment}
else
false -> {:error, :forbidden}
nil -> {:error, :not_found}
:error -> {:error, :not_found}
end
end
def change_request(%HelpRequest{} = request, attrs \\ %{}) do
request
|> HelpRequest.create_changeset(attrs)

View File

@ -1,5 +1,12 @@
defmodule WhoNeedHelp.Tracking do
@moduledoc "Consent-driven, foreground-only active match tracking."
@moduledoc """
Consent-driven active-match tracking.
Browsers share only while the LiveView remains open. The Android client can
continue through a user-started foreground service with a persistent
notification. Only the current position is retained while a session is
active.
"""
import Ecto.Query
alias Ecto.Multi
@ -14,6 +21,16 @@ defmodule WhoNeedHelp.Tracking do
Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}")
end
def active_session?(%Scope{user: user} = scope, %Assignment{} = assignment) do
Help.participant?(scope, assignment) &&
Repo.exists?(
from session in TrackingSession,
where:
session.assignment_id == ^assignment.id and session.user_id == ^user.id and
session.active
)
end
def start_session(
%Scope{user: user} = scope,
%Assignment{} = assignment,

View File

@ -0,0 +1,37 @@
defmodule WhoNeedHelpWeb.MobileTrackingController do
use WhoNeedHelpWeb, :controller
alias WhoNeedHelp.Accounts.Scope
alias WhoNeedHelp.{Help, Tracking}
def update(conn, %{"assignment_id" => assignment_id} = params) do
with %Scope{user: user} = scope when not is_nil(user) <- conn.assigns.current_scope,
{:ok, assignment} <- Help.get_assignment_for_participant(scope, assignment_id),
{:ok, _position} <- Tracking.update_position(scope, assignment, params) do
send_resp(conn, :no_content, "")
else
nil -> send_resp(conn, :unauthorized, "")
%Scope{user: nil} -> send_resp(conn, :unauthorized, "")
{:error, :not_found} -> send_resp(conn, :not_found, "")
{:error, :forbidden} -> send_resp(conn, :forbidden, "")
{:error, :tracking_not_active} -> send_resp(conn, :conflict, "")
{:error, :rate_limited} -> send_resp(conn, :too_many_requests, "")
{:error, %Ecto.Changeset{}} -> send_resp(conn, :unprocessable_entity, "")
{:error, _reason} -> send_resp(conn, :unprocessable_entity, "")
end
end
def stop(conn, %{"assignment_id" => assignment_id}) do
with %Scope{user: user} = scope when not is_nil(user) <- conn.assigns.current_scope,
{:ok, assignment} <- Help.get_assignment_for_participant(scope, assignment_id),
{:ok, _result} <- Tracking.stop_session(scope, assignment) do
send_resp(conn, :no_content, "")
else
nil -> send_resp(conn, :unauthorized, "")
%Scope{user: nil} -> send_resp(conn, :unauthorized, "")
{:error, :not_found} -> send_resp(conn, :not_found, "")
{:error, :forbidden} -> send_resp(conn, :forbidden, "")
{:error, _reason} -> send_resp(conn, :unprocessable_entity, "")
end
end
end

View File

@ -64,7 +64,7 @@
<div class="text-3xl">2</div>
<h3 class="mt-4 font-bold">Match and coordinate</h3>
<p class="mt-2 text-sm text-base-content/65">
Use private chat and optional foreground tracking.
Use private chat and optional live location sharing.
</p>
</div>
<div class="rounded-2xl bg-base-200 p-6">

View File

@ -37,9 +37,11 @@
<section class="rounded-3xl border border-base-300 p-6">
<h2 class="text-xl font-bold">Location privacy</h2>
<p class="mt-2 text-sm leading-6">
Public location can be hidden, approximate, or explicitly exact. Foreground tracking is
optional. The current exact point is deleted when sharing stops, a match ends, or either
participant blocks the other; derived movement and proximity evidence may remain.
Public location can be hidden, approximate, or explicitly exact. Live tracking is
optional. A browser shares only while its page is open; Android shows a persistent
foreground-service notification while sharing. The current exact point is deleted when
sharing stops, a match ends, or either participant blocks the other; derived movement and
proximity evidence may remain.
</p>
</section>

View File

@ -7,13 +7,29 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def mount(%{"id" => id}, _session, socket) do
case Help.get_request(socket.assigns.current_scope, id) do
{:ok, request} ->
socket = assign(socket, :subscribed_assignment_id, nil)
native_client =
connected?(socket) &&
get_connect_params(socket)["client_type"] == "android"
socket =
socket
|> assign(:subscribed_assignment_id, nil)
|> assign(:native_client, native_client)
if connected?(socket), do: Help.subscribe_request(id)
socket = maybe_subscribe_assignment(socket, request)
{:ok, load(socket, request, false)}
tracking_active =
request.assignment &&
Tracking.active_session?(socket.assigns.current_scope, request.assignment)
socket =
socket
|> load(request, tracking_active)
|> maybe_start_native_tracking()
{:ok, socket}
{:error, :not_found} ->
{:ok,
@ -50,7 +66,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_info({:tracking_stopped, user_id}, socket) do
positions = Map.delete(socket.assigns.positions, user_id)
{:noreply, assign_positions(socket, positions)}
socket =
socket
|> assign_positions(positions)
|> then(fn socket ->
if user_id == socket.assigns.current_scope.user.id do
assign(socket, :tracking_active, false)
else
socket
end
end)
{:noreply, socket}
end
@impl true
@ -66,8 +94,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("cancel-request", _, socket) do
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
{:ok, _request} -> {:noreply, put_flash(socket, :info, "Request cancelled.")}
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
{:ok, _request} ->
{:noreply, put_flash(socket, :info, "Request cancelled.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, message(reason))}
end
end
@ -82,8 +113,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) do
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do
{:ok, _} -> {:noreply, put_flash(socket, :info, "Handover code verified.")}
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
{:ok, _} ->
{:noreply, put_flash(socket, :info, "Handover code verified.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, message(reason))}
end
end
@ -106,8 +140,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("start-tracking", _, socket) do
case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do
{:ok, _} -> {:noreply, assign(socket, :tracking_active, true)}
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
{:ok, _} ->
{:noreply,
socket
|> assign(:tracking_active, true)
|> maybe_start_native_tracking()}
{:error, reason} ->
{:noreply, put_flash(socket, :error, message(reason))}
end
end
@ -124,8 +164,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
def handle_event("stop-tracking", _, socket) do
case Tracking.stop_session(socket.assigns.current_scope, socket.assigns.assignment) do
{:ok, _} -> {:noreply, assign(socket, :tracking_active, false)}
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
{:ok, _} ->
{:noreply,
socket
|> assign(:tracking_active, false)
|> maybe_stop_native_tracking()}
{:error, reason} ->
{:noreply, put_flash(socket, :error, message(reason))}
end
end
@ -219,7 +265,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
@impl true
def terminate(_reason, socket) do
case socket.assigns do
%{tracking_active: true, current_scope: scope, assignment: assignment}
%{
tracking_active: true,
native_client: false,
current_scope: scope,
assignment: assignment
}
when not is_nil(assignment) ->
Tracking.stop_session(scope, assignment)
@ -230,6 +281,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:ok
end
defp maybe_start_native_tracking(
%{assigns: %{native_client: true, tracking_active: true, assignment: assignment}} =
socket
)
when not is_nil(assignment) do
push_event(socket, "native-tracking-start", %{assignment_id: assignment.id})
end
defp maybe_start_native_tracking(socket), do: socket
defp maybe_stop_native_tracking(%{assigns: %{native_client: true}} = socket) do
push_event(socket, "native-tracking-stop", %{})
end
defp maybe_stop_native_tracking(socket), do: socket
defp transition(socket, fun, success) do
case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do
{:ok, _} -> {:noreply, put_flash(socket, :info, success)}
@ -711,9 +778,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
:if={@participant && @assignment.status in [:accepted, :in_progress]}
class="rounded-3xl border border-base-300 p-5"
>
<h3 class="font-bold">Foreground live location</h3>
<h3 class="font-bold">Live location</h3>
<p class="mt-2 text-xs text-base-content/55">
Works only while this page remains open. Raw coordinates are removed when sharing stops.
In a browser this works only while the page remains open. The Android app shows a
persistent notification and can continue after it is minimized. Raw coordinates are
removed when sharing stops.
</p>
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
<button

View File

@ -18,6 +18,14 @@ defmodule WhoNeedHelpWeb.Router do
plug :accepts, ["json"]
end
pipeline :mobile do
plug :accepts, ["json"]
plug :fetch_session
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
scope "/healthz", WhoNeedHelpWeb do
pipe_through :api
@ -25,6 +33,13 @@ defmodule WhoNeedHelpWeb.Router do
get "/ready", HealthController, :ready
end
scope "/mobile", WhoNeedHelpWeb do
pipe_through :mobile
post "/tracking/:assignment_id/position", MobileTrackingController, :update
post "/tracking/:assignment_id/stop", MobileTrackingController, :stop
end
scope "/", WhoNeedHelpWeb do
pipe_through :browser

View File

@ -14,9 +14,13 @@ set -a
set +a
: "${WNH_DEBUG_BASE_URL:?Set WNH_DEBUG_BASE_URL in .env}"
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}"
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}"
exec docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
--target artifact \
--output "type=local,dest=$ROOT/android/dist" \
"$ROOT/android"

View File

@ -0,0 +1,122 @@
defmodule WhoNeedHelpWeb.MobileTrackingControllerTest do
use WhoNeedHelpWeb.ConnCase, async: false
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Accounts.Scope, Catalog, Help, Repo, Tracking}
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
setup %{conn: conn} do
requester = user_fixture(display_name: "Requester")
helper = user_fixture(display_name: "Helper")
requester_scope = Scope.for_user(requester)
helper_scope = Scope.for_user(helper)
category = Catalog.seed_defaults()
{:ok, request} = Help.create_request(requester_scope, request_attrs(category))
{:ok, assignment} = Help.accept_request(helper_scope, request.id)
%{
assignment: assignment,
helper: helper,
helper_scope: helper_scope,
logged_in_conn: log_in_user(conn, helper)
}
end
test "an authenticated participant updates and stops native tracking", context do
{:ok, session} = Tracking.start_session(context.helper_scope, context.assignment)
{conn, csrf_token} = csrf_connection(context.logged_in_conn, ~p"/requests")
conn =
json_post(
conn,
~p"/mobile/tracking/#{context.assignment.id}/position",
csrf_token,
%{
latitude: 50.4501,
longitude: 30.5234,
accuracy_meters: 7.5
}
)
assert response(conn, 204) == ""
assert %Position{} = Repo.get_by(Position, tracking_session_id: session.id)
conn =
conn
|> recycle()
|> json_post(
~p"/mobile/tracking/#{context.assignment.id}/stop",
csrf_token,
%{}
)
assert response(conn, 204) == ""
refute Repo.get_by(Position, tracking_session_id: session.id)
refute Repo.get_by(TrackingSession, id: session.id, active: true)
end
test "an authenticated non-participant cannot update tracking", context do
outsider = user_fixture(display_name: "Outsider")
{conn, csrf_token} = csrf_connection(log_in_user(build_conn(), outsider), ~p"/requests")
conn =
json_post(
conn,
~p"/mobile/tracking/#{context.assignment.id}/position",
csrf_token,
%{latitude: 50.4501, longitude: 30.5234, accuracy_meters: 7.5}
)
assert response(conn, 403) == ""
end
test "an anonymous request with a valid CSRF token remains unauthorized",
%{conn: conn} =
context do
{conn, csrf_token} = csrf_connection(conn, ~p"/")
conn =
json_post(
conn,
~p"/mobile/tracking/#{context.assignment.id}/position",
csrf_token,
%{latitude: 50.4501, longitude: 30.5234, accuracy_meters: 7.5}
)
assert response(conn, 401) == ""
end
defp csrf_connection(conn, path) do
conn = get(conn, path)
html = html_response(conn, 200)
[_, token] = Regex.run(~r/<meta name="csrf-token" content="([^"]+)"/, html)
{recycle(conn), token}
end
defp json_post(conn, path, csrf_token, body) do
conn
|> put_req_header("accept", "application/json")
|> put_req_header("content-type", "application/json")
|> put_req_header("x-csrf-token", csrf_token)
|> post(path, Jason.encode!(body))
end
defp request_attrs(category) do
%{
"title" => "Medicine is ready at the pharmacy",
"description" => "The medicine is reserved and paid for.",
"pickup_instructions" => "Ask for order WNH.",
"location_label" => "Central district",
"latitude" => "50.4501",
"longitude" => "30.5234",
"urgency" => "now",
"location_visibility" => "approximate_public",
"structured_data" => %{"pickup_status" => "reserved"},
"expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
"category_id" => category.id
}
end
end