1 /* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.sdklib.internal.export; 18 19 import com.android.sdklib.SdkConstants; 20 import com.android.sdklib.io.FileWrapper; 21 import com.android.sdklib.io.IAbstractFile; 22 import com.android.sdklib.io.StreamException; 23 import com.android.sdklib.xml.AndroidManifestParser; 24 import com.android.sdklib.xml.ManifestData; 25 import com.android.sdklib.xml.ManifestData.SupportsScreens; 26 27 import org.xml.sax.SAXException; 28 29 import java.io.BufferedReader; 30 import java.io.File; 31 import java.io.FileInputStream; 32 import java.io.FileOutputStream; 33 import java.io.IOException; 34 import java.io.InputStreamReader; 35 import java.io.OutputStreamWriter; 36 import java.io.PrintStream; 37 import java.util.ArrayList; 38 import java.util.Calendar; 39 import java.util.Collections; 40 import java.util.Formatter; 41 import java.util.HashMap; 42 import java.util.List; 43 import java.util.Map; 44 45 import javax.xml.parsers.ParserConfigurationException; 46 47 /** 48 * Helper to export multiple APKs from 1 or or more projects. 49 * <strong>This class is not meant to be accessed from multiple threads</strong> 50 */ 51 public class MultiApkExportHelper { 52 53 private final static String PROP_VERSIONCODE = "versionCode"; 54 private final static String PROP_PACKAGE = "package"; 55 56 private final String mExportProjectRoot; 57 private final String mAppPackage; 58 private final int mVersionCode; 59 private final Target mTarget; 60 61 private ArrayList<ProjectConfig> mProjectList; 62 private ArrayList<ApkData> mApkDataList; 63 64 final static int MAX_MINOR = 100; 65 final static int MAX_BUILDINFO = 100; 66 final static int OFFSET_BUILD_INFO = MAX_MINOR; 67 final static int OFFSET_VERSION_CODE = OFFSET_BUILD_INFO * MAX_BUILDINFO; 68 69 private final static String FILE_CONFIG = "projects.config"; 70 private final static String FILE_MINOR_CODE = "minor.codes"; 71 private final static String FOLDER_LOG = "logs"; 72 private final PrintStream mStdio; 73 74 public static final class ExportException extends Exception { 75 private static final long serialVersionUID = 1L; 76 77 public ExportException(String message) { 78 super(message); 79 } 80 81 public ExportException(String format, Object... args) { 82 super(String.format(format, args)); 83 } 84 85 public ExportException(Throwable cause, String format, Object... args) { 86 super(String.format(format, args), cause); 87 } 88 89 public ExportException(String message, Throwable cause) { 90 super(message, cause); 91 } 92 } 93 94 public static enum Target { 95 RELEASE("release"), CLEAN("clean"); 96 97 private final String mName; 98 99 Target(String name) { 100 mName = name; 101 } 102 103 public String getTarget() { 104 return mName; 105 } 106 107 public static Target getTarget(String value) { 108 for (Target t : values()) { 109 if (t.mName.equals(value)) { 110 return t; 111 } 112 113 } 114 115 return null; 116 } 117 } 118 119 public MultiApkExportHelper(String exportProjectRoot, String appPackage, 120 int versionCode, Target target, PrintStream stdio) { 121 mExportProjectRoot = exportProjectRoot; 122 mAppPackage = appPackage; 123 mVersionCode = versionCode; 124 mTarget = target; 125 mStdio = stdio; 126 } 127 128 public List<ApkData> getApkData(String projectList) throws ExportException { 129 if (mTarget != Target.RELEASE) { 130 throw new IllegalArgumentException("getApkData must only be called for Target.RELEASE"); 131 } 132 133 // get the list of apk to export and their configuration. 134 List<ProjectConfig> projects = getProjects(projectList); 135 136 // look to see if there's a config file from a previous export 137 File configProp = new File(mExportProjectRoot, FILE_CONFIG); 138 if (configProp.isFile()) { 139 compareProjectsToConfigFile(projects, configProp); 140 } 141 142 // look to see if there's a minor properties file 143 File minorCodeProp = new File(mExportProjectRoot, FILE_MINOR_CODE); 144 Map<Integer, Integer> minorCodeMap = null; 145 if (minorCodeProp.isFile()) { 146 minorCodeMap = getMinorCodeMap(minorCodeProp); 147 } 148 149 // get the apk from the projects. 150 return getApkData(projects, minorCodeMap); 151 } 152 153 /** 154 * Returns the list of projects defined by the <var>projectList</var> string. 155 * The projects are checked to be valid Android project and to represent a valid set 156 * of projects for multi-apk export. 157 * If a project does not exist or is not valid, the method will throw a {@link BuildException}. 158 * The string must be a list of paths, relative to the export project path (given to 159 * {@link #MultiApkExportHelper(String, String, int, Target)}), separated by the colon (':') 160 * character. The path separator is expected to be forward-slash ('/') on all platforms. 161 * @param projects the string containing all the relative paths to the projects. This is 162 * usually read from export.properties. 163 * @throws ExportException 164 */ 165 public List<ProjectConfig> getProjects(String projectList) throws ExportException { 166 String[] paths = projectList.split("\\:"); 167 168 mProjectList = new ArrayList<ProjectConfig>(); 169 170 for (String path : paths) { 171 path = path.replaceAll("\\/", File.separator); 172 processProject(path, mProjectList); 173 } 174 175 return mProjectList; 176 } 177 178 /** 179 * Writes post-export logs and other files. 180 * @throws ExportException if writing the files failed. 181 */ 182 public void writeLogs() throws ExportException { 183 writeConfigProperties(); 184 writeMinorVersionProperties(); 185 writeApkLog(); 186 } 187 188 private void writeConfigProperties() throws ExportException { 189 OutputStreamWriter writer = null; 190 try { 191 writer = new OutputStreamWriter( 192 new FileOutputStream(new File(mExportProjectRoot, FILE_CONFIG))); 193 194 writer.append("# PROJECT CONFIG -- DO NOT DELETE.\n"); 195 writeValue(writer, PROP_VERSIONCODE, mVersionCode); 196 197 for (ProjectConfig project : mProjectList) { 198 writeValue(writer,project.getRelativePath(), 199 project.getConfigString(false /*onlyManifestData*/)); 200 } 201 202 writer.flush(); 203 } catch (Exception e) { 204 throw new ExportException("Failed to write config log", e); 205 } finally { 206 try { 207 if (writer != null) { 208 writer.close(); 209 } 210 } catch (IOException e) { 211 throw new ExportException("Failed to write config log", e); 212 } 213 } 214 } 215 216 private void writeMinorVersionProperties() throws ExportException { 217 OutputStreamWriter writer = null; 218 try { 219 writer = new OutputStreamWriter( 220 new FileOutputStream(new File(mExportProjectRoot, FILE_MINOR_CODE))); 221 222 writer.append( 223 "# Minor version codes.\n" + 224 "# To create update to select APKs without updating the main versionCode\n" + 225 "# edit this file and manually increase the minor version for the select\n" + 226 "# build info.\n" + 227 "# Format of the file is <buildinfo>:<minor>\n"); 228 writeValue(writer, PROP_VERSIONCODE, mVersionCode); 229 230 for (ApkData apk : mApkDataList) { 231 writeValue(writer, Integer.toString(apk.getBuildInfo()), apk.getMinorCode()); 232 } 233 234 writer.flush(); 235 } catch (Exception e) { 236 throw new ExportException("Failed to write minor log", e); 237 } finally { 238 try { 239 if (writer != null) { 240 writer.close(); 241 } 242 } catch (IOException e) { 243 throw new ExportException("Failed to write minor log", e); 244 } 245 } 246 } 247 248 private void writeApkLog() throws ExportException { 249 OutputStreamWriter writer = null; 250 try { 251 File logFolder = new File(mExportProjectRoot, FOLDER_LOG); 252 if (logFolder.isFile()) { 253 throw new ExportException("Cannot create folder '%1$s', file is in the way!", 254 FOLDER_LOG); 255 } else if (logFolder.exists() == false) { 256 logFolder.mkdir(); 257 } 258 259 Formatter formatter = new Formatter(); 260 formatter.format("%1$s.%2$d-%3$tY%3$tm%3$td-%3$tH%3$tM.log", 261 mAppPackage, mVersionCode, 262 Calendar.getInstance().getTime()); 263 264 writer = new OutputStreamWriter( 265 new FileOutputStream(new File(logFolder, formatter.toString()))); 266 267 writer.append("# Multi-APK BUILD LOG.\n"); 268 writeValue(writer, PROP_PACKAGE, mAppPackage); 269 writeValue(writer, PROP_VERSIONCODE, mVersionCode); 270 271 for (ApkData apk : mApkDataList) { 272 // if there are soft variant, do not display the main log line, as it's not actually 273 // exported. 274 Map<String, String> softVariants = apk.getSoftVariantMap(); 275 if (softVariants.size() > 0) { 276 for (String softVariant : softVariants.keySet()) { 277 writer.append(apk.getLogLine(softVariant)); 278 writer.append('\n'); 279 } 280 } else { 281 writer.append(apk.getLogLine(null)); 282 writer.append('\n'); 283 } 284 } 285 286 writer.flush(); 287 } catch (Exception e) { 288 throw new ExportException("Failed to write build log", e); 289 } finally { 290 try { 291 if (writer != null) { 292 writer.close(); 293 } 294 } catch (IOException e) { 295 throw new ExportException("Failed to write build log", e); 296 } 297 } 298 } 299 300 private void writeValue(OutputStreamWriter writer, String name, String value) 301 throws IOException { 302 writer.append(name).append(':').append(value).append('\n'); 303 } 304 305 private void writeValue(OutputStreamWriter writer, String name, int value) throws IOException { 306 writeValue(writer, name, Integer.toString(value)); 307 } 308 309 private List<ApkData> getApkData(List<ProjectConfig> projects, 310 Map<Integer, Integer> minorCodes) { 311 mApkDataList = new ArrayList<ApkData>(); 312 313 // get all the apkdata from all the projects 314 for (ProjectConfig config : projects) { 315 mApkDataList.addAll(config.getApkDataList()); 316 } 317 318 // sort the projects and assign buildInfo 319 Collections.sort(mApkDataList); 320 int buildInfo = 0; 321 for (ApkData data : mApkDataList) { 322 data.setBuildInfo(buildInfo); 323 if (minorCodes != null) { 324 Integer minorCode = minorCodes.get(buildInfo); 325 if (minorCode != null) { 326 data.setMinorCode(minorCode); 327 } 328 } 329 330 buildInfo++; 331 } 332 333 return mApkDataList; 334 } 335 336 /** 337 * Checks a project for inclusion in the list of exported APK. 338 * <p/>This performs a check on the manifest, as well as gathers more information about 339 * mutli-apk from the project's default.properties file. 340 * If the manifest is correct, a list of apk to export is created and returned. 341 * 342 * @param projectFolder the folder of the project to check 343 * @param projects the list of project to file with the project if it passes validation. 344 * @throws ExportException in case of error. 345 */ 346 private void processProject(String relativePath, 347 ArrayList<ProjectConfig> projects) throws ExportException { 348 349 // resolve the relative path 350 File projectFolder; 351 try { 352 File path = new File(mExportProjectRoot, relativePath); 353 354 projectFolder = path.getCanonicalFile(); 355 356 // project folder must exist and be a directory 357 if (projectFolder.isDirectory() == false) { 358 throw new ExportException( 359 "Project folder '%1$s' is not a valid directory.", 360 projectFolder.getAbsolutePath()); 361 } 362 } catch (IOException e) { 363 throw new ExportException( 364 e, "Failed to resolve path %1$s", relativePath); 365 } 366 367 try { 368 // Check AndroidManifest.xml is present 369 IAbstractFile androidManifest = new FileWrapper(projectFolder, 370 SdkConstants.FN_ANDROID_MANIFEST_XML); 371 372 if (androidManifest.exists() == false) { 373 throw new ExportException(String.format( 374 "%1$s is not a valid project (%2$s not found).", 375 relativePath, androidManifest.getOsLocation())); 376 } 377 378 // output the relative path resolution. 379 mStdio.println(String.format("%1$s => %2$s", relativePath, 380 projectFolder.getAbsolutePath())); 381 382 // parse the manifest of the project. 383 ManifestData manifestData = AndroidManifestParser.parse(androidManifest); 384 385 // validate the application package name 386 String manifestPackage = manifestData.getPackage(); 387 if (mAppPackage.equals(manifestPackage) == false) { 388 throw new ExportException( 389 "%1$s package value is not valid. Found '%2$s', expected '%3$s'.", 390 androidManifest.getOsLocation(), manifestPackage, mAppPackage); 391 } 392 393 // validate that the manifest has no versionCode set. 394 if (manifestData.getVersionCode() != null) { 395 throw new ExportException( 396 "%1$s is not valid: versionCode must not be set for multi-apk export.", 397 androidManifest.getOsLocation()); 398 } 399 400 // validate that the minSdkVersion is not a codename 401 int minSdkVersion = manifestData.getMinSdkVersion(); 402 if (minSdkVersion == ManifestData.MIN_SDK_CODENAME) { 403 throw new ExportException( 404 "Codename in minSdkVersion is not supported by multi-apk export."); 405 } 406 407 // compare to other projects already processed to make sure that they are not 408 // identical. 409 for (ProjectConfig otherProject : projects) { 410 // Multiple apk export support difference in: 411 // - min SDK Version 412 // - Screen version 413 // - GL version 414 // - ABI (not managed at the Manifest level). 415 // if those values are the same between 2 manifest, then it's an error. 416 417 418 // first the minSdkVersion. 419 if (minSdkVersion == otherProject.getMinSdkVersion()) { 420 // if it's the same compare the rest. 421 SupportsScreens currentSS = manifestData.getSupportsScreensValues(); 422 SupportsScreens previousSS = otherProject.getSupportsScreens(); 423 boolean sameSupportsScreens = currentSS.hasSameScreenSupportAs(previousSS); 424 425 // if it's the same, then it's an error. Can't export 2 projects that have the 426 // same approved (for multi-apk export) hard-properties. 427 if (manifestData.getGlEsVersion() == otherProject.getGlEsVersion() && 428 sameSupportsScreens) { 429 430 throw new ExportException( 431 "Android manifests must differ in at least one of the following values:\n" + 432 "- minSdkVersion\n" + 433 "- SupportsScreen (screen sizes only)\n" + 434 "- GL ES version.\n" + 435 "%1$s and %2$s are considered identical for multi-apk export.", 436 relativePath, 437 otherProject.getRelativePath()); 438 } 439 440 // At this point, either supports-screens or GL are different. 441 // Because supports-screens is the highest priority properties to be 442 // (potentially) different, we must do some extra checks on it. 443 // It must either be the same in both projects (difference is only on GL value), 444 // or follow theses rules: 445 // - Property in each projects must be strictly different, ie both projects 446 // cannot support the same screen size(s). 447 // - Property in each projects cannot overlap, ie a projects cannot support 448 // both a lower and a higher screen size than the other project. 449 // (ie APK1 supports small/large and APK2 supports normal). 450 if (sameSupportsScreens == false) { 451 if (currentSS.hasStrictlyDifferentScreenSupportAs(previousSS) == false) { 452 throw new ExportException( 453 "APK differentiation by Supports-Screens cannot support different APKs supporting the same screen size.\n" + 454 "%1$s supports %2$s\n" + 455 "%3$s supports %4$s\n", 456 relativePath, currentSS.toString(), 457 otherProject.getRelativePath(), previousSS.toString()); 458 } 459 460 if (currentSS.overlapWith(previousSS)) { 461 throw new ExportException( 462 "Unable to compute APK priority due to incompatible difference in Supports-Screens values.\n" + 463 "%1$s supports %2$s\n" + 464 "%3$s supports %4$s\n", 465 relativePath, currentSS.toString(), 466 otherProject.getRelativePath(), previousSS.toString()); 467 } 468 } 469 } 470 } 471 472 // project passes first validation. Attempt to create a ProjectConfig object. 473 474 ProjectConfig config = ProjectConfig.create(projectFolder, relativePath, manifestData); 475 projects.add(config); 476 } catch (SAXException e) { 477 throw new ExportException(e, "Failed to validate %1$s", relativePath); 478 } catch (IOException e) { 479 throw new ExportException(e, "Failed to validate %1$s", relativePath); 480 } catch (StreamException e) { 481 throw new ExportException(e, "Failed to validate %1$s", relativePath); 482 } catch (ParserConfigurationException e) { 483 throw new ExportException(e, "Failed to validate %1$s", relativePath); 484 } 485 } 486 487 /** 488 * Checks an existing list of {@link ProjectConfig} versus a config file. 489 * @param projects the list of projects to check 490 * @param configProp the config file (must have been generated from a previous export) 491 * @return true if the projects and config file match 492 * @throws ExportException in case of error 493 */ 494 private void compareProjectsToConfigFile(List<ProjectConfig> projects, File configProp) 495 throws ExportException { 496 InputStreamReader reader = null; 497 BufferedReader bufferedReader = null; 498 try { 499 reader = new InputStreamReader(new FileInputStream(configProp)); 500 bufferedReader = new BufferedReader(reader); 501 String line; 502 503 // List of the ProjectConfig that need to be checked. This is to detect 504 // new Projects added to the setup. 505 // removed projects are detected when an entry in the config file doesn't match 506 // any ProjectConfig in the list. 507 ArrayList<ProjectConfig> projectsToCheck = new ArrayList<ProjectConfig>(); 508 projectsToCheck.addAll(projects); 509 510 // store the project that doesn't match. 511 ProjectConfig badMatch = null; 512 String errorMsg = null; 513 514 // recorded whether we checked the version code. this is for when we compare 515 // a project config 516 boolean checkedVersion = false; 517 518 int lineNumber = 0; 519 while ((line = bufferedReader.readLine()) != null) { 520 lineNumber++; 521 line = line.trim(); 522 if (line.length() == 0 || line.startsWith("#")) { 523 continue; 524 } 525 526 // read the name of the property 527 int colonPos = line.indexOf(':'); 528 if (colonPos == -1) { 529 // looks like there's an invalid line! 530 throw new ExportException( 531 "Failed to read existing build log. Line %d is not a property line.", 532 lineNumber); 533 } 534 535 String name = line.substring(0, colonPos); 536 String value = line.substring(colonPos + 1); 537 538 if (PROP_VERSIONCODE.equals(name)) { 539 try { 540 int versionCode = Integer.parseInt(value); 541 if (versionCode < mVersionCode) { 542 // this means this config file is obsolete and we can ignore it. 543 return; 544 } else if (versionCode > mVersionCode) { 545 // we're exporting at a lower versionCode level than the config file? 546 throw new ExportException( 547 "Incompatible versionCode: Exporting at versionCode %1$d but %2$s file indicate previous export with versionCode %3$d.", 548 mVersionCode, FILE_CONFIG, versionCode); 549 } else if (badMatch != null) { 550 // looks like versionCode is a match, but a project 551 // isn't compatible. 552 break; 553 } else { 554 // record that we did check the versionCode 555 checkedVersion = true; 556 } 557 } catch (NumberFormatException e) { 558 throw new ExportException( 559 "Failed to read integer property %1$s at line %2$d.", 560 PROP_VERSIONCODE, lineNumber); 561 } 562 } else { 563 // looks like this is (or should be) a project line. 564 // name of the property is the relative path. 565 // look for a matching project. 566 ProjectConfig found = null; 567 for (int i = 0 ; i < projectsToCheck.size() ; i++) { 568 ProjectConfig p = projectsToCheck.get(i); 569 if (p.getRelativePath().equals(name)) { 570 found = p; 571 projectsToCheck.remove(i); 572 break; 573 } 574 } 575 576 if (found == null) { 577 // deleted project! 578 throw new ExportException( 579 "Project %1$s has been removed from the list of projects to export.\n" + 580 "Any change in the multi-apk configuration requires an increment of the versionCode in export.properties.", 581 name); 582 } else { 583 // make a map of properties 584 HashMap<String, String> map = new HashMap<String, String>(); 585 String[] properties = value.split(";"); 586 for (String prop : properties) { 587 int equalPos = prop.indexOf('='); 588 map.put(prop.substring(0, equalPos), prop.substring(equalPos + 1)); 589 } 590 591 errorMsg = found.compareToProperties(map); 592 if (errorMsg != null) { 593 // bad project config, record the project 594 badMatch = found; 595 596 // if we've already checked that the versionCode didn't already change 597 // we stop right away. 598 if (checkedVersion) { 599 break; 600 } 601 } 602 } 603 604 } 605 606 } 607 608 if (badMatch != null) { 609 throw new ExportException( 610 "Config for project %1$s has changed from previous export with versionCode %2$d:\n" + 611 "\t%3$s\n" + 612 "Any change in the multi-apk configuration requires an increment of the versionCode in export.properties.", 613 badMatch.getRelativePath(), mVersionCode, errorMsg); 614 } else if (projectsToCheck.size() > 0) { 615 throw new ExportException( 616 "Project %1$s was not part of the previous export with versionCode %2$d.\n" + 617 "Any change in the multi-apk configuration requires an increment of the versionCode in export.properties.", 618 projectsToCheck.get(0).getRelativePath(), mVersionCode); 619 } 620 621 } catch (IOException e) { 622 throw new ExportException(e, "Failed to read existing config log: %s", FILE_CONFIG); 623 } finally { 624 try { 625 if (reader != null) { 626 reader.close(); 627 } 628 } catch (IOException e) { 629 throw new ExportException(e, "Failed to read existing config log: %s", FILE_CONFIG); 630 } 631 } 632 } 633 634 private Map<Integer, Integer> getMinorCodeMap(File minorProp) throws ExportException { 635 InputStreamReader reader = null; 636 BufferedReader bufferedReader = null; 637 try { 638 reader = new InputStreamReader(new FileInputStream(minorProp)); 639 bufferedReader = new BufferedReader(reader); 640 String line; 641 642 boolean foundVersionCode = false; 643 Map<Integer, Integer> map = new HashMap<Integer, Integer>(); 644 645 int lineNumber = 0; 646 while ((line = bufferedReader.readLine()) != null) { 647 lineNumber++; 648 line = line.trim(); 649 if (line.length() == 0 || line.startsWith("#")) { 650 continue; 651 } 652 653 // read the name of the property 654 int colonPos = line.indexOf(':'); 655 if (colonPos == -1) { 656 // looks like there's an invalid line! 657 throw new ExportException( 658 "Failed to read existing build log. Line %d is not a property line.", 659 lineNumber); 660 } 661 662 String name = line.substring(0, colonPos); 663 String value = line.substring(colonPos + 1); 664 665 if (PROP_VERSIONCODE.equals(name)) { 666 try { 667 int versionCode = Integer.parseInt(value); 668 if (versionCode < mVersionCode) { 669 // this means this minor file is obsolete and we can ignore it. 670 return null; 671 } else if (versionCode > mVersionCode) { 672 // we're exporting at a lower versionCode level than the minor file? 673 throw new ExportException( 674 "Incompatible versionCode: Exporting at versionCode %1$d but %2$s file indicate previous export with versionCode %3$d.", 675 mVersionCode, FILE_MINOR_CODE, versionCode); 676 } 677 foundVersionCode = true; 678 } catch (NumberFormatException e) { 679 throw new ExportException( 680 "Failed to read integer property %1$s at line %2$d.", 681 PROP_VERSIONCODE, lineNumber); 682 } 683 } else { 684 try { 685 map.put(Integer.valueOf(name), Integer.valueOf(value)); 686 } catch (NumberFormatException e) { 687 throw new ExportException( 688 "Failed to read buildInfo property '%1$s' at line %2$d.", 689 line, lineNumber); 690 } 691 } 692 } 693 694 // if there was no versionCode found, we can't garantee that the minor version 695 // found are for this versionCode 696 if (foundVersionCode == false) { 697 throw new ExportException( 698 "%1$s property missing from file %2$s.", PROP_VERSIONCODE, FILE_MINOR_CODE); 699 } 700 701 return map; 702 } catch (IOException e) { 703 throw new ExportException(e, "Failed to read existing minor log: %s", FILE_MINOR_CODE); 704 } finally { 705 try { 706 if (reader != null) { 707 reader.close(); 708 } 709 } catch (IOException e) { 710 throw new ExportException(e, "Failed to read existing minor log: %s", 711 FILE_MINOR_CODE); 712 } 713 } 714 } 715 } 716