1 /* 2 * Copyright (C) 2016 The Android Open Source Project 3 * Copyright (C) 2016 Mopria Alliance, Inc. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.bips; 19 20 import android.net.Uri; 21 import android.os.Bundle; 22 import android.print.PrintJobId; 23 import android.printservice.PrintJob; 24 import android.util.Log; 25 26 import com.android.bips.discovery.ConnectionListener; 27 import com.android.bips.discovery.DiscoveredPrinter; 28 import com.android.bips.discovery.MdnsDiscovery; 29 import com.android.bips.ipp.Backend; 30 import com.android.bips.ipp.CapabilitiesCache; 31 import com.android.bips.ipp.CertificateStore; 32 import com.android.bips.ipp.JobStatus; 33 import com.android.bips.jni.BackendConstants; 34 import com.android.bips.jni.LocalPrinterCapabilities; 35 import com.android.bips.p2p.P2pPrinterConnection; 36 import com.android.bips.p2p.P2pUtils; 37 38 import java.util.ArrayList; 39 import java.util.StringJoiner; 40 import java.util.function.Consumer; 41 42 /** 43 * Manage the process of delivering a print job 44 */ 45 class LocalPrintJob implements MdnsDiscovery.Listener, ConnectionListener, 46 CapabilitiesCache.OnLocalPrinterCapabilities { 47 private static final String TAG = LocalPrintJob.class.getSimpleName(); 48 private static final boolean DEBUG = false; 49 private static final String IPP_SCHEME = "ipp"; 50 private static final String IPPS_SCHEME = "ipps"; 51 52 /** Maximum time to wait to find a printer before failing the job */ 53 private static final int DISCOVERY_TIMEOUT = 2 * 60 * 1000; 54 55 // Internal job states 56 private static final int STATE_INIT = 0; 57 private static final int STATE_DISCOVERY = 1; 58 private static final int STATE_CAPABILITIES = 2; 59 private static final int STATE_DELIVERING = 3; 60 private static final int STATE_SECURITY = 4; 61 private static final int STATE_CANCEL = 5; 62 private static final int STATE_DONE = 6; 63 64 private final BuiltInPrintService mPrintService; 65 private final PrintJob mPrintJob; 66 private final Backend mBackend; 67 68 private int mState; 69 private Consumer<LocalPrintJob> mCompleteConsumer; 70 private Uri mPath; 71 private DelayedAction mDiscoveryTimeout; 72 private P2pPrinterConnection mConnection; 73 private LocalPrinterCapabilities mCapabilities; 74 private CertificateStore mCertificateStore; 75 private long mStartTime; 76 private ArrayList<String> mBlockedReasons = new ArrayList<>(); 77 78 /** 79 * Construct the object; use {@link #start(Consumer)} to begin job processing. 80 */ LocalPrintJob(BuiltInPrintService printService, Backend backend, PrintJob printJob)81 LocalPrintJob(BuiltInPrintService printService, Backend backend, PrintJob printJob) { 82 mPrintService = printService; 83 mBackend = backend; 84 mPrintJob = printJob; 85 mCertificateStore = mPrintService.getCertificateStore(); 86 mState = STATE_INIT; 87 88 // Tell the job it is blocked (until start()) 89 mPrintJob.start(); 90 mPrintJob.block(printService.getString(R.string.waiting_to_send)); 91 } 92 93 /** 94 * Begin the process of delivering the job. Internally, discovers the target printer, 95 * obtains its capabilities, delivers the job to the printer, and waits for job completion. 96 * 97 * @param callback Callback to be issued when job processing is complete 98 */ start(Consumer<LocalPrintJob> callback)99 void start(Consumer<LocalPrintJob> callback) { 100 mStartTime = System.currentTimeMillis(); 101 // TODO: Log job attempted event here using getJobAttemptedBundle() 102 if (DEBUG) Log.d(TAG, "start() " + mPrintJob); 103 if (mState != STATE_INIT) { 104 Log.w(TAG, "Invalid start state " + mState); 105 return; 106 } 107 mPrintJob.start(); 108 109 // Acquire a lock so that WiFi isn't put to sleep while we send the job 110 mPrintService.lockWifi(); 111 112 mState = STATE_DISCOVERY; 113 mCompleteConsumer = callback; 114 mDiscoveryTimeout = mPrintService.delay(DISCOVERY_TIMEOUT, () -> { 115 if (DEBUG) Log.d(TAG, "Discovery timeout"); 116 if (mState == STATE_DISCOVERY) { 117 finish(false, mPrintService.getString(R.string.printer_offline)); 118 } 119 }); 120 121 mPrintService.getDiscovery().start(this); 122 } 123 124 /** 125 * Restart the job if possible. 126 */ restart()127 void restart() { 128 if (DEBUG) Log.d(TAG, "restart() " + mPrintJob + " in state " + mState); 129 if (mState == STATE_SECURITY) { 130 mCapabilities.certificate = mCertificateStore.get(mCapabilities.uuid); 131 deliver(); 132 } 133 } 134 cancel()135 void cancel() { 136 if (DEBUG) Log.d(TAG, "cancel() " + mPrintJob + " in state " + mState); 137 138 switch (mState) { 139 case STATE_DISCOVERY: 140 case STATE_CAPABILITIES: 141 case STATE_SECURITY: 142 // Cancel immediately 143 mState = STATE_CANCEL; 144 finish(false, null); 145 break; 146 147 case STATE_DELIVERING: 148 // Request cancel and wait for completion 149 mState = STATE_CANCEL; 150 mBackend.cancel(); 151 break; 152 } 153 Bundle bundle = getJobCompletedAnalyticsBundle(BackendConstants.JOB_DONE_CANCELLED); 154 bundle.putString(BackendConstants.PARAM_ERROR_MESSAGES, getStringifiedBlockedReasons()); 155 // TODO: Log job completed event here with the above bundle 156 } 157 getPrintJobId()158 PrintJobId getPrintJobId() { 159 return mPrintJob.getId(); 160 } 161 162 @Override onPrinterFound(DiscoveredPrinter printer)163 public void onPrinterFound(DiscoveredPrinter printer) { 164 if (mState != STATE_DISCOVERY) { 165 return; 166 } 167 if (!printer.getId(mPrintService).equals(mPrintJob.getInfo().getPrinterId())) { 168 return; 169 } 170 171 if (DEBUG) Log.d(TAG, "onPrinterFound() " + printer.name + " state=" + mState); 172 173 if (P2pUtils.isP2p(printer)) { 174 // Launch a P2P connection attempt 175 mConnection = new P2pPrinterConnection(mPrintService, printer, this); 176 return; 177 } 178 179 if (P2pUtils.isOnConnectedInterface(mPrintService, printer) && mConnection == null) { 180 // Hold the P2P connection up during printing 181 mConnection = new P2pPrinterConnection(mPrintService, printer, this); 182 } 183 184 // We have a good path so stop discovering and get capabilities 185 mPrintService.getDiscovery().stop(this); 186 mState = STATE_CAPABILITIES; 187 mPath = printer.path; 188 // Upgrade to IPPS path if present 189 for (Uri path : printer.paths) { 190 if (IPPS_SCHEME.equals(path.getScheme())) { 191 mPath = path; 192 break; 193 } 194 } 195 196 mPrintService.getCapabilitiesCache().request(printer, true, this); 197 } 198 199 @Override onPrinterLost(DiscoveredPrinter printer)200 public void onPrinterLost(DiscoveredPrinter printer) { 201 // Ignore (the capability request, if any, will fail) 202 } 203 204 @Override onConnectionComplete(DiscoveredPrinter printer)205 public void onConnectionComplete(DiscoveredPrinter printer) { 206 // Ignore late connection events 207 if (mState != STATE_DISCOVERY) { 208 return; 209 } 210 211 if (printer == null) { 212 finish(false, mPrintService.getString(R.string.failed_printer_connection)); 213 } else if (mPrintJob.isBlocked()) { 214 mPrintJob.start(); 215 } 216 } 217 218 @Override onConnectionDelayed(boolean delayed)219 public void onConnectionDelayed(boolean delayed) { 220 if (DEBUG) Log.d(TAG, "onConnectionDelayed " + delayed); 221 222 // Ignore late events 223 if (mState != STATE_DISCOVERY) { 224 return; 225 } 226 227 if (delayed) { 228 mPrintJob.block(mPrintService.getString(R.string.connect_hint_text)); 229 } else { 230 // Remove block message 231 mPrintJob.start(); 232 } 233 } 234 getPrintJob()235 PrintJob getPrintJob() { 236 return mPrintJob; 237 } 238 239 @Override onCapabilities(LocalPrinterCapabilities capabilities)240 public void onCapabilities(LocalPrinterCapabilities capabilities) { 241 if (DEBUG) Log.d(TAG, "Capabilities for " + mPath + " are " + capabilities); 242 if (mState != STATE_CAPABILITIES) { 243 return; 244 } 245 246 if (capabilities == null) { 247 finish(false, mPrintService.getString(R.string.printer_offline)); 248 } else { 249 if (DEBUG) Log.d(TAG, "Starting backend print of " + mPrintJob); 250 if (mDiscoveryTimeout != null) { 251 mDiscoveryTimeout.cancel(); 252 } 253 mCapabilities = capabilities; 254 deliver(); 255 } 256 } 257 deliver()258 private void deliver() { 259 // Upgrade to IPPS if necessary 260 Uri newUri = Uri.parse(mCapabilities.path); 261 if (IPPS_SCHEME.equals(newUri.getScheme()) && newUri.getPort() > 0 && 262 IPP_SCHEME.equals(mPath.getScheme())) { 263 mPath = mPath.buildUpon().scheme(IPPS_SCHEME).encodedAuthority(mPath.getHost() + 264 ":" + newUri.getPort()).build(); 265 } 266 267 if (DEBUG) Log.d(TAG, "deliver() to " + mPath); 268 if (mCapabilities.certificate != null && !IPPS_SCHEME.equals(mPath.getScheme())) { 269 mState = STATE_SECURITY; 270 mPrintJob.block(mPrintService.getString(R.string.printer_not_encrypted)); 271 mPrintService.notifyCertificateChange(mCapabilities.name, 272 mPrintJob.getInfo().getPrinterId(), mCapabilities.uuid, null); 273 } else { 274 mState = STATE_DELIVERING; 275 mPrintJob.start(); 276 mBackend.print(mPath, mPrintJob, mCapabilities, this::handleJobStatus); 277 } 278 } 279 handleJobStatus(JobStatus jobStatus)280 private void handleJobStatus(JobStatus jobStatus) { 281 if (DEBUG) Log.d(TAG, "onJobStatus() " + jobStatus); 282 283 byte[] certificate = jobStatus.getCertificate(); 284 if (certificate != null && mCapabilities != null) { 285 // If there is no certificate, record this one 286 if (mCertificateStore.get(mCapabilities.uuid) == null) { 287 if (DEBUG) Log.d(TAG, "Recording new certificate"); 288 mCertificateStore.put(mCapabilities.uuid, certificate); 289 } 290 } 291 292 mBlockedReasons.addAll(jobStatus.getBlockedReasons()); 293 294 switch (jobStatus.getJobState()) { 295 case BackendConstants.JOB_STATE_DONE: 296 Bundle bundle = getJobCompletedAnalyticsBundle(jobStatus.getJobResult()); 297 298 switch (jobStatus.getJobResult()) { 299 case BackendConstants.JOB_DONE_OK: 300 finish(true, null); 301 break; 302 case BackendConstants.JOB_DONE_CANCELLED: 303 mState = STATE_CANCEL; 304 finish(false, null); 305 bundle.putString( 306 BackendConstants.PARAM_ERROR_MESSAGES, 307 getStringifiedBlockedReasons()); 308 break; 309 case BackendConstants.JOB_DONE_CORRUPT: 310 finish(false, mPrintService.getString(R.string.unreadable_input)); 311 bundle.putString( 312 BackendConstants.PARAM_ERROR_MESSAGES, 313 getStringifiedBlockedReasons()); 314 break; 315 default: 316 // Job failed 317 if (jobStatus.getBlockedReasonId() == R.string.printer_bad_certificate) { 318 handleBadCertificate(jobStatus); 319 } else { 320 finish(false, null); 321 } 322 bundle.putString( 323 BackendConstants.PARAM_ERROR_MESSAGES, 324 getStringifiedBlockedReasons()); 325 break; 326 } 327 // TODO: Log JobCompleted analytic with the bundle here 328 break; 329 330 case BackendConstants.JOB_STATE_BLOCKED: 331 if (mState == STATE_CANCEL) { 332 return; 333 } 334 int blockedId = jobStatus.getBlockedReasonId(); 335 blockedId = (blockedId == 0) ? R.string.printer_check : blockedId; 336 String blockedReason = mPrintService.getString(blockedId); 337 mPrintJob.block(blockedReason); 338 break; 339 340 case BackendConstants.JOB_STATE_RUNNING: 341 if (mState == STATE_CANCEL) { 342 return; 343 } 344 mPrintJob.start(); 345 break; 346 } 347 } 348 handleBadCertificate(JobStatus jobStatus)349 private void handleBadCertificate(JobStatus jobStatus) { 350 byte[] certificate = jobStatus.getCertificate(); 351 352 if (certificate == null) { 353 mPrintJob.fail(mPrintService.getString(R.string.printer_bad_certificate)); 354 } else { 355 if (DEBUG) Log.d(TAG, "Certificate change detected."); 356 mState = STATE_SECURITY; 357 mPrintJob.block(mPrintService.getString(R.string.printer_bad_certificate)); 358 mPrintService.notifyCertificateChange(mCapabilities.name, 359 mPrintJob.getInfo().getPrinterId(), mCapabilities.uuid, certificate); 360 } 361 } 362 363 /** 364 * Terminate the job, issuing appropriate notifications. 365 * 366 * @param success true if the printer reported successful job completion 367 * @param error reason for job failure if known 368 */ finish(boolean success, String error)369 private void finish(boolean success, String error) { 370 if (DEBUG) Log.d(TAG, "finish() success=" + success + ", error=" + error); 371 mPrintService.getDiscovery().stop(this); 372 if (mDiscoveryTimeout != null) { 373 mDiscoveryTimeout.cancel(); 374 } 375 if (mConnection != null) { 376 mConnection.close(); 377 } 378 mPrintService.unlockWifi(); 379 mBackend.closeDocument(); 380 if (success) { 381 // Job must not be blocked before completion 382 mPrintJob.start(); 383 mPrintJob.complete(); 384 } else if (mState == STATE_CANCEL) { 385 mPrintJob.cancel(); 386 } else { 387 mPrintJob.fail(error); 388 } 389 mState = STATE_DONE; 390 mCompleteConsumer.accept(LocalPrintJob.this); 391 } 392 393 /** 394 * Get stringified blocked reasons delimited by '|' 395 * @return delimited string of blocked reasons 396 */ getStringifiedBlockedReasons()397 private String getStringifiedBlockedReasons() { 398 StringJoiner reasons = new StringJoiner("|"); 399 for (String reason: mBlockedReasons) { 400 reasons.add(reason); 401 } 402 return reasons.toString(); 403 } 404 405 /** 406 * Get the job completed analytics bundle 407 * 408 * @param result result of the job 409 * @return analytics bundle 410 */ getJobCompletedAnalyticsBundle(String result)411 private Bundle getJobCompletedAnalyticsBundle(String result) { 412 Bundle bundle = new Bundle(); 413 bundle.putString(BackendConstants.PARAM_JOB_ID, mPrintJob.getId().toString()); 414 bundle.putLong(BackendConstants.PARAM_DATE_TIME, System.currentTimeMillis()); 415 // TODO: Add real location 416 bundle.putString(BackendConstants.PARAM_LOCATION, "United States"); 417 // TODO: Add real user id 418 bundle.putString(BackendConstants.PARAM_USER_ID, "userid"); 419 bundle.putString(BackendConstants.PARAM_RESULT, result); 420 bundle.putLong( 421 BackendConstants.PARAM_ELAPSED_TIME_ALL, System.currentTimeMillis() - mStartTime); 422 return bundle; 423 } 424 425 /** 426 * Get the job started analytics bundle 427 * 428 * @return analytics bundle 429 */ getJobAttemptedAnalyticsBundle()430 private Bundle getJobAttemptedAnalyticsBundle() { 431 Bundle bundle = new Bundle(); 432 bundle.putString(BackendConstants.PARAM_JOB_ID, mPrintJob.getId().toString()); 433 bundle.putLong(BackendConstants.PARAM_DATE_TIME, System.currentTimeMillis()); 434 // TODO: Add real location 435 bundle.putString(BackendConstants.PARAM_LOCATION, "United States"); 436 bundle.putInt( 437 BackendConstants.PARAM_JOB_PAGES, 438 mPrintJob.getInfo().getCopies() * mPrintJob.getDocument().getInfo().getPageCount()); 439 // TODO: Add real user id 440 bundle.putString(BackendConstants.PARAM_USER_ID, "userid"); 441 // TODO: Determine whether the print job came from share to BIPS or from print system 442 bundle.putString(BackendConstants.PARAM_SOURCE_PATH, "ShareToBips || PrintSystem"); 443 return bundle; 444 } 445 } 446