chromium/chrome/browser/ash/printing/cups_print_job_manager_impl.cc

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include <cups/cups.h>

#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>

#include "base/check.h"
#include "base/check_op.h"
#include "base/compiler_specific.h"
#include "base/containers/flat_map.h"
#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/notreached.h"
#include "base/strings/strcat.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "base/timer/timer.h"
#include "chrome/browser/ash/printing/cups_print_job.h"
#include "chrome/browser/ash/printing/cups_print_job_manager.h"
#include "chrome/browser/ash/printing/cups_print_job_manager_utils.h"
#include "chrome/browser/ash/printing/cups_printers_manager.h"
#include "chrome/browser/ash/printing/cups_printers_manager_factory.h"
#include "chrome/browser/ash/printing/history/print_job_info.pb.h"
#include "chrome/browser/ash/printing/history/print_job_info_proto_conversions.h"
#include "chrome/browser/browser_process.h"
#include "chrome/browser/chromeos/printing/cups_wrapper.h"
#include "chrome/browser/printing/print_job.h"
#include "chrome/browser/printing/print_job_manager.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/browser/scalable_iph/scalable_iph_factory.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/ash/components/scalable_iph/scalable_iph.h"
#include "chromeos/printing/printing_constants.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "printing/printed_document.h"
#include "printing/printing_utils.h"
#include "ui/base/l10n/l10n_util.h"

namespace ash {

namespace {

using ::chromeos::CupsWrapper;
using StatusReason = crosapi::mojom::StatusReason::Reason;

// The rate at which we will poll CUPS for print job updates.
constexpr base::TimeDelta kPollRate = base::Milliseconds(1000);

// Threshold for giving up on communicating with CUPS.
const int kRetryMax = 6;

// Enumeration of print job results for histograms.  Do not modify!
enum JobResultForHistogram {
  UNKNOWN = 0,              // unidentified result
  FINISHED = 1,             // successful completion of job
  TIMEOUT_CANCEL = 2,       // cancelled due to timeout
  PRINTER_CANCEL = 3,       // cancelled by printer
  LOST = 4,                 // final state never received
  FILTER_FAILED = 5,        // filter failed
  CLIENT_UNAUTHORIZED = 6,  // cancelled due to client unauthorized
  RESULT_MAX
};

// Holds the print job data for recording to metrics upon print job completion.
struct PrinterMetrics {
  bool printer_manually_selected;
  std::optional<StatusReason> printer_status_reason;
};

// Returns the appropriate JobResultForHistogram for a given |state|.  Only
// FINISHED and PRINTER_CANCEL are derived from CupsPrintJob::State.
JobResultForHistogram ResultForHistogram(CupsPrintJob::State state) {
  switch (state) {
    case CupsPrintJob::State::STATE_DOCUMENT_DONE:
      return FINISHED;
    case CupsPrintJob::State::STATE_CANCELLED:
      return PRINTER_CANCEL;
    default:
      break;
  }

  return UNKNOWN;
}

void RecordJobResult(JobResultForHistogram result,
                     bool affected_by_ipp_usb_migration) {
  UMA_HISTOGRAM_ENUMERATION("Printing.CUPS.JobResult", result, RESULT_MAX);
  if (affected_by_ipp_usb_migration) {
    base::UmaHistogramEnumeration(
        "Printing.CUPS.JobResultForUsbPrintersWithIppAndPpd", result,
        RESULT_MAX);
  }
}

}  // namespace

class CupsPrintJobManagerImpl : public CupsPrintJobManager {
 public:
  explicit CupsPrintJobManagerImpl(Profile* profile)
      : CupsPrintJobManager(profile),
        cups_wrapper_(CupsWrapper::Create()),
        weak_ptr_factory_(this) {
    // NOTE: base::Unretained(this) is safe here because this object owns
    // |subscription_| and the callback won't be invoked after |subscription_|
    // is destroyed.
    subscription_ = g_browser_process->print_job_manager()->AddDocDoneCallback(
        base::BindRepeating(&CupsPrintJobManagerImpl::OnDocDone,
                            base::Unretained(this)));
    timer_.SetTaskRunner(content::GetUIThreadTaskRunner({}));
  }

  CupsPrintJobManagerImpl(const CupsPrintJobManagerImpl&) = delete;
  CupsPrintJobManagerImpl& operator=(const CupsPrintJobManagerImpl&) = delete;

  ~CupsPrintJobManagerImpl() override = default;

  // CupsPrintJobManager overrides:
  // Must be run from the UI thread.
  void CancelPrintJob(CupsPrintJob* job) override {
    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
    job->set_state(CupsPrintJob::State::STATE_CANCELLED);
    NotifyJobCanceled(job->GetWeakPtr());
    // Ideally we should wait for IPP response.
    FinishPrintJob(job);
  }

  bool SuspendPrintJob(CupsPrintJob* job) override {
    NOTREACHED_IN_MIGRATION() << "Pause printer is not implemented";
    return false;
  }

  bool ResumePrintJob(CupsPrintJob* job) override {
    NOTREACHED_IN_MIGRATION() << "Resume printer is not implemented";
    return false;
  }

  void OnDocDone(::printing::PrintJob* job,
                 ::printing::PrintedDocument* document,
                 int job_id) {
    // Store the printer data for metrics to be recorded upon print job status
    // updates.
    const std::string printer_id =
        base::UTF16ToUTF8(document->settings().device_name());
    const std::string key = CupsPrintJob::CreateUniqueId(printer_id, job_id);
    DCHECK(!printer_metrics_cache_.contains(key));
    printer_metrics_cache_.emplace(
        key, PrinterMetrics{job->settings().printer_manually_selected(),
                            job->settings().printer_status_reason()});

    // This event occurs after the print job has been successfully sent to the
    // spooler which is when we begin tracking the print queue.
    DCHECK(document);
    std::u16string title = ::printing::SimplifyDocumentTitle(document->name());
    if (title.empty()) {
      title = ::printing::SimplifyDocumentTitle(
          l10n_util::GetStringUTF16(IDS_DEFAULT_PRINT_DOCUMENT_TITLE));
    }

    // Calculate page total for given document to ensure UI displays the correct
    // count when document has copies.
    const int total_page_number = CalculatePrintJobTotalPages(document);
    CreatePrintJob(printer_id, base::UTF16ToUTF8(title), job_id,
                   total_page_number, job->source(), job->source_id(),
                   PrintSettingsToProto(document->settings()));
  }

  // Begin monitoring a print job for a given |printer_id| with the given
  // |title| with the pages |total_page_number|.
  bool CreatePrintJob(const std::string& printer_id,
                      const std::string& title,
                      uint32_t job_id,
                      int total_page_number,
                      ::printing::PrintJob::Source source,
                      const std::string& source_id,
                      const printing::proto::PrintSettings& settings) override {
    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

    Profile* profile = ProfileManager::GetPrimaryUserProfile();
    if (!profile) {
      LOG(WARNING) << "Cannot find printer without a valid profile.";
      return false;
    }

    auto* manager = CupsPrintersManagerFactory::GetForBrowserContext(profile);
    if (!manager) {
      LOG(WARNING)
          << "CupsPrintersManager could not be found for the current profile.";
      return false;
    }

    std::optional<chromeos::Printer> printer = manager->GetPrinter(printer_id);
    if (!printer) {
      LOG(WARNING)
          << "Printer was removed while job was in progress.  It cannot "
             "be tracked";
      return false;
    }

    // Record print job with scalable IPH framework.
    scalable_iph::ScalableIph* scalable_iph =
        ScalableIphFactory::GetForBrowserContext(profile);
    if (scalable_iph) {
      scalable_iph->RecordEvent(
          scalable_iph::ScalableIph::Event::kPrintJobCreated);
    }

    // Create a new print job.
    auto cpj = std::make_unique<CupsPrintJob>(*printer, job_id, title,
                                              total_page_number, source,
                                              source_id, settings);
    std::string key = cpj->GetUniqueId();
    jobs_[key] = std::move(cpj);

    CupsPrintJob* job = jobs_[key].get();
    NotifyJobCreated(job->GetWeakPtr());

    // Always start jobs in the waiting state.
    job->set_state(CupsPrintJob::State::STATE_WAITING);
    NotifyJobUpdated(job->GetWeakPtr());

    // Run a query now.
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE, base::BindOnce(&CupsPrintJobManagerImpl::PostQuery,
                                  weak_ptr_factory_.GetWeakPtr()));
    // Start the timer for ongoing queries.
    ScheduleQuery();

    return true;
  }

 private:
  void FinishPrintJob(CupsPrintJob* job) {
    // Copy job_id and printer_id.  |job| is about to be freed.
    const int job_id = job->job_id();
    const std::string printer_id = job->printer().id();

    // Stop monitoring jobs after we cancel them.  The user no longer cares.
    const std::string unique_id = job->GetUniqueId();
    jobs_.erase(unique_id);
    printer_metrics_cache_.erase(unique_id);

    cups_wrapper_->CancelJob(printer_id, job_id);
  }

  // Schedule a query of CUPS for print job status with a delay of |delay|.
  void ScheduleQuery(int attempt_count = 1) {
    timer_.Start(FROM_HERE, kPollRate * attempt_count,
                 base::BindRepeating(&CupsPrintJobManagerImpl::PostQuery,
                                     weak_ptr_factory_.GetWeakPtr()));
  }

  void PostQuery() {
    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

    // The set of active printers is expected to be small.
    std::set<std::string> printer_ids;
    for (const auto& entry : jobs_) {
      printer_ids.insert(entry.second->printer().id());
    }
    std::vector<std::string> ids{printer_ids.begin(), printer_ids.end()};

    cups_wrapper_->QueryCupsPrintJobs(
        ids, base::BindOnce(&CupsPrintJobManagerImpl::UpdateJobs,
                            weak_ptr_factory_.GetWeakPtr()));
  }

  // Process jobs from CUPS and perform notifications.
  // Use job information to update local job states.  Previously completed jobs
  // could be in |jobs| but those are ignored as we will not emit updates for
  // them after they are completed.
  void UpdateJobs(std::unique_ptr<CupsWrapper::QueryResult> result) {
    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

    // If the query failed, either retry or purge.
    if (!result->success) {
      retry_count_++;
      LOG(WARNING) << "Failed to query CUPS for queue status.  Schedule retry ("
                   << retry_count_ << ")";
      if (retry_count_ > kRetryMax) {
        LOG(ERROR) << "CUPS is unreachable.  Giving up on all jobs.";
        timer_.Stop();
        PurgeJobs();
      } else {
        // Backoff the polling frequency. Give CUPS a chance to recover.
        DCHECK_GE(1, retry_count_);
        ScheduleQuery(retry_count_);
      }
      return;
    }

    // A query has completed.  Reset retry counter.
    retry_count_ = 0;

    std::vector<std::string> active_jobs;
    for (const auto& queue : result->queues) {
      for (auto& job : queue.jobs) {
        std::string key = CupsPrintJob::CreateUniqueId(job.printer_id, job.id);
        const auto& entry = jobs_.find(key);
        if (entry == jobs_.end())
          continue;

        CupsPrintJob* print_job = entry->second.get();

        if (UpdatePrintJob(queue.printer_status, job, print_job)) {
          // The state of the job changed, notify observers.
          NotifyJobStateUpdate(print_job->GetWeakPtr());
        }

        const bool affected_by_ipp_usb_migration =
            print_job->printer().AffectedByIppUsbMigration();
        if (print_job->error_code() ==
            chromeos::PrinterErrorCode::CLIENT_UNAUTHORIZED) {
          // Job needs to be forcibly cancelled, CUPS will keep the job in held
          // and the job cannot be resumed in chromeos.
          FinishPrintJob(print_job);
          RecordJobResult(CLIENT_UNAUTHORIZED, affected_by_ipp_usb_migration);
        } else if (print_job->IsExpired()) {
          // Job needs to be forcibly cancelled.
          RecordJobResult(TIMEOUT_CANCEL, affected_by_ipp_usb_migration);
          FinishPrintJob(print_job);
          // Beware, print_job was removed from jobs_ and
          // deleted.
        } else if (print_job->PipelineDead()) {
          RecordJobResult(FILTER_FAILED, affected_by_ipp_usb_migration);
          FinishPrintJob(print_job);
        } else if (print_job->IsJobFinished()) {
          // Cleanup completed jobs.
          VLOG(1) << "Removing Job " << print_job->document_title();
          RecordJobResult(ResultForHistogram(print_job->state()),
                          affected_by_ipp_usb_migration);
          jobs_.erase(entry);
          printer_metrics_cache_.erase(key);
        } else {
          active_jobs.push_back(key);
        }
      }
    }

    if (active_jobs.empty()) {
      // CUPS has stopped reporting jobs.  Stop polling.
      timer_.Stop();
      if (!jobs_.empty()) {
        // We're tracking jobs that we didn't receive an update for.  Something
        // bad has happened.
        LOG(ERROR) << "Lost track of (" << jobs_.size() << ") jobs";
        PurgeJobs();
      }
    }
  }

