From c863b47ed116076adb764f6b695909397ee9716b Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Tue, 28 Jul 2026 04:15:05 +0300 Subject: [PATCH] Harden Android location consent and physical-device verification --- android/Dockerfile | 3 + android/app/build.gradle.kts | 1 + .../mobile/AndroidClientInstrumentedTest.java | 186 +++++++++++--- .../CrossClientStagingInstrumentedTest.java | 32 ++- .../whoneedhelp/mobile/FixtureHttpServer.java | 31 ++- android/app/src/main/AndroidManifest.xml | 1 + .../org/whoneedhelp/mobile/MainActivity.java | 219 ++++++++++++++-- .../whoneedhelp/mobile/TrackingService.java | 2 +- .../res/layout/dialog_location_permission.xml | 21 ++ .../app/src/main/res/values-ru/strings.xml | 7 + .../app/src/main/res/values-uk/strings.xml | 7 + android/app/src/main/res/values/strings.xml | 7 + scripts/android-browser-staging-e2e.sh | 237 +++++++++++++----- scripts/android-build.sh | 5 + scripts/android-development-build.sh | 7 +- 15 files changed, 637 insertions(+), 129 deletions(-) create mode 100644 android/app/src/main/res/layout/dialog_location_permission.xml diff --git a/android/Dockerfile b/android/Dockerfile index c08ea38..a36f060 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -142,6 +142,9 @@ USER 65532:65532 COPY --from=android-sdk \ /workspace/android/app/build/outputs/apk/debug/app-debug.apk \ /who-need-help-debug.apk +COPY --from=android-sdk \ + /workspace/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ + /who-need-help-debug-androidTest.apk COPY --from=android-sdk \ /workspace/android/app/build/reports/lint-results-debug.html \ /lint-results-debug.html diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b8785d0..35e38a9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -331,6 +331,7 @@ tasks.withType().configureEach { dependencies { implementation("androidx.activity:activity:1.13.0") + implementation("androidx.core.locationbutton:locationbutton:1.0.0-alpha01") implementation("androidx.credentials:credentials:1.6.0") implementation("androidx.credentials:credentials-play-services-auth:1.6.0") implementation("androidx.fragment:fragment:1.8.9") diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java index 8863af5..b9fb5e5 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java @@ -66,7 +66,9 @@ public final class AndroidClientInstrumentedTest { context.stopService(new Intent(context, TrackingService.class)); context.getSystemService(NotificationManager.class).cancelAll(); device.pressBack(); - server.close(); + if (server != null) { + server.close(); + } } @Test @@ -107,6 +109,23 @@ public final class AndroidClientInstrumentedTest { .withElement(findElement(Locator.ID, "location")) .perform(webClick()); + UiObject2 locationButton = device.wait( + Until.findObject( + By.res(context.getPackageName(), "location_button") + ), + UI_TIMEOUT_MS + ); + assertNotNull( + "The native Android location explanation was not shown", + locationButton + ); + assertTrue( + device.hasObject( + By.text(context.getString(R.string.location_button_title)) + ) + ); + locationButton.click(); + UiObject2 allow = null; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -156,17 +175,52 @@ public final class AndroidClientInstrumentedTest { allow.click(); waitForLocationPermission(); - assertNotNull( - "WebView geolocation did not report a granted position", - server.awaitRequestEndingWith( - "/geolocation-granted", - 15, + FixtureHttpServer.RecordedRequest callback = + server.awaitRequestContaining( + "/geolocation-", + 35, TimeUnit.SECONDS + ); + assertNotNull( + "WebView geolocation did not return a callback", + callback + ); + assertFalse( + "WebView treated the granted Android permission as denied", + callback.path.endsWith("/geolocation-denied-1") + ); + assertTrue("Android location permission was not retained", hasLocationPermission()); + } + } + + @Test + public void test02bTrackingRequiresExplicitNativeDisclosure() throws Exception { + try (ActivityScenario scenario = launch("/tracking-disclosure")) { + onWebView() + .withElement(findElement(Locator.ID, "tracking")) + .perform(webClick()); + + UiObject2 disclosure = device.wait( + Until.findObject( + By.text(context.getString(R.string.tracking_disclosure_title)) + ), + UI_TIMEOUT_MS + ); + assertNotNull("The live-location disclosure was not shown", disclosure); + assertTrue( + device.hasObject( + By.text(context.getString(R.string.tracking_disclosure_detail)) ) ); - onWebView() - .withElement(findElement(Locator.ID, "marker")) - .check(webMatches(getText(), containsString("location-granted"))); + + UiObject2 cancel = device.wait( + Until.findObject(By.res("android", "button2")), + UI_TIMEOUT_MS + ); + assertNotNull("The live-location disclosure had no Cancel action", cancel); + cancel.click(); + + waitForServiceState(false); } } @@ -195,6 +249,47 @@ public final class AndroidClientInstrumentedTest { } } + @Test + public void test03bSameOriginDeepLinkUpdatesRunningActivity() { + ActivityScenario scenario = + launch("/notifications?section=settings"); + + try { + onWebView() + .withElement(findElement(Locator.ID, "marker")) + .check( + webMatches( + getText(), + containsString("loaded:/notifications?section=settings") + ) + ); + + Intent notificationIntent = new Intent( + Intent.ACTION_VIEW, + Uri.parse(BuildConfig.BASE_URL + "/notifications"), + context, + MainActivity.class + ); + scenario.onActivity(activity -> activity.onNewIntent(notificationIntent)); + + onWebView() + .withElement(findElement(Locator.ID, "marker")) + .check( + webMatches( + getText(), + containsString("loaded:/notifications") + ) + ); + } finally { + // Calling onNewIntent directly is deliberate: this regression test + // exercises the already-running Activity branch without depending + // on an OEM task switcher. ActivityScenario.close() can wait + // indefinitely after that synthetic lifecycle callback on HyperOS, + // so finish the test Activity explicitly instead. + scenario.onActivity(MainActivity::finish); + } + } + @Test public void test04ForegroundTrackingPostsLocationAndNotificationStop() throws Exception { grantTrackingPermissions(); @@ -275,33 +370,31 @@ public final class AndroidClientInstrumentedTest { server.awaitRequestEndingWith(positionPath, 15, TimeUnit.SECONDS) ); - int foregroundCount = server.requestCountEndingWith(positionPath); device.pressHome(); - assertNotNull( - "Tracking stopped after the Activity left the foreground", - server.awaitRequestAfterCount( - positionPath, - foregroundCount, - 15, - TimeUnit.SECONDS - ) - ); + SystemClock.sleep(1_000); assertTrue(serviceIsRunning()); + assertTrue( + "Location updates were unregistered after the Activity left the foreground", + locationUpdatesAreRegistered() + ); + assertTrue( + "The foreground tracking notification disappeared after pressing Home", + trackingNotificationIsActive() + ); - int stoppedActivityCount = server.requestCountEndingWith(positionPath); scenario.close(); - assertNotNull( - "Tracking stopped after the Activity was destroyed", - server.awaitRequestAfterCount( - positionPath, - stoppedActivityCount, - 15, - TimeUnit.SECONDS - ) - ); + SystemClock.sleep(1_000); assertTrue(serviceIsRunning()); + assertTrue( + "Location updates were unregistered after the Activity was destroyed", + locationUpdatesAreRegistered() + ); + assertTrue( + "The foreground tracking notification disappeared after Activity destruction", + trackingNotificationIsActive() + ); openNotificationAndClickStop(); assertNotNull( @@ -388,18 +481,21 @@ public final class AndroidClientInstrumentedTest { ); assertNotNull("Foreground tracking notification was not visible", active); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - sendStopActionFromActiveNotification(); - return; - } - UiObject2 stop = device.wait( Until.findObject(By.text(context.getString(R.string.tracking_stop_action))), UI_TIMEOUT_MS ); - assertNotNull("Foreground tracking notification had no Stop action", stop); - stop.click(); + if (stop != null) { + stop.click(); + return; + } + + // Some OEM notification shades keep foreground-service actions collapsed + // even after the notification is opened. Validate and invoke the actual + // action exposed by Android instead of treating that UI choice as an + // application failure. + sendStopActionFromActiveNotification(); } private void sendStopActionFromActiveNotification() { @@ -501,4 +597,22 @@ public final class AndroidClientInstrumentedTest { .contains(TrackingService.class.getName()); } + private boolean locationUpdatesAreRegistered() throws Exception { + return device + .executeShellCommand("dumpsys location") + .contains(context.getPackageName()); + } + + private boolean trackingNotificationIsActive() { + NotificationManager manager = context.getSystemService(NotificationManager.class); + + for (StatusBarNotification notification : manager.getActiveNotifications()) { + if (notification.getId() == TrackingService.NOTIFICATION_ID) { + return true; + } + } + + return false; + } + } diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java index ad68935..c3a2426 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java @@ -8,6 +8,7 @@ import static androidx.test.espresso.web.webdriver.DriverAtoms.getText; import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; import static androidx.test.espresso.web.webdriver.DriverAtoms.webKeys; import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import android.app.Notification; @@ -23,7 +24,10 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.espresso.web.webdriver.Locator; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.By; import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.Until; import org.junit.Test; import org.junit.runner.RunWith; @@ -32,7 +36,7 @@ import java.util.concurrent.TimeUnit; @RunWith(AndroidJUnit4.class) public final class CrossClientStagingInstrumentedTest { - private static final long PAGE_TIMEOUT_SECONDS = 45; + private static final long PAGE_TIMEOUT_SECONDS = 120; private final Context context = ApplicationProvider.getApplicationContext(); private final UiDevice device = @@ -77,6 +81,7 @@ public final class CrossClientStagingInstrumentedTest { click("send-message-button"); waitForElementText("messages", androidMessage); click("share-location-button"); + confirmTrackingDisclosure(); waitForServiceState(true); waitForElementText("messages", browserReply); waitForNotification( @@ -236,6 +241,31 @@ public final class CrossClientStagingInstrumentedTest { throw timeout; } + private void confirmTrackingDisclosure() { + UiObject2 disclosure = device.wait( + Until.findObject( + By.text(context.getString(R.string.tracking_disclosure_title)) + ), + TimeUnit.SECONDS.toMillis(PAGE_TIMEOUT_SECONDS) + ); + assertNotNull("The live-location disclosure was not shown", disclosure); + assertTrue( + device.hasObject( + By.text(context.getString(R.string.tracking_disclosure_detail)) + ) + ); + + UiObject2 continueButton = device.wait( + Until.findObject(By.res("android", "button1")), + TimeUnit.SECONDS.toMillis(PAGE_TIMEOUT_SECONDS) + ); + assertNotNull( + "The live-location disclosure had no Continue action", + continueButton + ); + continueButton.click(); + } + private void waitForServiceState(boolean expected) throws Exception { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java index c1899ea..6f288ac 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java @@ -88,6 +88,25 @@ final class FixtureHttpServer implements Closeable { return count; } + RecordedRequest awaitRequestContaining(String value, long timeout, TimeUnit unit) + throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + + while (System.nanoTime() < deadline) { + synchronized (requests) { + for (RecordedRequest request : requests) { + if (request.path.contains(value)) { + return request; + } + } + } + + Thread.sleep(50); + } + + return null; + } + RecordedRequest awaitRequestAfterCount( String suffix, int previousCount, @@ -223,10 +242,16 @@ final class FixtureHttpServer implements Closeable { + "" + + "" + ""; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0df829a..e94aa22 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + 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 878dcce..829fc6a 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -41,6 +41,7 @@ import androidx.credentials.GetCredentialResponse; import androidx.credentials.exceptions.ClearCredentialException; import androidx.credentials.exceptions.GetCredentialException; import androidx.credentials.exceptions.NoCredentialException; +import androidx.core.locationbutton.LocationButton; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; @@ -72,11 +73,14 @@ public final class MainActivity extends ComponentActivity { private TrustedOrigin trustedOrigin; private GeolocationPermissions.Callback pendingLocationCallback; private String pendingLocationOrigin; - private ActivityResultLauncher locationPermissionLauncher; + private ActivityResultLauncher webLocationPermissionLauncher; private ActivityResultLauncher nativeTrackingPermissionLauncher; private ActivityResultLauncher pushNotificationPermissionLauncher; private PendingNativeTracking pendingNativeTracking; private AlertDialog pageLoadErrorDialog; + private AlertDialog locationPermissionDialog; + private AlertDialog trackingDisclosureDialog; + private boolean webLocationPermissionRequestInFlight; private boolean mainFrameLoadFailed; private CredentialManager credentialManager; private ExecutorService nativeGoogleNetworkExecutor; @@ -91,15 +95,24 @@ public final class MainActivity extends ComponentActivity { trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG); credentialManager = CredentialManager.create(this); nativeGoogleNetworkExecutor = Executors.newSingleThreadExecutor(); - locationPermissionLauncher = registerForActivityResult( + webLocationPermissionLauncher = registerForActivityResult( new ActivityResultContracts.RequestMultiplePermissions(), result -> { - boolean granted = + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d( + LOG_TAG, + "Web location runtime permission result; granted=" + + hasLocationPermission() + ); + } + webLocationPermissionRequestInFlight = false; + boolean locationGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION)) || Boolean.TRUE.equals( result.get(Manifest.permission.ACCESS_COARSE_LOCATION) - ); - completeLocationPermission(granted); + ) + || hasLocationPermission(); + completeLocationPermission(locationGranted); } ); nativeTrackingPermissionLauncher = registerForActivityResult( @@ -296,6 +309,7 @@ public final class MainActivity extends ComponentActivity { @Override protected void onDestroy() { denyPendingLocation(); + dismissTrackingDisclosure(); dismissPageLoadError(); cancelNativeGoogleRequest(); @@ -322,27 +336,84 @@ public final class MainActivity extends ComponentActivity { return; } - denyPendingLocation(); - pendingLocationOrigin = origin; - pendingLocationCallback = callback; - - boolean granted = - checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED - || checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) - == PackageManager.PERMISSION_GRANTED; - - if (granted) { - completeLocationPermission(true); + if (hasLocationPermission()) { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d(LOG_TAG, "Web location permission already granted"); + } + callback.invoke(origin, true, false); return; } - locationPermissionLauncher.launch( - new String[] { - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - } + denyPendingLocation(); + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d(LOG_TAG, "Web location permission prompt requested"); + } + pendingLocationOrigin = origin; + pendingLocationCallback = callback; + showLocationPermissionDialog(); + } + + private void showLocationPermissionDialog() { + View content = getLayoutInflater().inflate( + R.layout.dialog_location_permission, + null ); + LocationButton locationButton = content.findViewById(R.id.location_button); + locationButton.setParentActivity(this); + locationButton.setOnPermissionResultListener(granted -> { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d( + LOG_TAG, + "LocationButton permission result; granted=" + + granted + + "; effective=" + + hasLocationPermission() + ); + } + webLocationPermissionRequestInFlight = false; + completeLocationPermission(granted || hasLocationPermission()); + }); + locationButton.setOnRequestPermissionsListener(() -> { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d(LOG_TAG, "LocationButton requested fallback permission flow"); + } + webLocationPermissionRequestInFlight = true; + webLocationPermissionLauncher.launch( + new String[] { + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + } + ); + }); + locationButton.setOnErrorListener(error -> { + webLocationPermissionRequestInFlight = false; + Log.w(LOG_TAG, "System location button failed", error); + Toast.makeText( + this, + R.string.location_button_failed, + Toast.LENGTH_LONG + ).show(); + completeLocationPermission(false); + }); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.location_button_title) + .setMessage(R.string.location_button_detail) + .setView(content) + .setNegativeButton( + R.string.cancel, + (ignored, which) -> completeLocationPermission(false) + ) + .create(); + locationPermissionDialog = dialog; + dialog.setOnCancelListener(ignored -> completeLocationPermission(false)); + dialog.setOnDismissListener(ignored -> { + if (locationPermissionDialog == dialog) { + locationPermissionDialog = null; + completeLocationPermission(false); + } + }); + dialog.show(); } private boolean hasLocationPermission() { @@ -363,7 +434,48 @@ public final class MainActivity extends ComponentActivity { } pendingNativeTracking = new PendingNativeTracking(assignmentId, csrfToken); + showTrackingDisclosure(); + } + private void showTrackingDisclosure() { + dismissTrackingDisclosure(); + + AtomicBoolean resolved = new AtomicBoolean(false); + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle(R.string.tracking_disclosure_title) + .setMessage(R.string.tracking_disclosure_detail) + .setPositiveButton(R.string.tracking_disclosure_continue, (ignored, which) -> { + resolved.set(true); + trackingDisclosureDialog = null; + continuePendingNativeTracking(); + }) + .setNegativeButton(R.string.cancel, (ignored, which) -> { + resolved.set(true); + trackingDisclosureDialog = null; + cancelPendingNativeTracking(); + }) + .create(); + + trackingDisclosureDialog = dialog; + dialog.setOnCancelListener(ignored -> { + if (resolved.compareAndSet(false, true)) { + trackingDisclosureDialog = null; + cancelPendingNativeTracking(); + } + }); + dialog.setOnDismissListener(ignored -> { + if ( + trackingDisclosureDialog == dialog + && resolved.compareAndSet(false, true) + ) { + trackingDisclosureDialog = null; + cancelPendingNativeTracking(); + } + }); + dialog.show(); + } + + private void continuePendingNativeTracking() { boolean notificationGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) @@ -387,6 +499,22 @@ public final class MainActivity extends ComponentActivity { nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0])); } + private void cancelPendingNativeTracking() { + pendingNativeTracking = null; + dispatchNativeTrackingError(); + } + + private void dismissTrackingDisclosure() { + AlertDialog dialog = trackingDisclosureDialog; + trackingDisclosureDialog = null; + + if (dialog != null && dialog.isShowing()) { + dialog.setOnCancelListener(null); + dialog.setOnDismissListener(null); + dialog.dismiss(); + } + } + private void configureNativeBridge() { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { return; @@ -832,12 +960,18 @@ public final class MainActivity extends ComponentActivity { pendingNativeTracking = null; if (pending == null) { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.w(LOG_TAG, "Native tracking refused: no pending request"); + } return; } String cookie = CookieManager.getInstance().getCookie(BuildConfig.BASE_URL); if (cookie == null || cookie.trim().isEmpty()) { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.w(LOG_TAG, "Native tracking refused: session cookie unavailable"); + } dispatchNativeTrackingError(); return; } @@ -855,7 +989,14 @@ public final class MainActivity extends ComponentActivity { } else { startService(intent); } + + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d(LOG_TAG, "Native tracking service start requested"); + } } catch (RuntimeException exception) { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.e(LOG_TAG, "Native tracking service could not start", exception); + } dispatchNativeTrackingError(); } } @@ -881,18 +1022,41 @@ public final class MainActivity extends ComponentActivity { } private void completeLocationPermission(boolean granted) { + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d( + LOG_TAG, + "Completing web location permission; granted=" + + granted + + "; callback=" + + (pendingLocationCallback != null) + ); + } + webLocationPermissionRequestInFlight = false; + if (pendingLocationCallback != null && pendingLocationOrigin != null) { pendingLocationCallback.invoke(pendingLocationOrigin, granted, false); } pendingLocationCallback = null; pendingLocationOrigin = null; + dismissLocationPermissionDialog(); } private void denyPendingLocation() { completeLocationPermission(false); } + private void dismissLocationPermissionDialog() { + AlertDialog dialog = locationPermissionDialog; + locationPermissionDialog = null; + + if (dialog != null && dialog.isShowing()) { + dialog.setOnCancelListener(null); + dialog.setOnDismissListener(null); + dialog.dismiss(); + } + } + private void openExternal(Uri uri) { String scheme = uri.getScheme(); @@ -1067,7 +1231,16 @@ public final class MainActivity extends ComponentActivity { @Override public void onGeolocationPermissionsHidePrompt() { - denyPendingLocation(); + if (PAGE_LOAD_DIAGNOSTICS) { + Log.d( + LOG_TAG, + "WebView hid location permission prompt; inFlight=" + + webLocationPermissionRequestInFlight + ); + } + if (!webLocationPermissionRequestInFlight) { + denyPendingLocation(); + } } } diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java b/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java index d4448c8..49db8b4 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/TrackingService.java @@ -39,7 +39,7 @@ public final class TrackingService extends Service implements LocationListener { 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; + static final int NOTIFICATION_ID = 4101; private final Object pendingLocationLock = new Object(); private final AtomicBoolean uploadRunning = new AtomicBoolean(false); diff --git a/android/app/src/main/res/layout/dialog_location_permission.xml b/android/app/src/main/res/layout/dialog_location_permission.xml new file mode 100644 index 0000000..40235f4 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_location_permission.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index a732fc0..0a34d2c 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -6,6 +6,7 @@ Не удалось загрузить Who Need Help. Проверьте подключение и повторите попытку. Повторить Закрыть + Отмена Безопасное подключение было отклонено. Этот тип ссылки не поддерживается. Геолокация активной помощи @@ -19,6 +20,12 @@ Новые обновления приостановлены. Нажмите «Остановить передачу», чтобы повторить удаление текущей позиции. Геолокация недоступна Включите геолокацию устройства, вернитесь к заявке и повторите попытку. + Использовать точную геолокацию один раз + Who Need Help использует вашу точную геолокацию только для этого действия, чтобы разместить выбранную точку или область. Это не запускает передачу геолокации в реальном времени. + Android не смог показать защищённую кнопку геолокации. Геолокация не передавалась. + Передавать текущую геолокацию участнику? + Who Need Help будет получать и отправлять вашу точную геолокацию назначенному заказчику или помощнику, пока передача включена, в том числе когда приложение свёрнуто. Постоянное уведомление останется видимым. Нажмите «Остановить передачу» в приложении или уведомлении, чтобы остановить её; текущая необработанная позиция после этого удаляется. Сводные данные безопасности могут храниться в соответствии с Политикой конфиденциальности. + Продолжить и передавать Обновления заявок о помощи Приватные уведомления о заявках, сообщениях и помощи поблизости. Откройте Who Need Help, чтобы просмотреть обновление. diff --git a/android/app/src/main/res/values-uk/strings.xml b/android/app/src/main/res/values-uk/strings.xml index 59481c0..9f5349f 100644 --- a/android/app/src/main/res/values-uk/strings.xml +++ b/android/app/src/main/res/values-uk/strings.xml @@ -6,6 +6,7 @@ Не вдалося завантажити Who Need Help. Перевірте з’єднання та повторіть спробу. Повторити Закрити + Скасувати Захищене з’єднання було відхилено. Цей тип посилання не підтримується. Геолокація активної допомоги @@ -19,6 +20,12 @@ Нові оновлення призупинено. Натисніть «Зупинити передачу», щоб повторити видалення поточної позиції. Геолокація недоступна Увімкніть геолокацію пристрою, поверніться до заявки та повторіть спробу. + Використати точну геолокацію один раз + Who Need Help використає вашу точну геолокацію лише для цієї дії, щоб розмістити вибрану точку або область. Це не запускає передавання геолокації в реальному часі. + Android не зміг показати захищену кнопку геолокації. Геолокація не передавалася. + Передавати поточну геолокацію учаснику? + Who Need Help отримуватиме й надсилатиме вашу точну геолокацію призначеному замовнику або помічнику, поки передавання ввімкнене, зокрема коли застосунок згорнуто. Постійне сповіщення залишатиметься видимим. Натисніть «Зупинити передавання» в застосунку або сповіщенні, щоб зупинити його; поточна необроблена позиція після цього видаляється. Зведені дані безпеки можуть зберігатися відповідно до Політики конфіденційності. + Продовжити й передавати Оновлення заявок про допомогу Приватні сповіщення про заявки, повідомлення та допомогу поблизу. Відкрийте Who Need Help, щоб переглянути оновлення. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c7eb571..e268754 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Could not load Who Need Help. Check your connection and retry. Retry Close + Cancel The secure connection was rejected. This link type is not supported. Active help location @@ -19,6 +20,12 @@ 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. + Use your precise location once + Who Need Help will use your precise location only for this action to place the point or area you selected. This does not start live tracking. + Android could not provide the secure location button. No location was shared. + Share live location with your match? + Who Need Help will collect and send your precise location to the matched requester or helper while sharing is on, including while the app is minimized. A persistent notification remains visible. Use Stop sharing in the app or notification to stop; the current raw position is then deleted. Summary safety evidence may be retained as described in the Privacy Policy. + Continue and share Help request updates Private request, message, and nearby-help notifications. Open Who Need Help to view the update. diff --git a/scripts/android-browser-staging-e2e.sh b/scripts/android-browser-staging-e2e.sh index 22857cc..deb4556 100755 --- a/scripts/android-browser-staging-e2e.sh +++ b/scripts/android-browser-staging-e2e.sh @@ -7,6 +7,9 @@ cd "$ROOT" ENV_FILE="$ROOT/.env" variant=${WNH_ANDROID_PUBLIC_VARIANT:-staging} +physical_serial=${WNH_ANDROID_PHYSICAL_SERIAL:-} +physical_mode=0 +host_adb= case "$variant" in development) @@ -25,6 +28,31 @@ case "$variant" in ;; esac +if [[ -n "$physical_serial" ]]; then + if [[ "$variant" != development ]]; then + echo "Physical-device cross-client E2E is restricted to development." >&2 + exit 2 + fi + + if [[ ! "$physical_serial" =~ ^[A-Za-z0-9._:-]+$ ]]; then + echo "WNH_ANDROID_PHYSICAL_SERIAL contains unsupported characters." >&2 + exit 2 + fi + + host_adb=$(command -v adb || true) + if [[ -z "$host_adb" ]]; then + echo "adb is required for physical-device cross-client E2E." >&2 + exit 2 + fi + + if [[ "$("$host_adb" -s "$physical_serial" get-state 2>/dev/null || true)" != device ]]; then + echo "The selected physical Android device is not connected and authorised." >&2 + exit 2 + fi + + physical_mode=1 +fi + APK="$ROOT/android/dist-$variant/who-need-help-$variant.apk" TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk" remote_target= @@ -104,7 +132,7 @@ if [[ -n "$remote_target" ]]; then esac fi -if [[ ! -e /dev/kvm ]]; then +if [[ "$physical_mode" -eq 0 && ! -e /dev/kvm ]]; then echo "/dev/kvm is required for the containerized Android emulator." >&2 exit 1 fi @@ -332,6 +360,15 @@ COMMIT; SQL } +physical_fixture_user_count() { + docker compose exec -T db sh -c \ + 'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' </dev/null 2>&1; then + if [[ "$physical_mode" -eq 1 ]] && + [[ "$(adb get-state 2>/dev/null || true)" == device ]]; then + adb logcat -d >"$output_dir/logcat.txt" 2>&1 || true + adb shell dumpsys notification --noredact >"$output_dir/notifications.txt" 2>&1 || true + adb shell dumpsys activity services "$package" >"$output_dir/services.txt" 2>&1 || true + adb exec-out screencap -p >"$output_dir/final.png" 2>/dev/null || true + elif docker inspect "$container" >/dev/null 2>&1; then adb logcat -d >"$output_dir/logcat.txt" 2>&1 || true adb shell dumpsys notification --noredact >"$output_dir/notifications.txt" 2>&1 || true adb shell dumpsys activity services "$package" >"$output_dir/services.txt" 2>&1 || true @@ -511,7 +558,46 @@ cleanup() { status=1 else snapshot_database "$output_dir/database-after.txt" - if ! diff -u \ + if [[ "$physical_mode" -eq 1 ]]; then + grep -v ' push_devices ' "$output_dir/database-before.txt" \ + >"$output_dir/database-before-without-push-devices.txt" + grep -v ' push_devices ' "$output_dir/database-after.txt" \ + >"$output_dir/database-after-without-push-devices.txt" + + if ! diff -u \ + "$output_dir/database-before-without-push-devices.txt" \ + "$output_dir/database-after-without-push-devices.txt" \ + >"$output_dir/database-cleanup.diff"; then + echo "Physical Android/browser cleanup did not restore non-device table counts." >&2 + status=1 + fi + + physical_fixture_user_count \ + >"$output_dir/fixture-prefix-count.txt" + if [[ "$(tr -d '[:space:]' <"$output_dir/fixture-prefix-count.txt")" != 0 ]]; then + echo "Physical Android/browser cleanup retained run-scoped users." >&2 + status=1 + fi + + before_push_devices=$( + awk '$1 == "push_devices" {print $3}' \ + "$output_dir/database-before.txt" + ) + after_push_devices=$( + awk '$1 == "push_devices" {print $3}' \ + "$output_dir/database-after.txt" + ) + { + printf 'before=%s\n' "$before_push_devices" + printf 'after=%s\n' "$after_push_devices" + } >"$output_dir/push-device-counts.txt" + + if [[ -z "$before_push_devices" || -z "$after_push_devices" ]] || + ((after_push_devices > before_push_devices)); then + echo "Physical Android/browser cleanup retained a new push device." >&2 + status=1 + fi + elif ! diff -u \ "$output_dir/database-before.txt" \ "$output_dir/database-after.txt" \ >"$output_dir/database-cleanup.diff"; then @@ -565,7 +651,12 @@ REMOTE_CLEANUP printf 'true\n' fi printf 'fixture_prefix_absent=' - if [[ -f "$output_dir/database-cleanup.diff" ]] && + if [[ "$physical_mode" -eq 1 ]] && + [[ -f "$output_dir/fixture-prefix-count.txt" ]] && + [[ "$(tr -d '[:space:]' <"$output_dir/fixture-prefix-count.txt")" == 0 ]]; then + printf 'true\n' + elif [[ "$physical_mode" -eq 0 ]] && + [[ -f "$output_dir/database-cleanup.diff" ]] && [[ ! -s "$output_dir/database-cleanup.diff" ]]; then printf 'true\n' else @@ -653,76 +744,92 @@ else fi docker build --tag "$browser_image" e2e \ >"$output_dir/browser-build.log" -docker build \ - --build-arg "WNH_DEBUG_BASE_URL=$WNH_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" \ - --build-arg "ANDROID_EMULATOR_SYSTEM_IMAGE=$android_system_image" \ - --target emulator \ - --tag "$android_image" \ - android >"$output_dir/android-image-build.log" -docker volume create "$avd_volume" >"$output_dir/avd-volume.txt" +if [[ "$physical_mode" -eq 1 ]]; then + adb shell input keyevent KEYCODE_WAKEUP + if [[ -z "$(adb shell pm path "$package" 2>/dev/null || true)" ]]; then + echo "The current development APK is not installed on the selected physical device." >&2 + exit 1 + fi + if [[ -z "$(adb shell pm path "${package}.test" 2>/dev/null || true)" ]]; then + echo "The current development instrumentation APK is not installed on the selected physical device." >&2 + exit 1 + fi +else + docker build \ + --build-arg "WNH_DEBUG_BASE_URL=$WNH_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" \ + --build-arg "ANDROID_EMULATOR_SYSTEM_IMAGE=$android_system_image" \ + --target emulator \ + --tag "$android_image" \ + android >"$output_dir/android-image-build.log" -docker run -d \ - --name "$container" \ - --device /dev/kvm \ - --mount \ - "type=volume,source=$avd_volume,target=/home/gradle/.android/avd/who_need_help_test.avd" \ - "$android_image" >"$output_dir/container-id.txt" + docker volume create "$avd_volume" >"$output_dir/avd-volume.txt" -if [[ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]]; then - docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true - echo "Android cross-client emulator exited before ADB became available; inspect $output_dir/emulator.log." >&2 - exit 1 -fi + docker run -d \ + --name "$container" \ + --device /dev/kvm \ + --mount \ + "type=volume,source=$avd_volume,target=/home/gradle/.android/avd/who_need_help_test.avd" \ + "$android_image" >"$output_dir/container-id.txt" -adb_ready=0 -for _attempt in $(seq 1 90); do if [[ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]]; then - break + docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true + echo "Android emulator exited before ADB became available; inspect $output_dir/emulator.log." >&2 + exit 1 fi - if [[ "$(adb get-state 2>/dev/null || true)" == device ]]; then - adb_ready=1 - break + adb_ready=0 + for _attempt in $(seq 1 90); do + if [[ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]]; then + break + fi + + if [[ "$(adb get-state 2>/dev/null || true)" == device ]]; then + adb_ready=1 + break + fi + + sleep 2 + done + + if [[ "$adb_ready" -ne 1 ]]; then + docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true + echo "Android emulator did not expose a ready ADB device." >&2 + exit 1 fi - sleep 2 -done + booted= + for _attempt in $(seq 1 90); do + booted=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + [[ "$booted" == 1 ]] && break + sleep 2 + done -if [[ "$adb_ready" -ne 1 ]]; then - docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true - echo "Android cross-client emulator did not expose a ready ADB device." >&2 - exit 1 + if [[ "$booted" != 1 ]]; then + echo "Android emulator did not finish booting." >&2 + exit 1 + fi + + adb shell input keyevent 82 + adb shell settings put global window_animation_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global animator_duration_scale 0 + adb shell cmd location set-location-enabled true + docker cp "$APK" "$container:/tmp/who-need-help-public.apk" + docker cp "$TEST_APK" "$container:/tmp/who-need-help-public-androidTest.apk" + adb install -r /tmp/who-need-help-public.apk >"$output_dir/install.txt" + adb install -r /tmp/who-need-help-public-androidTest.apk \ + >"$output_dir/test-install.txt" fi -booted= -for _attempt in $(seq 1 90); do - booted=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') - [[ "$booted" == 1 ]] && break - sleep 2 -done - -if [[ "$booted" != 1 ]]; then - echo "Android cross-client emulator did not finish booting." >&2 - exit 1 -fi - -adb shell input keyevent 82 -adb shell settings put global window_animation_scale 0 -adb shell settings put global transition_animation_scale 0 -adb shell settings put global animator_duration_scale 0 -adb shell cmd location set-location-enabled true -docker cp "$APK" "$container:/tmp/who-need-help-public.apk" -docker cp "$TEST_APK" "$container:/tmp/who-need-help-public-androidTest.apk" -adb install -r /tmp/who-need-help-public.apk >"$output_dir/install.txt" -adb install -r /tmp/who-need-help-public-androidTest.apk \ - >"$output_dir/test-install.txt" adb shell pm list instrumentation >"$output_dir/instrumentation.txt" adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION adb shell pm grant "$package" android.permission.POST_NOTIFICATIONS -adb emu geo fix 30.5237 50.4504 +if [[ "$physical_mode" -eq 0 ]]; then + adb emu geo fix 30.5237 50.4504 +fi network_ready=0 for _attempt in $(seq 1 45); do @@ -753,7 +860,9 @@ helper_login_path=$(jq -r '.helper_login_path' "$output_dir/fixture.json") requester_email=$(jq -r '.users.requester.email' "$output_dir/fixture.json") adb logcat -c -adb emu geo fix 30.5237 50.4504 +if [[ "$physical_mode" -eq 0 ]]; then + adb emu geo fix 30.5237 50.4504 +fi start_android_phase exchangeMessageAndTrackingWithBrowser tracking_started=0 @@ -771,7 +880,9 @@ for _attempt in $(seq 1 45); do tracking_started=1 break fi - adb emu geo fix 30.5237 50.4504 >/dev/null + if [[ "$physical_mode" -eq 0 ]]; then + adb emu geo fix 30.5237 50.4504 >/dev/null + fi sleep 1 done @@ -821,6 +932,8 @@ fi printf 'run_id=%s\n' "$run_id" printf 'android_api=%s\n' "$android_api" printf 'android_system_image=%s\n' "$android_system_image" + printf 'android_device=%s\n' \ + "$([[ "$physical_mode" -eq 1 ]] && printf physical || printf emulator)" printf 'package=%s\n' "$package" printf 'public_origin=%s\n' "$WNH_BASE_URL" printf 'magic_link_login=true\n' diff --git a/scripts/android-build.sh b/scripts/android-build.sh index 9456e80..b66ba3e 100755 --- a/scripts/android-build.sh +++ b/scripts/android-build.sh @@ -3,6 +3,7 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE=${WNH_ENV_FILE:-"$ROOT/.env"} +DEBUG_BASE_URL_OVERRIDE=${WNH_DEBUG_BASE_URL_OVERRIDE:-} case "$ENV_FILE" in /*) ;; @@ -19,6 +20,10 @@ set -a . "$ENV_FILE" set +a +if [ -n "$DEBUG_BASE_URL_OVERRIDE" ]; then + WNH_DEBUG_BASE_URL=$DEBUG_BASE_URL_OVERRIDE +fi + : "${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}" diff --git a/scripts/android-development-build.sh b/scripts/android-development-build.sh index 5b64aa1..8a25e5c 100755 --- a/scripts/android-development-build.sh +++ b/scripts/android-development-build.sh @@ -4,9 +4,6 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env" config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} -SIGNING_DIR=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"} -KEYSTORE="$SIGNING_DIR/who-need-help-development.p12" -PASSWORD_FILE="$SIGNING_DIR/who-need-help-development.password" "$ROOT/scripts/ensure-local-public-origin.sh" "$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" development @@ -16,6 +13,10 @@ set -a . "$ENV_FILE" set +a +SIGNING_DIR=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"} +KEYSTORE="$SIGNING_DIR/who-need-help-development.p12" +PASSWORD_FILE="$SIGNING_DIR/who-need-help-development.password" + : "${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}" : "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in .env}"