Tauri 2.0 で ネイティブモジュール作成(Android版) [MLKitでオンデバイス翻訳]

2024/10/14に公開

1.はじめに

Tauri 2.0 が StableRelease になった!

Tauri 2.0 が StableRelease になったんですが、これがモバイルアプリも作成できるとのこと。

https://forest.watch.impress.co.jp/docs/news/1628498.html

  • モバイルサポート。デスクトップだけでなく、iOS/Android向けのアプリも「Tauri」で(上記リンクより引用)

Tauri 2.0 って?(個人的な理解)

ざっくり、 "Tauri" はクロスプラットフォーム対応のアプリフレームワークで、
バージョン 2.0 からは Android や iOS 向けのアプリケーションのビルドも可能になった。 フロントエンドが WebView だからフロントエンドフレームワークは大体なんでも選べて、バックエンドは Rust だけど、 Kotlin や Swift でも書けるから各OS向けのネイティブモジュールが書ける。ありていに言えば Electron + React Native みたいなやつ

おもしろそ~!

ただ、 Tauri 2.0 から提供されるのは WebView と基本的なモジュールだけのようです。 じゃあ Android や iOS など各OSのネイティブな機能にアクセスするためにはどうすればいいの? ということで、ネイティブモジュールの作成をやってみました。

成果物(参考)

こんな感じのものを作りました。入力したテキストを英訳して返します。

https://github.com/SH1R4S4G1/tauri-plugin-tauri-mlkit-plugin

注意事項

https://v2.tauri.app/develop/plugins/

https://v2.tauri.app/develop/plugins/develop-mobile/

前提条件

Cargo とか Rust とか create-tauri-app とか tauri-cli とか Android Studio とか Node.js(系のもの) とかのインストールが必要と思われる。とりあえず1つ目のプロジェクトを作ってビルドしてみるとその辺が確認できるのでオススメ。

https://v2.tauri.app/start/create-project/

環境構築

Android で JAVA_HOME に Path を通す必要があるので注意! この辺目を通しておけば引っかからないと思います。

https://v2.tauri.app/start/prerequisites/#android

2.プロジェクトのスタート

プラグインを作り始めてからはこんな感じ。実は create-tauri-app で普通の Tauri プロジェクト(非プラグインのプロジェクト)を作るときは tauri-cli が必須じゃないので、プラグインづくりを始めてから tauri-cli をインストールする人もいるのでは。プラグイン名には "tauri-plugin-" が自動でくっつくようなので注意。

cargo install tauri-cli
cargo tauri plugin new my-tauri-app --android
cd my-tauri-app
npm install
npm run build
cd examples/tauri-app
npm install
npm tairi android init
npm tairi android dev

Android のエミュレータで examples/tauri-app が実行されたら OK です。

3.基本的なコード

生成されるテンプレートに "Ping" という Command が登録されているので、これを書き換えていけばOKです。

雰囲気としては、 cargo tauri plugin new [name] してあげると、ほかのアプリに公開するネイティブモジュール本体の部分と、それを同じリポジトリ内でお試しで実行するための examples/tauri-app が出来る感じ。作成したネイティブモジュールは、 examples/tauri-app で npm tairi android dev を実行すればお試しできます。このへんは React Native のネイティブモジュールづくりとかと同じ感じ?

以下のようにそれぞれ編集

3-1.ネイティブモジュール関係

android\build.gradle.kts

Androidの設定ファイル

build.gradle.kts
    implementation("com.google.mlkit:translate:17.0.3")

android\src\main\java\ExamplePlugin.kt

ネイティブモジュールの本体