  // Mark remaining jobs as errors and remove active jobs.
  void PurgeJobs() {
    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

    for (const auto& entry : jobs_) {
      // Declare all lost jobs errors.
      CupsPrintJob* job = entry.second.get();
      RecordJobResult(LOST, job->printer().AffectedByIppUsbMigration());
      job->set_state(CupsPrintJob::State::STATE_FAILED);
      NotifyJobStateUpdate(job->GetWeakPtr());
    }

    jobs_.clear();
    printer_metrics_cache_.clear();
  }

  // Notify observers that a state update has occurred for |job|.
  void NotifyJobStateUpdate(base::WeakPtr<CupsPrintJob> job) {
    DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

    if (!job)
      return;

    switch (job->state()) {
      case CupsPrintJob::State::STATE_NONE:
        // CupsPrintJob::State does not require notification.
        break;
      case CupsPrintJob::State::STATE_WAITING:
        NotifyJobUpdated(job);
        break;
      case CupsPrintJob::State::STATE_STARTED:
        NotifyJobStarted(job);
        break;
      case CupsPrintJob::State::STATE_PAGE_DONE:
        NotifyJobUpdated(job);
        break;
      case CupsPrintJob::State::STATE_RESUMED:
        NotifyJobResumed(job);
        break;
      case CupsPrintJob::State::STATE_SUSPENDED:
        NotifyJobSuspended(job);
        break;
      case CupsPrintJob::State::STATE_CANCELLED:
        NotifyJobCanceled(job);
        break;
      case CupsPrintJob::State::STATE_FAILED:
        NotifyJobFailed(job);
        break;
      case CupsPrintJob::State::STATE_DOCUMENT_DONE:
        NotifyJobDone(job);
        break;
      case CupsPrintJob::State::STATE_ERROR:
        NotifyJobUpdated(job);
        break;
    }

    RecordPrinterMetricIfJobComplete(job);
  }

