// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.physicalweb;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.AsyncTask;
import android.os.Build;
import org.chromium.base.Log;
import org.chromium.base.ThreadUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.GoogleAPIKeys;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeVersionInfo;
import org.chromium.chrome.browser.physicalweb.PwsClient.FetchIconCallback;
import org.chromium.chrome.browser.physicalweb.PwsClient.ResolveScanCallback;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Formatter;
import java.util.Locale;
/**
* This class sends requests to the Physical Web Service.
*/
class PwsClientImpl implements PwsClient {
private static final String TAG = "PhysicalWeb";
private static final String ENDPOINT_URL =
"https://physicalweb.googleapis.com/v1alpha1/urls:resolve";
// Format strings for creating the User-Agent string. It should somewhat resemble the Chrome for
// Android User-Agent but doesn't need to match perfectly as this value will only be seen by the
// Physical Web metadata service and favicon fetcher.
// The WebKit version is not accessible from here so it is reported as 0.0.
private static final String USER_AGENT_FORMAT =
"Mozilla/5.0 (%s) AppleWebKit/0.0 (KHTML, like Gecko) %s Safari/0.0";
private static final String OS_INFO_FORMAT = "Linux; Android %s; %s Build/%s";
private static final String PRODUCT_FORMAT = "Chrome/%s Mobile";
// HTTP request header strings, lazily initialized.
private static String sUserAgent;
private static String sAcceptLanguage;
// Cached locale string. When the default locale changes, recreate the Accept-Language header.
private static String sDefaultLocale;
// The context must be valid for as long as this client is in use, since it is used to recreate
// the Accept-Language header when the locale changes.
private final Context mContext;
public PwsClientImpl(Context context) {
mContext = context;
}
private String getApiKey() {
if (ChromeVersionInfo.isStableBuild()) {
return GoogleAPIKeys.GOOGLE_API_KEY;
} else {
return GoogleAPIKeys.GOOGLE_API_KEY_PHYSICAL_WEB_TEST;
}
}
private static JSONObject createResolveScanPayload(Collection<UrlInfo> urls)
throws JSONException {
// Encode the urls.
JSONArray objects = new JSONArray();
for (UrlInfo urlInfo : urls) {
JSONObject obj = new JSONObject();
obj.put("url", urlInfo.getUrl());
objects.put(obj);
}
// Organize the data into a single object.
JSONObject jsonObject = new JSONObject();
jsonObject.put("urls", objects);
return jsonObject;
}
private static Collection<PwsResult> parseResolveScanResponse(JSONObject result) {
// Get the metadata array.
Collection<PwsResult> pwsResults = new ArrayList<>();
JSONArray metadata = result.optJSONArray("results");
if (metadata == null) {
// There are no valid results.
return pwsResults;
}
// Loop through the metadata for each url.
for (int i = 0; i < metadata.length(); i++) {
try {
pwsResults.add(PwsResult.jsonDeserialize(metadata.getJSONObject(i)));
} catch (JSONException e) {
Log.e(TAG, "PWS returned invalid data", e);
continue;
}
}
return pwsResults;
}
/**
* Send an HTTP request to the PWS to resolve a set of URLs.
* @param broadcastUrls The URLs to resolve.
* @param resolveScanCallback The callback to be run when the response is received.
*/
@Override
public void resolve(final Collection<UrlInfo> broadcastUrls,
final ResolveScanCallback resolveScanCallback) {
// Create the response callback.
JsonObjectHttpRequest.RequestCallback requestCallback =
new JsonObjectHttpRequest.RequestCallback() {
@Override
public void onResponse(JSONObject result) {
ThreadUtils.assertOnUiThread();
Collection<PwsResult> pwsResults = parseResolveScanResponse(result);
resolveScanCallback.onPwsResults(pwsResults);
}
@Override
public void onError(int responseCode, Exception e) {
ThreadUtils.assertOnUiThread();
String httpErr = "";
if (responseCode > 0) {
httpErr = ", HTTP " + responseCode;
}
Log.e(TAG, "Error making request to PWS%s", httpErr);
resolveScanCallback.onPwsResults(new ArrayList<PwsResult>());
}
};
// Create the request.
HttpRequest request = null;
try {
JSONObject payload = createResolveScanPayload(broadcastUrls);
String url = ENDPOINT_URL + "?key=" + getApiKey();
request = new JsonObjectHttpRequest(url, getUserAgent(), getAcceptLanguage(), payload,
requestCallback);
} catch (MalformedURLException e) {
Log.e(TAG, "Error creating PWS HTTP request", e);
return;
} catch (JSONException e) {
Log.e(TAG, "Error creating PWS JSON payload", e);
return;
}
// The callback will be called on the main thread.
AsyncTask.THREAD_POOL_EXECUTOR.execute(request);
}
/**
* Send an HTTP request to fetch a favicon.
* @param iconUrl The URL of the favicon.
* @param fetchIconCallback The callback to be run when the icon is received.
*/
@Override
public void fetchIcon(final String iconUrl,
final FetchIconCallback fetchIconCallback) {
// Create the response callback.
BitmapHttpRequest.RequestCallback requestCallback =
new BitmapHttpRequest.RequestCallback() {
@Override
public void onResponse(Bitmap iconBitmap) {
fetchIconCallback.onIconReceived(iconUrl, iconBitmap);
}
@Override
public void onError(int responseCode, Exception e) {
ThreadUtils.assertOnUiThread();
String httpErr = "";
if (responseCode > 0) {
httpErr = ", HTTP " + responseCode;
}
Log.e(TAG, "Error requesting icon%s", httpErr);
}
};
// Create the request.
BitmapHttpRequest request = null;
try {
request = new BitmapHttpRequest(iconUrl, getUserAgent(), getAcceptLanguage(),
requestCallback);
} catch (MalformedURLException e) {
Log.e(TAG, "Error creating icon request", e);
return;
}
// The callback will be called on the main thread.
AsyncTask.THREAD_POOL_EXECUTOR.execute(request);
}
/**
* Recreate the Chrome for Android User-Agent string as closely as possible without calling any
* native code.
* @return A User-Agent string
*/
@VisibleForTesting
String getUserAgent() {
if (sUserAgent == null) {
// Build the OS info string.
// eg: Linux; Android 5.1.1; Nexus 4 Build/LMY48T
String osInfo = String.format(OS_INFO_FORMAT, Build.VERSION.RELEASE, Build.MODEL,
Build.ID);
// Build the product string.
// eg: Chrome/50.0.2661.89 Mobile
String product = String.format(PRODUCT_FORMAT, ChromeVersionInfo.getProductVersion());
// Build the User-Agent string.
// eg: Mozilla/5.0 (Linux; Android 5.1.1; Nexus 4 Build/LMY48T) AppleWebKit/0.0 (KHTML,
// like Gecko) Chrome/50.0.2661.89 Mobile Safari/0.0
sUserAgent = String.format(USER_AGENT_FORMAT, osInfo, product);
}
return sUserAgent;
}
/**
* Construct the Accept-Language string based on the current locale.
* @return An Accept-Language string.
*/
@VisibleForTesting
String getAcceptLanguage() {
String defaultLocale = Locale.getDefault().toString();
if (sDefaultLocale == null || !sDefaultLocale.equals(defaultLocale)) {
String acceptLanguages = mContext.getResources().getString(R.string.accept_languages);
acceptLanguages = prependToAcceptLanguagesIfNecessary(defaultLocale, acceptLanguages);
sAcceptLanguage = generateAcceptLanguageHeader(acceptLanguages);
sDefaultLocale = defaultLocale;
}
return sAcceptLanguage;
}
/**
* Handle the special cases in converting a language code/region code pair into an ISO-639-1
* language tag.
* @param language The 2-character language code
* @param region The 2-character country code
* @return A language tag.
*/
@VisibleForTesting
static String makeLanguageTag(String language, String region) {
// Java mostly follows ISO-639-1 and ICU, except for the following three.
// See documentation on java.util.Locale constructor for more.
String isoLanguage;
if ("iw".equals(language)) {
isoLanguage = "he";
} else if ("ji".equals(language)) {
isoLanguage = "yi";
} else if ("in".equals(language)) {
isoLanguage = "id";
} else {
isoLanguage = language;
}
return isoLanguage + "-" + region;
}
/**
* Get the language code for the default locale and prepend it to the Accept-Language string if
* it isn't already present. The logic should match PrependToAcceptLanguagesIfNecessary in
* chrome/browser/android/preferences/pref_service_bridge.cc
* @param locale A string representing the default locale.
* @param acceptLanguages The default language list for the language of the user's locale.
* @return An updated language list.
*/
@VisibleForTesting
static String prependToAcceptLanguagesIfNecessary(String locale, String acceptLanguages)
{
if (locale.length() != 5 || locale.charAt(2) != '_') {
return acceptLanguages;
}
String language = locale.substring(0, 2);
String region = locale.substring(3);
String languageTag = makeLanguageTag(language, region);
if (acceptLanguages.contains(languageTag)) {
return acceptLanguages;
}
Formatter parts = new Formatter();
parts.format("%s,", languageTag);
// If language is not in the accept languages list, also add language code.
// This will work with the IDS_ACCEPT_LANGUAGES localized strings bundled with Chrome but
// may fail on arbitrary lists of language tags due to differences in case and whitespace.
if (!acceptLanguages.contains(language + ",") && !acceptLanguages.endsWith(language)) {
parts.format("%s,", language);
}
parts.format("%s", acceptLanguages);
return parts.toString();
}
/**
* Given a list of comma-delimited language codes in decreasing order of preference, insert
* q-values to represent the relative quality/precedence of each language. The logic should
* match GenerateAcceptLanguageHeader in net/http/http_util.cc.
* @param languageList A comma-delimited list of language codes containing no whitespace.
* @return An Accept-Language header with q-values.
*/
@VisibleForTesting
static String generateAcceptLanguageHeader(String languageList) {
// We use integers for qvalue and qvalue decrement that are 10 times larger than actual
// values to avoid a problem with comparing two floating point numbers.
int kQvalueDecrement10 = 2;
int qvalue10 = 10;
String[] parts = languageList.split(",");
Formatter langListWithQ = new Formatter();
for (String language : parts) {
if (qvalue10 == 10) {
// q=1.0 is implicit
langListWithQ.format("%s", language);
} else {
langListWithQ.format(",%s;q=0.%d", language, qvalue10);
}
// It does not make sense to have 'q=0'.
if (qvalue10 > kQvalueDecrement10) {
qvalue10 -= kQvalueDecrement10;
}
}
return langListWithQ.toString();
}
}