1 /* 2 * Copyright (C) 2013 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 android.media; 18 19 import static android.media.MediaPlayer.MEDIA_ERROR_UNSUPPORTED; 20 21 import android.compat.annotation.UnsupportedAppUsage; 22 import android.net.NetworkUtils; 23 import android.os.IBinder; 24 import android.os.StrictMode; 25 import android.util.Log; 26 27 import com.android.internal.annotations.GuardedBy; 28 29 import java.io.BufferedInputStream; 30 import java.io.IOException; 31 import java.io.InputStream; 32 import java.net.CookieHandler; 33 import java.net.HttpURLConnection; 34 import java.net.MalformedURLException; 35 import java.net.NoRouteToHostException; 36 import java.net.ProtocolException; 37 import java.net.Proxy; 38 import java.net.URL; 39 import java.net.UnknownServiceException; 40 import java.util.HashMap; 41 import java.util.Map; 42 import java.util.concurrent.atomic.AtomicInteger; 43 44 /** @hide */ 45 public class MediaHTTPConnection extends IMediaHTTPConnection.Stub { 46 private static final String TAG = "MediaHTTPConnection"; 47 private static final boolean VERBOSE = false; 48 49 // connection timeout - 30 sec 50 private static final int CONNECT_TIMEOUT_MS = 30 * 1000; 51 52 @GuardedBy("this") 53 @UnsupportedAppUsage 54 private long mCurrentOffset = -1; 55 56 @GuardedBy("this") 57 @UnsupportedAppUsage 58 private URL mURL = null; 59 60 @GuardedBy("this") 61 @UnsupportedAppUsage 62 private Map<String, String> mHeaders = null; 63 64 // volatile so that disconnect() can be called without acquiring a lock. 65 // All other access is @GuardedBy("this"). 66 @UnsupportedAppUsage 67 private volatile HttpURLConnection mConnection = null; 68 69 @GuardedBy("this") 70 @UnsupportedAppUsage 71 private long mTotalSize = -1; 72 73 @GuardedBy("this") 74 private InputStream mInputStream = null; 75 76 @GuardedBy("this") 77 @UnsupportedAppUsage 78 private boolean mAllowCrossDomainRedirect = true; 79 80 @GuardedBy("this") 81 @UnsupportedAppUsage 82 private boolean mAllowCrossProtocolRedirect = true; 83 84 // from com.squareup.okhttp.internal.http 85 private final static int HTTP_TEMP_REDIRECT = 307; 86 private final static int MAX_REDIRECTS = 20; 87 88 // The number of threads that are currently running disconnect() (possibly 89 // not yet holding the synchronized lock). 90 private final AtomicInteger mNumDisconnectingThreads = new AtomicInteger(0); 91 92 @UnsupportedAppUsage MediaHTTPConnection()93 public MediaHTTPConnection() { 94 CookieHandler cookieHandler = CookieHandler.getDefault(); 95 if (cookieHandler == null) { 96 Log.w(TAG, "MediaHTTPConnection: Unexpected. No CookieHandler found."); 97 } 98 99 native_setup(); 100 } 101 102 @Override 103 @UnsupportedAppUsage connect(String uri, String headers)104 public synchronized IBinder connect(String uri, String headers) { 105 if (VERBOSE) { 106 Log.d(TAG, "connect: uri=" + uri + ", headers=" + headers); 107 } 108 109 try { 110 disconnect(); 111 mAllowCrossDomainRedirect = true; 112 mURL = new URL(uri); 113 mHeaders = convertHeaderStringToMap(headers); 114 } catch (MalformedURLException e) { 115 return null; 116 } 117 118 return native_getIMemory(); 119 } 120 parseBoolean(String val)121 private static boolean parseBoolean(String val) { 122 try { 123 return Long.parseLong(val) != 0; 124 } catch (NumberFormatException e) { 125 return "true".equalsIgnoreCase(val) || 126 "yes".equalsIgnoreCase(val); 127 } 128 } 129 130 /* returns true iff header is internal */ filterOutInternalHeaders(String key, String val)131 private synchronized boolean filterOutInternalHeaders(String key, String val) { 132 if ("android-allow-cross-domain-redirect".equalsIgnoreCase(key)) { 133 mAllowCrossDomainRedirect = parseBoolean(val); 134 // cross-protocol redirects are also controlled by this flag 135 mAllowCrossProtocolRedirect = mAllowCrossDomainRedirect; 136 } else { 137 return false; 138 } 139 return true; 140 } 141 convertHeaderStringToMap(String headers)142 private synchronized Map<String, String> convertHeaderStringToMap(String headers) { 143 HashMap<String, String> map = new HashMap<String, String>(); 144 145 String[] pairs = headers.split("\r\n"); 146 for (String pair : pairs) { 147 int colonPos = pair.indexOf(":"); 148 if (colonPos >= 0) { 149 String key = pair.substring(0, colonPos); 150 String val = pair.substring(colonPos + 1); 151 152 if (!filterOutInternalHeaders(key, val)) { 153 map.put(key, val); 154 } 155 } 156 } 157 158 return map; 159 } 160 161 @Override 162 @UnsupportedAppUsage disconnect()163 public void disconnect() { 164 mNumDisconnectingThreads.incrementAndGet(); 165 try { 166 HttpURLConnection connectionToDisconnect = mConnection; 167 // Call disconnect() before blocking for the lock in order to ensure that any 168 // other thread that is blocked in readAt() will return quickly. 169 if (connectionToDisconnect != null) { 170 connectionToDisconnect.disconnect(); 171 } 172 synchronized (this) { 173 // It's possible that while we were waiting to acquire the lock, another thread 174 // concurrently started a new connection; if so, we're disconnecting that one 175 // here, too. 176 teardownConnection(); 177 mHeaders = null; 178 mURL = null; 179 } 180 } finally { 181 mNumDisconnectingThreads.decrementAndGet(); 182 } 183 } 184 teardownConnection()185 private synchronized void teardownConnection() { 186 if (mConnection != null) { 187 if (mInputStream != null) { 188 try { 189 mInputStream.close(); 190 } catch (IOException e) { 191 } 192 mInputStream = null; 193 } 194 195 mConnection.disconnect(); 196 mConnection = null; 197 198 mCurrentOffset = -1; 199 } 200 } 201 isLocalHost(URL url)202 private static final boolean isLocalHost(URL url) { 203 if (url == null) { 204 return false; 205 } 206 207 String host = url.getHost(); 208 209 if (host == null) { 210 return false; 211 } 212 213 try { 214 if (host.equalsIgnoreCase("localhost")) { 215 return true; 216 } 217 if (NetworkUtils.numericToInetAddress(host).isLoopbackAddress()) { 218 return true; 219 } 220 } catch (IllegalArgumentException iex) { 221 } 222 return false; 223 } 224 seekTo(long offset)225 private synchronized void seekTo(long offset) throws IOException { 226 teardownConnection(); 227 228 try { 229 int response; 230 int redirectCount = 0; 231 232 URL url = mURL; 233 234 // do not use any proxy for localhost (127.0.0.1) 235 boolean noProxy = isLocalHost(url); 236 237 while (true) { 238 // If another thread is concurrently disconnect()ing, there's a race 239 // between them and us. Therefore, we check mNumDisconnectingThreads shortly 240 // (not atomically) before & after writing mConnection. This guarantees that 241 // we won't "lose" a disconnect by creating a new connection that might 242 // miss the disconnect. 243 // 244 // Note that throwing an instanceof IOException is also what this thread 245 // would have done if another thread disconnect()ed the connection while 246 // this thread was blocked reading from that connection further down in this 247 // loop. 248 if (mNumDisconnectingThreads.get() > 0) { 249 throw new IOException("concurrently disconnecting"); 250 } 251 if (noProxy) { 252 mConnection = (HttpURLConnection)url.openConnection(Proxy.NO_PROXY); 253 } else { 254 mConnection = (HttpURLConnection)url.openConnection(); 255 } 256 // If another thread is concurrently disconnecting, throwing IOException will 257 // cause us to release the lock, giving the other thread a chance to acquire 258 // it. It also ensures that the catch block will run, which will tear down 259 // the connection even if the other thread happens to already be on its way 260 // out of disconnect(). 261 if (mNumDisconnectingThreads.get() > 0) { 262 throw new IOException("concurrently disconnecting"); 263 } 264 // If we get here without having thrown, we know that other threads 265 // will see our write to mConnection. Any disconnect() on that mConnection 266 // instance will cause our read from/write to that connection instance below 267 // to encounter an instanceof IOException. 268 mConnection.setConnectTimeout(CONNECT_TIMEOUT_MS); 269 270 // handle redirects ourselves if we do not allow cross-domain redirect 271 mConnection.setInstanceFollowRedirects(mAllowCrossDomainRedirect); 272 273 if (mHeaders != null) { 274 for (Map.Entry<String, String> entry : mHeaders.entrySet()) { 275 mConnection.setRequestProperty( 276 entry.getKey(), entry.getValue()); 277 } 278 } 279 280 if (offset > 0) { 281 mConnection.setRequestProperty( 282 "Range", "bytes=" + offset + "-"); 283 } 284 285 response = mConnection.getResponseCode(); 286 if (response != HttpURLConnection.HTTP_MULT_CHOICE && 287 response != HttpURLConnection.HTTP_MOVED_PERM && 288 response != HttpURLConnection.HTTP_MOVED_TEMP && 289 response != HttpURLConnection.HTTP_SEE_OTHER && 290 response != HTTP_TEMP_REDIRECT) { 291 // not a redirect, or redirect handled by HttpURLConnection 292 break; 293 } 294 295 if (++redirectCount > MAX_REDIRECTS) { 296 throw new NoRouteToHostException("Too many redirects: " + redirectCount); 297 } 298 299 String method = mConnection.getRequestMethod(); 300 if (response == HTTP_TEMP_REDIRECT && 301 !method.equals("GET") && !method.equals("HEAD")) { 302 // "If the 307 status code is received in response to a 303 // request other than GET or HEAD, the user agent MUST NOT 304 // automatically redirect the request" 305 throw new NoRouteToHostException("Invalid redirect"); 306 } 307 String location = mConnection.getHeaderField("Location"); 308 if (location == null) { 309 throw new NoRouteToHostException("Invalid redirect"); 310 } 311 url = new URL(mURL /* TRICKY: don't use url! */, location); 312 if (!url.getProtocol().equals("https") && 313 !url.getProtocol().equals("http")) { 314 throw new NoRouteToHostException("Unsupported protocol redirect"); 315 } 316 boolean sameProtocol = mURL.getProtocol().equals(url.getProtocol()); 317 if (!mAllowCrossProtocolRedirect && !sameProtocol) { 318 throw new NoRouteToHostException("Cross-protocol redirects are disallowed"); 319 } 320 boolean sameHost = mURL.getHost().equals(url.getHost()); 321 if (!mAllowCrossDomainRedirect && !sameHost) { 322 throw new NoRouteToHostException("Cross-domain redirects are disallowed"); 323 } 324 325 if (response != HTTP_TEMP_REDIRECT) { 326 // update effective URL, unless it is a Temporary Redirect 327 mURL = url; 328 } 329 } 330 331 if (mAllowCrossDomainRedirect) { 332 // remember the current, potentially redirected URL if redirects 333 // were handled by HttpURLConnection 334 mURL = mConnection.getURL(); 335 } 336 337 if (response == HttpURLConnection.HTTP_PARTIAL) { 338 // Partial content, we cannot just use getContentLength 339 // because what we want is not just the length of the range 340 // returned but the size of the full content if available. 341 342 String contentRange = 343 mConnection.getHeaderField("Content-Range"); 344 345 mTotalSize = -1; 346 if (contentRange != null) { 347 // format is "bytes xxx-yyy/zzz 348 // where "zzz" is the total number of bytes of the 349 // content or '*' if unknown. 350 351 int lastSlashPos = contentRange.lastIndexOf('/'); 352 if (lastSlashPos >= 0) { 353 String total = 354 contentRange.substring(lastSlashPos + 1); 355 356 try { 357 mTotalSize = Long.parseLong(total); 358 } catch (NumberFormatException e) { 359 } 360 } 361 } 362 } else if (response != HttpURLConnection.HTTP_OK) { 363 throw new IOException(); 364 } else { 365 mTotalSize = mConnection.getContentLength(); 366 } 367 368 if (offset > 0 && response != HttpURLConnection.HTTP_PARTIAL) { 369 // Some servers simply ignore "Range" requests and serve 370 // data from the start of the content. 371 throw new ProtocolException(); 372 } 373 374 mInputStream = 375 new BufferedInputStream(mConnection.getInputStream()); 376 377 mCurrentOffset = offset; 378 } catch (IOException e) { 379 mTotalSize = -1; 380 teardownConnection(); 381 mCurrentOffset = -1; 382 383 throw e; 384 } 385 } 386 387 @Override 388 @UnsupportedAppUsage readAt(long offset, int size)389 public synchronized int readAt(long offset, int size) { 390 return native_readAt(offset, size); 391 } 392 readAt(long offset, byte[] data, int size)393 private synchronized int readAt(long offset, byte[] data, int size) { 394 StrictMode.ThreadPolicy policy = 395 new StrictMode.ThreadPolicy.Builder().permitAll().build(); 396 397 StrictMode.setThreadPolicy(policy); 398 399 try { 400 if (offset != mCurrentOffset) { 401 seekTo(offset); 402 } 403 404 int n = mInputStream.read(data, 0, size); 405 406 if (n == -1) { 407 // InputStream signals EOS using a -1 result, our semantics 408 // are to return a 0-length read. 409 n = 0; 410 } 411 412 mCurrentOffset += n; 413 414 if (VERBOSE) { 415 Log.d(TAG, "readAt " + offset + " / " + size + " => " + n); 416 } 417 418 return n; 419 } catch (ProtocolException e) { 420 Log.w(TAG, "readAt " + offset + " / " + size + " => " + e); 421 return MEDIA_ERROR_UNSUPPORTED; 422 } catch (NoRouteToHostException e) { 423 Log.w(TAG, "readAt " + offset + " / " + size + " => " + e); 424 return MEDIA_ERROR_UNSUPPORTED; 425 } catch (UnknownServiceException e) { 426 Log.w(TAG, "readAt " + offset + " / " + size + " => " + e); 427 return MEDIA_ERROR_UNSUPPORTED; 428 } catch (IOException e) { 429 if (VERBOSE) { 430 Log.d(TAG, "readAt " + offset + " / " + size + " => -1"); 431 } 432 return -1; 433 } catch (Exception e) { 434 if (VERBOSE) { 435 Log.d(TAG, "unknown exception " + e); 436 Log.d(TAG, "readAt " + offset + " / " + size + " => -1"); 437 } 438 return -1; 439 } 440 } 441 442 @Override getSize()443 public synchronized long getSize() { 444 if (mConnection == null) { 445 try { 446 seekTo(0); 447 } catch (IOException e) { 448 return -1; 449 } 450 } 451 452 return mTotalSize; 453 } 454 455 @Override 456 @UnsupportedAppUsage getMIMEType()457 public synchronized String getMIMEType() { 458 if (mConnection == null) { 459 try { 460 seekTo(0); 461 } catch (IOException e) { 462 return "application/octet-stream"; 463 } 464 } 465 466 return mConnection.getContentType(); 467 } 468 469 @Override 470 @UnsupportedAppUsage getUri()471 public synchronized String getUri() { 472 return mURL.toString(); 473 } 474 475 @Override finalize()476 protected void finalize() { 477 native_finalize(); 478 } 479 native_init()480 private static native final void native_init(); native_setup()481 private native final void native_setup(); native_finalize()482 private native final void native_finalize(); 483 native_getIMemory()484 private native final IBinder native_getIMemory(); native_readAt(long offset, int size)485 private native final int native_readAt(long offset, int size); 486 487 static { 488 System.loadLibrary("media_jni"); native_init()489 native_init(); 490 } 491 492 private long mNativeContext; 493 494 } 495