  // Only record metrics for print jobs with states that denote success complete
  // or an error status.
  void RecordPrinterMetricIfJobComplete(base::WeakPtr<CupsPrintJob> job) {
    const std::string unique_id = job->GetUniqueId();
    auto iter = printer_metrics_cache_.find(unique_id);
    if (iter == printer_metrics_cache_.end()) {
      return;
    }

    bool print_job_success;
    switch (job->state()) {
      // Consider these print job statuses as "in progress" jobs so don't record
      // metrics yet.
      case CupsPrintJob::State::STATE_NONE:
      case CupsPrintJob::State::STATE_WAITING:
      case CupsPrintJob::State::STATE_STARTED:
      case CupsPrintJob::State::STATE_PAGE_DONE:
      case CupsPrintJob::State::STATE_RESUMED:
        return;
      // The set of states for a print job considered "failed" for metrics
      // recording.
      case CupsPrintJob::State::STATE_SUSPENDED:
      case CupsPrintJob::State::STATE_CANCELLED:
      case CupsPrintJob::State::STATE_FAILED:
      case CupsPrintJob::State::STATE_ERROR:
        print_job_success = false;
        break;
      case CupsPrintJob::State::STATE_DOCUMENT_DONE:
        print_job_success = true;
        break;
    }

    // Record PrintAttemptOutcome.
    const PrinterMetrics& metrics = iter->second;
    chromeos::PrintAttemptOutcome print_attempt_outcome;
    if (print_job_success && metrics.printer_manually_selected) {
      print_attempt_outcome = chromeos::PrintAttemptOutcome::
          kPrintJobSuccessManuallySelectedPrinter;
    } else if (print_job_success && !metrics.printer_manually_selected) {
      print_attempt_outcome =
          chromeos::PrintAttemptOutcome::kPrintJobSuccessInitialPrinter;
    } else if (!print_job_success && metrics.printer_manually_selected) {
      print_attempt_outcome =
          chromeos::PrintAttemptOutcome::kPrintJobFailManuallySelectedPrinter;
    } else {
      print_attempt_outcome =
          chromeos::PrintAttemptOutcome::kPrintJobFailInitialPrinter;
    }
    base::UmaHistogramEnumeration("PrintPreview.PrintAttemptOutcome",
                                  print_attempt_outcome);

    // Record printer status print job success.
    if (metrics.printer_status_reason.has_value()) {
      const std::string histogram_name = base::StrCat(
          {"PrintPreview.PrinterStatus.",
           GetPrinterStatusHistogramName(metrics.printer_status_reason.value()),
           ".PrintJobSuccess"});
      base::UmaHistogramBoolean(histogram_name, print_job_success);
    }

    printer_metrics_cache_.erase(unique_id);
  }

  // Maps the printer status reason to the variant names used for the
  // "PrintPreview.PrinterStatus.{StatusReason}.PrintJobSuccess" histogram.
  std::string GetPrinterStatusHistogramName(
      StatusReason printer_status_reason) {
    switch (printer_status_reason) {
      case StatusReason::kUnknownReason:
        return "UnknownReason";
      case StatusReason::kDeviceError:
        return "DeviceError";
      case StatusReason::kDoorOpen:
        return "DoorOpen";
      case StatusReason::kLowOnInk:
        return "LowOnInk";
      case StatusReason::kLowOnPaper:
        return "LowOnPaper";
      case StatusReason::kNoError:
        return "NoError";
      case StatusReason::kOutOfInk:
        return "OutOfInk";
      case StatusReason::kOutOfPaper:
        return "OutOfPaper";
      case StatusReason::kOutputAreaAlmostFull:
        return "OutputAreaAlmostFull";
      case StatusReason::kOutputFull:
        return "OutputFull";
      case StatusReason::kPaperJam:
        return "PaperJam";
      case StatusReason::kPaused:
        return "Paused";
      case StatusReason::kPrinterQueueFull:
        return "PrinterQueueFull";
      case StatusReason::kPrinterUnreachable:
        return "PrinterUnreachable";
      case StatusReason::kStopped:
        return "Stopped";
      case StatusReason::kTrayMissing:
        return "TrayMissing";
      case StatusReason::kExpiredCertificate:
        return "ExpiredCertificate";
    }
  }

  // Ongoing print jobs.
  std::map<std::string, std::unique_ptr<CupsPrintJob>> jobs_;

  // Records the number of consecutive times the GetJobs query has failed.
  int retry_count_ = 0;

  // Stores the desired printer metrics in a map keyed by the CupsPrintJob
  // `unique_id`. Once the corresponding print job either fails or completes,
  // record the metrics entry to histograms and remove it from the map.
  base::flat_map<std::string, PrinterMetrics> printer_metrics_cache_;

  base::RepeatingTimer timer_;
  std::unique_ptr<CupsWrapper> cups_wrapper_;
  ::printing::PrintJobManager::DocDoneCallbackList::Subscription subscription_;
  base::WeakPtrFactory<CupsPrintJobManagerImpl> weak_ptr_factory_;
};

// static
CupsPrintJobManager* CupsPrintJobManager::CreateInstance(Profile* profile) {
  return new CupsPrintJobManagerImpl(profile);
}

}  // namespace ash