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 \ COPY --from=android-sdk \
/workspace/android/app/build/outputs/apk/debug/app-debug.apk \ /workspace/android/app/build/outputs/apk/debug/app-debug.apk \
/who-need-help-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 \ COPY --from=android-sdk \
/workspace/android/app/build/reports/lint-results-debug.html \ /workspace/android/app/build/reports/lint-results-debug.html \
/lint-results-debug.html /lint-results-debug.html

View File

@ -331,6 +331,7 @@ tasks.withType<JavaCompile>().configureEach {
dependencies { dependencies {
implementation("androidx.activity:activity:1.13.0") 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:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0") implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
implementation("androidx.fragment:fragment:1.8.9") 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.stopService(new Intent(context, TrackingService.class));
context.getSystemService(NotificationManager.class).cancelAll(); context.getSystemService(NotificationManager.class).cancelAll();
device.pressBack(); device.pressBack();
server.close(); if (server != null) {
server.close();
}
} }
@Test @Test
@ -107,6 +109,23 @@ public final class AndroidClientInstrumentedTest {
.withElement(findElement(Locator.ID, "location")) .withElement(findElement(Locator.ID, "location"))
.perform(webClick()); .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; UiObject2 allow = null;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@ -156,17 +175,52 @@ public final class AndroidClientInstrumentedTest {
allow.click(); allow.click();
waitForLocationPermission(); waitForLocationPermission();
assertNotNull( FixtureHttpServer.RecordedRequest callback =
"WebView geolocation did not report a granted position", server.awaitRequestContaining(
server.awaitRequestEndingWith( "/geolocation-",
"/geolocation-granted", 35,
15,
TimeUnit.SECONDS 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")) UiObject2 cancel = device.wait(
.check(webMatches(getText(), containsString("location-granted"))); 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 @Test
public void test04ForegroundTrackingPostsLocationAndNotificationStop() throws Exception { public void test04ForegroundTrackingPostsLocationAndNotificationStop() throws Exception {
grantTrackingPermissions(); grantTrackingPermissions();
@ -275,33 +370,31 @@ public final class AndroidClientInstrumentedTest {
server.awaitRequestEndingWith(positionPath, 15, TimeUnit.SECONDS) server.awaitRequestEndingWith(positionPath, 15, TimeUnit.SECONDS)
); );
int foregroundCount = server.requestCountEndingWith(positionPath);
device.pressHome(); device.pressHome();
assertNotNull( SystemClock.sleep(1_000);
"Tracking stopped after the Activity left the foreground",
server.awaitRequestAfterCount(
positionPath,
foregroundCount,
15,
TimeUnit.SECONDS
)
);
assertTrue(serviceIsRunning()); 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(); scenario.close();
assertNotNull( SystemClock.sleep(1_000);
"Tracking stopped after the Activity was destroyed",
server.awaitRequestAfterCount(
positionPath,
stoppedActivityCount,
15,
TimeUnit.SECONDS
)
);
assertTrue(serviceIsRunning()); assertTrue(serviceIsRunning());
assertTrue(
"Location updates were unregistered after the Activity was destroyed",
locationUpdatesAreRegistered()
);
assertTrue(
"The foreground tracking notification disappeared after Activity destruction",
trackingNotificationIsActive()
);
openNotificationAndClickStop(); openNotificationAndClickStop();
assertNotNull( assertNotNull(
@ -388,18 +481,21 @@ public final class AndroidClientInstrumentedTest {
); );
assertNotNull("Foreground tracking notification was not visible", active); assertNotNull("Foreground tracking notification was not visible", active);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
sendStopActionFromActiveNotification();
return;
}
UiObject2 stop = device.wait( UiObject2 stop = device.wait(
Until.findObject(By.text(context.getString(R.string.tracking_stop_action))), Until.findObject(By.text(context.getString(R.string.tracking_stop_action))),
UI_TIMEOUT_MS UI_TIMEOUT_MS
); );
assertNotNull("Foreground tracking notification had no Stop action", stop); if (stop != null) {
stop.click(); 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() { private void sendStopActionFromActiveNotification() {
@ -501,4 +597,22 @@ public final class AndroidClientInstrumentedTest {
.contains(TrackingService.class.getName()); .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.webClick;
import static androidx.test.espresso.web.webdriver.DriverAtoms.webKeys; import static androidx.test.espresso.web.webdriver.DriverAtoms.webKeys;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.app.Notification; import android.app.Notification;
@ -23,7 +24,10 @@ import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.web.webdriver.Locator; import androidx.test.espresso.web.webdriver.Locator;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.By;
import androidx.test.uiautomator.UiDevice; import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject2;
import androidx.test.uiautomator.Until;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@ -32,7 +36,7 @@ import java.util.concurrent.TimeUnit;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public final class CrossClientStagingInstrumentedTest { 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 Context context = ApplicationProvider.getApplicationContext();
private final UiDevice device = private final UiDevice device =
@ -77,6 +81,7 @@ public final class CrossClientStagingInstrumentedTest {
click("send-message-button"); click("send-message-button");
waitForElementText("messages", androidMessage); waitForElementText("messages", androidMessage);
click("share-location-button"); click("share-location-button");
confirmTrackingDisclosure();
waitForServiceState(true); waitForServiceState(true);
waitForElementText("messages", browserReply); waitForElementText("messages", browserReply);
waitForNotification( waitForNotification(
@ -236,6 +241,31 @@ public final class CrossClientStagingInstrumentedTest {
throw timeout; 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 { private void waitForServiceState(boolean expected) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15);

View File

@ -88,6 +88,25 @@ final class FixtureHttpServer implements Closeable {
return count; 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( RecordedRequest awaitRequestAfterCount(
String suffix, String suffix,
int previousCount, int previousCount,
@ -223,10 +242,16 @@ final class FixtureHttpServer implements Closeable {
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition(" + "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>{document.getElementById('marker').textContent='location-granted';" + "()=>{document.getElementById('marker').textContent='location-granted';"
+ "fetch('/geolocation-granted')}," + "fetch('/geolocation-granted')},"
+ "()=>{document.getElementById('marker').textContent='location-denied';" + "(error)=>{document.getElementById('marker').textContent="
+ "fetch('/geolocation-denied')}," + "'location-denied-'+error.code;"
+ "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">" + "fetch('/geolocation-denied-'+error.code)},"
+ "{enableHighAccuracy:false,maximumAge:3600000,timeout:30000})\">"
+ "Request location</button>" + "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>"; + "</body></html>";
} }

View File

@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.USE_LOCATION_BUTTON" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <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.ClearCredentialException;
import androidx.credentials.exceptions.GetCredentialException; import androidx.credentials.exceptions.GetCredentialException;
import androidx.credentials.exceptions.NoCredentialException; import androidx.credentials.exceptions.NoCredentialException;
import androidx.core.locationbutton.LocationButton;
import androidx.core.graphics.Insets; import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsCompat;
@ -72,11 +73,14 @@ public final class MainActivity extends ComponentActivity {
private TrustedOrigin trustedOrigin; private TrustedOrigin trustedOrigin;
private GeolocationPermissions.Callback pendingLocationCallback; private GeolocationPermissions.Callback pendingLocationCallback;
private String pendingLocationOrigin; private String pendingLocationOrigin;
private ActivityResultLauncher<String[]> locationPermissionLauncher; private ActivityResultLauncher<String[]> webLocationPermissionLauncher;
private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher; private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher;
private ActivityResultLauncher<String> pushNotificationPermissionLauncher; private ActivityResultLauncher<String> pushNotificationPermissionLauncher;
private PendingNativeTracking pendingNativeTracking; private PendingNativeTracking pendingNativeTracking;
private AlertDialog pageLoadErrorDialog; private AlertDialog pageLoadErrorDialog;
private AlertDialog locationPermissionDialog;
private AlertDialog trackingDisclosureDialog;
private boolean webLocationPermissionRequestInFlight;
private boolean mainFrameLoadFailed; private boolean mainFrameLoadFailed;
private CredentialManager credentialManager; private CredentialManager credentialManager;
private ExecutorService nativeGoogleNetworkExecutor; private ExecutorService nativeGoogleNetworkExecutor;
@ -91,15 +95,24 @@ public final class MainActivity extends ComponentActivity {
trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG); trustedOrigin = TrustedOrigin.parse(BuildConfig.BASE_URL, BuildConfig.DEBUG);
credentialManager = CredentialManager.create(this); credentialManager = CredentialManager.create(this);
nativeGoogleNetworkExecutor = Executors.newSingleThreadExecutor(); nativeGoogleNetworkExecutor = Executors.newSingleThreadExecutor();
locationPermissionLauncher = registerForActivityResult( webLocationPermissionLauncher = registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(), new ActivityResultContracts.RequestMultiplePermissions(),
result -> { 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_FINE_LOCATION))
|| Boolean.TRUE.equals( || Boolean.TRUE.equals(
result.get(Manifest.permission.ACCESS_COARSE_LOCATION) result.get(Manifest.permission.ACCESS_COARSE_LOCATION)
); )
completeLocationPermission(granted); || hasLocationPermission();
completeLocationPermission(locationGranted);
} }
); );
nativeTrackingPermissionLauncher = registerForActivityResult( nativeTrackingPermissionLauncher = registerForActivityResult(
@ -296,6 +309,7 @@ public final class MainActivity extends ComponentActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
denyPendingLocation(); denyPendingLocation();
dismissTrackingDisclosure();
dismissPageLoadError(); dismissPageLoadError();
cancelNativeGoogleRequest(); cancelNativeGoogleRequest();
@ -322,27 +336,84 @@ public final class MainActivity extends ComponentActivity {
return; return;
} }
denyPendingLocation(); if (hasLocationPermission()) {
pendingLocationOrigin = origin; if (PAGE_LOAD_DIAGNOSTICS) {
pendingLocationCallback = callback; Log.d(LOG_TAG, "Web location permission already granted");
}
boolean granted = callback.invoke(origin, true, false);
checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
|| checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED;
if (granted) {
completeLocationPermission(true);
return; return;
} }
locationPermissionLauncher.launch( denyPendingLocation();
new String[] { if (PAGE_LOAD_DIAGNOSTICS) {
Manifest.permission.ACCESS_FINE_LOCATION, Log.d(LOG_TAG, "Web location permission prompt requested");
Manifest.permission.ACCESS_COARSE_LOCATION }
} 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() { private boolean hasLocationPermission() {
@ -363,7 +434,48 @@ public final class MainActivity extends ComponentActivity {
} }
pendingNativeTracking = new PendingNativeTracking(assignmentId, csrfToken); 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 = boolean notificationGranted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|| checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) || checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
@ -387,6 +499,22 @@ public final class MainActivity extends ComponentActivity {
nativeTrackingPermissionLauncher.launch(permissions.toArray(new String[0])); 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() { private void configureNativeBridge() {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
return; return;
@ -832,12 +960,18 @@ public final class MainActivity extends ComponentActivity {
pendingNativeTracking = null; pendingNativeTracking = null;
if (pending == null) { if (pending == null) {
if (PAGE_LOAD_DIAGNOSTICS) {
Log.w(LOG_TAG, "Native tracking refused: no pending request");
}
return; return;
} }
String cookie = CookieManager.getInstance().getCookie(BuildConfig.BASE_URL); String cookie = CookieManager.getInstance().getCookie(BuildConfig.BASE_URL);
if (cookie == null || cookie.trim().isEmpty()) { if (cookie == null || cookie.trim().isEmpty()) {
if (PAGE_LOAD_DIAGNOSTICS) {
Log.w(LOG_TAG, "Native tracking refused: session cookie unavailable");
}
dispatchNativeTrackingError(); dispatchNativeTrackingError();
return; return;
} }
@ -855,7 +989,14 @@ public final class MainActivity extends ComponentActivity {
} else { } else {
startService(intent); startService(intent);
} }
if (PAGE_LOAD_DIAGNOSTICS) {
Log.d(LOG_TAG, "Native tracking service start requested");
}
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
if (PAGE_LOAD_DIAGNOSTICS) {
Log.e(LOG_TAG, "Native tracking service could not start", exception);
}
dispatchNativeTrackingError(); dispatchNativeTrackingError();
} }
} }
@ -881,18 +1022,41 @@ public final class MainActivity extends ComponentActivity {
} }
private void completeLocationPermission(boolean granted) { 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) { if (pendingLocationCallback != null && pendingLocationOrigin != null) {
pendingLocationCallback.invoke(pendingLocationOrigin, granted, false); pendingLocationCallback.invoke(pendingLocationOrigin, granted, false);
} }
pendingLocationCallback = null; pendingLocationCallback = null;
pendingLocationOrigin = null; pendingLocationOrigin = null;
dismissLocationPermissionDialog();
} }
private void denyPendingLocation() { private void denyPendingLocation() {
completeLocationPermission(false); 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) { private void openExternal(Uri uri) {
String scheme = uri.getScheme(); String scheme = uri.getScheme();
@ -1067,7 +1231,16 @@ public final class MainActivity extends ComponentActivity {
@Override @Override
public void onGeolocationPermissionsHidePrompt() { 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_CSRF_TOKEN = "csrf_token";
private static final String EXTRA_COOKIE = "cookie"; private static final String EXTRA_COOKIE = "cookie";
private static final String CHANNEL_ID = "active_help_tracking"; 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 Object pendingLocationLock = new Object();
private final AtomicBoolean uploadRunning = new AtomicBoolean(false); 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="page_load_failed">Не удалось загрузить Who Need Help. Проверьте подключение и повторите попытку.</string>
<string name="retry">Повторить</string> <string name="retry">Повторить</string>
<string name="close">Закрыть</string> <string name="close">Закрыть</string>
<string name="cancel">Отмена</string>
<string name="secure_connection_failed">Безопасное подключение было отклонено.</string> <string name="secure_connection_failed">Безопасное подключение было отклонено.</string>
<string name="unsupported_link">Этот тип ссылки не поддерживается.</string> <string name="unsupported_link">Этот тип ссылки не поддерживается.</string>
<string name="tracking_channel_name">Геолокация активной помощи</string> <string name="tracking_channel_name">Геолокация активной помощи</string>
@ -19,6 +20,12 @@
<string name="tracking_stop_failed_detail">Новые обновления приостановлены. Нажмите «Остановить передачу», чтобы повторить удаление текущей позиции.</string> <string name="tracking_stop_failed_detail">Новые обновления приостановлены. Нажмите «Остановить передачу», чтобы повторить удаление текущей позиции.</string>
<string name="tracking_location_unavailable">Геолокация недоступна</string> <string name="tracking_location_unavailable">Геолокация недоступна</string>
<string name="tracking_location_unavailable_detail">Включите геолокацию устройства, вернитесь к заявке и повторите попытку.</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_name">Обновления заявок о помощи</string>
<string name="updates_channel_description">Приватные уведомления о заявках, сообщениях и помощи поблизости.</string> <string name="updates_channel_description">Приватные уведомления о заявках, сообщениях и помощи поблизости.</string>
<string name="updates_default_body">Откройте Who Need Help, чтобы просмотреть обновление.</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="page_load_failed">Не вдалося завантажити Who Need Help. Перевірте з’єднання та повторіть спробу.</string>
<string name="retry">Повторити</string> <string name="retry">Повторити</string>
<string name="close">Закрити</string> <string name="close">Закрити</string>
<string name="cancel">Скасувати</string>
<string name="secure_connection_failed">Захищене з’єднання було відхилено.</string> <string name="secure_connection_failed">Захищене з’єднання було відхилено.</string>
<string name="unsupported_link">Цей тип посилання не підтримується.</string> <string name="unsupported_link">Цей тип посилання не підтримується.</string>
<string name="tracking_channel_name">Геолокація активної допомоги</string> <string name="tracking_channel_name">Геолокація активної допомоги</string>
@ -19,6 +20,12 @@
<string name="tracking_stop_failed_detail">Нові оновлення призупинено. Натисніть «Зупинити передачу», щоб повторити видалення поточної позиції.</string> <string name="tracking_stop_failed_detail">Нові оновлення призупинено. Натисніть «Зупинити передачу», щоб повторити видалення поточної позиції.</string>
<string name="tracking_location_unavailable">Геолокація недоступна</string> <string name="tracking_location_unavailable">Геолокація недоступна</string>
<string name="tracking_location_unavailable_detail">Увімкніть геолокацію пристрою, поверніться до заявки та повторіть спробу.</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_name">Оновлення заявок про допомогу</string>
<string name="updates_channel_description">Приватні сповіщення про заявки, повідомлення та допомогу поблизу.</string> <string name="updates_channel_description">Приватні сповіщення про заявки, повідомлення та допомогу поблизу.</string>
<string name="updates_default_body">Відкрийте Who Need Help, щоб переглянути оновлення.</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="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string>
<string name="retry">Retry</string> <string name="retry">Retry</string>
<string name="close">Close</string> <string name="close">Close</string>
<string name="cancel">Cancel</string>
<string name="secure_connection_failed">The secure connection was rejected.</string> <string name="secure_connection_failed">The secure connection was rejected.</string>
<string name="unsupported_link">This link type is not supported.</string> <string name="unsupported_link">This link type is not supported.</string>
<string name="tracking_channel_name">Active help location</string> <string name="tracking_channel_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_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">Location is unavailable</string>
<string name="tracking_location_unavailable_detail">Enable device location, then return to the request and try again.</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_name">Help request updates</string>
<string name="updates_channel_description">Private request, message, and nearby-help notifications.</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> <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" ENV_FILE="$ROOT/.env"
variant=${WNH_ANDROID_PUBLIC_VARIANT:-staging} variant=${WNH_ANDROID_PUBLIC_VARIANT:-staging}
physical_serial=${WNH_ANDROID_PHYSICAL_SERIAL:-}
physical_mode=0
host_adb=
case "$variant" in case "$variant" in
development) development)
@ -25,6 +28,31 @@ case "$variant" in
;; ;;
esac 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" APK="$ROOT/android/dist-$variant/who-need-help-$variant.apk"
TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk" TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk"
remote_target= remote_target=
@ -104,7 +132,7 @@ if [[ -n "$remote_target" ]]; then
esac esac
fi 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 echo "/dev/kvm is required for the containerized Android emulator." >&2
exit 1 exit 1
fi fi
@ -332,6 +360,15 @@ COMMIT;
SQL 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() { run_fixture_tool() {
local action=$1 local action=$1
@ -429,7 +466,11 @@ REMOTE_FIXTURE_OWNER
} }
adb() { 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() { run_browser_phase() {
@ -497,7 +538,13 @@ cleanup() {
local status=$? local status=$?
trap - EXIT HUP INT TERM 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 logcat -d >"$output_dir/logcat.txt" 2>&1 || true
adb shell dumpsys notification --noredact >"$output_dir/notifications.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 shell dumpsys activity services "$package" >"$output_dir/services.txt" 2>&1 || true
@ -511,7 +558,46 @@ cleanup() {
status=1 status=1
else else
snapshot_database "$output_dir/database-after.txt" 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-before.txt" \
"$output_dir/database-after.txt" \ "$output_dir/database-after.txt" \
>"$output_dir/database-cleanup.diff"; then >"$output_dir/database-cleanup.diff"; then
@ -565,7 +651,12 @@ REMOTE_CLEANUP
printf 'true\n' printf 'true\n'
fi fi
printf 'fixture_prefix_absent=' 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 [[ ! -s "$output_dir/database-cleanup.diff" ]]; then
printf 'true\n' printf 'true\n'
else else
@ -653,76 +744,92 @@ else
fi fi
docker build --tag "$browser_image" e2e \ docker build --tag "$browser_image" e2e \
>"$output_dir/browser-build.log" >"$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 \ docker volume create "$avd_volume" >"$output_dir/avd-volume.txt"
--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"
if [[ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]]; then docker run -d \
docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true --name "$container" \
echo "Android cross-client emulator exited before ADB became available; inspect $output_dir/emulator.log." >&2 --device /dev/kvm \
exit 1 --mount \
fi "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 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 fi
if [[ "$(adb get-state 2>/dev/null || true)" == device ]]; then adb_ready=0
adb_ready=1 for _attempt in $(seq 1 90); do
break 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 fi
sleep 2 booted=
done 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 if [[ "$booted" != 1 ]]; then
docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true echo "Android emulator did not finish booting." >&2
echo "Android cross-client emulator did not expose a ready ADB device." >&2 exit 1
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 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 list instrumentation >"$output_dir/instrumentation.txt"
adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION
adb shell pm grant "$package" android.permission.POST_NOTIFICATIONS 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 network_ready=0
for _attempt in $(seq 1 45); do 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") requester_email=$(jq -r '.users.requester.email' "$output_dir/fixture.json")
adb logcat -c 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 start_android_phase exchangeMessageAndTrackingWithBrowser
tracking_started=0 tracking_started=0
@ -771,7 +880,9 @@ for _attempt in $(seq 1 45); do
tracking_started=1 tracking_started=1
break break
fi 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 sleep 1
done done
@ -821,6 +932,8 @@ fi
printf 'run_id=%s\n' "$run_id" printf 'run_id=%s\n' "$run_id"
printf 'android_api=%s\n' "$android_api" printf 'android_api=%s\n' "$android_api"
printf 'android_system_image=%s\n' "$android_system_image" 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 'package=%s\n' "$package"
printf 'public_origin=%s\n' "$WNH_BASE_URL" printf 'public_origin=%s\n' "$WNH_BASE_URL"
printf 'magic_link_login=true\n' printf 'magic_link_login=true\n'

View File

@ -3,6 +3,7 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE=${WNH_ENV_FILE:-"$ROOT/.env"} ENV_FILE=${WNH_ENV_FILE:-"$ROOT/.env"}
DEBUG_BASE_URL_OVERRIDE=${WNH_DEBUG_BASE_URL_OVERRIDE:-}
case "$ENV_FILE" in case "$ENV_FILE" in
/*) ;; /*) ;;
@ -19,6 +20,10 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a 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_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_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_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) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env" ENV_FILE="$ROOT/.env"
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} 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/ensure-local-public-origin.sh"
"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" development "$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" development
@ -16,6 +13,10 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a 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_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_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}"
: "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in .env}" : "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in .env}"