通过


电话链接 - 无缝任务连续性

安装了“链接到 Windows”包的 Android 移动设备可以编程方式共享 Android 应用中的最新任务,以在 Windows 电脑上继续执行(例如网站 URL、文档链接、音乐曲目等)。

跨设备任务连续性正在演变为使用 Continuity SDK 提供与 Windows 任务栏的更深入的本机集成,以自然和直观的方式更好地为客户提供服务。 尽管仍支持电话链接任务连续性应用的原始实现,但对于新实现,我们建议在适用于 Windows 任务栏集成的 Continuity SDK 中使用跨设备恢复(XDR)。 了解详细信息:使用 Continuity SDK(Android 和 Windows 应用程序)的跨设备恢复(XDR)。

Continuity SDK 支持使用跨设备恢复(XDR)实现更无缝的跨设备体验,其中显示任务延续图标可帮助你直接从 Windows 任务栏恢复最近的 Android 设备任务(无需依赖电话链接应用界面)。

了解如何以编程方式将 Android 应用(例如网站 URL、文档链接、音乐曲目等)中的最近任务共享到 已设置手机链接的 Windows 电脑。 此功能仅适用于 支持电话链接体验的设备

方案要求

Android 应用必须满足以下条件才能访问“链接到 Windows”任务连续性:

  • 请同步 Windows 电脑可访问的有效 Web URL
  • DO 同步云文档链接,供 Windows 电脑访问
  • 务必将本地文档链接同步到必须通过你的应用在移动设备上访问的 Windows 电脑
  • 请勿每分钟同步 60 次以上
  • 如果用户未参与应用体验,则请勿同步内容

电话链接将在“最近使用”和“最近使用的网站”下的“应用”节点和通知浮出控件中显示同步的内容。

最近使用的应用和网站的手机连接屏幕截图

使用 Continuity SDK(Android 和 Windows 应用程序)的跨设备恢复(XDR) 将在 Windows 任务栏上显示同步的内容。

Windows 任务栏屏幕截图

受限访问功能 (LAF) 审批

电话链接任务连续性是受限访问功能(LAF)。 若要获取访问权限,需要从Microsoft获得批准,以便与 Android 移动设备上预加载的“链接到 Windows”包进行互作。

若要请求访问权限,请向 wincrossdeviceapi@microsoft.com 发送电子邮件并附带下列信息。

  • 用户体验说明
  • 用户在本地访问 Web 或文档的应用程序的屏幕截图
  • 应用程序的 PackageId
  • 应用程序的 Google Play 商店链接

如果请求获得批准,则会收到有关如何解锁该功能的说明。 审批将以你的通信信息作为依据,但前提是你的场景能满足上文概述的场景要求

数据处理

通过使用电话链接任务连续性,Microsoft将根据 Microsoft服务协议Microsoft隐私声明处理和传输数据。 传输到用户的已链接设备的数据可能会通过 Microsoft 的云服务进行处理,从而确保各设备之间的可靠数据传输。 此 API 所处理的数据不会由受最终用户控制约束的 Microsoft 的云服务来保留。

在应用包中集成的 Continuity SDK 可确保提供给 API 的数据仅由受信任的Microsoft包处理。

下面是集成的一般准则和代码示例。 有关详细的集成指南,请参阅 SDK 的 Kotlin 文档。

Android 应用清单声明

应用清单是一个 XML 文件,用作 Android 应用的蓝图。 声明文件向作系统提供有关应用的结构、组件、权限等的信息。对于任务连续性,需要以下声明才能使用“链接到 Windows”。

功能元数据

合作伙伴应用需要首先在应用清单中注册元数据。

若要参与应用上下文协定,必须为支持的应用上下文类型声明元数据。 例如,若要为 应用切换 功能添加应用上下文提供程序元数据,请执行以下作:

<application...>
<meta-data
android:name="com.microsoft.crossdevice.applicationContextProvider"
android:value="true" />
</application>

如果应用支持多个类型的应用上下文,则必须添加每种类型的元数据。 当前支持的元数据类型包括:

<meta-data
android:name="com.microsoft.crossdevice.browserContextProvider"
android:value="true" />

<meta-data
android:name="com.microsoft.crossdevice.applicationContextProvider"
android:value="true" />

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

若要添加新类型,元数据名称格式应为“com.microsoft.crossdevice.xxxProvider”。

应用还必须在清单中声明触发器类型元数据。 这些声明可帮助系统确定应用应如何以及何时通知 Load-Time 编织(LTW)某些功能处于活动状态。

