diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a8ec01b..9d53b8f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -347,4 +347,7 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") androidTestImplementation("androidx.test.espresso:espresso-web:3.7.0") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0") + androidTestImplementation( + "androidx.core.locationbutton:locationbutton-testing:1.0.0-alpha01" + ) } diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java index b9fb5e5..831225d 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java @@ -14,13 +14,19 @@ import android.Manifest; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; +import android.app.permissionui.LocationButtonRequest; +import android.app.permissionui.LocationButtonSession; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.SystemClock; import android.service.notification.StatusBarNotification; +import android.view.ViewGroup; +import androidx.core.locationbutton.LocationButton; +import androidx.core.locationbutton.LocationButtonCompat; +import androidx.core.locationbutton.testing.TestLocationButtonProvider; import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -40,6 +46,7 @@ import org.junit.runners.MethodSorters; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; @RunWith(AndroidJUnit4.class) @FixMethodOrder(MethodSorters.NAME_ASCENDING) @@ -71,6 +78,35 @@ public final class AndroidClientInstrumentedTest { } } + @Test + public void test00ColdStartShowsLoadingStateUntilFirstPageIsVisible() throws Exception { + server.delayNextGet("/delayed-start", 1_500); + + try (ActivityScenario scenario = launch("/delayed-start")) { + assertTrue( + "Cold start did not expose a native loading state", + device.wait( + Until.hasObject( + By.res(context.getPackageName(), "page_loading") + ), + 1_000 + ) + ); + assertTrue( + "Native loading state remained after the first page became visible", + device.wait( + Until.gone( + By.res(context.getPackageName(), "page_loading") + ), + UI_TIMEOUT_MS + ) + ); + onWebView() + .withElement(findElement(Locator.ID, "marker")) + .check(webMatches(getText(), containsString("loaded:/delayed-start"))); + } + } + @Test public void test01TrackingWithoutLocationPermissionStopsLocally() throws Exception { assertFalse(hasLocationPermission()); @@ -124,56 +160,46 @@ public final class AndroidClientInstrumentedTest { By.text(context.getString(R.string.location_button_title)) ) ); - locationButton.click(); - UiObject2 allow = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + AtomicReference session = new AtomicReference<>(); + TestLocationButtonProvider provider = new TestLocationButtonProvider(context) { + @Override + protected void onSessionRequestReceived( + LocationButtonRequest request, + LocationButtonSession openedSession + ) { + session.set(openedSession); + } + }; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - allow = device.wait( - Until.findObject( - By.res( - "com.android.packageinstaller", - "permission_allow_button" - ) - ), - UI_TIMEOUT_MS - ); + TestLocationButtonProvider finalProvider = provider; + scenario.onActivity(activity -> { + LocationButton button = activity.locationButtonForTesting(); + + if (button == null) { + throw new AssertionError( + "The native location button was not attached to its dialog" + ); + } + + ViewGroup parent = (ViewGroup) button.getParent(); + int index = parent.indexOfChild(button); + ViewGroup.LayoutParams layoutParams = button.getLayoutParams(); + + parent.removeViewAt(index); + LocationButtonCompat.setLocationButtonProvider(button, finalProvider); + parent.addView(button, index, layoutParams); + }); + + LocationButtonSession openedSession = waitForLocationButtonSession(session); + grantLocationPermission(); + provider.notifyPermissionResult(openedSession, true); + } else { + locationButton.click(); + clickLegacyLocationPermissionAllow(); } - if (allow == null && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - allow = device.wait( - Until.findObject( - By.res( - "com.google.android.packageinstaller", - "permission_allow_button" - ) - ), - UI_TIMEOUT_MS - ); - } - - if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - allow = device.wait( - Until.findObject( - By.res( - LOCATION_PERMISSION_CONTROLLER, - "permission_allow_foreground_only_button" - ) - ), - UI_TIMEOUT_MS - ); - } - - if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - allow = device.wait( - Until.findObject(By.textContains("While using the app")), - UI_TIMEOUT_MS - ); - } - - assertNotNull("Android location permission prompt was not shown", allow); - allow.click(); - waitForLocationPermission(); FixtureHttpServer.RecordedRequest callback = server.awaitRequestContaining( @@ -193,6 +219,74 @@ public final class AndroidClientInstrumentedTest { } } + private LocationButtonSession waitForLocationButtonSession( + AtomicReference session + ) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(UI_TIMEOUT_MS); + + while (System.nanoTime() < deadline) { + LocationButtonSession openedSession = session.get(); + + if (openedSession != null) { + return openedSession; + } + + Thread.sleep(50); + } + + throw new AssertionError("The Android 17 test location session did not open"); + } + + private void clickLegacyLocationPermissionAllow() { + UiObject2 allow = null; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + allow = device.wait( + Until.findObject( + By.res( + "com.android.packageinstaller", + "permission_allow_button" + ) + ), + UI_TIMEOUT_MS + ); + } + + if (allow == null && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + allow = device.wait( + Until.findObject( + By.res( + "com.google.android.packageinstaller", + "permission_allow_button" + ) + ), + UI_TIMEOUT_MS + ); + } + + if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + allow = device.wait( + Until.findObject( + By.res( + LOCATION_PERMISSION_CONTROLLER, + "permission_allow_foreground_only_button" + ) + ), + UI_TIMEOUT_MS + ); + } + + if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + allow = device.wait( + Until.findObject(By.textContains("While using the app")), + UI_TIMEOUT_MS + ); + } + + assertNotNull("Android location permission prompt was not shown", allow); + allow.click(); + } + @Test public void test02bTrackingRequiresExplicitNativeDisclosure() throws Exception { try (ActivityScenario scenario = launch("/tracking-disclosure")) { @@ -558,20 +652,29 @@ public final class AndroidClientInstrumentedTest { } private void grantTrackingPermissions() throws Exception { - String packageName = context.getPackageName(); - device.executeShellCommand( - "pm grant " + packageName + " " + Manifest.permission.ACCESS_FINE_LOCATION - ); + grantLocationPermission(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { device.executeShellCommand( - "pm grant " + packageName + " " + Manifest.permission.POST_NOTIFICATIONS + "pm grant " + + context.getPackageName() + + " " + + Manifest.permission.POST_NOTIFICATIONS ); } assertTrue(hasLocationPermission()); } + private void grantLocationPermission() throws Exception { + device.executeShellCommand( + "pm grant " + + context.getPackageName() + + " " + + Manifest.permission.ACCESS_FINE_LOCATION + ); + } + private void waitForServiceState(boolean expected) throws Exception { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java index 6f288ac..b47598c 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java @@ -39,6 +39,8 @@ final class FixtureHttpServer implements Closeable { private volatile boolean running = true; private volatile boolean disconnectStopRequest; private volatile String disconnectNextGetPath; + private volatile String delayedNextGetPath; + private volatile long delayedNextGetMillis; FixtureHttpServer() throws IOException { URI base = URI.create(BuildConfig.BASE_URL); @@ -69,6 +71,11 @@ final class FixtureHttpServer implements Closeable { disconnectNextGetPath = path; } + void delayNextGet(String path, long delayMillis) { + delayedNextGetPath = path; + delayedNextGetMillis = delayMillis; + } + RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit) throws InterruptedException { return awaitRequestAfterCount(suffix, 0, timeout, unit); @@ -208,6 +215,19 @@ final class FixtureHttpServer implements Closeable { return; } + if ("GET".equals(request.method) && request.path.equals(delayedNextGetPath)) { + long delayMillis = delayedNextGetMillis; + delayedNextGetPath = null; + delayedNextGetMillis = 0; + + try { + Thread.sleep(delayMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return; + } + } + if ("GET".equals(request.method)) { writeResponse( connection.getOutputStream(), diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java index 4016575..40918cd 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -14,7 +14,6 @@ import android.os.Bundle; import android.os.CancellationSignal; import android.util.Log; import android.view.View; -import android.view.ViewGroup; import android.webkit.CookieManager; import android.webkit.GeolocationPermissions; import android.webkit.SslErrorHandler; @@ -25,7 +24,6 @@ import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; -import android.widget.FrameLayout; import android.widget.Toast; import androidx.activity.ComponentActivity; @@ -70,6 +68,7 @@ public final class MainActivity extends ComponentActivity { private static final boolean PAGE_LOAD_DIAGNOSTICS = BuildConfig.DEBUG || BuildConfig.SAFE_PAGE_LOAD_DIAGNOSTICS; private WebView webView; + private View pageLoadingView; private TrustedOrigin trustedOrigin; private GeolocationPermissions.Callback pendingLocationCallback; private String pendingLocationOrigin; @@ -82,6 +81,8 @@ public final class MainActivity extends ComponentActivity { private AlertDialog trackingDisclosureDialog; private boolean webLocationPermissionRequestInFlight; private boolean mainFrameLoadFailed; + private long pageLoadGeneration; + private long scheduledRevealGeneration = -1; private CredentialManager credentialManager; private ExecutorService nativeGoogleNetworkExecutor; private CancellationSignal nativeGoogleCancellationSignal; @@ -143,23 +144,10 @@ public final class MainActivity extends ComponentActivity { } } ); - webView = new WebView(this); - webView.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ); - FrameLayout contentView = new FrameLayout(this); - contentView.setId(R.id.main_content); - contentView.setLayoutParams( - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ); - contentView.addView(webView); - setContentView(contentView); + setContentView(R.layout.activity_main); + View contentView = requireView(R.id.main_content); + webView = requireView(R.id.web_view); + pageLoadingView = requireView(R.id.page_loading); applySystemBarInsets(contentView); WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG); @@ -201,9 +189,39 @@ public final class MainActivity extends ComponentActivity { if (savedInstanceState == null || webView.restoreState(savedInstanceState) == null) { webView.loadUrl(initialUrl()); + } else { + hidePageLoading(); } } + private T requireView(int id) { + T view = findViewById(id); + + if (view == null) { + throw new IllegalStateException("Required activity view is missing: " + id); + } + + return view; + } + + private void showPageLoading() { + if (pageLoadingView == null || webView == null) { + return; + } + + webView.setVisibility(View.INVISIBLE); + pageLoadingView.setVisibility(View.VISIBLE); + } + + private void hidePageLoading() { + if (pageLoadingView == null || webView == null) { + return; + } + + webView.setVisibility(View.VISIBLE); + pageLoadingView.setVisibility(View.GONE); + } + private void applySystemBarInsets(View contentView) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { return; @@ -420,6 +438,16 @@ public final class MainActivity extends ComponentActivity { dialog.show(); } + LocationButton locationButtonForTesting() { + AlertDialog dialog = locationPermissionDialog; + + if (dialog == null) { + return null; + } + + return dialog.findViewById(R.id.location_button); + } + private boolean hasLocationPermission() { return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED @@ -1089,6 +1117,7 @@ public final class MainActivity extends ComponentActivity { return; } + hidePageLoading(); dismissPageLoadError(); pageLoadErrorDialog = new AlertDialog.Builder(this) .setTitle(R.string.page_load_failed_title) @@ -1174,7 +1203,10 @@ public final class MainActivity extends ComponentActivity { @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { mainFrameLoadFailed = false; + pageLoadGeneration++; + scheduledRevealGeneration = -1; dismissPageLoadError(); + showPageLoading(); if (PAGE_LOAD_DIAGNOSTICS) { Log.d(LOG_TAG, "Main-frame load started: path=" + safeLogPath(Uri.parse(url))); @@ -1182,14 +1214,21 @@ public final class MainActivity extends ComponentActivity { if (!trustedOrigin.matches(url)) { view.stopLoading(); + hidePageLoading(); openExternal(Uri.parse(url)); } } + @Override + public void onPageCommitVisible(WebView view, String url) { + revealPageWhenDrawn(view, url); + } + @Override public void onPageFinished(WebView view, String url) { if (!mainFrameLoadFailed) { dismissPageLoadError(); + revealPageWhenDrawn(view, url); } if (PAGE_LOAD_DIAGNOSTICS) { @@ -1200,6 +1239,33 @@ public final class MainActivity extends ComponentActivity { dispatchPendingPushToken(); } + private void revealPageWhenDrawn(WebView view, String url) { + if ( + mainFrameLoadFailed + || !trustedOrigin.matches(url) + || scheduledRevealGeneration == pageLoadGeneration + ) { + return; + } + + long generation = pageLoadGeneration; + scheduledRevealGeneration = generation; + view.postVisualStateCallback( + generation, + new WebView.VisualStateCallback() { + @Override + public void onComplete(long requestId) { + if ( + requestId == pageLoadGeneration + && !mainFrameLoadFailed + ) { + hidePageLoading(); + } + } + } + ); + } + private void scheduleMapDiagnostics(WebView view, Uri uri) { String path = uri.getPath(); diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5e664a --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 0a34d2c..77eaab5 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -4,6 +4,7 @@ Нет приложения, которое может открыть эту ссылку. Страница недоступна Не удалось загрузить Who Need Help. Проверьте подключение и повторите попытку. + Безопасная загрузка… Повторить Закрыть Отмена diff --git a/android/app/src/main/res/values-uk/strings.xml b/android/app/src/main/res/values-uk/strings.xml index 9f5349f..e9473cc 100644 --- a/android/app/src/main/res/values-uk/strings.xml +++ b/android/app/src/main/res/values-uk/strings.xml @@ -4,6 +4,7 @@ Немає застосунку, який може відкрити це посилання. Сторінка недоступна Не вдалося завантажити Who Need Help. Перевірте з’єднання та повторіть спробу. + Безпечне завантаження… Повторити Закрити Скасувати diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 209f916..296b6ef 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -2,4 +2,7 @@ #14532D #0A2F1A + #FAFAFA + #202124 + #5F6368 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e268754..8d526ba 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ No app can open this link. Page unavailable Could not load Who Need Help. Check your connection and retry. + Loading securely… Retry Close Cancel diff --git a/scripts/android-instrumentation-test.sh b/scripts/android-instrumentation-test.sh index bf64264..f0b02e9 100755 --- a/scripts/android-instrumentation-test.sh +++ b/scripts/android-instrumentation-test.sh @@ -214,8 +214,28 @@ if [ "$instrumentation_status" -ne 0 ] \ fi docker exec "$container" adb shell am force-stop org.whoneedhelp.mobile.debug -docker exec "$container" adb root >"$output/adb-root.txt" +set +e +docker exec "$container" adb root >"$output/adb-root.txt" 2>&1 +adb_root_status=$? +set -e docker exec "$container" adb wait-for-device + +if [ "$adb_root_status" -ne 0 ]; then + adb_uid=$(docker exec "$container" adb shell id -u | tr -d '\r') + + if [ "$adb_uid" != "0" ]; then + docker exec "$container" adb root >>"$output/adb-root.txt" 2>&1 + docker exec "$container" adb wait-for-device + fi +fi + +adb_uid=$(docker exec "$container" adb shell id -u | tr -d '\r') + +if [ "$adb_uid" != "0" ]; then + echo "The process-death probe requires a root-capable emulator; observed uid=$adb_uid." >&2 + exit 1 +fi + docker exec "$container" adb shell am instrument -w -r \ -e class org.whoneedhelp.mobile.TrackingProcessDeathProbeTest \ org.whoneedhelp.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner \