blob: 77ea77cff3bc0c6a490fa2f391c84794c2b50f9e [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Listens for download events and provides corresponding
* notifications in ChromeVox.
*/
import {TestImportManager} from '/common/testing/test_import_manager.js';
import {Msgs} from '../common/msgs.js';
import {SettingsManager} from '../common/settings_manager.js';
import {QueueMode} from '../common/tts_types.js';
import {Output} from './output/output.js';
type DownloadDelta = chrome.downloads.DownloadDelta;
type DownloadItem = chrome.downloads.DownloadItem;
import DownloadState = chrome.downloads.State;
interface DownloadInfo {
fileName: string;
notifyProgressId: number;
time: number;
percentComplete: number;
}
/** Handles all download events and notifications for ChromeVox. */
export class DownloadHandler {
/**
* Maps download item ID to an object containing its file name and progress
* update function.
*/
private downloadItemData_: Record<number, DownloadInfo> = {};
static instance: DownloadHandler;
/**
* Performs initialization. Populates downloadItemData_ object and registers
* event listener for chrome.downloads.onChanged events.
*/
static init() {
DownloadHandler.instance = new DownloadHandler();
// Populate downloadItemData_.
// Retrieve 20 most recent downloads sorted by most recent start time.
chrome.downloads.search(
{orderBy: ['-startTime'], limit: FILE_LIMIT},
(results: DownloadItem[]) =>
DownloadHandler.instance.populateDownloadItemData_(results));
// Note: No event listener for chrome.downloads.onCreated because
// onCreated does not actually correspond to when the download starts;
// it corresponds to when the user clicks the download button, which
// sometimes leads to a screen where the user can decide where to save the
// download.
// Fired when any of a DownloadItem's properties, except bytesReceived and
// estimatedEndTime, change. Only contains properties that changed.
chrome.downloads.onChanged.addListener(
(item: DownloadDelta) => DownloadHandler.instance.onChanged_(item));
}
/**
* Notifies user of download progress for file.
* @param id The ID of the file we are providing an update for.
*/
private notifyProgress_(id: number): void {
chrome.downloads.search(
{id},
(results: DownloadItem[]) => this.notifyProgressResults_(results));
}
notifyProgressResults_(results: DownloadItem[]) {
if (results?.length !== 1) {
return;
}
// Results should have only one item because IDs are unique.
const updatedItem = results[0];
const storedItem = this.downloadItemData_[updatedItem.id];
const percentComplete =
Math.round((updatedItem.bytesReceived / updatedItem.totalBytes) * 100);
const percentDelta = percentComplete - storedItem.percentComplete;
// Convert time delta from milliseconds to seconds.
const timeDelta = Math.round((Date.now() - storedItem.time) / 1000);
// Calculate notification score for this download.
// This equation was determined by targeting 30 seconds and 50% complete
// as reasonable milestones before giving an update.
const score = percentDelta + (5 / 3) * timeDelta;
// Only report downloads that have scores above the threshold value.
if (score > UPDATE_THRESHOLD) {
// Update state.
storedItem.time = Date.now();
storedItem.percentComplete = percentComplete;
// Determine time remaining and units.
if (!updatedItem.estimatedEndTime) {
return;
}
const endTime = new Date(updatedItem.estimatedEndTime);
let timeRemaining = Math.round((endTime.getTime() - Date.now()) / 1000);
let timeUnit = '';
if (!timeRemaining || (timeRemaining < 0)) {
return;
} else if (timeRemaining < 60) {
// Seconds. Use up until 1 minute remaining.
timeUnit = Msgs.getMsgWithCount('seconds', timeRemaining);
} else if (timeRemaining < 3600) {
// Minutes. Use up until 1 hour remaining.
timeRemaining = Math.floor(timeRemaining / 60);
timeUnit = Msgs.getMsgWithCount('minutes', timeRemaining);
} else if (timeRemaining < 36000) {
// Hours. Use up until 10 hours remaining.
timeRemaining = Math.floor(timeRemaining / 3600);
timeUnit = Msgs.getMsgWithCount('hours', timeRemaining);
} else {
// If 10+ hours remaining, do not report progress.
return;
}
const optSubs: string[] = [
storedItem.percentComplete.toString(),
storedItem.fileName,
timeRemaining.toString(),
timeUnit.toString(),
];
this.speechAndBrailleOutput_(
'download_progress', QueueMode.FLUSH, optSubs);
}
}
private onChanged_(delta: DownloadDelta): void {
// The type of notification ChromeVox reports can be inferred based on the
// available properties, as they have been observed to be mutually
// exclusive.
const name = delta.filename;
const state = delta.state;
const paused = delta.paused;
// The ID is always set no matter what.
const id = delta.id;
const storedItem = this.downloadItemData_[id];
// New download if we're not tracking the item and if the filename was
// previously empty.
if (!storedItem && name?.previous === '') {
this.startTrackingDownloadDelta_(delta);
// Speech and braille output.
const optSub = this.downloadItemData_[id].fileName;
this.speechAndBrailleOutput_(
'download_started', QueueMode.FLUSH, [optSub]);
} else if (state) {
const currentState = state.current;
let msgId = '';
// Only give notification for COMPLETE and INTERRUPTED.
// IN_PROGRESS notifications are given by notifyProgress function.
if (currentState === DownloadState.COMPLETE) {
msgId = 'download_completed';
} else if (currentState === DownloadState.INTERRUPTED) {
msgId = 'download_stopped';
} else {
return;
}
const optSubs = [storedItem.fileName];
clearInterval(storedItem.notifyProgressId);
delete this.downloadItemData_[id];
// Speech and braille output.
this.speechAndBrailleOutput_(msgId, QueueMode.FLUSH, optSubs);
} else if (paused) {
// Will be either resumed or paused.
let msgId = 'download_resumed';
const optSubs = [storedItem.fileName];
if (paused.current === true) {
// Download paused.
msgId = 'download_paused';
clearInterval(storedItem.notifyProgressId);
} else {
// Download resumed.
storedItem.notifyProgressId = setInterval(
() => this.notifyProgress_(id), INTERVAL_TIME_MILLISECONDS);
storedItem.time = Date.now();
}
// Speech and braille output.
this.speechAndBrailleOutput_(msgId, QueueMode.FLUSH, optSubs);
}
}
private populateDownloadItemData_(results: DownloadItem[]): void {
if (!results || results.length === 0) {
return;
}
for (const item of results) {
// If download is in progress, start tracking it.
if (item.state === DownloadState.IN_PROGRESS) {
this.startTrackingDownloadItem_(item);
}
}
}
/**
* Output download notification as speech and braille.
* @param msgId The msgId for Output.
* @param queueMode The queue mode.
* @param optSubs Substitution strings.
*/
private speechAndBrailleOutput_(
msgId: string, queueMode: QueueMode, optSubs: string[]): void {
if (SettingsManager.get('announceDownloadNotifications')) {
const msg = Msgs.getMsg(msgId, optSubs);
new Output().withString(msg).withQueueMode(queueMode).go();
}
}
/**
* Store item data.
* @param item The download item to track.
*/
private startTrackingDownloadItem_(item: DownloadItem): void {
const id = item.id;
// Don't add if we are already tracking file.
if (this.downloadItemData_[id]) {
return;
}
const fullPath = item.filename;
const fileName = fullPath.substring(fullPath.lastIndexOf('/') + 1);
const notifyProgressId =
setInterval(() => this.notifyProgress_(id), INTERVAL_TIME_MILLISECONDS);
let percentComplete = 0;
if (item.bytesReceived && item.totalBytes) {
percentComplete =
Math.round((item.bytesReceived / item.totalBytes) * 100);
}
this.downloadItemData_[id] =
{fileName, notifyProgressId, time: Date.now(), percentComplete};
}
/**
* Store item data.
* @param item The download item to track.
*/
private startTrackingDownloadDelta_(item: DownloadDelta) {
const id = item.id;
// Don't add if we are already tracking file.
if (this.downloadItemData_[id]) {
return;
}
const fullPath = item.filename?.current;
const fileName =
fullPath ? fullPath.substring(fullPath.lastIndexOf('/') + 1) : '';
const notifyProgressId =
setInterval(() => this.notifyProgress_(id), INTERVAL_TIME_MILLISECONDS);
this.downloadItemData_[id] =
{fileName, notifyProgressId, time: Date.now(), percentComplete: 0};
}
}
// Local to module.
/**
* Threshold value used when determining whether to report an update to user.
*/
const UPDATE_THRESHOLD = 100;
/** The limit for the number of download results to receive when querying. */
const FILE_LIMIT = 20;
/** The time interval, in milliseconds, for calling notifyProgress. */
const INTERVAL_TIME_MILLISECONDS = 10000;
TestImportManager.exportForTesting(DownloadHandler);