Android 使用 GeckoView 并实现 js 交互、权限交互

参考文档:

geckoview版本
引入文档(有坑 下面会给出正确引入方式)
官方示例代码1
官方示例代码2

参考了两位大神的博客和demo:

GeckoView js交互实现
geckoview-jsdemo

引入方式:

java 复制代码
        maven {
            url "https://maven.mozilla.org/maven2/"
        }
java 复制代码
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
java 复制代码
implementation 'org.mozilla.geckoview:geckoview-arm64-v8a:111.0.20230309232128'

使用方式:

控件:

java 复制代码
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <org.mozilla.geckoview.GeckoView
        android:id="@+id/web_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ProgressBar
        android:id="@+id/web_progress"
        style="@style/Web.ProgressBar.Horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="visible"
        tools:progress="50" />

</RelativeLayout>

初始化及配置

java 复制代码
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;

import android.Manifest;
import android.content.Context;
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.content.res.TypedArray;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.ScrollView;
import android.widget.Spinner;
import android.widget.TextView;

import org.json.JSONException;
import org.json.JSONObject;
import org.mozilla.geckoview.GeckoResult;
import org.mozilla.geckoview.GeckoRuntime;
import org.mozilla.geckoview.GeckoRuntimeSettings;
import org.mozilla.geckoview.GeckoSession;
import org.mozilla.geckoview.GeckoSessionSettings;
import org.mozilla.geckoview.GeckoView;
import org.mozilla.geckoview.WebExtension;
import java.util.Locale;

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivityTag";

    // 权限回调码
    private static final int CAMERA_PERMISSION_REQUEST_CODE = 1000;
    // web - 测试环境
    private static final String WEB_URL = "https://xxx.xxx.com/";

    private static final String EXTENSION_LOCATION = "resource://android/assets/messaging/";
    private static final String EXTENSION_ID = "messaging@example.com";

    private static GeckoRuntime sRuntime = null;
    private GeckoSession session;
    private static WebExtension.Port mPort;
    private GeckoSession.PermissionDelegate.Callback mCallback;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        setupGeckoView();
    }

    private void setupGeckoView() {
        // 初始化控件
        GeckoView geckoView = findViewById(R.id.gecko_view);
        ProgressBar web_progress = findViewById(R.id.web_progress);

        if (sRuntime == null) {
            GeckoRuntimeSettings.Builder builder = new GeckoRuntimeSettings.Builder()
                    .allowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL)
                    .javaScriptEnabled(true)
                    .doubleTapZoomingEnabled(true)
                    .inputAutoZoomEnabled(true)
                    .forceUserScalableEnabled(true)
                    .aboutConfigEnabled(true)
                    .loginAutofillEnabled(true)
                    .webManifest(true)
                    .consoleOutput(true)
                    .remoteDebuggingEnabled(BuildConfig.DEBUG)
                    .debugLogging(BuildConfig.DEBUG);
            sRuntime = GeckoRuntime.create(this, builder.build());
        }

        // 建立交互
        installExtension();

        session = new GeckoSession();
        GeckoSessionSettings settings = session.getSettings();
        settings.setAllowJavascript(true);
        settings.setUserAgentMode(GeckoSessionSettings.USER_AGENT_MODE_MOBILE);

        session.getPanZoomController().setIsLongpressEnabled(false);

        // 监听网页加载进度
        session.setProgressDelegate(new GeckoSession.ProgressDelegate() {
            @Override
            public void onPageStart(GeckoSession session, String url) {
                // 网页开始加载时的操作
                if (web_progress != null) {
                    web_progress.setVisibility(View.VISIBLE);
                }
            }

            @Override
            public void onPageStop(GeckoSession session, boolean success) {
                // 网页加载完成时的操作
                if (web_progress != null) {
                    web_progress.setVisibility(View.GONE);
                }
            }

            @Override
            public void onProgressChange(GeckoSession session, int progress) {
                // 网页加载进度变化时的操作
                if (web_progress != null) {
                    web_progress.setProgress(progress);
                }
            }
        });

        // 权限
        session.setPermissionDelegate(new GeckoSession.PermissionDelegate() {
            @Override
            public void onAndroidPermissionsRequest(@NonNull final GeckoSession session,
                                                    final String[] permissions,
                                                    @NonNull final Callback callback) {
                mCallback = callback;

                if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED
                        || ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
                    ActivityCompat.requestPermissions(MainActivity.this, permissions, CAMERA_PERMISSION_REQUEST_CODE);
                } else {
                    callback.grant();
                }
            }

            @Nullable
            @Override
            public GeckoResult<Integer> onContentPermissionRequest(@NonNull GeckoSession session, @NonNull ContentPermission perm) {
                return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW);
            }

            @Override
            public void onMediaPermissionRequest(@NonNull final GeckoSession session,
                                                 @NonNull final String uri,
                                                 final MediaSource[] video,
                                                 final MediaSource[] audio,
                                                 @NonNull final MediaCallback callback) {

                final String host = Uri.parse(uri).getAuthority();
                final String title;
                if (audio == null) {
                    title = getString(R.string.request_video, host);
                } else if (video == null) {
                    title = getString(R.string.request_audio, host);
                } else {
                    title = getString(R.string.request_media, host);
                }

                String[] videoNames = normalizeMediaName(video);
                String[] audioNames = normalizeMediaName(audio);

                final AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);

                final LinearLayout container = addStandardLayout(builder, title, null);
                final Spinner videoSpinner;
                if (video != null) {
                    videoSpinner = addMediaSpinner(builder.getContext(), container, video, videoNames); // create spinner and add to alert UI
                } else {
                    videoSpinner = null;
                }

                final Spinner audioSpinner;
                if (audio != null) {
                    audioSpinner = addMediaSpinner(builder.getContext(), container, audio, audioNames); // create spinner and add to alert UI
                } else {
                    audioSpinner = null;
                }

                builder.setNegativeButton(android.R.string.cancel, null)
                        .setPositiveButton(android.R.string.ok,
                                new DialogInterface.OnClickListener() {
                                    @Override
                                    public void onClick(final DialogInterface dialog, final int which) {
                                        // gather selected media devices and grant access
                                        final MediaSource video = (videoSpinner != null)
                                                ? (MediaSource) videoSpinner.getSelectedItem() : null;
                                        final MediaSource audio = (audioSpinner != null)
                                                ? (MediaSource) audioSpinner.getSelectedItem() : null;
                                        callback.grant(video, audio);
                                    }
                                });

                final AlertDialog dialog = builder.create();
                dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
                    @Override
                    public void onDismiss(final DialogInterface dialog) {
                        callback.reject();
                    }
                });
                dialog.show();
            }
        });

        session.open(sRuntime);
        geckoView.setSession(session);
        // 打开web地址
        session.loadUri(WEB_URL);
    }

    /**
     * 建立交互
     */
    private void installExtension() {
        sRuntime.getWebExtensionController()
                .ensureBuiltIn(EXTENSION_LOCATION, EXTENSION_ID)
                .accept(
                        extension -> {
                            Log.i(TAG, "Extension installed: " + extension);
                            runOnUiThread(() -> {
                                assert extension != null;
                                extension.setMessageDelegate(mMessagingDelegate, "Android");
                            });
                        },
                        e -> Log.e(TAG, "Error registering WebExtension", e)
                );
    }

    private final WebExtension.MessageDelegate mMessagingDelegate = new WebExtension.MessageDelegate() {
        @Nullable
        @Override
        public void onConnect(@NonNull WebExtension.Port port) {
            Log.e(TAG, "MessageDelegate onConnect");
            mPort = port;
            mPort.setDelegate(mPortDelegate);
        }
    };

    /**
     * 接收 JS 发送的消息
     */
    private final WebExtension.PortDelegate mPortDelegate = new WebExtension.PortDelegate() {
        @Override
        public void onPortMessage(final @NonNull Object message,
                                  final @NonNull WebExtension.Port port) {
            Log.e(TAG, "from extension: " + message);
            try {
//                ToastUtils.showLong("收到js调用: " + message);
                if (message instanceof JSONObject) {
                    JSONObject jsonobject = (JSONObject) message;
                    /*
                     * jsonobject 格式
                     *
                     *  {
                     *    "action": "JSBridge",
                     *    "data": {
                     *          "args":"字符串",
                     *          "function":"方法名"
                     *    }
                     *  }
                     */
                    String action = jsonobject.getString("action");
                    if ("JSBridge".equals(action)) {
                        JSONObject data = jsonobject.getJSONObject("data");
                        String function = data.getString("function");
                        if (!TextUtils.isEmpty(function)) {
                            String args = data.getString("args");
                            switch (function) {
                                // 与前端定义的方法名 示例:callSetToken
                                case "callSetToken": {

                                    break;
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onDisconnect(final @NonNull WebExtension.Port port) {
            Log.e(TAG, "MessageDelegate:onDisconnect");
            if (port == mPort) {
                mPort = null;
            }
        }
    };

    /**
     * 向 js 发送数据 示例:evaluateJavascript("callStartUpload", "startUpload");
     *
     * @param methodName 定义的方法名
     * @param data       发送的数据
     */
    private void evaluateJavascript(String methodName, String data) {
        try {
            long id = System.currentTimeMillis();
            JSONObject message = new JSONObject();
            message.put("action", "evalJavascript");
            message.put("data", "window." + methodName + "('" + data + "')");
            message.put("id", id);
            mPort.postMessage(message);
            Log.e(TAG,"mPort.postMessage:" + message);
        } catch (JSONException ex) {
            throw new RuntimeException(ex);
        }
    }
    
    /**
     * web 端:
     *
     * 接收消息示例:window.callStartUpload = function(data){console.log(data)}
     *
     * 发送消息示例:
     * if(typeof window.JSBridge !== 'undefined'){
     *   window.JSBridge.postMessage({function:name, args})
     * }
     *
     */

    private int getViewPadding(final AlertDialog.Builder builder) {
        final TypedArray attr =
                builder
                        .getContext()
                        .obtainStyledAttributes(new int[]{android.R.attr.listPreferredItemPaddingLeft});
        final int padding = attr.getDimensionPixelSize(0, 1);
        attr.recycle();
        return padding;
    }

    private LinearLayout addStandardLayout(
            final AlertDialog.Builder builder, final String title, final String msg) {
        final ScrollView scrollView = new ScrollView(builder.getContext());
        final LinearLayout container = new LinearLayout(builder.getContext());
        final int horizontalPadding = getViewPadding(builder);
        final int verticalPadding = (msg == null || msg.isEmpty()) ? horizontalPadding : 0;
        container.setOrientation(LinearLayout.VERTICAL);
        container.setPadding(
                /* left */ horizontalPadding, /* top */ verticalPadding,
                /* right */ horizontalPadding, /* bottom */ verticalPadding);
        scrollView.addView(container);
        builder.setTitle(title).setMessage(msg).setView(scrollView);
        return container;
    }

    private Spinner addMediaSpinner(
            final Context context,
            final ViewGroup container,
            final GeckoSession.PermissionDelegate.MediaSource[] sources,
            final String[] sourceNames) {
        final ArrayAdapter<GeckoSession.PermissionDelegate.MediaSource> adapter =
                new ArrayAdapter<GeckoSession.PermissionDelegate.MediaSource>(context, android.R.layout.simple_spinner_item) {
                    private View convertView(final int position, final View view) {
                        if (view != null) {
                            final GeckoSession.PermissionDelegate.MediaSource item = getItem(position);
                            ((TextView) view).setText(sourceNames != null ? sourceNames[position] : item.name);
                        }
                        return view;
                    }

                    @Override
                    public View getView(final int position, View view, final ViewGroup parent) {
                        return convertView(position, super.getView(position, view, parent));
                    }

                    @Override
                    public View getDropDownView(final int position, final View view, final ViewGroup parent) {
                        return convertView(position, super.getDropDownView(position, view, parent));
                    }
                };
        adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        adapter.addAll(sources);

        final Spinner spinner = new Spinner(context);
        spinner.setAdapter(adapter);
        spinner.setSelection(0);
        container.addView(spinner);
        return spinner;
    }

    private String[] normalizeMediaName(final GeckoSession.PermissionDelegate.MediaSource[] sources) {
        if (sources == null) {
            return null;
        }

        String[] res = new String[sources.length];
        for (int i = 0; i < sources.length; i++) {
            final int mediaSource = sources[i].source;
            final String name = sources[i].name;
            if (GeckoSession.PermissionDelegate.MediaSource.SOURCE_CAMERA == mediaSource) {
                if (name.toLowerCase(Locale.ROOT).contains("front")) {
                    res[i] = getString(R.string.media_front_camera);
                } else {
                    res[i] = getString(R.string.media_back_camera);
                }
            } else if (!name.isEmpty()) {
                res[i] = name;
            } else if (GeckoSession.PermissionDelegate.MediaSource.SOURCE_MICROPHONE == mediaSource) {
                res[i] = getString(R.string.media_microphone);
            } else {
                res[i] = getString(R.string.media_other);
            }
        }

        return res;
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // 授予权限
                mCallback.grant();
            } else {
                // 拒绝权限
                mCallback.reject();
            }
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (session != null) {
            session.close();
        }
    }
}

资源文件配置:

在assets下新建:messaging 文件夹

.eslintrc.js

java 复制代码
/* 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/. */

"use strict";

module.exports = {
  env: {
    webextensions: true,
  },
};

background.js

java 复制代码
// Establish connection with app
'use strict';
const port = browser.runtime.connectNative("Android");

async function sendMessageToTab(message) {
 try {
   let tabs = await browser.tabs.query({})
   console.log(`background:tabs:${tabs}`)
   return await browser.tabs.sendMessage(
     tabs[tabs.length - 1].id,
     message
   )
 } catch (e) {
   console.log(`background:sendMessageToTab:req:error:${e}`)
   return e.toString();
 }
}
//监听 app message
port.onMessage.addListener(request => {
 let action = request.action;
 if(action === "evalJavascript") {
     sendMessageToTab(request).then((resp) => {
       port.postMessage(resp);
     }).catch((e) => {
       console.log(`background:sendMessageToTab:resp:error:${e}`)
     });
   }
})

//接收 content.js message
browser.runtime.onMessage.addListener((data, sender) => {
   let action = data.action;
   console.log("background:content:onMessage:" + action);
   if (action === 'JSBridge') {
       port.postMessage(data);
   }
   return Promise.resolve('done');
})

content.js

java 复制代码
console.log(`content:start`);
let JSBridge = {
    postMessage: function (message) {
        browser.runtime.sendMessage({
            action: "JSBridge",
            data: message
        });
    }
}
window.wrappedJSObject.JSBridge = cloneInto(
    JSBridge,
    window,
    { cloneFunctions: true });

browser.runtime.onMessage.addListener((data, sender) => {
    console.log("content:eval:" + data);
    if (data.action === 'evalJavascript') {
        let evalCallBack = {
            id: data.id,
            action: "evalJavascript",
        }
        try {
            let result = window.eval(data.data);
            console.log("content:eval:result" + result);
            if (result) {
                evalCallBack.data = result;
            } else {
                evalCallBack.data = "";
            }
        } catch (e) {
            evalCallBack.data = e.toString();
            return Promise.resolve(evalCallBack);
        }
        return Promise.resolve(evalCallBack);
    }
});

manifest.json

java 复制代码
{
  "manifest_version": 2,
  "name": "messaging",
  "description": "Uses the proxy API to block requests to specific hosts.",
  "version": "3.0",
  "browser_specific_settings": {
    "gecko": {
      "strict_min_version": "65.0",
      "id": "messaging@example.com"
    }
  },
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "content.js"
      ],
      "run_at": "document_start"
    }
  ],
  "background": {
    "scripts": [
      "background.js"
    ]
  },
  "permissions": [
    "nativeMessaging",
    "nativeMessagingFromContent",
    "geckoViewAddons",
    "webNavigation",
    "geckoview",
    "tabs",
    "<all_urls>"
  ],
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
}

其他资源文件:

themes.xml

java 复制代码
    <!-- WebView进度条 -->
    <style name="Web.ProgressBar.Horizontal" parent="@android:style/Widget.ProgressBar.Horizontal">
        <item name="android:progressDrawable">@drawable/web_view_progress</item>
        <item name="android:minHeight">2dp</item>
        <item name="android:maxHeight">2dp</item>
    </style>

web_view_progress

java 复制代码
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:id="@android:id/background">
        <shape>
            <corners android:radius="0dp" />

            <gradient
                android:angle="270"
                android:centerY="0.75"
                android:endColor="#A0B3CF"
                android:startColor="#A0B3CF" />

        </shape>
    </item>

    <item android:id="@android:id/progress">
        <clip>
            <shape>
                <corners android:radius="0dp" />

                <gradient
                    android:angle="270"
                    android:endColor="@color/colorPrimary"
                    android:startColor="@color/colorPrimary" />
            </shape>
        </clip>
    </item>

</layer-list>

colors.xml

java 复制代码
    <color name="colorPrimary">#FF2673FF</color>

strings.xml

java 复制代码
    <string name="device_sharing_microphone">麦克风打开</string>
    <string name="device_sharing_camera">摄像头打开</string>
    <string name="device_sharing_camera_and_mic">摄像头和麦克风打开</string>

    <string name="media_back_camera">背面摄像头</string>
    <string name="media_front_camera">前置摄像头</string>
    <string name="media_microphone">麦克风</string>
    <string name="media_other">未知来源</string>

    <string name="request_video">与共享视频 "%1$s"</string>
    <string name="request_audio">与共享音频 "%1$s"</string>
    <string name="request_media">与共享视频和音频 "%1$s"</string>
相关推荐
前端百草阁4 分钟前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜4 分钟前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
Backstroke fish5 分钟前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple5 分钟前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five7 分钟前
TypeScript项目中Axios的封装
开发语言·前端·javascript
临枫5417 分钟前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
酷酷的威朗普8 分钟前
医院绩效考核系统
javascript·css·vue.js·typescript·node.js·echarts·html5
前端每日三省9 分钟前
面试题-TS(八):什么是装饰器(decorators)?如何在 TypeScript 中使用它们?
开发语言·前端·javascript
小刺猬_9859 分钟前
(超详细)数组方法 ——— splice( )
前端·javascript·typescript
契机再现10 分钟前
babel与AST
javascript·webpack·typescript