ExamplePlugin.kt
    package net.smart9b.plugin.tauri_mlkit_plugin
    
    import android.app.Activity
    import app.tauri.annotation.Command
    import app.tauri.annotation.InvokeArg
    import app.tauri.annotation.TauriPlugin
    import app.tauri.plugin.JSObject
    import app.tauri.plugin.Plugin
    import app.tauri.plugin.Invoke
    import com.google.mlkit.nl.translate.Translation
    import com.google.mlkit.nl.translate.TranslatorOptions
    import com.google.mlkit.nl.translate.TranslateLanguage
    
    @InvokeArg
    class TranslateArgs {
      var text: String? = null
    }
    
    @TauriPlugin
    class ExamplePlugin(private val activity: Activity): Plugin(activity) {
        @Command
        fun translate(invoke: Invoke) {
            val args = invoke.parseArgs(TranslateArgs::class.java)
    
            val options = TranslatorOptions.Builder()
                .setSourceLanguage(TranslateLanguage.JAPANESE)
                .setTargetLanguage(TranslateLanguage.ENGLISH)
                .build()
            val translator = Translation.getClient(options)
    
            translator.downloadModelIfNeeded()
                .addOnSuccessListener {
                    // モデルのダウンロードが成功した場合、翻訳を実行
                    translator.translate(args.text ?: "").addOnSuccessListener { translatedText ->
                        val ret = JSObject()
                        ret.put("value", translatedText)
                        invoke.resolve(ret)
                    }.addOnFailureListener { exception ->
                        invoke.reject("Translation failed: ${exception.message}")
                    }
                }
                .addOnFailureListener { exception ->
                    invoke.reject("Model download failed: ${exception.message}")
                }
        }
    }

android\src\main\java\Example.kt

必要ないので削除

Example.kt
    //削除

android\src\main\AndroidManifest.xml

翻訳Modelのダウンロードが発生するので、インターネットアクセスを許可

AndroidManifest.xml
    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
        <uses-permission android:name="android.permission.INTERNET" />
    </manifest>

3-2.RUSTコード関係

src\mobile.rs

モバイルアプリからAPIにアクセスするRUSTコード

mobile.rs
    /// Access to the tauri-mlkit-plugin APIs.
    pub struct TauriMlkitPlugin<R: Runtime>(PluginHandle<R>);
    
    impl<R: Runtime> TauriMlkitPlugin<R> {
      pub fn translate(&self, payload: TranslateRequest) -> crate::Result<TranslateResponse> {
        self
          .0
          .run_mobile_plugin("translate", payload)
          .map_err(Into::into)
      }
    }

src\commands.rs

コマンドを管理するRUSTコード

commands.rs
    use tauri::{AppHandle, command, Runtime};
    
    use crate::models::*;
    use crate::Result;
    use crate::TauriMlkitPluginExt;
    
    #[command]
    pub(crate) async fn translate<R: Runtime>(
        app: AppHandle<R>,
        payload: TranslateRequest,
    ) -> Result<TranslateResponse> {
        app.tauri_mlkit_plugin().translate(payload)
    }

src\models.rs

モデルを管理するRUSTコード

models.rs
    use serde::{Deserialize, Serialize};

    #[derive(Debug, Deserialize, Serialize)]
    #[serde(rename_all = "camelCase")]
    pub struct TranslateRequest {
      pub text: String,
    }
    
    #[derive(Debug, Clone, Default, Deserialize, Serialize)]
    #[serde(rename_all = "camelCase")]
    pub struct TranslateResponse {
      pub value: String,
    }

src\desktop.rs

デスクトップアプリからAPIを利用するRUSTコード
コンパイルエラー対策でとりあえず仮に設定

desktop.rs
    use serde::de::DeserializeOwned;
    use tauri::{plugin::PluginApi, AppHandle, Runtime};
    
    use crate::models::*;
    
    pub fn init<R: Runtime, C: DeserializeOwned>(
      app: &AppHandle<R>,
      _api: PluginApi<R, C>,
    ) -> crate::Result<TauriMlkitPlugin<R>> {
      Ok(TauriMlkitPlugin(app.clone()))
    }
    
    /// Access to the tauri-mlkit-plugin APIs.
    pub struct TauriMlkitPlugin<R: Runtime>(AppHandle<R>);
    
    impl<R: Runtime> TauriMlkitPlugin<R> {
      pub fn translate(&self, payload: TranslateRequest) -> crate::Result<TranslateResponse> {
        // デスクトップ版では実際の翻訳を行わず、入力をそのまま返します
        Ok(TranslateResponse {
          value: payload.text,
        })
      }
    }

build.rs

コマンドを書き換えておく

