|
@@ -1,408 +0,0 @@
|
|
|
-/*
|
|
|
|
|
- * Copyright (c) 2016, Psiphon Inc.
|
|
|
|
|
- * All rights reserved.
|
|
|
|
|
- *
|
|
|
|
|
- * This program is free software: you can redistribute it and/or modify
|
|
|
|
|
- * it under the terms of the GNU General Public License as published by
|
|
|
|
|
- * the Free Software Foundation, either version 3 of the License, or
|
|
|
|
|
- * (at your option) any later version.
|
|
|
|
|
- *
|
|
|
|
|
- * This program is distributed in the hope that it will be useful,
|
|
|
|
|
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
- * GNU General Public License for more details.
|
|
|
|
|
- *
|
|
|
|
|
- * You should have received a copy of the GNU General Public License
|
|
|
|
|
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
- *
|
|
|
|
|
- */
|
|
|
|
|
-
|
|
|
|
|
-package ca.psiphon;
|
|
|
|
|
-
|
|
|
|
|
-import android.annotation.TargetApi;
|
|
|
|
|
-import android.content.Context;
|
|
|
|
|
-import android.net.ConnectivityManager;
|
|
|
|
|
-import android.net.NetworkInfo;
|
|
|
|
|
-import android.os.Build;
|
|
|
|
|
-import android.telephony.TelephonyManager;
|
|
|
|
|
-import android.util.Base64;
|
|
|
|
|
-
|
|
|
|
|
-import org.json.JSONArray;
|
|
|
|
|
-import org.json.JSONException;
|
|
|
|
|
-import org.json.JSONObject;
|
|
|
|
|
-
|
|
|
|
|
-import java.io.File;
|
|
|
|
|
-import java.io.FileInputStream;
|
|
|
|
|
-import java.io.FileOutputStream;
|
|
|
|
|
-import java.io.IOException;
|
|
|
|
|
-import java.io.PrintStream;
|
|
|
|
|
-import java.security.KeyStore;
|
|
|
|
|
-import java.security.KeyStoreException;
|
|
|
|
|
-import java.security.NoSuchAlgorithmException;
|
|
|
|
|
-import java.security.cert.CertificateException;
|
|
|
|
|
-import java.security.cert.X509Certificate;
|
|
|
|
|
-import java.util.ArrayList;
|
|
|
|
|
-import java.util.Enumeration;
|
|
|
|
|
-import java.util.List;
|
|
|
|
|
-import java.util.Locale;
|
|
|
|
|
-import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
|
|
-
|
|
|
|
|
-import go.psi.Psi;
|
|
|
|
|
-
|
|
|
|
|
-public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
|
|
|
|
|
-
|
|
|
|
|
- public interface TunneledApp {
|
|
|
|
|
- Context getContext();
|
|
|
|
|
- String getPsiphonConfig();
|
|
|
|
|
- void onDiagnosticMessage(String message);
|
|
|
|
|
- void onAvailableEgressRegions(List<String> regions);
|
|
|
|
|
- void onSocksProxyPortInUse(int port);
|
|
|
|
|
- void onHttpProxyPortInUse(int port);
|
|
|
|
|
- void onListeningSocksProxyPort(int port);
|
|
|
|
|
- void onListeningHttpProxyPort(int port);
|
|
|
|
|
- void onUpstreamProxyError(String message);
|
|
|
|
|
- void onConnecting();
|
|
|
|
|
- void onConnected();
|
|
|
|
|
- void onHomepage(String url);
|
|
|
|
|
- void onClientRegion(String region);
|
|
|
|
|
- void onClientUpgradeDownloaded(String filename);
|
|
|
|
|
- void onSplitTunnelRegion(String region);
|
|
|
|
|
- void onUntunneledAddress(String address);
|
|
|
|
|
- void onBytesTransferred(long sent, long received);
|
|
|
|
|
- void onStartedWaitingForNetworkConnectivity();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private final TunneledApp mTunneledApp;
|
|
|
|
|
- private AtomicBoolean mIsWaitingForNetworkConnectivity;
|
|
|
|
|
-
|
|
|
|
|
- // Only one PsiphonVpn instance may exist at a time, as the underlying
|
|
|
|
|
- // go.psi.Psi contains global state.
|
|
|
|
|
- private static PsiphonTunnel mPsiphonTunnel;
|
|
|
|
|
-
|
|
|
|
|
- public static synchronized PsiphonTunnel newPsiphonTunnel(TunneledApp tunneledApp) {
|
|
|
|
|
- if (mPsiphonTunnel != null) {
|
|
|
|
|
- mPsiphonTunnel.stop();
|
|
|
|
|
- }
|
|
|
|
|
- // Load the native go code embedded in psi.aar
|
|
|
|
|
- System.loadLibrary("gojni");
|
|
|
|
|
- mPsiphonTunnel = new PsiphonTunnel(tunneledApp);
|
|
|
|
|
- return mPsiphonTunnel;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private PsiphonTunnel(TunneledApp tunneledApp) {
|
|
|
|
|
- mTunneledApp = tunneledApp;
|
|
|
|
|
- mIsWaitingForNetworkConnectivity = new AtomicBoolean(false);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- public Object clone() throws CloneNotSupportedException {
|
|
|
|
|
- throw new CloneNotSupportedException();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- //----------------------------------------------------------------------------------------------
|
|
|
|
|
- // Public API
|
|
|
|
|
- //----------------------------------------------------------------------------------------------
|
|
|
|
|
-
|
|
|
|
|
- public synchronized boolean start(String embeddedServerEntries) {
|
|
|
|
|
- return startPsiphon(embeddedServerEntries);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- public synchronized void stop() {
|
|
|
|
|
- stopPsiphon();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- //----------------------------------------------------------------------------------------------
|
|
|
|
|
- // PsiphonProvider (Core support) interface implementation
|
|
|
|
|
- //----------------------------------------------------------------------------------------------
|
|
|
|
|
-
|
|
|
|
|
- @Override
|
|
|
|
|
- public void Notice(String noticeJSON) {
|
|
|
|
|
- handlePsiphonNotice(noticeJSON);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
|
|
|
|
|
- @Override
|
|
|
|
|
- public void BindToDevice(long fileDescriptor) throws Exception {
|
|
|
|
|
- // This PsiphonProvider function is only called in TunnelWholeDevice mode
|
|
|
|
|
- throw new Exception("BindToDevice not supported");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @Override
|
|
|
|
|
- public long HasNetworkConnectivity() {
|
|
|
|
|
- boolean hasConnectivity = hasNetworkConnectivity(mTunneledApp.getContext());
|
|
|
|
|
- boolean wasWaitingForNetworkConnectivity = mIsWaitingForNetworkConnectivity.getAndSet(!hasConnectivity);
|
|
|
|
|
- if (!hasConnectivity && !wasWaitingForNetworkConnectivity) {
|
|
|
|
|
- // HasNetworkConnectivity may be called many times, but only call
|
|
|
|
|
- // onStartedWaitingForNetworkConnectivity once per loss of connectivity,
|
|
|
|
|
- // so the HostService may log a single message.
|
|
|
|
|
- mTunneledApp.onStartedWaitingForNetworkConnectivity();
|
|
|
|
|
- }
|
|
|
|
|
- // TODO: change to bool return value once gobind supports that type
|
|
|
|
|
- return hasConnectivity ? 1 : 0;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private static boolean hasNetworkConnectivity(Context context) {
|
|
|
|
|
- ConnectivityManager connectivityManager =
|
|
|
|
|
- (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
|
|
|
|
- NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
|
|
|
|
|
- return networkInfo != null && networkInfo.isConnected();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @Override
|
|
|
|
|
- public String GetPrimaryDnsServer() {
|
|
|
|
|
- // This PsiphonProvider function is only called in TunnelWholeDevice mode
|
|
|
|
|
- return "";
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @Override
|
|
|
|
|
- public String GetSecondaryDnsServer() {
|
|
|
|
|
- // This PsiphonProvider function is only called in TunnelWholeDevice mode
|
|
|
|
|
- return "";
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- //----------------------------------------------------------------------------------------------
|
|
|
|
|
- // Psiphon Tunnel Core
|
|
|
|
|
- //----------------------------------------------------------------------------------------------
|
|
|
|
|
-
|
|
|
|
|
- private boolean startPsiphon(String embeddedServerEntries) {
|
|
|
|
|
- stopPsiphon();
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage("starting Psiphon library");
|
|
|
|
|
- try {
|
|
|
|
|
- Psi.Start(
|
|
|
|
|
- loadPsiphonConfig(mTunneledApp.getContext()),
|
|
|
|
|
- embeddedServerEntries,
|
|
|
|
|
- this,
|
|
|
|
|
- false);
|
|
|
|
|
- } catch (java.lang.Exception e) {
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage("failed to start Psiphon library: " + e.getMessage());
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage("Psiphon library started");
|
|
|
|
|
- return true;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private void stopPsiphon() {
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage("stopping Psiphon library");
|
|
|
|
|
- Psi.Stop();
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage("Psiphon library stopped");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private String loadPsiphonConfig(Context context)
|
|
|
|
|
- throws IOException, JSONException {
|
|
|
|
|
-
|
|
|
|
|
- // Load settings from the raw resource JSON config file and
|
|
|
|
|
- // update as necessary. Then write JSON to disk for the Go client.
|
|
|
|
|
- JSONObject json = new JSONObject(mTunneledApp.getPsiphonConfig());
|
|
|
|
|
-
|
|
|
|
|
- // On Android, these directories must be set to the app private storage area.
|
|
|
|
|
- // The Psiphon library won't be able to use its current working directory
|
|
|
|
|
- // and the standard temporary directories do not exist.
|
|
|
|
|
- json.put("DataStoreDirectory", context.getFilesDir());
|
|
|
|
|
-
|
|
|
|
|
- File remoteServerListDownload = new File(context.getFilesDir(), "remote_server_list");
|
|
|
|
|
- json.put("RemoteServerListDownloadFilename", remoteServerListDownload.getPath());
|
|
|
|
|
-
|
|
|
|
|
- // Note: onConnecting/onConnected logic assumes 1 tunnel connection
|
|
|
|
|
- json.put("TunnelPoolSize", 1);
|
|
|
|
|
-
|
|
|
|
|
- // Continue to run indefinitely until connected
|
|
|
|
|
- json.put("EstablishTunnelTimeoutSeconds", 0);
|
|
|
|
|
-
|
|
|
|
|
- // This parameter is for stats reporting
|
|
|
|
|
- json.put("TunnelWholeDevice", 0);
|
|
|
|
|
-
|
|
|
|
|
- json.put("EmitBytesTransferred", true);
|
|
|
|
|
-
|
|
|
|
|
- json.put("UseIndistinguishableTLS", true);
|
|
|
|
|
-
|
|
|
|
|
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
|
|
|
|
|
- json.put("UseTrustedCACertificatesForStockTLS", true);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- // Also enable indistinguishable TLS for HTTPS requests that
|
|
|
|
|
- // require system CAs.
|
|
|
|
|
- json.put(
|
|
|
|
|
- "TrustedCACertificatesFilename",
|
|
|
|
|
- setupTrustedCertificates(mTunneledApp.getContext()));
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage(e.getMessage());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- json.put("DeviceRegion", getDeviceRegion(mTunneledApp.getContext()));
|
|
|
|
|
-
|
|
|
|
|
- return json.toString();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private void handlePsiphonNotice(String noticeJSON) {
|
|
|
|
|
- try {
|
|
|
|
|
- // All notices are sent on as diagnostic messages
|
|
|
|
|
- // except those that may contain private user data.
|
|
|
|
|
- boolean diagnostic = true;
|
|
|
|
|
-
|
|
|
|
|
- JSONObject notice = new JSONObject(noticeJSON);
|
|
|
|
|
- String noticeType = notice.getString("noticeType");
|
|
|
|
|
-
|
|
|
|
|
- if (noticeType.equals("Tunnels")) {
|
|
|
|
|
- int count = notice.getJSONObject("data").getInt("count");
|
|
|
|
|
- if (count > 0) {
|
|
|
|
|
- mTunneledApp.onConnected();
|
|
|
|
|
- } else {
|
|
|
|
|
- mTunneledApp.onConnecting();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("AvailableEgressRegions")) {
|
|
|
|
|
- JSONArray egressRegions = notice.getJSONObject("data").getJSONArray("regions");
|
|
|
|
|
- ArrayList<String> regions = new ArrayList<String>();
|
|
|
|
|
- for (int i=0; i<egressRegions.length(); i++) {
|
|
|
|
|
- regions.add(egressRegions.getString(i));
|
|
|
|
|
- }
|
|
|
|
|
- mTunneledApp.onAvailableEgressRegions(regions);
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("SocksProxyPortInUse")) {
|
|
|
|
|
- mTunneledApp.onSocksProxyPortInUse(notice.getJSONObject("data").getInt("port"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("HttpProxyPortInUse")) {
|
|
|
|
|
- mTunneledApp.onHttpProxyPortInUse(notice.getJSONObject("data").getInt("port"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("ListeningSocksProxyPort")) {
|
|
|
|
|
- int port = notice.getJSONObject("data").getInt("port");
|
|
|
|
|
- mTunneledApp.onListeningSocksProxyPort(port);
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("ListeningHttpProxyPort")) {
|
|
|
|
|
- int port = notice.getJSONObject("data").getInt("port");
|
|
|
|
|
- mTunneledApp.onListeningHttpProxyPort(port);
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("UpstreamProxyError")) {
|
|
|
|
|
- mTunneledApp.onUpstreamProxyError(notice.getJSONObject("data").getString("message"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("ClientUpgradeDownloaded")) {
|
|
|
|
|
- mTunneledApp.onClientUpgradeDownloaded(notice.getJSONObject("data").getString("filename"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("Homepage")) {
|
|
|
|
|
- mTunneledApp.onHomepage(notice.getJSONObject("data").getString("url"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("ClientRegion")) {
|
|
|
|
|
- mTunneledApp.onClientRegion(notice.getJSONObject("data").getString("region"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("SplitTunnelRegion")) {
|
|
|
|
|
- mTunneledApp.onSplitTunnelRegion(notice.getJSONObject("data").getString("region"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("UntunneledAddress")) {
|
|
|
|
|
- mTunneledApp.onUntunneledAddress(notice.getJSONObject("data").getString("address"));
|
|
|
|
|
-
|
|
|
|
|
- } else if (noticeType.equals("BytesTransferred")) {
|
|
|
|
|
- diagnostic = false;
|
|
|
|
|
- JSONObject data = notice.getJSONObject("data");
|
|
|
|
|
- mTunneledApp.onBytesTransferred(data.getLong("sent"), data.getLong("received"));
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (diagnostic) {
|
|
|
|
|
- String diagnosticMessage = noticeType + ": " + notice.getJSONObject("data").toString();
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage(diagnosticMessage);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- } catch (JSONException e) {
|
|
|
|
|
- // Ignore notice
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private String setupTrustedCertificates(Context context) throws Exception {
|
|
|
|
|
-
|
|
|
|
|
- // Copy the Android system CA store to a local, private cert bundle file.
|
|
|
|
|
- //
|
|
|
|
|
- // This results in a file that can be passed to SSL_CTX_load_verify_locations
|
|
|
|
|
- // for use with OpenSSL modes in tunnel-core.
|
|
|
|
|
- // https://www.openssl.org/docs/manmaster/ssl/SSL_CTX_load_verify_locations.html
|
|
|
|
|
- //
|
|
|
|
|
- // TODO: to use the path mode of load_verify_locations would require emulating
|
|
|
|
|
- // the filename scheme used by c_rehash:
|
|
|
|
|
- // https://www.openssl.org/docs/manmaster/apps/c_rehash.html
|
|
|
|
|
- // http://stackoverflow.com/questions/19237167/the-new-subject-hash-openssl-algorithm-differs
|
|
|
|
|
-
|
|
|
|
|
- File directory = context.getDir("PsiphonCAStore", Context.MODE_PRIVATE);
|
|
|
|
|
-
|
|
|
|
|
- final String errorMessage = "copy AndroidCAStore failed";
|
|
|
|
|
- try {
|
|
|
|
|
-
|
|
|
|
|
- File file = new File(directory, "certs.dat");
|
|
|
|
|
-
|
|
|
|
|
- // Pave a fresh copy on every run, which ensures we're not using old certs.
|
|
|
|
|
- // Note: assumes KeyStore doesn't return revoked certs.
|
|
|
|
|
- //
|
|
|
|
|
- // TODO: this takes under 1 second, but should we avoid repaving every time?
|
|
|
|
|
- file.delete();
|
|
|
|
|
-
|
|
|
|
|
- PrintStream output = null;
|
|
|
|
|
- try {
|
|
|
|
|
- output = new PrintStream(new FileOutputStream(file));
|
|
|
|
|
-
|
|
|
|
|
- KeyStore keyStore;
|
|
|
|
|
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
|
|
|
|
|
- keyStore = KeyStore.getInstance("AndroidCAStore");
|
|
|
|
|
- keyStore.load(null, null);
|
|
|
|
|
- } else {
|
|
|
|
|
- keyStore = KeyStore.getInstance("BKS");
|
|
|
|
|
- FileInputStream inputStream = new FileInputStream("/etc/security/cacerts.bks");
|
|
|
|
|
- try {
|
|
|
|
|
- keyStore.load(inputStream, "changeit".toCharArray());
|
|
|
|
|
- } finally {
|
|
|
|
|
- if (inputStream != null) {
|
|
|
|
|
- inputStream.close();
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- Enumeration<String> aliases = keyStore.aliases();
|
|
|
|
|
- while (aliases.hasMoreElements()) {
|
|
|
|
|
- String alias = aliases.nextElement();
|
|
|
|
|
- X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
|
|
|
|
-
|
|
|
|
|
- output.println("-----BEGIN CERTIFICATE-----");
|
|
|
|
|
- String pemCert = new String(Base64.encode(cert.getEncoded(), Base64.NO_WRAP), "UTF-8");
|
|
|
|
|
- // OpenSSL appears to reject the default linebreaking done by Base64.encode,
|
|
|
|
|
- // so we manually linebreak every 64 characters
|
|
|
|
|
- for (int i = 0; i < pemCert.length() ; i+= 64) {
|
|
|
|
|
- output.println(pemCert.substring(i, Math.min(i + 64, pemCert.length())));
|
|
|
|
|
- }
|
|
|
|
|
- output.println("-----END CERTIFICATE-----");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- mTunneledApp.onDiagnosticMessage("prepared PsiphonCAStore");
|
|
|
|
|
-
|
|
|
|
|
- return file.getAbsolutePath();
|
|
|
|
|
-
|
|
|
|
|
- } finally {
|
|
|
|
|
- if (output != null) {
|
|
|
|
|
- output.close();
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- } catch (KeyStoreException e) {
|
|
|
|
|
- throw new Exception(errorMessage, e);
|
|
|
|
|
- } catch (NoSuchAlgorithmException e) {
|
|
|
|
|
- throw new Exception(errorMessage, e);
|
|
|
|
|
- } catch (CertificateException e) {
|
|
|
|
|
- throw new Exception(errorMessage, e);
|
|
|
|
|
- } catch (IOException e) {
|
|
|
|
|
- throw new Exception(errorMessage, e);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private static String getDeviceRegion(Context context) {
|
|
|
|
|
- String region = "";
|
|
|
|
|
- TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
|
|
|
|
|
- if (telephonyManager != null) {
|
|
|
|
|
- region = telephonyManager.getSimCountryIso();
|
|
|
|
|
- if (region.length() == 0 && telephonyManager.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) {
|
|
|
|
|
- region = telephonyManager.getNetworkCountryIso();
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if (region.length() == 0) {
|
|
|
|
|
- Locale defaultLocale = Locale.getDefault();
|
|
|
|
|
- if (defaultLocale != null) {
|
|
|
|
|
- region = defaultLocale.getCountry();
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return region.toUpperCase(Locale.US);
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|