Googlebook でのクロスデバイス継続性をサポート

ユーザーは、ドキュメントの確認、メッセージのトリアージ、さまざまなアイテムの閲覧などのタスクを Android スマートフォンで開始し、その後、大画面のディスプレイ、物理キーボード、マルチ ウィンドウ ワークスペースを備えた Chromebook に移行したいと考えることがよくあります。

Android 17(API レベル 37)以降の続行機能により、Googlebook のタスクバーに、ユーザーが近くのスマートフォンで実行していたアクティビティのアプリアイコン(候補)が表示されます。ユーザーは、ワンクリックで Googlebook でアクティビティを再開できます。

クロスデバイス ハンドオフを実装すると、アプリでアクティビティ コンテキストを転送できるようになり、ユーザーは、スマートフォンで中断したところから、アダプティブ Android アプリまたはパソコンのブラウザでワークフローを再開できます。

ハンドオフ フローを選択する

クロスデバイス ハンドオフを有効にする前に、Googlebook で [続行] の候補が選択されたときに、スマートフォン アプリの各アクティビティをどのように開くかを決定します。

引き継ぎフロー Googlebook でのターゲット設定 最適な用途 Key API
アプリ間のハンドオフ デスクトップ ウィンドウで実行されているネイティブ Android アプリ Google Play での大画面とデスクトップ ウィンドウ表示向けに最適化されたアプリ HandoffActivityData.Builder
ウェブ フォールバックによるアプリ間のハンドオフ インストールされている場合はネイティブ Android アプリ、インストールされていない場合はデフォルトのデスクトップ ブラウザの URL ネイティブのデスクトップ ウィンドウ エクスペリエンス向けに最適化されているが、Googlebook にアプリがインストールされていない場合に信頼性の高いブラウザ フォールバックを必要とするアプリ setFallbackUri
ウェブへの直接ハンドオフ デフォルトのパソコン用ブラウザの URL(Android アプリリンク用に設定されている場合は、ネイティブ アプリによってインターセプトされます) ウェブ アプリケーションが主なデスクトップ エクスペリエンスとなるワークフロー HandoffActivityData.createWebHandoff

アクティビティの引き継ぎを有効にする

Continue On はデフォルトで無効になっており、アクティビティごとに構成されます。スマートフォンで送信アクティビティごとに次の手順を行います。

  1. アクティビティがハンドオフに必要な状態を読み込んだら、setHandoffEnabled を呼び出して true を渡します。有効にすると、アクティビティがフォアグラウンドにあるときやバックグラウンドに移行するときに、システムがハンドオフ データをリクエストできるようになります。
  2. onHandoffActivityDataRequested をオーバーライドして、null 以外の HandoffActivityData オブジェクトを返します。isHandoffEnabled をいつでもクエリして、アクティビティでハンドオフが有効になっているかどうかを確認できます。
import android.app.Activity
import android.app.HandoffActivityData
import android.app.HandoffActivityDataRequestInfo
import android.os.Bundle
import android.os.PersistableBundle

class DocumentEditorActivity : Activity() {

    private var documentId: String = "doc_42"
    private var scrollOffsetPx: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Enable handoff once the activity is ready to transfer state.
        setHandoffEnabled(true, null)
    }

    override fun onHandoffActivityDataRequested(
        handoffRequestInfo: HandoffActivityDataRequestInfo
    ): HandoffActivityData {
        val extras = PersistableBundle().apply {
            putString(EXTRA_DOCUMENT_ID, documentId)
            putInt(EXTRA_SCROLL_OFFSET, scrollOffsetPx)
        }

        return HandoffActivityData.Builder(getComponentName())
            .setExtras(extras)
            .build()
    }

    companion object {
        const val EXTRA_DOCUMENT_ID = "extra_document_id"
        const val EXTRA_SCROLL_OFFSET = "extra_scroll_offset"
    }
}

引き継ぎ用のパッケージ アクティビティの状態

システムは、次の 2 つのシナリオで onHandoffActivityDataRequested を呼び出します。

  • フォアグラウンド ハンドオフ: アクティビティがスマートフォンのフォアグラウンドにあり、ユーザーが Googlebook のタスクバーからハンドオフを開始します。
  • バックグラウンド状態のキャプチャ: アクティビティがスマートフォンのバックグラウンドに移動し、ユーザーが後で Googlebook の候補を選択した場合に備えて、システムがハンドオフ ペイロードをキャッシュに保存します。

ハンドオフ リクエストが有効かどうかを確認する

