通过


使用 Continuity SDK 为 Android 和 Windows 应用程序实现跨设备恢复 (XDR)

本文为第一方和第三方开发人员提供了有关如何在应用程序中使用 Continuity SDK 集成功能的综合指南。 Continuity SDK 支持无缝跨设备体验,使用户能够跨不同的平台(包括 Android 和 Windows)恢复活动。

按照本指南作,可以通过使用 Continuity SDK 利用 XDR 跨多个设备创建流畅且集成的用户体验。

重要

在 Windows 中初始化和恢复

简历是受限访问功能(LAF)。 若要获取对此 API 的访问权限,需要从Microsoft获得批准,以便与 Android 移动设备上的“链接到 Windows”包进行互作。

若要请求访问权限,请发送电子邮件 wincrossdeviceapi@microsoft.com ,其中包含下面列出的信息:

  • 您的用户体验描述
  • 用户在本地访问 Web 或文档的应用程序的屏幕截图
  • 应用程序的 PackageId
  • 应用程序的 Google Play 商店 URL

如果请求获得批准,将收到有关如何解锁该功能的说明。 只要你的情景符合情景要求,审批将视你的交流情况而定。

先决条件

对于 Android 应用程序,请确保在集成 Continuity SDK 之前满足以下要求:

  • 最低 SDK 版本:24
  • Kotlin 版本:1.9.x
  • 链接到 Windows (LTW):1.241101.XX

对于 Windows 应用程序,请确保满足以下要求:

  • 最低 Windows 版本:Windows 11
  • 开发环境:Visual Studio 2019 或更高版本

注释

目前不支持 iOS 应用程序与 Continuity SDK 集成。

配置开发环境

以下部分提供了为 Android 和 Windows 应用程序设置开发环境的分步说明。

Android 设置

若要为 Android 设置开发环境,请执行以下步骤:

  1. 若要设置捆绑包,请通过以下版本中提供的库下载和使用 .aar 文件: Windows 跨设备 SDK 版本

  2. 在 Android 应用程序的 AndroidManifest.xml 文件中添加元标记。 以下代码片段演示如何添加所需的元标记:

    <meta-data 
        android:name="com.microsoft.crossdevice.resumeActivityProvider" 
        android:value="true" /> 
    
    <meta-data 
        android:name="com.microsoft.crossdevice.trigger.PartnerApp" 
        android:value="4" /> 
    

API 集成步骤

清单声明后,应用开发人员可以通过遵循简单的代码示例轻松发送其应用上下文。

应用必须:

  1. 初始化/反初始化 Continuity SDK:
    1. 应用应确定调用 Initialize 和 DeInitialize 函数的适当时间。
    2. 调用 Initialize 函数后,应触发实现 IAppContextEventHandler 的回调。
  2. 发送/删除 AppContext
    1. 初始化 SDK 后,如果调用 onContextRequestReceived ,则表示已建立连接。 然后,应用可以将(包括创建和更新)AppContext 发送到 LTW,或者从 LTW 中删除 AppContext
    2. 如果手机和电脑之间没有连接,并且应用将 AppContext 发送到 LTW,则应用将收到 onContextResponseError 消息“电脑未连接”。
    3. 重新建立连接后,将再次调用 onContextRequestReceived 。 然后,应用可以将当前的 AppContext 发送到 LTW。
    4. onSyncServiceDisconnected 或反初始化 SDK 后,应用程序不应发送 AppContext

下面是一个代码示例。 有关 AppContext 中的所有必需字段和可选字段,请参阅 AppContext 说明

以下 Android 代码片段演示如何使用 Continuity SDK 发出 API 请求:

import android.os.Bundle 
import android.util.Log 
import android.widget.Button 
import android.widget.TextView 
import android.widget.Toast 
import androidx.activity.enableEdgeToEdge 
import androidx.appcompat.app.AppCompatActivity 
import androidx.core.view.ViewCompat 
import androidx.core.view.WindowInsetsCompat 
import androidx.lifecycle.LiveData 
import androidx.lifecycle.MutableLiveData 
import androidx.lifecycle.Observer 
import com.microsoft.crossdevicesdk.continuity.AppContext 
import com.microsoft.crossdevicesdk.continuity.AppContextManager 
import com.microsoft.crossdevicesdk.continuity.ContextRequestInfo 
import com.microsoft.crossdevicesdk.continuity.IAppContextEventHandler 
import com.microsoft.crossdevicesdk.continuity.IAppContextResponse 
import com.microsoft.crossdevicesdk.continuity.LogUtils 
import com.microsoft.crossdevicesdk.continuity.ProtocolConstants 
import java.util.UUID 

  

class MainActivity : AppCompatActivity() { 

    //Make buttons member variables --- 
    private lateinit var buttonSend: Button 
    private lateinit var buttonDelete: Button 
    private lateinit var buttonUpdate: Button 

    private val appContextResponse = object : IAppContextResponse { 
        override fun onContextResponseSuccess(response: AppContext) { 
            Log.d("MainActivity", "onContextResponseSuccess") 
            runOnUiThread { 
                Toast.makeText( 
                    this@MainActivity, 
                    "Context response success: ${response.contextId}", 
                    Toast.LENGTH_SHORT 
                ).show() 
            } 
        } 

        override fun onContextResponseError(response: AppContext, throwable: Throwable) { 
            Log.d("MainActivity", "onContextResponseError: ${throwable.message}") 
            runOnUiThread { 
                Toast.makeText( 
                    this@MainActivity, 
                    "Context response error: ${throwable.message}", 
                    Toast.LENGTH_SHORT 
                ).show() 

                // Check if the error message contains the specific string 
                if (throwable.message?.contains("PC is not connected") == true) { 
                    //App should stop sending intent once this callback is received 
                }
            } 
        } 
    } 

    private lateinit var appContextEventHandler: IAppContextEventHandler 

    private val _currentAppContext = MutableLiveData<AppContext?>() 

