在三星Galaxy手机上的三星互联网浏览器应用中,文件下载提示未显示。

3

背景和问题陈述
我们目前正在开发一个具有API后端的单页应用程序。该API向单页应用程序提供在服务器上按需动态生成的文件。有些文件生成所需的时间很短,而其他文件需要更长时间(大于5秒)。现在我们面临一个问题,即在三星Galaxy手机上的"Samsung Internet"浏览器中无法下载生成时间超过5秒的文件。在这些设备上,没有显示保存文件的提示。实际上,文件确实被下载了,但是设备上没有显示保存文件的提示。因此,用户无法在这些设备上打开或存储文件。

我们测试过的所有桌面浏览器以及三星Galaxy手机上的Chrome浏览器都可以正常下载文件。只是在三星Galaxy手机上的"Samsung Internet"浏览器中无法正常工作。

我的三星Galaxy手机和"Samsung Internet"浏览器的版本信息:

  • 型号:三星Galaxy S22+
  • One UI 版本:5.1
  • Android 版本:13
  • "Samsung Internet" 应用版本:21.0.0.41(这是"Samsung Internet"应用的最新版本)
如何重现 我们从我们的项目中提取了相关代码,并创建了一个简单的Web服务器和客户端来演示问题。您可以按照以下步骤重现此问题:
  • 克隆存储库:https://github.com/tobias-graf-p/file-download-issue
  • 进入server文件夹,并在计算机上启动服务器。请查阅server文件夹内的README文件以获取详细说明。
  • 进入client-js文件夹,并从计算机上提供客户端服务。请查阅client-js文件夹内的README文件以获取详细说明。
  • 现在通过导航到http://localhost:8081在计算机上打开客户端。您应该可以下载两个文件。文件1将立即提供,而文件2将延迟6秒后提供。
  • 现在使用与计算机连接到同一网络的三星Galaxy手机。使用"Samsung Internet"浏览器导航至http://your-local-ip-address:8081(将your-local-ip-address替换为实际计算机的本地IP地址)。在这里,您应该能够下载文件1。但是,如果尝试下载文件2,则没有任何反应(6秒后)。"Samsung Internet"浏览器不会向用户显示打开或保存文件2的提示(就像对文件1的处理方式一样)。
如何调试 您可以按照以下步骤来调试在您手机上运行的客户端:
  • 在您的三星Galaxy手机上启用USB调试功能:
    • 进入设置 > 关于手机 > 软件信息
    • 点击版本号七次
    • 进入设置 > 开发者选项
    • 启用USB调试
  • 使用Google Chrome检查客户端:
    • 通过USB线将手机连接到电脑。
    • 在电脑上的Google Chrome浏览器中输入chrome://inspect/
    • 在手机上确认权限请求。
    • 等待设备出现在电脑上的Chrome浏览器中。
    • 在手机的"Samsung Internet"浏览器中导航至http://your-local-ip-address:8081(将your-local-ip-address替换为实际的本地IP地址)。
    • 等待页面在电脑上的Chrome浏览器中显示。
    • 点击inspect
这将打开一个新的Chrome浏览器窗口,显示您手机上运行的客户端。现在您可以打开Chrome开发者工具(F12),分析客户端的网络流量和控制台输出。您会看到文件2确实被发送到客户端,但手机上没有显示任何提示。 代码片段 所有复现问题所需的相关代码都可以在此Github存储库中找到:https://github.com/tobias-graf-p/file-download-issue 作为参考,我在这里发布相关的代码片段: 用于提供文件的代码(服务器),完整文件
function serveFile(res, filePath, fileName, contentType) {
  const contentDisposition = `attachment; filename="${encodeURIComponent(fileName)}"`;
  res.setHeader('Content-Disposition', contentDisposition);
  res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
  res.setHeader('Content-Type', contentType);
  res.setHeader('Access-Control-Allow-Origin', '*');
  const fileStream = fs.createReadStream(filePath);
  fileStream.pipe(res);
}

const server = http.createServer((req, res) => {
  if (req.url === '/file1') {
    const filePath = path.join(__dirname, 'file1.txt');
    serveFile(res, filePath, 'file1.txt', 'text/plain');
  } else if (req.url === '/file2') {
    setTimeout(() => {
      const filePath = path.join(__dirname, 'file2.txt');
      serveFile(res, filePath, 'file2.txt', 'text/plain');
    }, 6000);
  } else {
    res.statusCode = 404;
    res.end('Not found');
  }
});

