1/* 2 * Copyright (C) 2015 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 17package libcore.io; 18 19import java.io.File; 20import java.io.FileNotFoundException; 21import java.io.FilterInputStream; 22import java.io.IOException; 23import java.io.InputStream; 24import java.net.JarURLConnection; 25import java.net.MalformedURLException; 26import java.net.URL; 27import java.net.URLConnection; 28import java.net.URLEncoder; 29import java.net.URLStreamHandler; 30import java.util.jar.JarFile; 31import java.util.zip.ZipEntry; 32import sun.net.www.ParseUtil; 33import sun.net.www.protocol.jar.Handler; 34 35/** 36 * A {@link URLStreamHandler} for a specific class path {@link JarFile}. This class avoids the need 37 * to open a jar file multiple times to read resources if the jar file can be held open. The 38 * {@link URLConnection} objects created are a subclass of {@link JarURLConnection}. 39 * 40 * <p>Use {@link #getEntryUrlOrNull(String)} to obtain a URL backed by this stream handler. 41 */ 42public class ClassPathURLStreamHandler extends Handler { 43 private final String fileUri; 44 private final JarFile jarFile; 45 46 public ClassPathURLStreamHandler(String jarFileName) throws IOException { 47 jarFile = new JarFile(jarFileName); 48 49 // File.toURI() is compliant with RFC 1738 in always creating absolute path names. If we 50 // construct the URL by concatenating strings, we might end up with illegal URLs for relative 51 // names. 52 this.fileUri = new File(jarFileName).toURI().toString(); 53 } 54 55 /** 56 * Returns a URL backed by this stream handler for the named resource, or {@code null} if the 57 * entry cannot be found under the exact name presented. 58 */ 59 public URL getEntryUrlOrNull(String entryName) { 60 if (findEntryWithDirectoryFallback(jarFile, entryName) != null) { 61 try { 62 // Encode the path to ensure that any special characters like # survive their trip through 63 // the URL. Entry names must use / as the path separator. 64 String encodedName = ParseUtil.encodePath(entryName, false); 65 return new URL("jar", null, -1, fileUri + "!/" + encodedName, this); 66 } catch (MalformedURLException e) { 67 throw new RuntimeException("Invalid entry name", e); 68 } 69 } 70 return null; 71 } 72 73 /** 74 * Returns true if an entry with the specified name exists and is stored (not compressed), 75 * and false otherwise. 76 */ 77 public boolean isEntryStored(String entryName) { 78 ZipEntry entry = jarFile.getEntry(entryName); 79 return entry != null && entry.getMethod() == ZipEntry.STORED; 80 } 81 82 @Override 83 protected URLConnection openConnection(URL url) throws IOException { 84 return new ClassPathURLConnection(url); 85 } 86 87 /** Used from tests to indicate this stream handler is finished with. */ 88 public void close() throws IOException { 89 jarFile.close(); 90 } 91 92 /** 93 * Finds an entry with the specified name in the {@code jarFile}. If an exact match isn't found it 94 * will also try with "/" appended, if appropriate. This is to maintain compatibility with 95 * {@link sun.net.www.protocol.jar.Handler} and its treatment of directory entries. 96 */ 97 static ZipEntry findEntryWithDirectoryFallback(JarFile jarFile, String entryName) { 98 ZipEntry entry = jarFile.getEntry(entryName); 99 if (entry == null && !entryName.endsWith("/") ) { 100 entry = jarFile.getEntry(entryName + "/"); 101 } 102 return entry; 103 } 104 105 private class ClassPathURLConnection extends JarURLConnection { 106 // The JarFile instance can be shared across URLConnections and should not be closed when it is: 107 // 108 // Sharing occurs if getUseCaches() is true when connect() is called (which can take place 109 // implicitly). useCachedJarFile records the state of sharing at connect() time. 110 // useCachedJarFile == true is the common case. If developers call getJarFile().close() when 111 // sharing is enabled then it will affect other users (current and future) of the shared 112 // JarFile. 113 // 114 // Developers could call ClassLoader.findResource().openConnection() to get a URLConnection and 115 // then call setUseCaches(false) before connect() to prevent sharing. The developer must then 116 // call getJarFile().close() or close() on the inputStream from getInputStream() will do it 117 // automatically. This is likely to be an extremely rare case. 118 // 119 // Most developers are not expecting to deal with the lifecycle of the underlying JarFile object 120 // at all. The presence of the getJarFile() method and setUseCaches() forces us to consider / 121 // handle it. 122 private JarFile connectionJarFile; 123 124 private ZipEntry jarEntry; 125 private InputStream jarInput; 126 private boolean closed; 127 128 /** 129 * Indicates the behavior of the {@link #jarFile}. If true, the reference is shared and should 130 * not be closed. If false, it must be closed. 131 */ 132 private boolean useCachedJarFile; 133 134 135 public ClassPathURLConnection(URL url) throws MalformedURLException { 136 super(url); 137 } 138 139 @Override 140 public void connect() throws IOException { 141 if (!connected) { 142 this.jarEntry = findEntryWithDirectoryFallback(ClassPathURLStreamHandler.this.jarFile, 143 getEntryName()); 144 if (jarEntry == null) { 145 throw new FileNotFoundException( 146 "URL does not correspond to an entry in the zip file. URL=" + url 147 + ", zipfile=" + jarFile.getName()); 148 } 149 useCachedJarFile = getUseCaches(); 150 connected = true; 151 } 152 } 153 154 @Override 155 public JarFile getJarFile() throws IOException { 156 connect(); 157 158 // We do cache in the surrounding class if useCachedJarFile is true to 159 // preserve garbage collection semantics and to avoid leak warnings. 160 if (useCachedJarFile) { 161 connectionJarFile = jarFile; 162 } else { 163 connectionJarFile = new JarFile(jarFile.getName()); 164 } 165 return connectionJarFile; 166 } 167 168 @Override 169 public InputStream getInputStream() throws IOException { 170 if (closed) { 171 throw new IllegalStateException("JarURLConnection InputStream has been closed"); 172 } 173 connect(); 174 if (jarInput != null) { 175 return jarInput; 176 } 177 return jarInput = new FilterInputStream(jarFile.getInputStream(jarEntry)) { 178 @Override 179 public void close() throws IOException { 180 super.close(); 181 // If the jar file is not cached then closing the input stream will close the 182 // URLConnection and any JarFile returned from getJarFile(). If the jar file is cached 183 // we must not close it because it will affect other URLConnections. 184 if (connectionJarFile != null && !useCachedJarFile) { 185 connectionJarFile.close(); 186 closed = true; 187 } 188 } 189 }; 190 } 191 192 /** 193 * Returns the content type of the entry based on the name of the entry. Returns 194 * non-null results ("content/unknown" for unknown types). 195 * 196 * @return the content type 197 */ 198 @Override 199 public String getContentType() { 200 String cType = guessContentTypeFromName(getEntryName()); 201 if (cType == null) { 202 cType = "content/unknown"; 203 } 204 return cType; 205 } 206 207 @Override 208 public int getContentLength() { 209 try { 210 connect(); 211 return (int) getJarEntry().getSize(); 212 } catch (IOException e) { 213 // Ignored 214 } 215 return -1; 216 } 217 } 218} 219