summaryrefslogtreecommitdiffstats
path: root/mobile/android/test_runner/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/test_runner/src/main')
-rw-r--r--mobile/android/test_runner/src/main/AndroidManifest.xml47
-rw-r--r--mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json11
-rw-r--r--mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java715
-rw-r--r--mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java77
-rw-r--r--mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java144
-rw-r--r--mobile/android/test_runner/src/main/res/drawable-nodpi/colors.pngbin0 -> 16210 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.pngbin0 -> 4856 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.pngbin0 -> 2304 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.pngbin0 -> 5593 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.pngbin0 -> 1836 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml77
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.pngbin0 -> 3056 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.pngbin0 -> 5024 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.pngbin0 -> 2096 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.pngbin0 -> 2858 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.pngbin0 -> 4569 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.pngbin0 -> 7098 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.pngbin0 -> 6464 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.pngbin0 -> 10676 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.pngbin0 -> 9250 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.pngbin0 -> 15523 bytes
-rw-r--r--mobile/android/test_runner/src/main/res/values/colors.xml9
-rw-r--r--mobile/android/test_runner/src/main/res/values/strings.xml7
-rw-r--r--mobile/android/test_runner/src/main/res/values/styles.xml11
24 files changed, 1098 insertions, 0 deletions
diff --git a/mobile/android/test_runner/src/main/AndroidManifest.xml b/mobile/android/test_runner/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..6f55aa27ab
--- /dev/null
+++ b/mobile/android/test_runner/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
+ <uses-permission android:name="android.permission.RECORD_AUDIO"/>
+ <uses-permission android:name="android.permission.CAMERA"/>
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
+
+ <application
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:usesCleartextTraffic="true"
+ android:theme="@style/AppTheme"
+ android:name="androidx.multidex.MultiDexApplication">
+ <uses-library android:name="android.test.runner" android:required="false"/>
+ <activity android:name=".TestRunnerActivity"
+ android:configChanges="orientation|screenSize"
+ android:exported="true"/>
+ <activity-alias android:name=".App" android:targetActivity=".TestRunnerActivity"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <action android:name="org.mozilla.geckoview.test_runner.XPCSHELL_TEST"/>
+ <category android:name="android.intent.category.DEFAULT"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity-alias>
+
+ <!-- This is used to run xpcshell tests -->
+ <service android:name=".XpcshellTestRunnerService$i0" android:enabled="true" android:exported="true" android:process=":xpcshell0"/>
+ <service android:name=".XpcshellTestRunnerService$i1" android:enabled="true" android:exported="true" android:process=":xpcshell1"/>
+ <service android:name=".XpcshellTestRunnerService$i2" android:enabled="true" android:exported="true" android:process=":xpcshell2"/>
+ <service android:name=".XpcshellTestRunnerService$i3" android:enabled="true" android:exported="true" android:process=":xpcshell3"/>
+ <service android:name=".XpcshellTestRunnerService$i4" android:enabled="true" android:exported="true" android:process=":xpcshell4"/>
+ <service android:name=".XpcshellTestRunnerService$i5" android:enabled="true" android:exported="true" android:process=":xpcshell5"/>
+ <service android:name=".XpcshellTestRunnerService$i6" android:enabled="true" android:exported="true" android:process=":xpcshell6"/>
+ <service android:name=".XpcshellTestRunnerService$i7" android:enabled="true" android:exported="true" android:process=":xpcshell7"/>
+ <service android:name=".XpcshellTestRunnerService$i8" android:enabled="true" android:exported="true" android:process=":xpcshell8"/>
+ <service android:name=".XpcshellTestRunnerService$i9" android:enabled="true" android:exported="true" android:process=":xpcshell9"/>
+ </application>
+</manifest>
diff --git a/mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json b/mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json
new file mode 100644
index 0000000000..3d68643af9
--- /dev/null
+++ b/mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json
@@ -0,0 +1,11 @@
+{
+ "manifest_version": 2,
+ "name": "Dummy test-runner-support",
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "test-runner-support@tests.mozilla.org"
+ }
+ },
+ "description": "This extension pretends to be the sender of native messages from tests. See mobile/android/modules/test/AppUiTestDelegate.sys.mjs for actual usage."
+}
diff --git a/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java
new file mode 100644
index 0000000000..3930ab545c
--- /dev/null
+++ b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java
@@ -0,0 +1,715 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test_runner;
+
+import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_EXPERIMENT_SLUG_NOT_FOUND;
+import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_FEATURE_NOT_FOUND;
+import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_UNKNOWN;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.SurfaceTexture;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Surface;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.geckoview.AllowOrDeny;
+import org.mozilla.geckoview.ContentBlocking;
+import org.mozilla.geckoview.ExperimentDelegate;
+import org.mozilla.geckoview.GeckoDisplay;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission;
+import org.mozilla.geckoview.GeckoSessionSettings;
+import org.mozilla.geckoview.GeckoView;
+import org.mozilla.geckoview.OrientationController;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebExtensionController;
+import org.mozilla.geckoview.WebRequestError;
+
+public class TestRunnerActivity extends Activity {
+ private static final String LOGTAG = "TestRunnerActivity";
+ private static final String ERROR_PAGE =
+ "<!DOCTYPE html><head><title>Error</title></head><body>Error!</body></html>";
+
+ static GeckoRuntime sRuntime;
+
+ private GeckoSession mPopupSession;
+ private GeckoSession mSession;
+ private GeckoView mView;
+ private boolean mKillProcessOnDestroy;
+
+ private HashMap<GeckoSession, Display> mDisplays = new HashMap<>();
+ private HashMap<String, ExtensionWrapper> mExtensions = new HashMap<>();
+
+ private static class ExtensionWrapper {
+ public WebExtension extension;
+ public HashMap<GeckoSession, WebExtension.Action> browserActions;
+ public HashMap<GeckoSession, WebExtension.Action> pageActions;
+
+ public ExtensionWrapper(final WebExtension extension) {
+ this.extension = extension;
+ browserActions = new HashMap<>();
+ pageActions = new HashMap<>();
+ }
+ }
+
+ private static class Display {
+ public final SurfaceTexture texture;
+ public final Surface surface;
+
+ private final int width;
+ private final int height;
+ private GeckoDisplay sessionDisplay;
+
+ public Display(final int width, final int height) {
+ this.width = width;
+ this.height = height;
+ texture = new SurfaceTexture(0);
+ texture.setDefaultBufferSize(width, height);
+ surface = new Surface(texture);
+ }
+
+ public void attach(final GeckoSession session) {
+ sessionDisplay = session.acquireDisplay();
+ sessionDisplay.surfaceChanged(
+ new GeckoDisplay.SurfaceInfo.Builder(surface).size(width, height).build());
+ }
+
+ public void release(final GeckoSession session) {
+ sessionDisplay.surfaceDestroyed();
+ session.releaseDisplay(sessionDisplay);
+ }
+ }
+
+ private static WebExtensionController webExtensionController() {
+ return sRuntime.getWebExtensionController();
+ }
+
+ private static OrientationController orientationController() {
+ return sRuntime.getOrientationController();
+ }
+
+ // Keeps track of all sessions for this test runner. The top session in the deque is the
+ // current active session for extension purposes.
+ private ArrayDeque<GeckoSession> mOwnedSessions = new ArrayDeque<>();
+
+ private GeckoSession.PermissionDelegate mPermissionDelegate =
+ new GeckoSession.PermissionDelegate() {
+ @Override
+ public GeckoResult<Integer> onContentPermissionRequest(
+ @NonNull final GeckoSession session, @NonNull ContentPermission perm) {
+ return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW);
+ }
+
+ @Override
+ public void onAndroidPermissionsRequest(
+ @NonNull final GeckoSession session,
+ @Nullable final String[] permissions,
+ @NonNull final Callback callback) {
+ callback.grant();
+ }
+ };
+
+ private GeckoSession.PromptDelegate mPromptDelegate =
+ new GeckoSession.PromptDelegate() {
+ Map<BasePrompt, GeckoResult<PromptResponse>> mPromptResults = new HashMap<>();
+ public GeckoSession.PromptDelegate.PromptInstanceDelegate mPromptInstanceDelegate =
+ new GeckoSession.PromptDelegate.PromptInstanceDelegate() {
+ @Override
+ public void onPromptDismiss(
+ final @NonNull GeckoSession.PromptDelegate.BasePrompt prompt) {
+ mPromptResults.get(prompt).complete(prompt.dismiss());
+ }
+ };
+
+ @Override
+ public GeckoResult<PromptResponse> onAlertPrompt(
+ @NonNull final GeckoSession session, @NonNull final AlertPrompt prompt) {
+ mPromptResults.put(prompt, new GeckoResult<>());
+ prompt.setDelegate(mPromptInstanceDelegate);
+ return mPromptResults.get(prompt);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> onButtonPrompt(
+ @NonNull final GeckoSession session, @NonNull final ButtonPrompt prompt) {
+ mPromptResults.put(prompt, new GeckoResult<>());
+ prompt.setDelegate(mPromptInstanceDelegate);
+ return mPromptResults.get(prompt);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> onTextPrompt(
+ @NonNull final GeckoSession session, @NonNull final TextPrompt prompt) {
+ mPromptResults.put(prompt, new GeckoResult<>());
+ prompt.setDelegate(mPromptInstanceDelegate);
+ return mPromptResults.get(prompt);
+ }
+ };
+
+ private GeckoSession.NavigationDelegate mNavigationDelegate =
+ new GeckoSession.NavigationDelegate() {
+ @Override
+ public void onLocationChange(
+ final GeckoSession session, final String url, final List<ContentPermission> perms) {
+ getActionBar().setSubtitle(url);
+ }
+
+ @Override
+ public GeckoResult<AllowOrDeny> onLoadRequest(
+ final GeckoSession session, final LoadRequest request) {
+ // Allow Gecko to load all URIs
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ @Override
+ public GeckoResult<GeckoSession> onNewSession(
+ final GeckoSession session, final String uri) {
+ webExtensionController().setTabActive(mOwnedSessions.peek(), false);
+ final GeckoSession newSession =
+ createBackgroundSession(session.getSettings(), /* active */ true);
+ webExtensionController().setTabActive(newSession, true);
+ return GeckoResult.fromValue(newSession);
+ }
+
+ @Override
+ public GeckoResult<String> onLoadError(
+ final GeckoSession session, final String uri, final WebRequestError error) {
+
+ return GeckoResult.fromValue("data:text/html," + ERROR_PAGE);
+ }
+ };
+
+ private GeckoSession.ContentDelegate mContentDelegate =
+ new GeckoSession.ContentDelegate() {
+ private void onContentProcessGone() {
+ if (System.getenv("MOZ_CRASHREPORTER_SHUTDOWN") != null) {
+ sRuntime.shutdown();
+ }
+ }
+
+ @Override
+ public void onCloseRequest(final GeckoSession session) {
+ closeSession(session);
+ }
+
+ @Override
+ public void onCrash(final GeckoSession session) {
+ onContentProcessGone();
+ }
+
+ @Override
+ public void onKill(final GeckoSession session) {
+ onContentProcessGone();
+ }
+ };
+
+ private WebExtension.TabDelegate mTabDelegate =
+ new WebExtension.TabDelegate() {
+ @Override
+ public GeckoResult<GeckoSession> onNewTab(
+ final WebExtension source, final WebExtension.CreateTabDetails details) {
+ GeckoSessionSettings settings = null;
+ if (details.cookieStoreId != null) {
+ settings = new GeckoSessionSettings.Builder().contextId(details.cookieStoreId).build();
+ }
+
+ if (details.active == Boolean.TRUE) {
+ webExtensionController().setTabActive(mOwnedSessions.peek(), false);
+ }
+ final GeckoSession newSession = createSession(settings, details.active == Boolean.TRUE);
+ return GeckoResult.fromValue(newSession);
+ }
+ };
+
+ private WebExtension.ActionDelegate mActionDelegate =
+ new WebExtension.ActionDelegate() {
+ private GeckoResult<GeckoSession> togglePopup(
+ final WebExtension extension, final boolean forceOpen) {
+ if (mPopupSession != null) {
+ mPopupSession.close();
+ if (!forceOpen) {
+ return null;
+ }
+ }
+
+ mPopupSession = createBackgroundSession(null, /* active */ false);
+ mPopupSession.open(sRuntime);
+
+ // Set the progress delegate in case there is an observer to the popup being loaded.
+ mTestApiImpl.setCurrentPopupExtension(extension);
+ mPopupSession.setProgressDelegate(mTestApiImpl);
+
+ return GeckoResult.fromValue(mPopupSession);
+ }
+
+ @Nullable
+ @Override
+ public GeckoResult<GeckoSession> onOpenPopup(
+ final WebExtension extension, final WebExtension.Action action) {
+ return togglePopup(extension, true);
+ }
+
+ @Nullable
+ @Override
+ public GeckoResult<GeckoSession> onTogglePopup(
+ final WebExtension extension, final WebExtension.Action action) {
+ return togglePopup(extension, false);
+ }
+
+ @Override
+ public void onBrowserAction(
+ final WebExtension extension,
+ final GeckoSession session,
+ final WebExtension.Action action) {
+ mExtensions.get(extension.id).browserActions.put(session, action);
+ }
+
+ @Override
+ public void onPageAction(
+ final WebExtension extension,
+ final GeckoSession session,
+ final WebExtension.Action action) {
+ mExtensions.get(extension.id).pageActions.put(session, action);
+ }
+ };
+
+ private WebExtension.SessionTabDelegate mSessionTabDelegate =
+ new WebExtension.SessionTabDelegate() {
+ @NonNull
+ @Override
+ public GeckoResult<AllowOrDeny> onCloseTab(
+ @Nullable final WebExtension source, @NonNull final GeckoSession session) {
+ closeSession(session);
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ @Override
+ public GeckoResult<AllowOrDeny> onUpdateTab(
+ @NonNull final WebExtension source,
+ @NonNull final GeckoSession session,
+ @NonNull final WebExtension.UpdateTabDetails updateDetails) {
+ if (updateDetails.active == Boolean.TRUE) {
+ // Move session to the top since it's now the active tab
+ mOwnedSessions.remove(session);
+ mOwnedSessions.addFirst(session);
+ }
+
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+ };
+
+ private class TestRunnerActivityDelegate implements GeckoView.ActivityContextDelegate {
+ public Context getActivityContext() {
+ return TestRunnerActivity.this;
+ }
+ }
+
+ private TestRunnerActivityDelegate mActivityDelegate = new TestRunnerActivityDelegate();
+
+ /**
+ * Creates a session and adds it to the owned sessions deque.
+ *
+ * @param active Whether this session is the "active" session for extension purposes. The active
+ * session always sit at the top of the owned sessions deque.
+ * @return the newly created session.
+ */
+ private GeckoSession createSession(final boolean active) {
+ return createSession(null, active);
+ }
+
+ /**
+ * Creates a session and adds it to the owned sessions deque.
+ *
+ * @param settings settings for the newly created {@link GeckoSession}, could be null if no extra
+ * settings need to be added.
+ * @param active Whether this session is the "active" session for extension purposes. The active
+ * session always sit at the top of the owned sessions deque.
+ * @return the newly created session.
+ */
+ private GeckoSession createSession(GeckoSessionSettings settings, final boolean active) {
+ if (settings == null) {
+ settings = new GeckoSessionSettings();
+ }
+
+ final GeckoSession session = new GeckoSession(settings);
+ session.setNavigationDelegate(mNavigationDelegate);
+ session.setContentDelegate(mContentDelegate);
+ session.setPermissionDelegate(mPermissionDelegate);
+ session.setPromptDelegate(mPromptDelegate);
+
+ final WebExtension.SessionController sessionController = session.getWebExtensionController();
+ for (final ExtensionWrapper wrapper : mExtensions.values()) {
+ sessionController.setActionDelegate(wrapper.extension, mActionDelegate);
+ sessionController.setTabDelegate(wrapper.extension, mSessionTabDelegate);
+ }
+
+ if (active) {
+ mOwnedSessions.addFirst(session);
+ } else {
+ mOwnedSessions.addLast(session);
+ }
+ return session;
+ }
+
+ /**
+ * Creates a session with a display attached.
+ *
+ * @param settings settings for the newly created {@link GeckoSession}, could be null if no extra
+ * settings need to be added.
+ * @param active Whether this session is the "active" session for extension purposes. The active
+ * session always sit at the top of the owned sessions deque.
+ * @return the newly created session.
+ */
+ private GeckoSession createBackgroundSession(
+ final GeckoSessionSettings settings, final boolean active) {
+ final GeckoSession session = createSession(settings, active);
+
+ final Display display = new Display(mView.getWidth(), mView.getHeight());
+ display.attach(session);
+
+ mDisplays.put(session, display);
+
+ return session;
+ }
+
+ private void closeSession(final GeckoSession session) {
+ if (session == mOwnedSessions.peek()) {
+ webExtensionController().setTabActive(session, false);
+ }
+ if (mDisplays.containsKey(session)) {
+ final Display display = mDisplays.remove(session);
+ display.release(session);
+ }
+ mOwnedSessions.remove(session);
+ session.close();
+ if (!mOwnedSessions.isEmpty()) {
+ // Pick the top session as the current active
+ webExtensionController().setTabActive(mOwnedSessions.peek(), true);
+ }
+ }
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ final Intent intent = getIntent();
+
+ if ("org.mozilla.geckoview.test.XPCSHELL_TEST_MAIN".equals(intent.getAction())) {
+ // This activity is just a stub to run xpcshell tests in a service
+ return;
+ }
+
+ if (sRuntime == null) {
+ final GeckoRuntimeSettings.Builder runtimeSettingsBuilder =
+ new GeckoRuntimeSettings.Builder();
+
+ // Mochitest and reftest encounter rounding errors if we have a
+ // a window.devicePixelRation like 3.625, so simplify that here.
+ runtimeSettingsBuilder
+ .arguments(new String[] {"-purgecaches"})
+ .displayDpiOverride(160)
+ .displayDensityOverride(1.0f)
+ .remoteDebuggingEnabled(true)
+ .experimentDelegate(new TestRunnerExperimentDelegate());
+
+ final Bundle extras = intent.getExtras();
+ if (extras != null) {
+ runtimeSettingsBuilder.extras(extras);
+ }
+
+ final ContentBlocking.SafeBrowsingProvider googleLegacy =
+ ContentBlocking.SafeBrowsingProvider.from(
+ ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing-dummy/update")
+ .build();
+
+ final ContentBlocking.SafeBrowsingProvider google =
+ ContentBlocking.SafeBrowsingProvider.from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing4-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing4-dummy/update")
+ .build();
+
+ runtimeSettingsBuilder
+ .consoleOutput(true)
+ .contentBlocking(
+ new ContentBlocking.Settings.Builder()
+ .safeBrowsingProviders(google, googleLegacy)
+ .build());
+
+ sRuntime = GeckoRuntime.create(this, runtimeSettingsBuilder.build());
+
+ webExtensionController()
+ .setDebuggerDelegate(
+ new WebExtensionController.DebuggerDelegate() {
+ @Override
+ public void onExtensionListUpdated() {
+ refreshExtensionList();
+ }
+ });
+
+ webExtensionController()
+ .installBuiltIn("resource://android/assets/test-runner-support/")
+ .accept(
+ extension -> {
+ extension.setMessageDelegate(mApiEngine, "test-runner-support");
+ extension.setTabDelegate(mTabDelegate);
+ });
+
+ webExtensionController()
+ .setAddonManagerDelegate(new WebExtensionController.AddonManagerDelegate() {});
+
+ sRuntime.setDelegate(
+ () -> {
+ mKillProcessOnDestroy = true;
+ finish();
+ });
+ }
+
+ orientationController()
+ .setDelegate(
+ new OrientationController.OrientationDelegate() {
+ @Override
+ public GeckoResult<AllowOrDeny> onOrientationLock(int aOrientation) {
+ setRequestedOrientation(aOrientation);
+ return GeckoResult.allow();
+ }
+ });
+
+ mSession = createSession(/* active */ true);
+ webExtensionController().setTabActive(mOwnedSessions.peek(), true);
+ mSession.open(sRuntime);
+
+ // If we were passed a URI in the Intent, open it
+ final Uri uri = intent.getData();
+ if (uri != null) {
+ mSession.loadUri(uri.toString());
+ }
+
+ mView = new GeckoView(this);
+ mView.setSession(mSession);
+ setContentView(mView);
+ mView.setActivityContextDelegate(mActivityDelegate);
+
+ sRuntime.setServiceWorkerDelegate(
+ new GeckoRuntime.ServiceWorkerDelegate() {
+ @NonNull
+ @Override
+ public GeckoResult<GeckoSession> onOpenWindow(@NonNull String url) {
+ return mNavigationDelegate.onNewSession(mSession, url);
+ }
+ });
+ }
+
+ private final TestApiImpl mTestApiImpl = new TestApiImpl();
+ private final TestRunnerApiEngine mApiEngine = new TestRunnerApiEngine(mTestApiImpl);
+
+ private class TestApiImpl implements TestRunnerApiEngine.Api, GeckoSession.ProgressDelegate {
+ private GeckoResult<WebExtension> mOnPopupLoaded;
+ // Stores which extension opened the current popup
+ private WebExtension mCurrentPopupExtension;
+
+ @Override
+ public void onPageStop(final GeckoSession session, final boolean success) {
+ if (mOnPopupLoaded != null) {
+ mOnPopupLoaded.complete(mCurrentPopupExtension);
+ mOnPopupLoaded = null;
+ mCurrentPopupExtension = null;
+ }
+ session.setProgressDelegate(null);
+ }
+
+ public void setCurrentPopupExtension(final WebExtension extension) {
+ mCurrentPopupExtension = extension;
+ }
+
+ private GeckoResult<Void> clickAction(
+ final String extensionId, final HashMap<GeckoSession, WebExtension.Action> actions) {
+ final GeckoSession active = mOwnedSessions.peek();
+
+ WebExtension.Action action = actions.get(active);
+ if (action == null) {
+ // Get default action if there's no specific one
+ action = actions.get(null);
+ }
+
+ if (action == null) {
+ return GeckoResult.fromException(
+ new RuntimeException("No browser action for " + extensionId));
+ }
+
+ action.click();
+ return GeckoResult.fromValue(null);
+ }
+
+ @Override
+ public GeckoResult<Void> clickPageAction(final String extensionId) {
+ if (!mExtensions.containsKey(extensionId)) {
+ return GeckoResult.fromException(
+ new RuntimeException("Extension not found: " + extensionId));
+ }
+
+ return clickAction(extensionId, mExtensions.get(extensionId).pageActions);
+ }
+
+ @Override
+ public GeckoResult<Void> clickBrowserAction(final String extensionId) {
+ if (!mExtensions.containsKey(extensionId)) {
+ return GeckoResult.fromException(
+ new RuntimeException("Extension not found: " + extensionId));
+ }
+
+ return clickAction(extensionId, mExtensions.get(extensionId).browserActions);
+ }
+
+ @Override
+ public GeckoResult<Void> closePopup() {
+ if (mPopupSession != null) {
+ mPopupSession.close();
+ mPopupSession = null;
+ }
+ return null;
+ }
+
+ @Override
+ public GeckoResult<Void> awaitExtensionPopup(final String extensionId) {
+ mOnPopupLoaded = new GeckoResult<>();
+ return mOnPopupLoaded.accept(
+ extension -> {
+ if (!extension.id.equals(extensionId)) {
+ throw new IllegalStateException(
+ "Expecting panel from extension: "
+ + extensionId
+ + " found "
+ + extension.id
+ + " instead.");
+ }
+ });
+ }
+ }
+
+ // Random start timestamp for the BrowsingDataDelegate API.
+ private static final int CLEAR_DATA_START_TIMESTAMP = 1234;
+
+ private void refreshExtensionList() {
+ webExtensionController()
+ .list()
+ .accept(
+ extensions -> {
+ mExtensions.clear();
+ for (final WebExtension extension : extensions) {
+ mExtensions.put(extension.id, new ExtensionWrapper(extension));
+ extension.setActionDelegate(mActionDelegate);
+ extension.setTabDelegate(mTabDelegate);
+
+ extension.setBrowsingDataDelegate(
+ new WebExtension.BrowsingDataDelegate() {
+ @Nullable
+ @Override
+ public GeckoResult<Settings> onGetSettings() {
+ final long types =
+ Type.CACHE
+ | Type.COOKIES
+ | Type.HISTORY
+ | Type.FORM_DATA
+ | Type.DOWNLOADS;
+ return GeckoResult.fromValue(
+ new Settings(CLEAR_DATA_START_TIMESTAMP, types, types));
+ }
+ });
+
+ for (final GeckoSession session : mOwnedSessions) {
+ final WebExtension.SessionController controller =
+ session.getWebExtensionController();
+ controller.setActionDelegate(extension, mActionDelegate);
+ controller.setTabDelegate(extension, mSessionTabDelegate);
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ mSession.close();
+ super.onDestroy();
+
+ if (mKillProcessOnDestroy) {
+ android.os.Process.killProcess(android.os.Process.myPid());
+ }
+ }
+
+ public GeckoView getGeckoView() {
+ return mView;
+ }
+
+ public GeckoSession getGeckoSession() {
+ return mSession;
+ }
+
+ class TestRunnerExperimentDelegate implements ExperimentDelegate {
+ @Override
+ public GeckoResult<JSONObject> onGetExperimentFeature(@NonNull String feature) {
+ GeckoResult<JSONObject> result = new GeckoResult<>();
+ if (feature.equals("test")) {
+ try {
+ result.complete(new JSONObject().put("item-one", true).put("item-two", 5));
+ } catch (JSONException e) {
+ result.completeExceptionally(new ExperimentException(ERROR_UNKNOWN));
+ }
+ } else {
+ result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND));
+ }
+ return result;
+ }
+
+ @Override
+ public GeckoResult<Void> onRecordExposureEvent(@NonNull String feature) {
+ GeckoResult<Void> result = new GeckoResult<>();
+ if (feature.equals("test")) {
+ result.complete(null);
+ } else {
+ result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND));
+ }
+ return result;
+ }
+
+ @Override
+ public GeckoResult<Void> onRecordExperimentExposureEvent(
+ @NonNull String feature, @NonNull String slug) {
+ GeckoResult<Void> result = new GeckoResult<>();
+ if (feature.equals("test") && slug.equals("test")) {
+ result.complete(null);
+ } else if (!slug.equals("test") && feature.equals("test")) {
+ result.completeExceptionally(new ExperimentException(ERROR_EXPERIMENT_SLUG_NOT_FOUND));
+ } else {
+ result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND));
+ }
+ return result;
+ }
+
+ @Override
+ public GeckoResult<Void> onRecordMalformedConfigurationEvent(
+ @NonNull String feature, @NonNull String part) {
+ GeckoResult<Void> result = new GeckoResult<>();
+ if (feature.equals("test")) {
+ result.complete(null);
+ } else {
+ result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND));
+ }
+ return result;
+ }
+ }
+}
diff --git a/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java
new file mode 100644
index 0000000000..c6b8e797da
--- /dev/null
+++ b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test_runner;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.WebExtension;
+
+// Receives API calls via mobile/android/modules/test/AppUiTestDelegate.sys.mjs
+// and forwards the calls to the Api impl.
+//
+// This interface allows JS/HTML-based mochitests to invoke test-only logic
+// that is implemented by the embedder, e.g. by TestRunnerActivity.
+//
+// There is no implementation for xpcshell tests because the underlying concepts
+// are not available to xpcshell on desktop Firefox. If there is ever a desire
+// to create an instance, do so from XpcshellTestRunnerService.java.
+//
+// The supported messages are documented in AppTestDelegateParent.sys.mjs.
+public class TestRunnerApiEngine implements WebExtension.MessageDelegate {
+ private static final String LOGTAG = "TestRunnerAPI";
+
+ public interface Api {
+ GeckoResult<Void> clickBrowserAction(String extensionId);
+
+ GeckoResult<Void> clickPageAction(String extensionId);
+
+ GeckoResult<Void> closePopup();
+
+ GeckoResult<Void> awaitExtensionPopup(String extensionId);
+ }
+
+ private final Api mImpl;
+
+ public TestRunnerApiEngine(final Api impl) {
+ mImpl = impl;
+ }
+
+ @SuppressWarnings("unchecked")
+ private GeckoResult<Object> handleMessage(final JSONObject message) throws JSONException {
+ final String type = message.getString("type");
+
+ Log.i(LOGTAG, "Test API: " + type);
+
+ if ("clickBrowserAction".equals(type)) {
+ return (GeckoResult) mImpl.clickBrowserAction(message.getString("extensionId"));
+ } else if ("clickPageAction".equals(type)) {
+ return (GeckoResult) mImpl.clickPageAction(message.getString("extensionId"));
+ } else if ("closeBrowserAction".equals(type)) {
+ return (GeckoResult) mImpl.closePopup();
+ } else if ("closePageAction".equals(type)) {
+ return (GeckoResult) mImpl.closePopup();
+ } else if ("awaitExtensionPanel".equals(type)) {
+ return (GeckoResult) mImpl.awaitExtensionPopup(message.getString("extensionId"));
+ }
+
+ return GeckoResult.fromException(new RuntimeException("Unrecognized command " + type));
+ }
+
+ @Nullable
+ @Override
+ public GeckoResult<Object> onMessage(
+ @NonNull final String nativeApp,
+ @NonNull final Object message,
+ @NonNull final WebExtension.MessageSender sender) {
+ try {
+ return handleMessage((JSONObject) message);
+ } catch (final JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+}
diff --git a/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java
new file mode 100644
index 0000000000..529b740c28
--- /dev/null
+++ b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test_runner;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+import androidx.annotation.Nullable;
+import java.util.HashMap;
+import org.mozilla.geckoview.ContentBlocking;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebExtensionController;
+
+public class XpcshellTestRunnerService extends Service {
+ public static final class i0 extends XpcshellTestRunnerService {}
+
+ public static final class i1 extends XpcshellTestRunnerService {}
+
+ public static final class i2 extends XpcshellTestRunnerService {}
+
+ public static final class i3 extends XpcshellTestRunnerService {}
+
+ public static final class i4 extends XpcshellTestRunnerService {}
+
+ public static final class i5 extends XpcshellTestRunnerService {}
+
+ public static final class i6 extends XpcshellTestRunnerService {}
+
+ public static final class i7 extends XpcshellTestRunnerService {}
+
+ public static final class i8 extends XpcshellTestRunnerService {}
+
+ public static final class i9 extends XpcshellTestRunnerService {}
+
+ private static final String LOGTAG = "XpcshellTestRunner";
+ static GeckoRuntime sRuntime;
+
+ private HashMap<String, WebExtension> mExtensions = new HashMap<>();
+
+ private static WebExtensionController webExtensionController() {
+ return sRuntime.getWebExtensionController();
+ }
+
+ @Override
+ public synchronized int onStartCommand(Intent intent, int flags, int startId) {
+ if (sRuntime != null) {
+ // We don't support restarting GeckoRuntime
+ throw new RuntimeException("Cannot start more than once");
+ }
+
+ final Bundle extras = intent.getExtras();
+ for (final String key : extras.keySet()) {
+ Log.i(LOGTAG, "Got extras " + key + "=" + extras.get(key));
+ }
+
+ final ContentBlocking.SafeBrowsingProvider googleLegacy =
+ ContentBlocking.SafeBrowsingProvider.from(
+ ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing-dummy/update")
+ .build();
+
+ final ContentBlocking.SafeBrowsingProvider google =
+ ContentBlocking.SafeBrowsingProvider.from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ .getHashUrl("http://mochi.test:8888/safebrowsing4-dummy/gethash")
+ .updateUrl("http://mochi.test:8888/safebrowsing4-dummy/update")
+ .build();
+
+ final GeckoRuntimeSettings runtimeSettings =
+ new GeckoRuntimeSettings.Builder()
+ .arguments(new String[] {"-xpcshell"})
+ .extras(extras)
+ .consoleOutput(true)
+ .contentBlocking(
+ new ContentBlocking.Settings.Builder()
+ .safeBrowsingProviders(google, googleLegacy)
+ .build())
+ .build();
+
+ sRuntime = GeckoRuntime.create(this, runtimeSettings);
+
+ webExtensionController()
+ .setDebuggerDelegate(
+ new WebExtensionController.DebuggerDelegate() {
+ @Override
+ public void onExtensionListUpdated() {
+ refreshExtensionList();
+ }
+ });
+
+ webExtensionController()
+ .setAddonManagerDelegate(new WebExtensionController.AddonManagerDelegate() {});
+
+ sRuntime.setDelegate(
+ () -> {
+ stopSelf();
+ System.exit(0);
+ });
+
+ return Service.START_NOT_STICKY;
+ }
+
+ // Random start timestamp for the BrowsingDataDelegate API.
+ private static final int CLEAR_DATA_START_TIMESTAMP = 1234;
+
+ private void refreshExtensionList() {
+ webExtensionController()
+ .list()
+ .accept(
+ extensions -> {
+ mExtensions.clear();
+ for (final WebExtension extension : extensions) {
+ mExtensions.put(extension.id, extension);
+
+ extension.setBrowsingDataDelegate(
+ new WebExtension.BrowsingDataDelegate() {
+ @Nullable
+ @Override
+ public GeckoResult<Settings> onGetSettings() {
+ final long types =
+ Type.CACHE
+ | Type.COOKIES
+ | Type.HISTORY
+ | Type.FORM_DATA
+ | Type.DOWNLOADS;
+ return GeckoResult.fromValue(
+ new Settings(CLEAR_DATA_START_TIMESTAMP, types, types));
+ }
+ });
+ }
+ });
+ }
+
+ @Override
+ public synchronized IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors.png
new file mode 100644
index 0000000000..c9a2788e53
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.png
new file mode 100644
index 0000000000..da4eba73b3
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.png
new file mode 100644
index 0000000000..c402e73bb6
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.png
new file mode 100644
index 0000000000..eda5c5ebf0
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.png
new file mode 100644
index 0000000000..0ce5a631c4
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..cd75f1434a
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<vector
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:height="108dp"
+ android:width="108dp"
+ android:viewportHeight="108"
+ android:viewportWidth="108">
+ <path android:fillColor="#26A69A"
+ android:pathData="M0,0h108v108h-108z"/>
+ <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+</vector>
diff --git a/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..a2f5908281
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..1b52399808
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..ff10afd6e1
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..115a4c768a
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..dcd3cd8083
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..459ca609d3
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..8ca12fe024
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..8e19b410a1
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..b824ebdd48
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..4c19a13c23
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/mobile/android/test_runner/src/main/res/values/colors.xml b/mobile/android/test_runner/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..3a96673022
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <color name="colorPrimary">#3F51B5</color>
+ <color name="colorPrimaryDark">#303F9F</color>
+ <color name="colorAccent">#FF4081</color>
+</resources>
diff --git a/mobile/android/test_runner/src/main/res/values/strings.xml b/mobile/android/test_runner/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..7831a536eb
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <string name="app_name">GeckoView Test Runner</string>
+</resources>
diff --git a/mobile/android/test_runner/src/main/res/values/styles.xml b/mobile/android/test_runner/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..60abe4bf63
--- /dev/null
+++ b/mobile/android/test_runner/src/main/res/values/styles.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<resources>
+ <!-- Base application theme. -->
+ <style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar">
+ <!-- Customize your theme here. -->
+ </style>
+</resources>