    private val currentAppContext: LiveData<AppContext?> get() = _currentAppContext 

    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        enableEdgeToEdge() 
        setContentView(R.layout.activity_main) 
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> 
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) 
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) 
            insets 
        } 

        LogUtils.setDebugMode(true) 
        var ready = false 
        buttonSend = findViewById(R.id.buttonSend) 
        buttonDelete = findViewById(R.id.buttonDelete) 
        buttonUpdate = findViewById(R.id.buttonUpdate) 
        setButtonDisabled(buttonSend) 
        setButtonDisabled(buttonDelete) 
        setButtonDisabled(buttonUpdate) 

        buttonSend.setOnClickListener { 
            if (ready) { 
                sendResumeActivity() 
            } 
        } 

        buttonDelete.setOnClickListener { 
            if (ready) { 
                deleteResumeActivity() 
            } 
        } 

        buttonUpdate.setOnClickListener { 
            if (ready) { 
                updateResumeActivity() 
            }
        } 

        appContextEventHandler = object : IAppContextEventHandler { 

            override fun onContextRequestReceived(contextRequestInfo: ContextRequestInfo) { 
                LogUtils.d("MainActivity", "onContextRequestReceived") 
                ready = true 
                setButtonEnabled(buttonSend) 
                setButtonEnabled(buttonDelete) 
                setButtonEnabled(buttonUpdate) 

            } 

  

            override fun onInvalidContextRequestReceived(throwable: Throwable) { 
                Log.d("MainActivity", "onInvalidContextRequestReceived") 

            } 

  

            override fun onSyncServiceDisconnected() { 
                Log.d("MainActivity", "onSyncServiceDisconnected") 
                ready = false 
                setButtonDisabled(buttonSend) 
                setButtonDisabled(buttonDelete) 
            } 
        } 

        // Initialize the AppContextManager 
        AppContextManager.initialize(this.applicationContext, appContextEventHandler) 

        // Update currentAppContext text view. 
        val textView = findViewById<TextView>(R.id.appContext) 

        currentAppContext.observe(this, Observer { appContext -> 
            appContext?.let { 
                textView.text = 
                    "Current app context: ${it.contextId}\n App ID: ${it.appId}\n Created: ${it.createTime}\n Updated: ${it.lastUpdatedTime}\n Type: ${it.type}" 
                Log.d("MainActivity", "Current app context: ${it.contextId}") 
            } ?: run { 
                textView.text = "No current app context available" 
                Log.d("MainActivity", "No current app context available") 
            } 
        }) 
    } 


    // Send resume activity to LTW 
    private fun sendResumeActivity() { 
        val appContext = AppContext().apply { 
            this.contextId = generateContextId() 
            this.appId = applicationContext.packageName 
            this.createTime = System.currentTimeMillis() 
            this.lastUpdatedTime = System.currentTimeMillis() 
            this.type = ProtocolConstants.TYPE_RESUME_ACTIVITY 
        } 

        _currentAppContext.value = appContext 
        AppContextManager.sendAppContext(this.applicationContext, appContext, appContextResponse) 
    } 

    // Delete resume activity from LTW 
    private fun deleteResumeActivity() { 
        currentAppContext.value?.let { 
            AppContextManager.deleteAppContext( 
                this.applicationContext, 
                it.contextId, 
                appContextResponse 
            ) 
            _currentAppContext.value = null 
        } ?: run { 
            Toast.makeText(this, "No resume activity to delete", Toast.LENGTH_SHORT).show() 
            Log.d("MainActivity", "No resume activity to delete") 
        }
    } 

    private fun updateResumeActivity() { 
        currentAppContext.value?.let { 
            it.lastUpdatedTime = System.currentTimeMillis() 
            AppContextManager.sendAppContext(this.applicationContext, it, appContextResponse) 
            _currentAppContext.postValue(it) 
        } ?: run { 
            Toast.makeText(this, "No resume activity to update", Toast.LENGTH_SHORT).show() 
            Log.d("MainActivity", "No resume activity to update") 
        } 
    } 

    private fun setButtonDisabled(button: Button) { 
        button.isEnabled = false 
        button.alpha = 0.5f 
    } 

    private fun setButtonEnabled(button: Button) { 
        button.isEnabled = true 
        button.alpha = 1.0f 
    } 

    override fun onDestroy() { 
        super.onDestroy() 
        // Deinitialize the AppContextManager 
        AppContextManager.deInitialize(this.applicationContext) 
    } 

    override fun onStart() { 
        super.onStart() 
        // AppContextManager.initialize(this.applicationContext, appContextEventHandler) 
    } 


    override fun onStop() { 
        super.onStop() 
        // AppContextManager.deInitialize(this.applicationContext) 
    } 

    private fun generateContextId(): String { 
        return "${packageName}.${UUID.randomUUID()}" 
    } 

} 

集成验证步骤

若要验证应用程序中 Continuity SDK 的集成,请执行以下步骤:

制备

若要准备集成验证,需要执行以下步骤:

  1. 确保已安装专用 LTW。

  2. 将 LTW 连接到电脑:

    查看 如何在电脑上管理移动设备 的指南以获取说明。

    注释

    如果在扫描 QR 码后未重定向到 LTW,请先打开 LTW 并扫描应用中的 QR 码。

  3. 验证合作伙伴应用是否已集成 Continuity SDK。

Validation

接下来,按照以下步骤验证集成:

  1. 启动应用并初始化 SDK。 确认调用 onContextRequestReceived
  2. 调用 onContextRequestReceived 后,应用可以将 AppContext 发送到 LTW。 如果在发送 AppContext 后调用 onContextResponseSuccess,则 SDK 集成成功。
  3. 如果应用在电脑锁定或断开连接时发送 AppContext ,请验证 onContextResponseError 是否使用“电脑未连接”调用。
  4. 还原连接后,请确保再次调用 onContextRequestReceived ,然后应用可以将当前 AppContext 发送到 LTW。

