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
|
WNH_DEBUG_BASE_URL=http://localhost:4010
|
||||||
# Release builds require a public HTTPS origin.
|
# Release builds require a public HTTPS origin.
|
||||||
WNH_BASE_URL=https://whoneedhelp.imalto.site
|
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
|
# Public raster tile template used by MapLibre. Use a provider whose policy and
|
||||||
# capacity match the deployment before a public launch.
|
# capacity match the deployment before a public launch.
|
||||||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
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
|
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
|
||||||
and expiry paths.
|
and expiry paths.
|
||||||
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
|
- 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.
|
- Handover code plus both-party confirmation before verified completion.
|
||||||
- Double-blind reviews, public trust summaries, and a helper leaderboard that
|
- Double-blind reviews, public trust summaries, and a helper leaderboard that
|
||||||
prioritizes unique location-supported and handover-verified counterparts
|
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.
|
- Public manual social links, always marked unverified in this MVP.
|
||||||
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
|
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
|
||||||
- Native Android WebView client with the same authenticated LiveView, map,
|
- Native Android WebView client with the same authenticated LiveView, map,
|
||||||
private chat, and foreground geolocation flows. The reproducible Docker
|
private chat, and a user-started location foreground service. Its persistent
|
||||||
target currently exports a debug APK; production signing and store
|
notification exposes Stop, it continues while the Activity is minimized, and
|
||||||
publication are not configured.
|
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
|
- 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.
|
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`
|
- 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.
|
replicas by default.
|
||||||
|
|
||||||
OAuth social verification, public coffee/cinema/hiking activities, background
|
OAuth social verification, public coffee/cinema/hiking activities, background
|
||||||
tracking, platform payments, production Android signing/store publication,
|
PWA or unattended location tracking, platform payments, production Android
|
||||||
iOS, automatic punitive fraud decisions, and jurisdiction-specific
|
signing/store publication, iOS, automatic punitive fraud decisions, and
|
||||||
public-launch policies are deliberately not claimed as complete.
|
jurisdiction-specific public-launch policies are deliberately not claimed as
|
||||||
|
complete.
|
||||||
|
|
||||||
## Fast start with Docker Compose
|
## Fast start with Docker Compose
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,15 @@ WORKDIR /workspace/android
|
||||||
COPY --chown=gradle:gradle . .
|
COPY --chown=gradle:gradle . .
|
||||||
|
|
||||||
ARG WNH_DEBUG_BASE_URL
|
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 \
|
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
|
||||||
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
|
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
|
||||||
gradle --no-daemon \
|
gradle --no-daemon \
|
||||||
"-PWNH_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \
|
"-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
|
testDebugUnitTest lintDebug assembleDebug
|
||||||
|
|
||||||
FROM android-base AS emulator
|
FROM android-base AS emulator
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
# Who Need Help for Android
|
# Who Need Help for Android
|
||||||
|
|
||||||
This module is a native Android WebView shell for the Phoenix application. It
|
This module is a native Android WebView shell for the Phoenix application. It
|
||||||
keeps authentication cookies, LiveView WebSockets, MapLibre, chat, and
|
keeps authentication cookies, LiveView WebSockets, MapLibre, and chat in the
|
||||||
foreground geolocation in the same trusted origin.
|
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
|
## 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
|
allow cleartext traffic, while the WebView still restricts in-app navigation to
|
||||||
that one configured origin.
|
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:
|
Release builds do not have a default server. Supply the real HTTPS deployment:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./gradlew :app:assembleRelease \
|
./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
|
The build rejects a missing, HTTP, credentialed, query-bearing, or
|
||||||
|
|
@ -53,6 +63,8 @@ set -a
|
||||||
set +a
|
set +a
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \
|
--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 \
|
--target emulator \
|
||||||
-t who-need-help-android:emulator \
|
-t who-need-help-android:emulator \
|
||||||
android
|
android
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ plugins {
|
||||||
|
|
||||||
val releaseBaseUrl = providers.gradleProperty("WNH_BASE_URL").orElse("")
|
val releaseBaseUrl = providers.gradleProperty("WNH_BASE_URL").orElse("")
|
||||||
val debugBaseUrl = providers.gradleProperty("WNH_DEBUG_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 {
|
android {
|
||||||
namespace = "org.whoneedhelp.mobile"
|
namespace = "org.whoneedhelp.mobile"
|
||||||
|
|
@ -19,6 +22,12 @@ android {
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
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"
|
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"
|
"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"
|
"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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_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
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
@ -23,5 +26,10 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".TrackingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="location"
|
||||||
|
android:stopWithTask="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import android.content.pm.PackageManager;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.net.http.SslError;
|
import android.net.http.SslError;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.webkit.CookieManager;
|
import android.webkit.CookieManager;
|
||||||
import android.webkit.GeolocationPermissions;
|
import android.webkit.GeolocationPermissions;
|
||||||
|
import android.webkit.JavascriptInterface;
|
||||||
import android.webkit.SslErrorHandler;
|
import android.webkit.SslErrorHandler;
|
||||||
import android.webkit.WebChromeClient;
|
import android.webkit.WebChromeClient;
|
||||||
import android.webkit.WebResourceError;
|
import android.webkit.WebResourceError;
|
||||||
|
|
@ -27,6 +29,9 @@ import androidx.activity.OnBackPressedCallback;
|
||||||
import androidx.activity.result.ActivityResultLauncher;
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
import androidx.activity.result.contract.ActivityResultContracts;
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public final class MainActivity extends ComponentActivity {
|
public final class MainActivity extends ComponentActivity {
|
||||||
private static final String LOG_TAG = "WhoNeedHelpWebView";
|
private static final String LOG_TAG = "WhoNeedHelpWebView";
|
||||||
private WebView webView;
|
private WebView webView;
|
||||||
|
|
@ -34,6 +39,8 @@ public final class MainActivity extends ComponentActivity {
|
||||||
private GeolocationPermissions.Callback pendingLocationCallback;
|
private GeolocationPermissions.Callback pendingLocationCallback;
|
||||||
private String pendingLocationOrigin;
|
private String pendingLocationOrigin;
|
||||||
private ActivityResultLauncher<String[]> locationPermissionLauncher;
|
private ActivityResultLauncher<String[]> locationPermissionLauncher;
|
||||||
|
private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher;
|
||||||
|
private PendingNativeTracking pendingNativeTracking;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
|
@ -52,6 +59,24 @@ public final class MainActivity extends ComponentActivity {
|
||||||
completeLocationPermission(granted);
|
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 = new WebView(this);
|
||||||
webView.setLayoutParams(
|
webView.setLayoutParams(
|
||||||
new ViewGroup.LayoutParams(
|
new ViewGroup.LayoutParams(
|
||||||
|
|
@ -77,6 +102,7 @@ public final class MainActivity extends ComponentActivity {
|
||||||
cookieManager.setAcceptCookie(true);
|
cookieManager.setAcceptCookie(true);
|
||||||
cookieManager.setAcceptThirdPartyCookies(webView, false);
|
cookieManager.setAcceptThirdPartyCookies(webView, false);
|
||||||
|
|
||||||
|
webView.addJavascriptInterface(new NativeTrackingBridge(), "WhoNeedHelpAndroid");
|
||||||
webView.setWebViewClient(new TrustedWebViewClient());
|
webView.setWebViewClient(new TrustedWebViewClient());
|
||||||
webView.setWebChromeClient(new LocationWebChromeClient());
|
webView.setWebChromeClient(new LocationWebChromeClient());
|
||||||
getOnBackPressedDispatcher().addCallback(
|
getOnBackPressedDispatcher().addCallback(
|
||||||
|
|
@ -147,6 +173,7 @@ public final class MainActivity extends ComponentActivity {
|
||||||
|
|
||||||
if (webView != null) {
|
if (webView != null) {
|
||||||
webView.stopLoading();
|
webView.stopLoading();
|
||||||
|
webView.removeJavascriptInterface("WhoNeedHelpAndroid");
|
||||||
webView.setWebChromeClient(null);
|
webView.setWebChromeClient(null);
|
||||||
webView.setWebViewClient(null);
|
webView.setWebViewClient(null);
|
||||||
webView.destroy();
|
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) {
|
private void completeLocationPermission(boolean granted) {
|
||||||
if (pendingLocationCallback != null && pendingLocationOrigin != null) {
|
if (pendingLocationCallback != null && pendingLocationOrigin != null) {
|
||||||
pendingLocationCallback.invoke(pendingLocationOrigin, granted, false);
|
pendingLocationCallback.invoke(pendingLocationOrigin, granted, false);
|
||||||
|
|
@ -327,4 +449,28 @@ public final class MainActivity extends ComponentActivity {
|
||||||
denyPendingLocation();
|
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 (
|
if (
|
||||||
!acceptedScheme ||
|
!acceptedScheme ||
|
||||||
uri.getHost() == null ||
|
uri.getHost() == null ||
|
||||||
uri.getHost().isBlank() ||
|
uri.getHost().trim().isEmpty() ||
|
||||||
uri.getUserInfo() != null ||
|
uri.getUserInfo() != null ||
|
||||||
uri.getQuery() != null ||
|
uri.getQuery() != null ||
|
||||||
uri.getFragment() != 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="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="secure_connection_failed">The secure connection was rejected.</string>
|
||||||
<string name="unsupported_link">This link type is not supported.</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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ import {Hooks} from "./hooks"
|
||||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {_csrf_token: csrfToken},
|
params: {
|
||||||
|
_csrf_token: csrfToken,
|
||||||
|
client_type: window.WhoNeedHelpAndroid ? "android" : "browser"
|
||||||
|
},
|
||||||
hooks: {...colocatedHooks, ...Hooks},
|
hooks: {...colocatedHooks, ...Hooks},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -41,6 +44,12 @@ window.addEventListener("phx:reset-message-form", ({detail}) => {
|
||||||
const form = document.getElementById(detail.id)
|
const form = document.getElementById(detail.id)
|
||||||
if (form instanceof HTMLFormElement) form.reset()
|
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
|
// connect if there are any LiveViews on the page
|
||||||
liveSocket.connect()
|
liveSocket.connect()
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,16 @@ export const Hooks = {
|
||||||
LiveTracking: {
|
LiveTracking: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.destroying = false
|
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(
|
this.watchId = navigator.geolocation.watchPosition(
|
||||||
position => this.pushEvent("location-update", {
|
position => this.pushEvent("location-update", {
|
||||||
latitude: position.coords.latitude,
|
latitude: position.coords.latitude,
|
||||||
|
|
@ -96,6 +106,9 @@ export const Hooks = {
|
||||||
},
|
},
|
||||||
destroyed() {
|
destroyed() {
|
||||||
this.destroying = true
|
this.destroying = true
|
||||||
|
if (this.nativeError) {
|
||||||
|
window.removeEventListener("wnh:native-tracking-error", this.nativeError)
|
||||||
|
}
|
||||||
if (this.watchId !== undefined) navigator.geolocation.clearWatch(this.watchId)
|
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
|
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
|
The Android module is a thin same-origin WebView shell with one native
|
||||||
API client. Its debug origin is supplied at build time from the repository's
|
foreground location service. Its debug origin is supplied at build time from
|
||||||
ignored `.env`; release builds require an explicit HTTPS origin. Authentication
|
the repository's ignored `.env`; release builds require an explicit HTTPS
|
||||||
cookies, LiveView WebSockets, MapLibre, private chat, and foreground
|
origin. Authentication cookies, LiveView WebSockets, MapLibre, and private chat
|
||||||
geolocation therefore use the same Phoenix application paths and authorization
|
use the same Phoenix application as the browser. A JavaScript bridge starts the
|
||||||
rules as the browser. Production signing and distribution are separate
|
native service only from the visible Activity; the service posts the current
|
||||||
operational work and are not represented as complete.
|
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
|
## Application boundaries
|
||||||
|
|
||||||
|
|
@ -49,8 +51,8 @@ operational work and are not represented as complete.
|
||||||
- `Help`: requests, assignments, state transitions, handover codes, and
|
- `Help`: requests, assignments, state transitions, handover codes, and
|
||||||
completion evidence.
|
completion evidence.
|
||||||
- `Messaging`: match chat and delivery receipts.
|
- `Messaging`: match chat and delivery receipts.
|
||||||
- `Tracking`: foreground sharing sessions, current positions, and derived
|
- `Tracking`: consent-driven sharing sessions, ephemeral current positions,
|
||||||
proximity signals.
|
and derived proximity signals.
|
||||||
- `Trust`: reviews, reports, blocks, leaderboard/reputation projections,
|
- `Trust`: reviews, reports, blocks, leaderboard/reputation projections,
|
||||||
abuse signals, moderator audit events, and shared rate-limit policies.
|
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.
|
Locations are stored as WGS84 coordinates through the PostGIS Ecto extension.
|
||||||
Current movement and proximity evidence is derived in the application from two
|
Current movement and proximity evidence is derived in the application from two
|
||||||
current points and their browser-reported accuracy envelopes; no route is
|
current points and their client-reported accuracy envelopes; no route is
|
||||||
retained. MapLibre GL JS renders the map through a small JavaScript hook. The
|
retained. Browser updates stop with the page. Android updates may continue
|
||||||
tile/style URL and attribution are configuration, not hard-coded provider
|
while minimized only through the user-started foreground service and its
|
||||||
assumptions. Production operators must use a tile service whose policy and
|
visible notification; the app does not request `ACCESS_BACKGROUND_LOCATION`.
|
||||||
capacity fit the traffic; the public OpenStreetMap tile service is best-effort
|
MapLibre GL JS renders the map through a small JavaScript hook. The tile/style
|
||||||
and has a usage policy, not an application backend.
|
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
|
## Data durability and retention
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ or handle controlled substances.
|
||||||
public location, expiry time, and medicine-pickup category.
|
public location, expiry time, and medicine-pickup category.
|
||||||
3. Nearby helpers see the request on a list and map.
|
3. Nearby helpers see the request on a list and map.
|
||||||
4. One helper accepts it; requester and helper receive a private matched chat.
|
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.
|
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.
|
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
|
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.
|
- Medicine pickup/delivery requests.
|
||||||
- Hierarchical, data-driven categories with validated structured request fields.
|
- Hierarchical, data-driven categories with validated structured request fields.
|
||||||
- Request list, filters, details, and a MapLibre map.
|
- 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.
|
- Configurable location privacy.
|
||||||
- One-time handover confirmation.
|
- One-time handover confirmation.
|
||||||
- Double-blind reviews and a reputation summary.
|
- 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.
|
handover-verified counterpart counts.
|
||||||
- Configurable PostgreSQL-backed action limits shared by every web replica.
|
- Configurable PostgreSQL-backed action limits shared by every web replica.
|
||||||
- PWA installation and foreground geolocation.
|
- PWA installation and foreground geolocation.
|
||||||
- Native Android WebView client for the same-origin authenticated map, matched
|
- Native Android WebView client for the same-origin authenticated map and
|
||||||
chat, and foreground geolocation flows; reproducible debug APK build.
|
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.
|
- Admin-only local Codex moderation batch for category proposals.
|
||||||
|
|
||||||
## Explicitly outside the MVP
|
## 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
|
- Medical advice, prescriptions, pharmacy integrations, medication sale, and
|
||||||
controlled-substance delivery.
|
controlled-substance delivery.
|
||||||
- Platform payments, escrow, fees, or compulsory compensation.
|
- Platform payments, escrow, fees, or compulsory compensation.
|
||||||
- Background PWA location tracking.
|
- Background PWA or unattended location tracking.
|
||||||
- Multiple simultaneous helpers on one request.
|
- Multiple simultaneous helpers on one request.
|
||||||
- Public activities such as coffee, cinema, or hiking. These will be a separate
|
- Public activities such as coffee, cinema, or hiking. These will be a separate
|
||||||
future `Activity` mode, not an urgent-help category.
|
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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
## 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.
|
- `mix format --check-formatted`: passed in the final run.
|
||||||
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
|
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
|
||||||
`assembleDebug` passed; the final lint report contains no errors or warnings.
|
`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.
|
intended; release configuration accepts only an explicit HTTPS origin.
|
||||||
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness
|
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness
|
||||||
checks, and cross-node PubSub verification passed in the local kind cluster.
|
checks, and cross-node PubSub verification passed in the local kind cluster.
|
||||||
- Browser verification used headed Chrome. Fresh requester and helper pages
|
- Browser verification used headed Chrome. The authenticated matched-request
|
||||||
reported no console errors or warnings after the final rollout.
|
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):
|
Local generated evidence (ignored by Git):
|
||||||
|
|
||||||
- `output/playwright/final-request-map-chat.png`
|
- `output/playwright/final-request-map-chat.png`
|
||||||
- `output/playwright/final-privacy-profile.png`
|
- `output/playwright/final-privacy-profile.png`
|
||||||
- `output/android/final-image-smoke.png`
|
- `output/android/final-image-smoke.png`
|
||||||
|
- `output/android/foreground-notification.png`
|
||||||
|
|
||||||
Android artifact:
|
Android artifact:
|
||||||
|
|
||||||
- `android/dist/who-need-help-debug.apk`
|
- `android/dist/who-need-help-debug.apk`
|
||||||
- SHA-256:
|
- SHA-256 before the dependency-upgrade pass:
|
||||||
`4df66ec90e056c70f08841db2f0aa6178ba9cb64d4882478cd2cba88f5731003`
|
`063f3d8d877009ee229a403692e4b16517244dd9e7dddab4c2b3c202c5def4b8`
|
||||||
- Observed manifest values: version `0.1.0-debug`, minimum SDK 24, target and
|
- Observed manifest values: version `0.1.0-debug`, minimum SDK 24, target and
|
||||||
compile SDK 37.
|
compile SDK 37.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,18 @@ defmodule WhoNeedHelp.Help do
|
||||||
end
|
end
|
||||||
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
|
def change_request(%HelpRequest{} = request, attrs \\ %{}) do
|
||||||
request
|
request
|
||||||
|> HelpRequest.create_changeset(attrs)
|
|> HelpRequest.create_changeset(attrs)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
defmodule WhoNeedHelp.Tracking do
|
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
|
import Ecto.Query
|
||||||
alias Ecto.Multi
|
alias Ecto.Multi
|
||||||
|
|
@ -14,6 +21,16 @@ defmodule WhoNeedHelp.Tracking do
|
||||||
Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}")
|
Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "tracking:#{assignment_id}")
|
||||||
end
|
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(
|
def start_session(
|
||||||
%Scope{user: user} = scope,
|
%Scope{user: user} = scope,
|
||||||
%Assignment{} = assignment,
|
%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>
|
<div class="text-3xl">2</div>
|
||||||
<h3 class="mt-4 font-bold">Match and coordinate</h3>
|
<h3 class="mt-4 font-bold">Match and coordinate</h3>
|
||||||
<p class="mt-2 text-sm text-base-content/65">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl bg-base-200 p-6">
|
<div class="rounded-2xl bg-base-200 p-6">
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@
|
||||||
<section class="rounded-3xl border border-base-300 p-6">
|
<section class="rounded-3xl border border-base-300 p-6">
|
||||||
<h2 class="text-xl font-bold">Location privacy</h2>
|
<h2 class="text-xl font-bold">Location privacy</h2>
|
||||||
<p class="mt-2 text-sm leading-6">
|
<p class="mt-2 text-sm leading-6">
|
||||||
Public location can be hidden, approximate, or explicitly exact. Foreground tracking is
|
Public location can be hidden, approximate, or explicitly exact. Live tracking is
|
||||||
optional. The current exact point is deleted when sharing stops, a match ends, or either
|
optional. A browser shares only while its page is open; Android shows a persistent
|
||||||
participant blocks the other; derived movement and proximity evidence may remain.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,29 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
case Help.get_request(socket.assigns.current_scope, id) do
|
case Help.get_request(socket.assigns.current_scope, id) do
|
||||||
{:ok, request} ->
|
{: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)
|
if connected?(socket), do: Help.subscribe_request(id)
|
||||||
|
|
||||||
socket = maybe_subscribe_assignment(socket, request)
|
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} ->
|
{:error, :not_found} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -50,7 +66,19 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|
|
||||||
def handle_info({:tracking_stopped, user_id}, socket) do
|
def handle_info({:tracking_stopped, user_id}, socket) do
|
||||||
positions = Map.delete(socket.assigns.positions, user_id)
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -66,8 +94,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|
|
||||||
def handle_event("cancel-request", _, socket) do
|
def handle_event("cancel-request", _, socket) do
|
||||||
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
case Help.cancel_request(socket.assigns.current_scope, socket.assigns.request.id) do
|
||||||
{:ok, _request} -> {:noreply, put_flash(socket, :info, "Request cancelled.")}
|
{:ok, _request} ->
|
||||||
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :info, "Request cancelled.")}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -82,8 +113,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|
|
||||||
def handle_event("verify-code", %{"handover" => %{"code" => code}}, socket) 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
|
case Help.verify_handover(socket.assigns.current_scope, socket.assigns.assignment.id, code) do
|
||||||
{:ok, _} -> {:noreply, put_flash(socket, :info, "Handover code verified.")}
|
{:ok, _} ->
|
||||||
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply, put_flash(socket, :info, "Handover code verified.")}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -106,8 +140,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|
|
||||||
def handle_event("start-tracking", _, socket) do
|
def handle_event("start-tracking", _, socket) do
|
||||||
case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do
|
case Tracking.start_session(socket.assigns.current_scope, socket.assigns.assignment) do
|
||||||
{:ok, _} -> {:noreply, assign(socket, :tracking_active, true)}
|
{:ok, _} ->
|
||||||
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:tracking_active, true)
|
||||||
|
|> maybe_start_native_tracking()}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -124,8 +164,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|
|
||||||
def handle_event("stop-tracking", _, socket) do
|
def handle_event("stop-tracking", _, socket) do
|
||||||
case Tracking.stop_session(socket.assigns.current_scope, socket.assigns.assignment) do
|
case Tracking.stop_session(socket.assigns.current_scope, socket.assigns.assignment) do
|
||||||
{:ok, _} -> {:noreply, assign(socket, :tracking_active, false)}
|
{:ok, _} ->
|
||||||
{:error, reason} -> {:noreply, put_flash(socket, :error, message(reason))}
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:tracking_active, false)
|
||||||
|
|> maybe_stop_native_tracking()}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, message(reason))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -219,7 +265,12 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
@impl true
|
@impl true
|
||||||
def terminate(_reason, socket) do
|
def terminate(_reason, socket) do
|
||||||
case socket.assigns 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) ->
|
when not is_nil(assignment) ->
|
||||||
Tracking.stop_session(scope, assignment)
|
Tracking.stop_session(scope, assignment)
|
||||||
|
|
||||||
|
|
@ -230,6 +281,22 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
:ok
|
:ok
|
||||||
end
|
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
|
defp transition(socket, fun, success) do
|
||||||
case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do
|
case fun.(socket.assigns.current_scope, socket.assigns.assignment.id) do
|
||||||
{:ok, _} -> {:noreply, put_flash(socket, :info, success)}
|
{:ok, _} -> {:noreply, put_flash(socket, :info, success)}
|
||||||
|
|
@ -711,9 +778,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
:if={@participant && @assignment.status in [:accepted, :in_progress]}
|
:if={@participant && @assignment.status in [:accepted, :in_progress]}
|
||||||
class="rounded-3xl border border-base-300 p-5"
|
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">
|
<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>
|
</p>
|
||||||
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
|
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
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
|
scope "/healthz", WhoNeedHelpWeb do
|
||||||
pipe_through :api
|
pipe_through :api
|
||||||
|
|
||||||
|
|
@ -25,6 +33,13 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
get "/ready", HealthController, :ready
|
get "/ready", HealthController, :ready
|
||||||
end
|
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
|
scope "/", WhoNeedHelpWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,13 @@ set -a
|
||||||
set +a
|
set +a
|
||||||
|
|
||||||
: "${WNH_DEBUG_BASE_URL:?Set WNH_DEBUG_BASE_URL in .env}"
|
: "${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 \
|
exec docker build \
|
||||||
--build-arg "WNH_DEBUG_BASE_URL=$WNH_DEBUG_BASE_URL" \
|
--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 \
|
--target artifact \
|
||||||
--output "type=local,dest=$ROOT/android/dist" \
|
--output "type=local,dest=$ROOT/android/dist" \
|
||||||
"$ROOT/android"
|
"$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