Harden Android location consent and physical-device verification

This commit is contained in:
SimpleTest 2026-07-28 04:15:05 +03:00
parent cbb13d6888
commit c863b47ed1
15 changed files with 637 additions and 129 deletions

View File

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

View File

@ -331,6 +331,7 @@ tasks.withType<JavaCompile>().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")

View File

@ -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<MainActivity> 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<MainActivity> 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;
}
}

View File

@ -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);

View File

@ -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 {
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>{document.getElementById('marker').textContent='location-granted';"
+ "fetch('/geolocation-granted')},"
+ "()=>{document.getElementById('marker').textContent='location-denied';"
+ "fetch('/geolocation-denied')},"
+ "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">"
+ "(error)=>{document.getElementById('marker').textContent="
+ "'location-denied-'+error.code;"
+ "fetch('/geolocation-denied-'+error.code)},"
+ "{enableHighAccuracy:false,maximumAge:3600000,timeout:30000})\">"
+ "Request location</button>"
+ "<button id=\"tracking\" onclick=\"window.WhoNeedHelpAndroid.postMessage("
+ "JSON.stringify({action:'start',"
+ "assignment_id:'00000000-0000-4000-8000-000000000001',"
+ "csrf_token:'fixture-csrf'}))\">"
+ "Start live tracking</button>"
+ "</body></html>";
}

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.USE_LOCATION_BUTTON" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

View File

@ -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<String[]> locationPermissionLauncher;
private ActivityResultLauncher<String[]> webLocationPermissionLauncher;
private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher;
private ActivityResultLauncher<String> 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();
}
}
}

View File

@ -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);

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingTop="12dp"
android:paddingEnd="20dp"
android:paddingBottom="8dp">
<androidx.core.locationbutton.LocationButton
android:id="@+id/location_button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:textColor="@android:color/white"
android:iconTint="@android:color/white"
app:backgroundColor="@color/brand_green"
app:cornerRadius="16dp"
app:locationButtonTextType="use_precise_location" />
</FrameLayout>

View File

@ -6,6 +6,7 @@
<string name="page_load_failed">Не удалось загрузить Who Need Help. Проверьте подключение и повторите попытку.</string>
<string name="retry">Повторить</string>
<string name="close">Закрыть</string>
<string name="cancel">Отмена</string>
<string name="secure_connection_failed">Безопасное подключение было отклонено.</string>
<string name="unsupported_link">Этот тип ссылки не поддерживается.</string>
<string name="tracking_channel_name">Геолокация активной помощи</string>
@ -19,6 +20,12 @@
<string name="tracking_stop_failed_detail">Новые обновления приостановлены. Нажмите «Остановить передачу», чтобы повторить удаление текущей позиции.</string>
<string name="tracking_location_unavailable">Геолокация недоступна</string>
<string name="tracking_location_unavailable_detail">Включите геолокацию устройства, вернитесь к заявке и повторите попытку.</string>
<string name="location_button_title">Использовать точную геолокацию один раз</string>
<string name="location_button_detail">Who Need Help использует вашу точную геолокацию только для этого действия, чтобы разместить выбранную точку или область. Это не запускает передачу геолокации в реальном времени.</string>
<string name="location_button_failed">Android не смог показать защищённую кнопку геолокации. Геолокация не передавалась.</string>
<string name="tracking_disclosure_title">Передавать текущую геолокацию участнику?</string>
<string name="tracking_disclosure_detail">Who Need Help будет получать и отправлять вашу точную геолокацию назначенному заказчику или помощнику, пока передача включена, в том числе когда приложение свёрнуто. Постоянное уведомление останется видимым. Нажмите «Остановить передачу» в приложении или уведомлении, чтобы остановить её; текущая необработанная позиция после этого удаляется. Сводные данные безопасности могут храниться в соответствии с Политикой конфиденциальности.</string>
<string name="tracking_disclosure_continue">Продолжить и передавать</string>
<string name="updates_channel_name">Обновления заявок о помощи</string>
<string name="updates_channel_description">Приватные уведомления о заявках, сообщениях и помощи поблизости.</string>
<string name="updates_default_body">Откройте Who Need Help, чтобы просмотреть обновление.</string>

