feat: keep Android location sharing while minimized
This commit is contained in:
parent
30bfeef1fb
commit
68faa4795c
|
|
@ -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
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue
Block a user