isActiveRequest を使用して HandoffActivityDataRequestInfo を検査し、アクティブなユーザー開始ハンドオフとバックグラウンド保存を区別します。

  • isActiveRequest が true を返した場合、アクティビティが表示されている間に、ユーザーが別のデバイスで続行することを積極的にリクエストしています。
  • isActiveRequest が false を返した場合、システムはアクティビティの停止時に状態をキャッシュに保存しています。

タスクが Googlebook に移動していることを確認する視覚的インジケーターをスマートフォンに表示する場合は、onHandoffActivityDataRequested が遅延なく戻るように、UI の更新を非同期でスケジュールします。

状態エクストラとウェブ フォールバックをアタッチする

HandoffActivityData ペイロードには次の情報が含まれます。

  • ターゲット コンポーネント(アプリ間のハンドオフに必要): this.getComponentName()(アクティビティの ComponentName)または明示的な ComponentName(this, TargetActivity::class.java) を HandoffActivityData.Builder に渡して、Googlebook で起動するアクティビティを指定します。
  • Extras(省略可): 識別子と UI 座標(開いているドキュメント ID、アクティブなタブキー、スクロール位置など)を含む PersistableBundle を setExtras() に渡します。バンドルは 50 KB 未満にする必要があります。
  • フォールバック URI(省略可): Android アプリがインストールされていない場合に Googlebook がデフォルトのブラウザでタスクを開けるように、ウェブ Uri を setFallbackUri に渡します。
import android.app.Activity
import android.app.HandoffActivityData
import android.app.HandoffActivityDataRequestInfo
import android.app.HandoffActivityParams
import android.net.Uri
import android.os.Bundle
import android.os.PersistableBundle

class ArticleReaderActivity : Activity() {

    private var articleId: String = "android-desktop-windowing"
    private var readingProgressPercent: Int = 45

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setHandoffEnabled(
            true,
            HandoffActivityParams.Builder()
                .setAllowHandoffWithoutPackageInstalled(true)
                .build()
        )
    }

    override fun onHandoffActivityDataRequested(
        handoffRequestInfo: HandoffActivityDataRequestInfo
    ): HandoffActivityData {
        if (handoffRequestInfo.isActiveRequest()) {
            window.decorView.post {
                onHandoffStartedToSecondaryDevice()
            }
        }

        val extras = PersistableBundle().apply {
            putString(EXTRA_ARTICLE_ID, articleId)
            putInt(EXTRA_READING_PROGRESS, readingProgressPercent)
        }

        val fallbackWebUri = Uri.Builder()
            .scheme("https")
            .authority("example.com")
            .appendPath("articles")
            .appendPath(articleId)
            .appendQueryParameter("progress", readingProgressPercent.toString())
            .build()

        return HandoffActivityData.Builder(getComponentName())
            .setExtras(extras)
            .setFallbackUri(fallbackWebUri)
            .build()
    }

    private fun onHandoffStartedToSecondaryDevice() {
        // Save local drafts and update phone UI without blocking handoff.
    }

    companion object {
        const val EXTRA_ARTICLE_ID = "extra_article_id"
        const val EXTRA_READING_PROGRESS = "extra_reading_progress"
    }
}

ウェブへの直接ハンドオフを設定する

Googlebook の主なデスクトップ エクスペリエンスがウェブ アプリケーションである場合は、setAllowHandoffWithoutPackageInstalled(true) を伴う HandoffActivityParams を setHandoffEnabled に渡し、HandoffActivityData.Builder を使用する代わりに HandoffActivityData.createWebHandoff を呼び出します。

import android.app.Activity
import android.app.HandoffActivityData
import android.app.HandoffActivityDataRequestInfo
import android.app.HandoffActivityParams
import android.net.Uri
import android.os.Bundle

class WebDashboardActivity : Activity() {

    private var projectId: String = "proj_901"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setHandoffEnabled(
            true,
            HandoffActivityParams.Builder()
                .setAllowHandoffWithoutPackageInstalled(true)
                .build()
        )
    }

    override fun onHandoffActivityDataRequested(
        handoffRequestInfo: HandoffActivityDataRequestInfo
    ): HandoffActivityData {
        val webUri = Uri.Builder()
            .scheme("https")
            .authority("app.example.com")
            .appendPath("projects")
            .appendPath(projectId)
            .build()

        return HandoffActivityData.createWebHandoff(webUri)
    }
}

Googlebook で状態を受信して復元する