对于自我通知触发器,应用本身负责通知系统并在所有设备上启用,而不考虑原始设备制造商(OEM),应将触发器类型声明为:

<application ...
<meta-data
android:name="com.microsoft.crossdevice.trigger.PartnerApp"
android:value="the sum value of all features' binary codes" />

</application>

对于系统 API 触发器,应用依赖系统 API 触发“链接到 Windows”功能,仅在特定 OEM 设备上启用,触发器类型应声明为:

<application ...
<meta-data
android:name="com.microsoft.crossdevice.trigger.SystemApi"
android:value="the sum value of all features' binary codes" />

</application>

功能二进制代码现在为:

APPLICATION_CONTEXT: 1
BROWSER_HISTORY:     2
RESUME_ACTIVITY:     4

应用清单注册可能如以下示例所示:

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

    <application … 
 
       <!-- 
           This is the meta-data represents this app supports XDR, LTW will check  
           the package before we request app context. 
       --> 
       <meta-data 
                android:name="com.microsoft.crossdevice.resumeActivityProvider" 
                android:value="true" />

             <!-- 
           This is the meta-data represents this app supports trigger from app, the
           Value is the code of XDR feature, LTW will check if the app support partner
           app trigger when receiving trigger broadcast.
           --> 
       <meta-data 
                android:name="com.microsoft.crossdevice.trigger.PartnerApp" 
                android:value="4" />

    </application>  
</manifest> 

用于发送应用上下文的代码示例

添加应用清单声明后,“链接到 Windows”合作伙伴应用需要:

  1. 确定调用 Continuity SDK 的 Initialize 和 DeInitialize 函数 的适当时机。 调用 Initialize 函数后,应触发实现的 IAppContextEventHandler 回调。

  2. 初始化 Continuity SDK 后,如果 onContextRequestReceived() 调用,则表示已建立连接。 然后,应用可以将(包括创建和更新) 发送到 AppContext LTW,或者从 LTW 中删除 AppContext

请务必避免在访问令牌等中 AppContext发送任何敏感数据。 此外,如果生存期设置太短,则可能 AppContext 在发送到电脑之前过期。 建议至少设置 5 分钟的最低生存期。

class MainActivity : AppCompatActivity() {

    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()
            }
        }
    }

    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
        val buttonSend: Button = findViewById(R.id.buttonSend)
        val buttonDelete: Button = findViewById(R.id.buttonDelete)
        val buttonUpdate: Button = findViewById(R.id.buttonUpdate)
        setButtonDisabled(buttonSend)
        setButtonDisabled(buttonDelete)
        setButtonDisabled(buttonUpdate)
        buttonSend.setOnClickListener {
            if (ready) {
                sendAppContext()
            }
        }
        buttonDelete.setOnClickListener {
            if (ready) {
                deleteAppContext()
            }
        }
        buttonUpdate.setOnClickListener {
            if (ready) {
                updateAppContext()
            }
        }
        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 app context to LTW
    private fun sendAppContext() {
        val appContext = AppContext().apply {
            this.contextId = generateContextId()
            this.appId = applicationContext.packageName
            this.createTime = System.currentTimeMillis()
            this.lastUpdatedTime = System.currentTimeMillis()
            // Set the type of app context, for example, resume activity.
            this.type = ProtocolConstants.TYPE_RESUME_ACTIVITY
            // Set the rest fields in appContext
            //……
        }
        _currentAppContext.value = appContext
        AppContextManager.sendAppContext(this.applicationContext, appContext, appContextResponse)
    }

    // Delete app context from LTW
    private fun deleteAppContext() {
        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")
        }
    }

    // Update app context from LTW
    private fun updateAppContext() {
        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)
    }

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

有关所有 必需 字段和 可选 字段,请参阅 AppContext 说明

AppContext 说明

发送应用上下文时,合作伙伴应用应提供以下值:

密钥 额外的信息
contextId [必需] 用于将其与其他应用上下文区分开来。 每个应用上下文的唯一性。格式:“${packageName}.${UUID.randomUUID()}”
类型 [必需] 用于表示发送到 LTW 的应用上下文类型的二进制标志。 该值应与上述 requestedContextType 保持一致
createTime[必需] [FR1] 表示应用上下文创建时间的时间戳。
lastUpdatedTime[required] 表示应用上下文的上次更新时间的时间戳。 每当更新应用上下文的任意字段时,均需记录更新时间。
teamId [可选] 用于标识应用所属的组织或组。
intentUri [可选] 用于指示哪个应用可继续处理从原始设备移交的应用上下文。 最大长度为 2083 个字符。
appId [可选] 该上下文所针对的应用程序包。
title[optional] 此应用上下文的标题,例如文档名称或网页标题。
weblink[可选] 要在浏览器中进行加载以便继续处理应用上下文的网页的 URL。 最大长度为 2083 个字符。
preview[可选] 可表示应用上下文的预览图像的字节数
extras[optional] 一个键值对对象,其中包含在继续处理设备上继续处理应用上下文所需的应用特定状态信息。 当应用上下文具有其唯一数据时,需提供。
LifeTime[可选] 应用上下文的生存期(以毫秒为单位)。 仅用于正在进行的方案(如果未设置),默认值为 30 天。

浏览器连续性代码示例

此示例突出显示了 浏览器连续性 类型的用法,该类型与其他 AppContext 类型不同。

class MainActivity : AppCompatActivity() {

    private val appContextResponse = object : IAppContextResponse {
        override fun onContextResponseSuccess(response: AppContext) {
            Log.d("MainActivity", "onContextResponseSuccess")
        }

        override fun onContextResponseError(response: AppContext, throwable: Throwable) {
            Log.d("MainActivity", "onContextResponseError: ${throwable.message}")
        }
    }

    private lateinit var appContextEventHandler: IAppContextEventHandler

    private val browserHistoryContext: BrowserHistoryContext = BrowserHistoryContext()


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //……
        LogUtils.setDebugMode(true)
        var ready = false
        val buttonSend: Button = findViewById(R.id.buttonSend)
        val buttonDelete: Button = findViewById(R.id.buttonDelete)
        setButtonDisabled(buttonSend)
        setButtonDisabled(buttonDelete)
        buttonSend.setOnClickListener {
            if (ready) {
                sendBrowserHistory ()
            }
        }
        buttonDelete.setOnClickListener {
            if (ready) {
                clearBrowserHistory ()
            }
        }
        appContextEventHandler = object : IAppContextEventHandler {
            override fun onContextRequestReceived(contextRequestInfo: ContextRequestInfo) {
                LogUtils.d("MainActivity", "onContextRequestReceived")
                ready = true
                setButtonEnabled(buttonSend)
                setButtonEnabled(buttonDelete)
            }

            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)
    }

    // Send browser history to LTW
    private fun sendBrowserHistory () {
        browserHistoryContext.setAppId(this.packageName)
        browserHistoryContext.addBrowserContext(System.currentTimeMillis(),
             Uri.parse("https://www.bing.com/"), "Bing Search", null
        )
        AppContextManager.sendAppContext(this.applicationContext, browserHistoryContext, appContextResponse)

    }

    // Clear browser history from LTW
         private fun clearBrowserHistory() {
        browserHistoryContext.setAppId(this.packageName)
        browserHistoryContext.setBrowserContextEmptyFlag(true)
        AppContextManager.sendAppContext(this.applicationContext, browserHistoryContext, appContextResponse)
    }

    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)
    }

    //……
}

有关所有 必需 字段和 可选 字段,请参阅 BrowserContext 说明

BrowserContext 说明

合作伙伴应用可以调用 addBrowserContext 该方法来添加浏览器历史记录。 添加浏览器历史记录时应提供以下值:

密钥
browserWebUri [必需] 将在电脑上的浏览器中打开的 Web URI(http: 或 https:)。
title [必需] 网页的标题。
timestamp [必需] 首次打开或上次刷新网页的时间戳。
favIcon [可选] 网页的网站图标(以字节为单位),且通常应较小。

集成验证步骤

  1. 通过确保安装专用 LTW 来准备。 确认 LTW 已连接到电脑: 如何在电脑上管理移动设备。 确认 LTW 已连接到电话链接: 电话链接要求和设置。 如果在扫描 QR 码后,无法跳转到 LTW,请先打开 LTW 并扫描应用中的 QR 码。 最后,验证合作伙伴应用是否已集成 Continuity SDK。

  2. 通过启动应用并初始化 Continuity SDK 进行验证。 确认是否已 onContextRequestReceived() 调用。 调用后 onContextRequestReceived() ,应用可以将应用上下文发送到 LTW。 如果在 onContextResponseSuccess() 发送应用上下文后调用,则 SDK 集成成功。

GitHub 上的 Windows 跨设备存储库

GitHub 上的 Windows 跨设备存储库中查找有关将 Windows 跨设备 SDK 集成到项目中的信息。

有关常见问题解答的列表,请参阅 Phone Link 常见问题