From 68faa4795c8737418238f09ac45fc24f40c33af2 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 18 Jul 2026 17:56:49 +0300 Subject: [PATCH] feat: keep Android location sharing while minimized --- .env.example | 5 + README.md | 17 +- android/Dockerfile | 4 + android/README.md | 18 +- android/app/build.gradle.kts | 30 ++ android/app/src/main/AndroidManifest.xml | 8 + .../org/whoneedhelp/mobile/MainActivity.java | 146 ++++++ .../whoneedhelp/mobile/TrackingService.java | 436 ++++++++++++++++++ .../org/whoneedhelp/mobile/TrustedOrigin.java | 2 +- android/app/src/main/res/values/strings.xml | 11 + assets/js/app.js | 11 +- assets/js/hooks.js | 13 + docs/architecture.md | 35 +- docs/product-spec.md | 14 +- docs/verification.md | 16 +- lib/who_need_help/help.ex | 12 + lib/who_need_help/tracking.ex | 19 +- .../controllers/mobile_tracking_controller.ex | 37 ++ .../controllers/page_html/home.html.heex | 2 +- .../controllers/page_html/safety.html.heex | 8 +- .../live/request_live/show.ex | 97 +++- lib/who_need_help_web/router.ex | 15 + scripts/android-build.sh | 4 + .../mobile_tracking_controller_test.exs | 122 +++++ 24 files changed, 1024 insertions(+), 58 deletions(-) create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java create mode 100644 lib/who_need_help_web/controllers/mobile_tracking_controller.ex create mode 100644 test/who_need_help_web/controllers/mobile_tracking_controller_test.exs diff --git a/.env.example b/.env.example index 86ff6a6..769fb7f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index bb5a04c..b08f2ba 100644 --- a/README.md +++ b/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 diff --git a/android/Dockerfile b/android/Dockerfile index f99011a..31f1a24 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -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 diff --git a/android/README.md b/android/README.md index add7e40..d432ca9 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7a031b1..8e84fc2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" + ) } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f259808..51c5258 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + + diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java index 4e88ab0..5212b40 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -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 locationPermissionLauncher; + private ActivityResultLauncher 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 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; + } + } } diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java b/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java new file mode 100644 index 0000000..df90ae9 --- /dev/null +++ b/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java @@ -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> 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; + } +} diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java b/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java index 7e72d68..6a7e5a3 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/TrustedOrigin.java @@ -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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8af5ed5..3faf589 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,4 +5,15 @@ Could not load Who Need Help. Check your connection and retry. The secure connection was rejected. This link type is not supported. + Active help location + Visible location sharing for an active matched request. + Sharing live location + Who Need Help can continue sharing while minimized. + Stop sharing + Stopping location sharing + Waiting for the server to delete the current position. + Could not confirm Stop + New updates are paused. Tap Stop sharing to retry deleting the current position. + Location is unavailable + Enable device location, then return to the request and try again. diff --git a/assets/js/app.js b/assets/js/app.js index 11f6de1..a218994 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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() diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 92953b3..1908390 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -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) } } diff --git a/docs/architecture.md b/docs/architecture.md index 728e94b..573cfdb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/product-spec.md b/docs/product-spec.md index aab590c..0348595 100644 --- a/docs/product-spec.md +++ b/docs/product-spec.md @@ -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. diff --git a/docs/verification.md b/docs/verification.md index a6b892e..ede6a14 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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. diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index db37700..feb7e47 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -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) diff --git a/lib/who_need_help/tracking.ex b/lib/who_need_help/tracking.ex index a285cf1..8937893 100644 --- a/lib/who_need_help/tracking.ex +++ b/lib/who_need_help/tracking.ex @@ -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, diff --git a/lib/who_need_help_web/controllers/mobile_tracking_controller.ex b/lib/who_need_help_web/controllers/mobile_tracking_controller.ex new file mode 100644 index 0000000..3aee00e --- /dev/null +++ b/lib/who_need_help_web/controllers/mobile_tracking_controller.ex @@ -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 diff --git a/lib/who_need_help_web/controllers/page_html/home.html.heex b/lib/who_need_help_web/controllers/page_html/home.html.heex index 54352f9..2984822 100644 --- a/lib/who_need_help_web/controllers/page_html/home.html.heex +++ b/lib/who_need_help_web/controllers/page_html/home.html.heex @@ -64,7 +64,7 @@
2

Match and coordinate

- Use private chat and optional foreground tracking. + Use private chat and optional live location sharing.

diff --git a/lib/who_need_help_web/controllers/page_html/safety.html.heex b/lib/who_need_help_web/controllers/page_html/safety.html.heex index 545e93f..d6e2217 100644 --- a/lib/who_need_help_web/controllers/page_html/safety.html.heex +++ b/lib/who_need_help_web/controllers/page_html/safety.html.heex @@ -37,9 +37,11 @@

Location privacy

- 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.

diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex index 58d0379..63c89d2 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -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" > -

Foreground live location

+

Live location

- 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.