下载文件的代码(客户端),完整文件

async function downloadFile(endpoint) {
  console.log('downloadFile()');
  console.log('endpoint', endpoint);

  const response = await fetch(endpoint);
  console.log('response', response);

  const blob = await response.blob();
  console.log('blob', blob);

  const url = URL.createObjectURL(blob);
  console.log('url', url);

  const contentDispositionHeader = response.headers.get('Content-Disposition');
  const fileName = getFileName(contentDispositionHeader);
  console.log('fileName', fileName);

  const downloadLinkTag = document.createElement('a');
  downloadLinkTag.href = url;
  downloadLinkTag.download = fileName;

  console.log('before click');
  downloadLinkTag.click();
  console.log('after click');

  setTimeout(() => URL.revokeObjectURL(url), 0);
}

function getFileName(contentDispositionHeader) {
  let fileName = contentDispositionHeader
    .split(';')[1]
    .split('=')[1];
  if (fileName.startsWith('"')) {
    fileName = fileName.substring(1, fileName.length - 1);
  }
  if (fileName.endsWith('"')) {
    fileName = fileName.substring(0, fileName.length - 2);
  }
  return decodeURI(fileName);
}

在Github存储库中还有一个第二个客户端(简单的Angular应用程序),您也可以使用它来重现该问题。该客户端甚至包含了三种不同的方法来下载文件(使用带有对象URL的a标签,使用FileSaver.js和使用FileReader),这些方法都失败了(没有出现文件延迟提示)。
三种方法的代码,请参考完整文件
private downloadFile(apiUrl: string): void {
  this.http
    .get(apiUrl, { responseType: 'blob', observe: 'response' })
    .subscribe(response => {
      const fileName = this.getFileNameFromHeaders(response.headers);
      console.log('fileName', fileName);

      //
      // Approach #1: a-tag with object url
      //

      console.log('approach #1');
      const data = response.body;
      if (!data) {
        console.log('no data');
        return;
      }
      console.log('data', data);
      const url = URL.createObjectURL(data);
      console.log('url', url);
      const link = document.createElement('a');
      link.href = url;
      link.download = fileName;
      console.log('before click');
      link.click();
      console.log('after click');
      setTimeout(() => URL.revokeObjectURL(url), 0);

      //
      // Approach #2: FileSaver.js
      //

      console.log('approach #2');
      const blob = new Blob([response.body as Blob], {type: 'text/plain'});
      console.log('blob', blob);
      console.log('before saveAs');
      saveAs(blob, fileName);
      console.log('after saveAs');

      //
      // Approach #3: FileReader
      //

      console.log('approach #3');
      const reader = new FileReader();
      reader.onloadend = function(e) {
        console.log('reader.result', reader.result);
        const link = document.createElement('a');
        document.body.appendChild(link);
        link.href = reader.result as string;
        link.download = fileName;
        const clickEvent = new MouseEvent('click');
        console.log('before dispatch click event');
        link.dispatchEvent(clickEvent);
        console.log('after dispatch click event');
        setTimeout(()=> {
          document.body.removeChild(link);
        }, 0)
      }
      console.log('response.body', response.body);
      console.log('before readAsDataURL');
      reader.readAsDataURL(response.body as Blob);
      console.log('after readAsDataURL');
    });
}

private getFileNameFromHeaders(headers: HttpHeaders): string {
  const contentDisposition = headers.get('Content-Disposition');
  if (!contentDisposition) {
    return 'unknown.txt';
  }
  let fileName = contentDisposition
    .split(';')[1]
    .split('=')[1];
  if (fileName.startsWith('"')) {
    fileName = fileName.substring(1, fileName.length - 1);
  }
  if (fileName.endsWith('"')) {
    fileName = fileName.substring(0, fileName.length - 2);
  }
  return decodeURI(fileName);
}

