返回

浏览器到App双向文件传输:链接唤起及数据处理

Android

应用链接:从浏览器到应用的双向文件传输

当应用需要通过应用链接接收浏览器分享的文件,并处理后再返回处理结果时,可能会遇到挑战。一种常见的场景是用户在浏览器中选择了文件,应用随后接管处理,完成后再给用户反馈一个处理过的文件。这里,我们探讨这种双向文件传输机制。

挑战与分析

核心问题在于如何传递文件数据,单纯的应用链接(即深层链接)只能携带文本信息,无法直接传输二进制文件内容。直接使用window.location.href跳转传递URL参数是传递文本信息的方法,但该方法不足以承载大型文件数据。问题的根本在于应用链接设计目的不是传输文件本身,而是为了唤起特定的应用并传递操作指令,例如”打开这个文件”,“使用该参数执行特定操作”。

具体来看,HTML 原型代码使用了 app://action?operation=sign&filename=${encodeURIComponent(file.name)}这样的 URL 形式,希望在应用启动的同时传递文件名和操作。然而,这种做法只能传递文件 ,并没有实际的文件数据。因此,在应用中,即使接收到了 intent,也无从获取文件内容,这就造成了问题的根本原因。使用 adb 测试应用唤起时会工作,也证明了配置 intent-filter 部分是没有问题的,问题在于如何从浏览器传递文件数据。

解决方案:中间桥梁式文件传输

一种常见且可靠的解决方案是使用一个中间媒介来存储并中转文件数据,而后,通过应用链接传递文件所在位置的信息。

通常的做法:

  1. 浏览器上传文件:
    浏览器首先将用户选中的文件上传到服务器或者临时的存储空间中。这一步可以通过 JavaScript 的 fetchXMLHttpRequest 完成。
  2. 服务器返回临时文件链接:
    服务器上传完成后会返回一个唯一的,有时限的文件链接。
  3. 构建包含文件链接的应用链接:
    接着,将这个文件链接拼接到应用链接的 URL 中。 比如 app://action?operation=sign&file_url={file_url}
  4. 应用处理和下载:
    当应用通过此应用链接启动后,从 URL 中提取文件链接。然后使用应用自身的下载模块获取服务器的文件。

浏览器端代码示例:

document.getElementById('sendButton').addEventListener('click', function() {
    const fileInput = document.getElementById('fileInput');
    if (fileInput.files.length > 0) {
        const file = fileInput.files[0];
        const formData = new FormData();
        formData.append('file', file);

        fetch('/upload', {
            method: 'POST',
            body: formData
        }).then(response => response.json())
          .then(data => {
                const fileUrl = data.url; //假设服务器返回了文件URL
               const url = `app://action?operation=sign&file_url=${encodeURIComponent(fileUrl)}`;
               window.location.href = url;
            })
         .catch(error => {
                console.error('Error uploading file:', error);
                alert('文件上传失败。')
           });
    } else {
        alert('Please select a file first.');
    }
});

后端代码示例 (假设 NodeJS Express):

const express = require('express');
const multer  = require('multer')

const app = express();
const storage = multer.diskStorage({
    destination: function (req, file, cb) {
      cb(null, 'uploads/')
    },
    filename: function (req, file, cb) {
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        cb(null, file.fieldname + '-' + uniqueSuffix)
    }
})

const upload = multer({ storage: storage })

app.post('/upload', upload.single('file'), function(req, res){
    //生成供客户端访问的文件路径(可自定义路径规则或存储在云服务器),发送客户端
    const fileUrl = `https://yourserver.com/uploads/${req.file.filename}`

    res.send(JSON.stringify({
            url: fileUrl
        })
    )
});

app.use('/uploads',express.static('uploads')); //处理静态文件访问


app.listen(3000,() => {console.log('App is listening at http://localhost:3000')});

此代码使用 multer 中间件上传文件到服务器。你需要修改文件路径及存储方案。

Android 应用代码示例:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

       handleIntent(intent)

    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        intent?.let {
             handleIntent(it)
         }
    }
    private fun handleIntent(intent: Intent) {
            intent.data?.let { uri ->
                  val fileUrl = uri.getQueryParameter("file_url")

                  fileUrl?.let {
                      //异步下载文件,需要请求网络权限
                       //处理完成后调用 sendResultFile 返回文件
                         downloadFile(it){  file->

                         // 对 file 进行一些处理
                          val resultFile = processFile(file)
                         sendResultFile(resultFile)

                       }
                  }


           }

    }

    private fun downloadFile(fileUrl: String,  callback: (File) -> Unit ){

        val request = Request.Builder()
            .url(fileUrl)
            .build()
        
        // Use your download library (for example, OkHttp or HttpURLConnection) here
        val client = OkHttpClient()
           client.newCall(request).enqueue(object: Callback {
              override fun onFailure(call: Call, e: IOException) {
                  println("download error")
              }

                override fun onResponse(call: Call, response: Response) {
                        if(response.isSuccessful)
                        {
                          val inputStream = response.body!!.byteStream()
                         // 创建临时文件

                            val tmpFile =  File(cacheDir,"downloadfile.tmp");
                           tmpFile.createNewFile()

                           val outputStream = FileOutputStream(tmpFile)
                           inputStream.copyTo(outputStream)

                          callback(tmpFile);
                           println("Download Successfully");


                        } else
                        {
                              println("response code: "+ response.code.toString());
                        }


                   }

        })
      }
    //文件处理逻辑, 处理完成后得到 resultFile
   private  fun  processFile(file :File): File {
       //  process logic
      // ...
       //
       return  File(cacheDir, "process_result.tmp")
   }

    private  fun  sendResultFile(resultFile :File)
    {
            val intent = Intent(Intent.ACTION_VIEW)

        intent.setDataAndType(
             FileProvider.getUriForFile(
            this,
             this.packageName + ".provider",  resultFile),
            "application/*" //根据需要修改
         );

         intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);


       val  resolvedActivity = packageManager.resolveActivity(intent,  PackageManager.MATCH_DEFAULT_ONLY);
        if (resolvedActivity == null)
        {
            println("no found file handle app")
            return
         }
         startActivity(intent);



    }



}

需要在AndroidManifest.xml 中定义 fileprovider

 <provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.provider"
    android:exported="false"
    android:grantUriPermissions="true">
     <meta-data
       android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/filepaths" />
</provider>

创建一个 xml/filepaths.xml

 <paths xmlns:android="http://schemas.android.com/apk/res/android">
  <cache-path name="cache" path="."/>
  <!-- 你还可以定义其他存储的共享 path-->
</paths>

需要安装OkHttp 网络请求库。

build.gradle 中增加 Okhttp 依赖:

 dependencies {
       implementation("com.squareup.okhttp3:okhttp:4.11.0")

   }

安全考量

  • 服务器端:文件上传目录应配置妥当,防止恶意上传,对上传的文件类型做校验。
  • 临时链接的有效期:设置合理的有效期,防止链接长期有效造成泄露风险。
  • 应用端: 对下载的文件校验,以防止恶意替换,考虑HTTPS 安全连接下载。

通过这种“中间桥梁”的方式,便能优雅地处理文件在浏览器和应用之间的传递和处理,并完成数据传输和交互。