• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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