附加信息

  • 我们发现,如果服务器需要一些时间(> 5秒)来响应,问题不仅会出现,而且如果在模拟单击锚点之前,在客户端添加超过5秒的延迟,问题也会出现。看起来,“三星互联网”浏览器在用户点击下载按钮后应用了5秒的超时(这可能不应该发生)。
  • 此外,我们发现从a标签中删除download属性实际上会使“三星互联网”浏览器显示已下载的文件。但它会在浏览器窗口中显示已下载文件的内容,这对我们来说不是一个选项。(我们希望提示用户打开或存储文件。)我们可以通过使用const data = new Blob([data as Blob], {type: 'application/octet-stream'});代替const data = response.body;来欺骗“三星互联网”浏览器再次显示提示,但是这种方法会丢失文件名。(浏览器现在要求用户使用blob guid作为文件名存储文件,这对我们来说也不是一个选项。)

问题

我们做错了什么吗?有没有其他方法可以在三星Galaxy手机上显示下载提示? 这可能是“三星互联网”浏览器的一个bug吗?
2个回答

1
我可以确认,我也能重现这个问题。三星互联网浏览器似乎在用户点击下载按钮后应用了一个5秒的硬超时。模拟点击锚点标签并不能重置这个超时。我不明白为什么浏览器要这样做。实际上,我怀疑这是三星互联网浏览器的一个错误。
作为一种解决方法,您可以在收到API响应后临时存储blob对象的URL,然后(而不是直接模拟点击指向该URL的锚点标签)为用户提供另一个按钮,在文件在客户端可用时(这次从浏览器的blob存储中)实际获取文件。这样,当用户点击第二个下载按钮时,三星互联网浏览器的5秒超时重新开始计时。当然,这将需要对UI和与应用程序的用户交互流程进行更改。但只有在用户使用三星互联网浏览器时才需要这样做。
准备下载和在第二次用户交互后获取下载的代码(基于您的client-js代码示例):
let url = '';
let fileName = '';

async function prepare(endpoint) {
  console.log('perpare()');
  console.log('endpoint', endpoint);

  const response = await fetch(endpoint);
  console.log('response', response);

  const blob = await response.blob();
  console.log('blob', blob);

  url = URL.createObjectURL(blob);
  console.log('url', url);

  const contentDispositionHeader = response.headers.get('Content-Disposition');
  fileName = getFileName(contentDispositionHeader);
  console.log('fileName', fileName);

  console.log('file from ' + endpoint + 'is ready to be downloaded via another user click');
  alert('file from ' + endpoint + 'is ready to be downloaded via another user click');
}

function download() {
  console.log('download()');
  console.log('url', url);
  console.log('fileName', fileName);

  const downloadLinkTag = document.createElement('a');
  downloadLinkTag.href = url;
  downloadLinkTag.download = fileName;

  console.log('before click');
  downloadLinkTag.click();
  console.log('after click');

  setTimeout(() => URL.revokeObjectURL(url), 0);
}

触发这些功能的按钮:
<button onclick="prepare('http://192.168.178.43:3000/file1')">Prepare File 1</button><br />
<button onclick="prepare('http://192.168.178.43:3000/file2')">Prepare File 2 (with delay)</button><br />
<button onclick="download()">Download prepared file</button>

检测当前客户端是否使用三星互联网浏览器的代码:
const isSamsungInternetBrowser = /SamsungBrowser/.test(navigator.userAgent);
if (isSamsungInternetBrowser) { ... }

1
非常感谢您的回答。我暂时不会将其标记为答案,因为我认为这只是一个“权宜之计”。同时,我也怀疑这可能是三星互联网浏览器的一个错误,所以我们可能无法做更多的事情了。我已经向三星报告了此问题,并正在等待他们的反馈。 - TOG

0
三星在三星开发者论坛上回答了我的问题,并确认这种行为确实是设计上的。用户可以通过在三星Internet浏览器的设置中禁用“浏览隐私仪表板”>“阻止自动下载”选项来禁用此行为。
三星开发者论坛的回答: https://forum.developer.samsung.com/t/file-download-prompt-not-showing-in-samsung-internet-browser-app/25740 “三星会员”的回答: 这是一个安全补丁,用于防止无限自动下载。Chromium的自动下载实现与我们的不同。Chromium根据URL存储自动下载权限。在三星Internet的情况下,我们在浏览隐私仪表板中有一个常见按钮。当开发者将下载延迟5秒或更长时间时,布尔值has_gesture为false(因为函数HasTransientUserActivation()在特定超时后重置其值),因此我们立即阻止下载。为了解决这个问题,我们要求开发者要么不延迟5秒或更长时间进行下载,要么在浏览器隐私仪表板中禁用阻止自动下载按钮。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接