build.rs
    const COMMANDS: &[&str] = &["translate"];
    
    fn main() {
      tauri_plugin::Builder::new(COMMANDS)
        .android_path("android")
        .ios_path("ios")
        .build();
    }

3-3.パーミッション関係

permissions\autogenerated\reference.md

autogeneratedって書いてあるけど手で書き換えたけどいいの?少なくとも必要そうな設定ではあるので修正。

reference.md
    ## Default Permission
    
    Default permissions for the plugin
    
    - `allow-translate`
    
    ## Permission Table
    
    <table>
    <tr>
    <th>Identifier</th>
    <th>Description</th>
    </tr>
    
    
    <tr>
    <td>
    
    `tauri-mlkit-plugin:allow-translate`
    
    </td>
    <td>
    
    Enables the translate command without any pre-configured scope.
    
    </td>
    </tr>
    
    <tr>
    <td>
    
    `tauri-mlkit-plugin:deny-translate`
    
    </td>
    <td>
    
    Denies the translate command without any pre-configured scope.
    
    </td>
    </tr>
    </table>

permissions/default.toml

permissionsを書き換え

default.toml
    [default]
    description = "Default permissions for the plugin"
    permissions = ["allow-translate"]

3-4.フロントエンド関係

examples/tauri-app/src/App.svelte

フロントエンド側、ネイティブモジュールをインポートして普通に使える

App.svelte
    <script>
      import Greet from './lib/Greet.svelte'
      import { translate } from 'tauri-plugin-tauri-mlkit-plugin-api'
    
    	let response = ''
    	let inputText = ''
    
    	function updateResponse(returnValue) {
    		response += `[${new Date().toLocaleTimeString()}] ` + (typeof returnValue === 'string' ? returnValue : JSON.stringify(returnValue)) + '<br>'
    	}
    
    	function _translate() {
    		translate(inputText).then(updateResponse).catch(updateResponse)
    	}
    </script>
    
    <main class="container">
      <h1>Welcome to Tauri!</h1>
    
      <div class="row">
        <a href="https://vitejs.dev" target="_blank">
          <img src="/vite.svg" class="logo vite" alt="Vite Logo" />
        </a>
        <a href="https://tauri.app" target="_blank">
          <img src="/tauri.svg" class="logo tauri" alt="Tauri Logo" />
        </a>
        <a href="https://svelte.dev" target="_blank">
          <img src="/svelte.svg" class="logo svelte" alt="Svelte Logo" />
        </a>
      </div>
    
      <p>
        Click on the Tauri, Vite, and Svelte logos to learn more.
      </p>
    
      <div>
        <input bind:value={inputText} placeholder="Enter text to translate" />
        <button on:click="{_translate}">Translate</button>
        <div>{@html response}</div>
      </div>
    
    </main>
    
    <style>
      .logo.vite:hover {
        filter: drop-shadow(0 0 2em #747bff);
      }
    
      .logo.svelte:hover {
        filter: drop-shadow(0 0 2em #ff3e00);
      }
    </style>

guest-js/index.ts

プラグインを使う設定

index.ts
    import { invoke } from '@tauri-apps/api/core'
    
    export async function translate(text: string): Promise<string | null> {
      return await invoke<{value?: string}>('plugin:tauri-mlkit-plugin|translate', {
        payload: {
          text,
        },
      }).then((r) => (r.value ? r.value : null));
    }

4.ビルド時にエラーが出た

エミュレータではうまくいかなかった ので、実機でビルドした。

また、次のように修正。

android\build.gradle.kts

build.gradle.kts
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }

5.個人的な感想

なるほど Rust 分かんなくても書き換えていけばやれんことはないなと思いました(それでいいかはともかくとして)。

React Native でのネイティブモジュール作りと比べてみて、 Permission や Command 周りはいろいろ書かないといけないけど、 Command 名で分かりやすくリレーしている(?)のでわりと見通しは良い感じがしました。ただ、結局 React Native とおなじつらみ ( Kotlin と Swift は結局書かないといけない) は変わらないかも。

ここまで読んでいただきありがとうございました!
🌟 とか 💖 とかもらえると励みになります。

Discussion