ユーザーが Googlebook タスクバーで [Continue On] の候補を選択すると、システムはデスクトップ ウィンドウでターゲット ComponentName を起動し、起動 Intent に PersistableBundle エクストラを付加します。

Googlebook では、デスクトップ ウィンドウとマルチペイン レイアウトを考慮して、着信ハンドオフを処理します。

  1. ハンドオフ エクストラを抽出する: 受け取り側アクティビティの onCreate メソッドと onNewIntent メソッドの両方で、受信した Intent からエクストラを取得します。onNewIntent を処理すると、アクティビティが singleTop 起動モードを使用している場合や、既存のデスクトップ ウィンドウにルーティングしている場合に、ウィンドウがハンドオフされたアイテムに更新されます。
  2. マルチペイン レイアウトに状態を適応させる: スマートフォンの全画面詳細ビューを占有していた状態は、Googlebook のリストと詳細レイアウトまたはサポートペイン レイアウト内の選択されたアイテムにマッピングされることがよくあります。プライマリ リストの選択と詳細ペインの両方にデータを入力して、デスクトップ ウィンドウに完全なコンテキストが表示されるようにします。
  3. ウィンドウ サイズ変更時に復元された状態を保持する: Jetpack Compose の ViewModel(SavedStateHandle)または rememberSaveable にハンドオフされた状態を保存し、Googlebook での自由形式のウィンドウ サイズ変更で復元されたセッションがリセットされないようにします。
import android.app.Activity
import android.content.Intent
import android.os.Bundle

class DocumentEditorReceiverActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Restore from the handoff intent only on initial launch so subsequent
        // desktop window configuration changes use saved instance state.
        if (savedInstanceState == null) {
            handleHandoffIntent(intent)
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        handleHandoffIntent(intent)
    }

    private fun handleHandoffIntent(incomingIntent: Intent?) {
        val documentId = incomingIntent?.getStringExtra(EXTRA_DOCUMENT_ID)
            ?: return
        val scrollOffsetPx = incomingIntent.getIntExtra(EXTRA_SCROLL_OFFSET, 0)

        openDocumentInDesktopWorkspace(
            documentId = documentId,
            initialScrollOffsetPx = scrollOffsetPx
        )
    }

    private fun openDocumentInDesktopWorkspace(
        documentId: String,
        initialScrollOffsetPx: Int
    ) {
        // Load the document into the list-detail layout and restore scroll position.
    }

    companion object {
        const val EXTRA_DOCUMENT_ID = "extra_document_id"
        const val EXTRA_SCROLL_OFFSET = "extra_scroll_offset"
    }
}

スマートフォンと Googlebook 間の引き継ぎをテストする

実装をエンドツーエンドで確認するには:

  1. 両方のデバイスを準備する
    • Android スマートフォン(Android 17 以上を搭載)と Googlebook(またはデスクトップ ウィンドウ モードで実行されているテストデバイス)の両方で、同じ Google アカウントにログインします。
    • Bluetooth をオンにして、両方のデバイスを同じ Wi-Fi ネットワークに接続します。
    • 両方のデバイスで、[設定] > [接続済みのデバイス] > [接続の詳細設定] > [複数デバイス間サービス] > [アクティビティを続ける] を開き、設定のプロンプトに沿って タスクを有効にします。環境の前提条件については、セットアップとテストをご覧ください。
  2. アプリをインストールする
    • アプリ間のハンドオフの場合、スマートフォンと Googlebook の両方にビルドをインストールします。
    • ウェブ フォールバックまたはウェブへの直接ハンドオフの場合、ビルドをスマートフォンにインストールし、Googlebook でアプリをアンインストールしたままにして、ブラウザが想定どおりの URL を開くことを確認できるようにします。
  3. ハンドオフをトリガーする
    • スマートフォンでアプリを開き、ハンドオフを有効にするアクティビティに移動します(アプリ間のハンドオフの場合は setHandoffEnabled(true, null) を呼び出し、Googlebook でアプリをアンインストールしてテストする場合は setAllowHandoffWithoutPackageInstalled(true) で HandoffActivityParams を渡します)。
    • Googlebook のタスクバーの右側で、[Continue On] バッジ付きのアプリアイコンを探して選択します。
  4. デスクトップの復元を確認する
    • スマートフォンで表示していたアイテムとスクロール位置で、アプリがデスクトップ ウィンドウ(またはブラウザタブ)で開くことを確認します。
    • Googlebook でアプリ ウィンドウのサイズを変更し、復元されたコンテンツと選択がウィンドウ サイズクラスの切り替え後も保持されることを確認します。

参考情報