1 /* 2 * Copyright (C) 2023 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.server.wifi.entitlement.http; 18 19 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_HTTP_STATUS_NOT_SUCCESS; 20 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_MALFORMED_HTTP_RESPONSE; 21 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_SERVER_NOT_CONNECTABLE; 22 23 import static com.google.common.base.Strings.nullToEmpty; 24 import static com.google.common.net.HttpHeaders.CONTENT_ENCODING; 25 26 import static java.nio.charset.StandardCharsets.UTF_8; 27 import static java.util.concurrent.TimeUnit.SECONDS; 28 29 import android.annotation.NonNull; 30 import android.net.Network; 31 import android.text.TextUtils; 32 33 import androidx.annotation.VisibleForTesting; 34 import androidx.annotation.WorkerThread; 35 36 import com.android.libraries.entitlement.ServiceEntitlementException; 37 import com.android.server.wifi.entitlement.http.HttpConstants.ContentType; 38 39 import com.google.common.collect.ImmutableList; 40 import com.google.common.net.HttpHeaders; 41 42 import org.json.JSONObject; 43 44 import java.io.ByteArrayOutputStream; 45 import java.io.DataOutputStream; 46 import java.io.IOException; 47 import java.io.InputStream; 48 import java.io.OutputStream; 49 import java.net.URL; 50 import java.net.URLConnection; 51 import java.util.Map; 52 import java.util.zip.GZIPOutputStream; 53 54 import javax.net.ssl.HttpsURLConnection; 55 56 /** Implement the HTTP request method according to TS.43 specification. */ 57 public class HttpClient { 58 private static final String POST = "POST"; 59 @VisibleForTesting 60 static final String GZIP = "gzip"; 61 62 /** 63 * Starts a {@link HttpRequest} and gets the {@link HttpResponse} 64 * @throws ServiceEntitlementException if there is any connection error. 65 */ 66 @WorkerThread request(@onNull HttpRequest request)67 public static HttpResponse request(@NonNull HttpRequest request) 68 throws ServiceEntitlementException { 69 HttpsURLConnection connection = createConnection(request); 70 try { 71 if (POST.equals(request.requestMethod())) { 72 try (OutputStream out = new DataOutputStream(connection.getOutputStream())) { 73 JSONObject postDataJsonObject = request.postData(); 74 String postData; 75 // Some servers support JsonObject format of post data. But some servers support 76 //JsonArray format. If JsonObject post data is empty, we will use the JsonArray 77 //post data. Otherwise, use JsonObject post data. 78 if (postDataJsonObject.length() == 0) { 79 postData = request.postDataJsonArray().toString(); 80 } else { 81 postData = postDataJsonObject.toString(); 82 } 83 // Android JSON toString() escapes forward-slash with back-slash. It's not 84 // supported by some vendor and not mandatory in JSON spec. Undo escaping. 85 postData = postData.replace("\\/", "/"); 86 ImmutableList<String> list = request.requestProperties().get(CONTENT_ENCODING); 87 if ((list.size() > 0) && GZIP.equalsIgnoreCase(list.get(0))) { 88 out.write(toGzipBytes(postData)); 89 } else { 90 out.write(postData.getBytes(UTF_8)); 91 } 92 } 93 } 94 connection.connect(); // This is to trigger SocketTimeoutException early 95 return getHttpResponse(connection); 96 } catch (IOException ioe) { 97 String error; 98 try (InputStream in = connection.getErrorStream()) { 99 error = StreamUtils.inputStreamToStringSafe(in); 100 } catch (IOException e) { 101 error = ""; 102 } 103 throw new ServiceEntitlementException( 104 ERROR_HTTP_STATUS_NOT_SUCCESS, 105 "Connection error stream: " + error + " IOException: " + ioe, ioe); 106 } finally { 107 connection.disconnect(); 108 } 109 } 110 createConnection(@onNull HttpRequest request)111 private static HttpsURLConnection createConnection(@NonNull HttpRequest request) 112 throws ServiceEntitlementException { 113 try { 114 HttpsURLConnection connection; 115 final URL url = new URL(request.url()); 116 final Network network = request.network(); 117 if (network == null) { 118 connection = (HttpsURLConnection) url.openConnection(); 119 } else { 120 connection = (HttpsURLConnection) network.openConnection(url); 121 } 122 123 // add HTTP headers 124 for (Map.Entry<String, String> entry : request.requestProperties().entries()) { 125 connection.addRequestProperty(entry.getKey(), entry.getValue()); 126 } 127 128 // set parameters 129 connection.setRequestMethod(request.requestMethod()); 130 connection.setConnectTimeout((int) SECONDS.toMillis(request.timeoutInSec())); 131 connection.setReadTimeout((int) SECONDS.toMillis(request.timeoutInSec())); 132 if (POST.equals(request.requestMethod())) { 133 connection.setDoOutput(true); 134 } 135 return connection; 136 } catch (IOException ioe) { 137 throw new ServiceEntitlementException( 138 ERROR_SERVER_NOT_CONNECTABLE, "Configure connection failed!", ioe); 139 } 140 } 141 142 @NonNull getHttpResponse(@onNull HttpsURLConnection connection)143 private static HttpResponse getHttpResponse(@NonNull HttpsURLConnection connection) 144 throws ServiceEntitlementException { 145 HttpResponse.Builder responseBuilder = HttpResponse.builder(); 146 responseBuilder.setContentType(getContentType(connection)); 147 try { 148 final int responseCode = connection.getResponseCode(); 149 if (responseCode != HttpsURLConnection.HTTP_OK) { 150 throw new ServiceEntitlementException(ERROR_HTTP_STATUS_NOT_SUCCESS, responseCode, 151 connection.getHeaderField(HttpHeaders.RETRY_AFTER), 152 "Invalid connection response"); 153 } 154 responseBuilder.setResponseCode(responseCode); 155 responseBuilder.setResponseMessage(nullToEmpty(connection.getResponseMessage())); 156 } catch (IOException e) { 157 throw new ServiceEntitlementException( 158 ERROR_HTTP_STATUS_NOT_SUCCESS, "Read response code failed!", e); 159 } 160 try { 161 responseBuilder.setBody(readResponse(connection)); 162 } catch (IOException e) { 163 throw new ServiceEntitlementException( 164 ERROR_MALFORMED_HTTP_RESPONSE, "Read response body/message failed!", e); 165 } 166 return responseBuilder.build(); 167 } 168 169 @NonNull readResponse(@onNull URLConnection connection)170 private static String readResponse(@NonNull URLConnection connection) throws IOException { 171 try (InputStream in = connection.getInputStream()) { 172 // By default, this implementation of HttpsURLConnection requests that servers use gzip 173 // compression, and it automatically decompresses the data for callers of 174 // URLConnection.getInputStream(). In case the caller sets the Accept-Encoding request 175 // header explicitly to disable automatic decompression, the InputStream is decompressed 176 // here. 177 if (GZIP.equalsIgnoreCase(connection.getHeaderField(CONTENT_ENCODING))) { 178 return StreamUtils.inputStreamToGunzipString(in); 179 } 180 return StreamUtils.inputStreamToStringSafe(in); 181 } 182 } 183 getContentType(@onNull URLConnection connection)184 private static int getContentType(@NonNull URLConnection connection) { 185 String contentType = connection.getHeaderField(ContentType.NAME); 186 if (TextUtils.isEmpty(contentType)) { 187 return ContentType.UNKNOWN; 188 } 189 return HttpConstants.getContentType(contentType); 190 } 191 192 /** 193 * Converts the input string to UTF_8 byte array and gzip it. 194 */ 195 @VisibleForTesting 196 @NonNull toGzipBytes(@onNull String data)197 public static byte[] toGzipBytes(@NonNull String data) throws IOException { 198 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 199 try (GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) { 200 gzip.write(data.getBytes(UTF_8)); 201 } 202 return outputStream.toByteArray(); 203 } 204 } 205