View File

@ -6,6 +6,7 @@
<string name="page_load_failed">Не вдалося завантажити Who Need Help. Перевірте з’єднання та повторіть спробу.</string>
<string name="retry">Повторити</string>
<string name="close">Закрити</string>
<string name="cancel">Скасувати</string>
<string name="secure_connection_failed">Захищене з’єднання було відхилено.</string>
<string name="unsupported_link">Цей тип посилання не підтримується.</string>
<string name="tracking_channel_name">Геолокація активної допомоги</string>
@ -19,6 +20,12 @@
<string name="tracking_stop_failed_detail">Нові оновлення призупинено. Натисніть «Зупинити передачу», щоб повторити видалення поточної позиції.</string>
<string name="tracking_location_unavailable">Геолокація недоступна</string>
<string name="tracking_location_unavailable_detail">Увімкніть геолокацію пристрою, поверніться до заявки та повторіть спробу.</string>
<string name="location_button_title">Використати точну геолокацію один раз</string>
<string name="location_button_detail">Who Need Help використає вашу точну геолокацію лише для цієї дії, щоб розмістити вибрану точку або область. Це не запускає передавання геолокації в реальному часі.</string>
<string name="location_button_failed">Android не зміг показати захищену кнопку геолокації. Геолокація не передавалася.</string>
<string name="tracking_disclosure_title">Передавати поточну геолокацію учаснику?</string>
<string name="tracking_disclosure_detail">Who Need Help отримуватиме й надсилатиме вашу точну геолокацію призначеному замовнику або помічнику, поки передавання ввімкнене, зокрема коли застосунок згорнуто. Постійне сповіщення залишатиметься видимим. Натисніть «Зупинити передавання» в застосунку або сповіщенні, щоб зупинити його; поточна необроблена позиція після цього видаляється. Зведені дані безпеки можуть зберігатися відповідно до Політики конфіденційності.</string>
<string name="tracking_disclosure_continue">Продовжити й передавати</string>
<string name="updates_channel_name">Оновлення заявок про допомогу</string>
<string name="updates_channel_description">Приватні сповіщення про заявки, повідомлення та допомогу поблизу.</string>
<string name="updates_default_body">Відкрийте Who Need Help, щоб переглянути оновлення.</string>

View File

@ -6,6 +6,7 @@
<string name="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string>
<string name="retry">Retry</string>
<string name="close">Close</string>
<string name="cancel">Cancel</string>
<string name="secure_connection_failed">The secure connection was rejected.</string>
<string name="unsupported_link">This link type is not supported.</string>
<string name="tracking_channel_name">Active help location</string>
@ -19,6 +20,12 @@
<string name="tracking_stop_failed_detail">New updates are paused. Tap Stop sharing to retry deleting the current position.</string>
<string name="tracking_location_unavailable">Location is unavailable</string>
<string name="tracking_location_unavailable_detail">Enable device location, then return to the request and try again.</string>
<string name="location_button_title">Use your precise location once</string>
<string name="location_button_detail">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.</string>
<string name="location_button_failed">Android could not provide the secure location button. No location was shared.</string>
<string name="tracking_disclosure_title">Share live location with your match?</string>
<string name="tracking_disclosure_detail">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.</string>
<string name="tracking_disclosure_continue">Continue and share</string>
<string name="updates_channel_name">Help request updates</string>
<string name="updates_channel_description">Private request, message, and nearby-help notifications.</string>
<string name="updates_default_body">Open Who Need Help to view the update.</string>

View File

@ -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"' <<SQL
SELECT count(*)
FROM users
WHERE email LIKE 'wnh-android-e2e-$run_id-%';
SQL
}
run_fixture_tool() {
local action=$1
@ -429,7 +466,11 @@ REMOTE_FIXTURE_OWNER
}
adb() {
docker exec "$container" adb "$@"
if [[ "$physical_mode" -eq 1 ]]; then
"$host_adb" -s "$physical_serial" "$@"
else
docker exec "$container" adb "$@"
fi
}
run_browser_phase() {
@ -497,7 +538,13 @@ cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if docker inspect "$container" >/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'

View File

@ -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}"

View File

@ -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}"