下面的屏幕截图显示了当电脑断开连接时出现的错误消息“电脑未连接”,以及在重新连接后日志中对 onContextRequestReceived 再次调用的条目。

Windows 日志条目的屏幕截图,显示了电脑未连接错误和重新连接后的 onContextRequestReceived 日志条目。

AppContext

XDR 将 AppContext 定义为元数据,通过该元数据 XDR 可以了解要恢复的应用以及必须恢复应用程序的上下文。 应用可以使用功能,使用户能够在多个设备间回到他们在应用中进行的操作。 只要这些设备已预配跨设备体验主机(CDEH),任何移动应用创建的活动才会显示在用户的 Windows 设备上。  

每个应用程序都是不同的,Windows 负责理解目标应用程序的恢复状态,而 Windows 上的特定应用程序则负责理解上下文。 XDR 建议使用泛型架构,以满足所有第一方和第三方应用恢复方案的要求。

contextId

  • 必需:是
  • 说明:这是用于区分一个 AppContext 与另一个 AppContext 的唯一标识符。 它可确保每个 AppContext 都是唯一可识别的。
  • 用法:确保为每个 AppContext 生成唯一的 contextId,以避免冲突。

类型

  • 必需:是
  • 说明:这是一个表示二进制标志,指示要发送到“Link to Windows (LTW)”的 AppContext 类型。 该值应与 requestedContextType 保持一致。
  • 用法:根据要发送的上下文类型设置此标志。 例如,ProtocolConstants.TYPE_RESUME_ACTIVITY

createTime

  • 必需:是
  • 说明:此时间戳表示 AppContext 的创建时间。
  • 用法:记录 创建 AppContext 的确切时间。

intentUri

  • 必需:否,如果提供了 Web 链接
  • 说明:此 URI 指示哪些应用可以继续从原始设备移交的 AppContext
  • 用法:如果要指定特定应用来处理上下文,请提供此项。
  • 必需:否,如果在提供了 intentUri 的情况下
  • 说明:如果应用程序选择不使用应用商店应用,则此 URI 用于启动应用程序的 Web 终结点。 仅当未提供 intentUri 时,才使用此参数。 如果两者均已提供,intentUri将用于在 Windows 上恢复应用程序。
  • 用法:仅当应用程序想要在 Web 终结点上恢复而不是存储应用程序时使用。

appId

  • 必需:是
  • 说明:这是上下文所针对的应用程序的包名称。
  • 用法:将其设置为应用程序的包名称。

标题

  • 必需:是
  • 说明:这是 AppContext 的标题,例如文档名称或网页标题。
  • 用法:提供表示 AppContext 的有意义的标题。

预览

  • 必需:否
  • 说明:这些是可以表示 AppContext 的预览图像的字节。
  • 用法:提供预览图像(如果可用于为用户提供 AppContext 的可视表示形式)。

生命周期

  • 必需:否
  • 说明:这是毫秒的 AppContext 生存期。 它仅用于正在进行的场景。 如果未设置,则默认值为 5 分钟。
  • 用法:设置此项以定义 AppContext 应有效的时长。 最多可以设置 5 分钟的值。 任何更大的值将自动缩短为 5 分钟。

意图 URI

URI 允许启动其他应用来执行特定任务,从而启用有用的应用间的交互方案。 有关使用 URI 启动应用的详细信息,请参阅 启动 URI 的默认 Windows 应用创建指向应用内容的深层链接 |Android 开发人员

在 Windows 中处理 API 响应

本部分介绍如何在 Windows 应用程序中处理 API 响应。 Continuity SDK 提供了一种处理 Win32、UWP 和 Windows 应用 SDK 应用的 API 响应的方法。

Win32 应用示例

对于处理协议 URI 启动的 Win32 应用,需要执行以下步骤:

  1. 首先,需要在注册表中添加一个条目,如下:

    [HKEY_CLASSES_ROOT\partnerapp] 
    @="URL:PartnerApp Protocol" 
    "URL Protocol"="" 
    
    [HKEY_CLASSES_ROOT\partnerapp\shell\open\command] 
    @="\"C:\\path\\to\\PartnerAppExecutable.exe\" \"%1\"" 
    
  2. 必须在 Win32 应用的主要功能中处理启动:

    #include <windows.h> 
    #include <shellapi.h> 
    #include <string> 
    #include <iostream> 
    
    int CALLBACK wWinMain(HINSTANCE, HINSTANCE, PWSTR lpCmdLine, int) 
    { 
        // Check if there's an argument passed via lpCmdLine 
        std::wstring cmdLine(lpCmdLine); 
        std::wstring arguments; 
    
        if (!cmdLine.empty()) 
        { 
            // Check if the command-line argument starts with "partnerapp://", indicating a URI launch 
            if (cmdLine.find(L"partnerapp://") == 0) 
            { 
                // This is a URI protocol launch 
                // Process the URI as needed 
                // Example: Extract action and parameters from the URI 
                arguments = cmdLine;  // or further parse as required 
            } 
            else 
            {
                // Launched by command line or activation APIs 
            } 
        } 
        else 
        { 
            // Handle cases where no arguments were passed 
        } 
    
        return 0; 
    } 
    

UWP 应用

对于 UWP 应用,协议 URI 可以在项目的应用清单中注册。 以下步骤演示如何在 UWP 应用中处理协议激活。

  1. 首先,协议 URI 在 Package.appxmanifest 文件中注册,如下所示:

    <Applications> 
            <Application Id= ... > 
                <Extensions> 
                    <uap:Extension Category="windows.protocol"> 
                      <uap:Protocol Name="alsdk"> 
                        <uap:Logo>images\icon.png</uap:Logo> 
                        <uap:DisplayName>SDK Sample URI Scheme</uap:DisplayName> 
                      </uap:Protocol> 
                    </uap:Extension> 
              </Extensions> 
              ... 
            </Application> 
       <Applications> 
    
  2. 接下来,在 App.xaml.cs 文件中重写 OnActivated 方法,如下所示:

    public partial class App 
    { 
       protected override void OnActivated(IActivatedEventArgs args) 
      { 
          if (args.Kind == ActivationKind.Protocol) 
          { 
             ProtocolActivatedEventArgs eventArgs = args as ProtocolActivatedEventArgs; 
             // TODO: Handle URI activation 
             // The received URI is eventArgs.Uri.AbsoluteUri 
          } 
       } 
    } 
    

有关在 UWP 应用中处理 URI 启动的详细信息,请参阅 处理 URI 激活中的步骤 3。

WinUI 示例

以下代码片段演示如何使用 Windows 应用 SDK 处理 C++ WinUI 应用中的协议激活:

void App::OnActivated(winrt::Windows::ApplicationModel::Activation::IActivatedEventArgs const& args) 
{ 
     if (args.Kind() == winrt::Windows::ApplicationModel::Activation::ActivationKind::Protocol) 
     { 
         auto protocolArgs = args.as<winrt::Windows::ApplicationModel::Activation::ProtocolActivatedEventArgs>(); 
         auto uri = protocolArgs.Uri(); 
         std::wstring uriString = uri.AbsoluteUri().c_str(); 
         //Process the URI as per argument scheme 
     } 
} 

使用 Web 链接将启动应用程序的 Web 终结点。 应用开发人员需要确保从其 Android 应用程序提供的 Web 链接有效,因为 XDR 将使用系统的默认浏览器重定向到提供的 Web 链接。

处理从跨设备恢复获取的参数

每个应用负责反序列化和解密收到的参数,并相应地处理信息,以便将正在进行的上下文从手机传输到电脑。 例如,如果需要转移呼叫,应用必须能够从手机传达该上下文,桌面应用必须正确理解该上下文并继续加载。