]> git.basschouten.com Git - openhab-addons.git/blob
6dc9e05105d7750c2aad4ec216e7230b89c84efb
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.amazonechocontrol.internal;
14
15 import static org.openhab.binding.amazonechocontrol.internal.AmazonEchoControlBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.URISyntaxException;
19 import java.net.URLDecoder;
20 import java.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.util.HashMap;
23 import java.util.Hashtable;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.stream.Collectors;
27
28 import javax.net.ssl.HttpsURLConnection;
29 import javax.servlet.ServletException;
30 import javax.servlet.http.HttpServlet;
31 import javax.servlet.http.HttpServletRequest;
32 import javax.servlet.http.HttpServletResponse;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.amazonechocontrol.internal.handler.AccountHandler;
37 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates;
38 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates.BluetoothState;
39 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonBluetoothStates.PairedDevice;
40 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonDevices.Device;
41 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonMusicProvider;
42 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonNotificationSound;
43 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists;
44 import org.openhab.binding.amazonechocontrol.internal.jsons.JsonPlaylists.PlayList;
45 import org.openhab.core.thing.Thing;
46 import org.osgi.service.http.HttpService;
47 import org.osgi.service.http.NamespaceException;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50 import org.unbescape.html.HtmlEscape;
51
52 import com.google.gson.Gson;
53 import com.google.gson.JsonSyntaxException;
54
55 /**
56  * Provides the following functions
57  * --- Login ---
58  * Simple http proxy to forward the login dialog from amazon to the user through the binding
59  * so the user can enter a captcha or other extended login information
60  * --- List of devices ---
61  * Used to get the device information of new devices which are currently not known
62  * --- List of IDs ---
63  * Simple possibility for a user to get the ids needed for writing rules
64  *
65  * @author Michael Geramb - Initial Contribution
66  */
67 @NonNullByDefault
68 public class AccountServlet extends HttpServlet {
69
70     private static final long serialVersionUID = -1453738923337413163L;
71     private static final String FORWARD_URI_PART = "/FORWARD/";
72     private static final String PROXY_URI_PART = "/PROXY/";
73
74     private final Logger logger = LoggerFactory.getLogger(AccountServlet.class);
75
76     private final HttpService httpService;
77     private final String servletUrlWithoutRoot;
78     private final String servletUrl;
79     private final AccountHandler account;
80     private final String id;
81     private @Nullable Connection connectionToInitialize;
82     private final Gson gson;
83
84     public AccountServlet(HttpService httpService, String id, AccountHandler account, Gson gson) {
85         this.httpService = httpService;
86         this.account = account;
87         this.id = id;
88         this.gson = gson;
89
90         try {
91             servletUrlWithoutRoot = "amazonechocontrol/" + URLEncoder.encode(id, StandardCharsets.UTF_8);
92             servletUrl = "/" + servletUrlWithoutRoot;
93
94             Hashtable<Object, Object> initParams = new Hashtable<>();
95             initParams.put("servlet-name", servletUrl);
96
97             httpService.registerServlet(servletUrl, this, initParams, httpService.createDefaultHttpContext());
98         } catch (NamespaceException | ServletException e) {
99             throw new IllegalStateException(e.getMessage());
100         }
101     }
102
103     private Connection reCreateConnection() {
104         Connection oldConnection = connectionToInitialize;
105         if (oldConnection == null) {
106             oldConnection = account.findConnection();
107         }
108         return new Connection(oldConnection, this.gson);
109     }
110
111     public void dispose() {
112         httpService.unregister(servletUrl);
113     }
114
115     @Override
116     protected void doPut(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
117             throws ServletException, IOException {
118         doVerb("PUT", req, resp);
119     }
120
121     @Override
122     protected void doDelete(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
123             throws ServletException, IOException {
124         doVerb("DELETE", req, resp);
125     }
126
127     @Override
128     protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
129             throws ServletException, IOException {
130         doVerb("POST", req, resp);
131     }
132
133     void doVerb(String verb, @Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException {
134         if (req == null) {
135             return;
136         }
137         if (resp == null) {
138             return;
139         }
140         String requestUri = req.getRequestURI();
141         if (requestUri == null) {
142             return;
143         }
144         String baseUrl = requestUri.substring(servletUrl.length());
145         String uri = baseUrl;
146         String queryString = req.getQueryString();
147         if (queryString != null && queryString.length() > 0) {
148             uri += "?" + queryString;
149         }
150
151         Connection connection = this.account.findConnection();
152         if (connection != null && "/changedomain".equals(uri)) {
153             Map<String, String[]> map = req.getParameterMap();
154             String[] domainArray = map.get("domain");
155             if (domainArray == null) {
156                 logger.warn("Could not determine domain");
157                 return;
158             }
159             String domain = domainArray[0];
160             String loginData = connection.serializeLoginData();
161             Connection newConnection = new Connection(null, this.gson);
162             if (newConnection.tryRestoreLogin(loginData, domain)) {
163                 account.setConnection(newConnection);
164             }
165             resp.sendRedirect(servletUrl);
166             return;
167         }
168         if (uri.startsWith(PROXY_URI_PART)) {
169             // handle proxy request
170
171             if (connection == null) {
172                 returnError(resp, "Account not online");
173                 return;
174             }
175             String getUrl = "https://alexa." + connection.getAmazonSite() + "/"
176                     + uri.substring(PROXY_URI_PART.length());
177
178             String postData = null;
179             if ("POST".equals(verb) || "PUT".equals(verb)) {
180                 postData = req.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
181             }
182
183             this.handleProxyRequest(connection, resp, verb, getUrl, null, postData, true, connection.getAmazonSite());
184             return;
185         }
186
187         // handle post of login page
188         connection = this.connectionToInitialize;
189         if (connection == null) {
190             returnError(resp, "Connection not in initialize mode.");
191             return;
192         }
193
194         resp.addHeader("content-type", "text/html;charset=UTF-8");
195
196         Map<String, String[]> map = req.getParameterMap();
197         StringBuilder postDataBuilder = new StringBuilder();
198         for (String name : map.keySet()) {
199             if (postDataBuilder.length() > 0) {
200                 postDataBuilder.append('&');
201             }
202
203             postDataBuilder.append(name);
204             postDataBuilder.append('=');
205             String value = "";
206             if ("failedSignInCount".equals(name)) {
207                 value = "ape:AA==";
208             } else {
209                 String[] strings = map.get(name);
210                 if (strings != null && strings.length > 0 && strings[0] != null) {
211                     value = strings[0];
212                 }
213             }
214             postDataBuilder.append(URLEncoder.encode(value, StandardCharsets.UTF_8.name()));
215         }
216
217         uri = req.getRequestURI();
218         if (uri == null || !uri.startsWith(servletUrl)) {
219             returnError(resp, "Invalid request uri '" + uri + "'");
220             return;
221         }
222         String relativeUrl = uri.substring(servletUrl.length()).replace(FORWARD_URI_PART, "/");
223
224         String site = connection.getAmazonSite();
225         if (relativeUrl.startsWith("/ap/signin")) {
226             site = "amazon.com";
227         }
228         String postUrl = "https://www." + site + relativeUrl;
229         queryString = req.getQueryString();
230         if (queryString != null && queryString.length() > 0) {
231             postUrl += "?" + queryString;
232         }
233         String referer = "https://www." + site;
234         String postData = postDataBuilder.toString();
235         handleProxyRequest(connection, resp, "POST", postUrl, referer, postData, false, site);
236     }
237
238     @Override
239     protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp) throws IOException {
240         if (req == null) {
241             return;
242         }
243         if (resp == null) {
244             return;
245         }
246         String requestUri = req.getRequestURI();
247         if (requestUri == null) {
248             return;
249         }
250         String baseUrl = requestUri.substring(servletUrl.length());
251         String uri = baseUrl;
252         String queryString = req.getQueryString();
253         if (queryString != null && queryString.length() > 0) {
254             uri += "?" + queryString;
255         }
256         logger.debug("doGet {}", uri);
257         try {
258             Connection connection = this.connectionToInitialize;
259             if (uri.startsWith(FORWARD_URI_PART) && connection != null) {
260                 String getUrl = "https://www." + connection.getAmazonSite() + "/"
261                         + uri.substring(FORWARD_URI_PART.length());
262
263                 this.handleProxyRequest(connection, resp, "GET", getUrl, null, null, false, connection.getAmazonSite());
264                 return;
265             }
266
267             connection = this.account.findConnection();
268             if (uri.startsWith(PROXY_URI_PART)) {
269                 // handle proxy request
270
271                 if (connection == null) {
272                     returnError(resp, "Account not online");
273                     return;
274                 }
275                 String getUrl = "https://alexa." + connection.getAmazonSite() + "/"
276                         + uri.substring(PROXY_URI_PART.length());
277
278                 this.handleProxyRequest(connection, resp, "GET", getUrl, null, null, false, connection.getAmazonSite());
279                 return;
280             }
281
282             if (connection != null && connection.verifyLogin()) {
283                 // handle commands
284                 if ("/logout".equals(baseUrl) || "/logout/".equals(baseUrl)) {
285                     this.connectionToInitialize = reCreateConnection();
286                     this.account.setConnection(null);
287                     resp.sendRedirect(this.servletUrl);
288                     return;
289                 }
290                 // handle commands
291                 if ("/newdevice".equals(baseUrl) || "/newdevice/".equals(baseUrl)) {
292                     this.connectionToInitialize = new Connection(null, this.gson);
293                     this.account.setConnection(null);
294                     resp.sendRedirect(this.servletUrl);
295                     return;
296                 }
297
298                 if ("/devices".equals(baseUrl) || "/devices/".equals(baseUrl)) {
299                     handleDevices(resp, connection);
300                     return;
301                 }
302                 if ("/changeDomain".equals(baseUrl) || "/changeDomain/".equals(baseUrl)) {
303                     handleChangeDomain(resp, connection);
304                     return;
305                 }
306                 if ("/ids".equals(baseUrl) || "/ids/".equals(baseUrl)) {
307                     String serialNumber = getQueryMap(queryString).get("serialNumber");
308                     Device device = account.findDeviceJson(serialNumber);
309                     if (device != null) {
310                         Thing thing = account.findThingBySerialNumber(device.serialNumber);
311                         handleIds(resp, connection, device, thing);
312                         return;
313                     }
314                 }
315                 // return hint that everything is ok
316                 handleDefaultPageResult(resp, "The Account is logged in.", connection);
317                 return;
318             }
319             connection = this.connectionToInitialize;
320             if (connection == null) {
321                 connection = this.reCreateConnection();
322                 this.connectionToInitialize = connection;
323             }
324
325             if (!"/".equals(uri)) {
326                 String newUri = req.getServletPath() + "/";
327                 resp.sendRedirect(newUri);
328                 return;
329             }
330
331             String html = connection.getLoginPage();
332             returnHtml(connection, resp, html, "amazon.com");
333         } catch (URISyntaxException | InterruptedException e) {
334             logger.warn("get failed with uri syntax error", e);
335         }
336     }
337
338     public Map<String, String> getQueryMap(@Nullable String query) {
339         Map<String, String> map = new HashMap<>();
340         if (query != null) {
341             String[] params = query.split("&");
342             for (String param : params) {
343                 String[] elements = param.split("=");
344                 if (elements.length == 2) {
345                     String name = elements[0];
346                     String value = URLDecoder.decode(elements[1], StandardCharsets.UTF_8);
347                     map.put(name, value);
348                 }
349             }
350         }
351         return map;
352     }
353
354     private void handleChangeDomain(HttpServletResponse resp, Connection connection) {
355         StringBuilder html = createPageStart("Change Domain");
356         html.append("<form action='");
357         html.append(servletUrl);
358         html.append("/changedomain' method='post'>\nDomain:\n<input type='text' name='domain' value='");
359         html.append(connection.getAmazonSite());
360         html.append("'>\n<br>\n<input type=\"submit\" value=\"Submit\">\n</form>");
361
362         createPageEndAndSent(resp, html);
363     }
364
365     private void handleDefaultPageResult(HttpServletResponse resp, String message, Connection connection)
366             throws IOException {
367         StringBuilder html = createPageStart("");
368         html.append(HtmlEscape.escapeHtml4(message));
369         // logout link
370         html.append(" <a href='" + servletUrl + "/logout' >");
371         html.append(HtmlEscape.escapeHtml4("Logout"));
372         html.append("</a>");
373         // newdevice link
374         html.append(" | <a href='" + servletUrl + "/newdevice' >");
375         html.append(HtmlEscape.escapeHtml4("Logout and create new device id"));
376         html.append("</a>");
377         // customer id
378         html.append("<br>Customer Id: ");
379         html.append(HtmlEscape.escapeHtml4(connection.getCustomerId()));
380         // customer name
381         html.append("<br>Customer Name: ");
382         html.append(HtmlEscape.escapeHtml4(connection.getCustomerName()));
383         // device name
384         html.append("<br>App name: ");
385         html.append(HtmlEscape.escapeHtml4(connection.getDeviceName()));
386         // connection
387         html.append("<br>Connected to: ");
388         html.append(HtmlEscape.escapeHtml4(connection.getAlexaServer()));
389         // domain
390         html.append(" <a href='");
391         html.append(servletUrl);
392         html.append("/changeDomain'>Change</a>");
393
394         // Main UI link
395         html.append("<br><a href='/#!/settings/things/" + BINDING_ID + ":"
396                 + URLEncoder.encode(THING_TYPE_ACCOUNT.getId(), "UTF8") + ":" + URLEncoder.encode(id, "UTF8") + "'>");
397         html.append(HtmlEscape.escapeHtml4("Check Thing in Main UI"));
398         html.append("</a><br><br>");
399
400         // device list
401         html.append(
402                 "<table><tr><th align='left'>Device</th><th align='left'>Serial Number</th><th align='left'>State</th><th align='left'>Thing</th><th align='left'>Family</th><th align='left'>Type</th><th align='left'>Customer Id</th></tr>");
403         for (Device device : this.account.getLastKnownDevices()) {
404
405             html.append("<tr><td>");
406             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.accountName)));
407             html.append("</td><td>");
408             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.serialNumber)));
409             html.append("</td><td>");
410             html.append(HtmlEscape.escapeHtml4(device.online ? "Online" : "Offline"));
411             html.append("</td><td>");
412             Thing accountHandler = account.findThingBySerialNumber(device.serialNumber);
413             if (accountHandler != null) {
414                 html.append("<a href='" + servletUrl + "/ids/?serialNumber="
415                         + URLEncoder.encode(device.serialNumber, "UTF8") + "'>"
416                         + HtmlEscape.escapeHtml4(accountHandler.getLabel()) + "</a>");
417             } else {
418                 html.append("<a href='" + servletUrl + "/ids/?serialNumber="
419                         + URLEncoder.encode(device.serialNumber, "UTF8") + "'>" + HtmlEscape.escapeHtml4("Not defined")
420                         + "</a>");
421             }
422             html.append("</td><td>");
423             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.deviceFamily)));
424             html.append("</td><td>");
425             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.deviceType)));
426             html.append("</td><td>");
427             html.append(HtmlEscape.escapeHtml4(nullReplacement(device.deviceOwnerCustomerId)));
428             html.append("</td>");
429             html.append("</tr>");
430         }
431         html.append("</table>");
432         createPageEndAndSent(resp, html);
433     }
434
435     private void handleDevices(HttpServletResponse resp, Connection connection)
436             throws IOException, URISyntaxException, InterruptedException {
437         returnHtml(connection, resp, "<html>" + HtmlEscape.escapeHtml4(connection.getDeviceListJson()) + "</html>");
438     }
439
440     private String nullReplacement(@Nullable String text) {
441         if (text == null) {
442             return "<unknown>";
443         }
444         return text;
445     }
446
447     StringBuilder createPageStart(String title) {
448         StringBuilder html = new StringBuilder();
449         html.append("<html><head><title>"
450                 + HtmlEscape.escapeHtml4(BINDING_NAME + " - " + this.account.getThing().getLabel()));
451         if (!title.isEmpty()) {
452             html.append(" - ");
453             html.append(HtmlEscape.escapeHtml4(title));
454         }
455         html.append("</title><head><body>");
456         html.append("<h1>" + HtmlEscape.escapeHtml4(BINDING_NAME + " - " + this.account.getThing().getLabel()));
457         if (!title.isEmpty()) {
458             html.append(" - ");
459             html.append(HtmlEscape.escapeHtml4(title));
460         }
461         html.append("</h1>");
462         return html;
463     }
464
465     private void createPageEndAndSent(HttpServletResponse resp, StringBuilder html) {
466         // account overview link
467         html.append("<br><a href='" + servletUrl + "/../' >");
468         html.append(HtmlEscape.escapeHtml4("Account overview"));
469         html.append("</a><br>");
470
471         html.append("</body></html>");
472         resp.addHeader("content-type", "text/html;charset=UTF-8");
473         try {
474             resp.getWriter().write(html.toString());
475         } catch (IOException e) {
476             logger.warn("return html failed with IO error", e);
477         }
478     }
479
480     private void handleIds(HttpServletResponse resp, Connection connection, Device device, @Nullable Thing thing)
481             throws IOException, URISyntaxException {
482         StringBuilder html;
483         if (thing != null) {
484             html = createPageStart("Channel Options - " + thing.getLabel());
485         } else {
486             html = createPageStart("Device Information - No thing defined");
487         }
488         renderBluetoothMacChannel(connection, device, html);
489         renderAmazonMusicPlaylistIdChannel(connection, device, html);
490         renderPlayAlarmSoundChannel(connection, device, html);
491         renderMusicProviderIdChannel(connection, html);
492         renderCapabilities(connection, device, html);
493         createPageEndAndSent(resp, html);
494     }
495
496     private void renderCapabilities(Connection connection, Device device, StringBuilder html) {
497         html.append("<h2>Capabilities</h2>");
498         html.append("<table><tr><th align='left'>Name</th></tr>");
499         device.getCapabilities().forEach(
500                 capability -> html.append("<tr><td>").append(HtmlEscape.escapeHtml4(capability)).append("</td></tr>"));
501         html.append("</table>");
502     }
503
504     private void renderMusicProviderIdChannel(Connection connection, StringBuilder html) {
505         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_MUSIC_PROVIDER_ID)).append("</h2>");
506         html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
507         List<JsonMusicProvider> musicProviders = connection.getMusicProviders();
508         for (JsonMusicProvider musicProvider : musicProviders) {
509             List<String> properties = musicProvider.supportedProperties;
510             String providerId = musicProvider.id;
511             String displayName = musicProvider.displayName;
512             if (properties != null && properties.contains("Alexa.Music.PlaySearchPhrase") && providerId != null
513                     && !providerId.isEmpty() && "AVAILABLE".equals(musicProvider.availability) && displayName != null
514                     && !displayName.isEmpty()) {
515                 html.append("<tr><td>");
516                 html.append(HtmlEscape.escapeHtml4(displayName));
517                 html.append("</td><td>");
518                 html.append(HtmlEscape.escapeHtml4(providerId));
519                 html.append("</td></tr>");
520             }
521         }
522         html.append("</table>");
523     }
524
525     private void renderPlayAlarmSoundChannel(Connection connection, Device device, StringBuilder html) {
526         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_PLAY_ALARM_SOUND)).append("</h2>");
527         List<JsonNotificationSound> notificationSounds = List.of();
528         String errorMessage = "No notifications sounds found";
529         try {
530             notificationSounds = connection.getNotificationSounds(device);
531         } catch (IOException | HttpException | URISyntaxException | JsonSyntaxException | ConnectionException
532                 | InterruptedException e) {
533             errorMessage = e.getLocalizedMessage();
534         }
535         if (!notificationSounds.isEmpty()) {
536             html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
537             for (JsonNotificationSound notificationSound : notificationSounds) {
538                 if (notificationSound.folder == null && notificationSound.providerId != null
539                         && notificationSound.id != null && notificationSound.displayName != null) {
540                     String providerSoundId = notificationSound.providerId + ":" + notificationSound.id;
541
542                     html.append("<tr><td>");
543                     html.append(HtmlEscape.escapeHtml4(notificationSound.displayName));
544                     html.append("</td><td>");
545                     html.append(HtmlEscape.escapeHtml4(providerSoundId));
546                     html.append("</td></tr>");
547                 }
548             }
549             html.append("</table>");
550         } else {
551             html.append(HtmlEscape.escapeHtml4(errorMessage));
552         }
553     }
554
555     private void renderAmazonMusicPlaylistIdChannel(Connection connection, Device device, StringBuilder html) {
556         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_AMAZON_MUSIC_PLAY_LIST_ID))
557                 .append("</h2>");
558
559         JsonPlaylists playLists = null;
560         String errorMessage = "No playlists found";
561         try {
562             playLists = connection.getPlaylists(device);
563         } catch (IOException | HttpException | URISyntaxException | JsonSyntaxException | ConnectionException
564                 | InterruptedException e) {
565             errorMessage = e.getLocalizedMessage();
566         }
567
568         if (playLists != null) {
569             Map<String, PlayList @Nullable []> playlistMap = playLists.playlists;
570             if (playlistMap != null && !playlistMap.isEmpty()) {
571                 html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
572
573                 for (PlayList[] innerLists : playlistMap.values()) {
574                     {
575                         if (innerLists != null && innerLists.length > 0) {
576                             PlayList playList = innerLists[0];
577                             if (playList != null && playList.playlistId != null && playList.title != null) {
578                                 html.append("<tr><td>");
579                                 html.append(HtmlEscape.escapeHtml4(nullReplacement(playList.title)));
580                                 html.append("</td><td>");
581                                 html.append(HtmlEscape.escapeHtml4(nullReplacement(playList.playlistId)));
582                                 html.append("</td></tr>");
583                             }
584                         }
585                     }
586                 }
587                 html.append("</table>");
588             } else {
589                 html.append(HtmlEscape.escapeHtml4(errorMessage));
590             }
591         }
592     }
593
594     private void renderBluetoothMacChannel(Connection connection, Device device, StringBuilder html) {
595         html.append("<h2>").append(HtmlEscape.escapeHtml4("Channel " + CHANNEL_BLUETOOTH_MAC)).append("</h2>");
596         JsonBluetoothStates bluetoothStates = connection.getBluetoothConnectionStates();
597         if (bluetoothStates == null) {
598             return;
599         }
600         BluetoothState[] innerStates = bluetoothStates.bluetoothStates;
601         if (innerStates == null) {
602             return;
603         }
604         for (BluetoothState state : innerStates) {
605             if (state == null) {
606                 continue;
607             }
608             String stateDeviceSerialNumber = state.deviceSerialNumber;
609             if ((stateDeviceSerialNumber == null && device.serialNumber == null)
610                     || (stateDeviceSerialNumber != null && stateDeviceSerialNumber.equals(device.serialNumber))) {
611                 List<PairedDevice> pairedDeviceList = state.getPairedDeviceList();
612                 if (!pairedDeviceList.isEmpty()) {
613                     html.append("<table><tr><th align='left'>Name</th><th align='left'>Value</th></tr>");
614                     for (PairedDevice pairedDevice : pairedDeviceList) {
615                         html.append("<tr><td>");
616                         html.append(HtmlEscape.escapeHtml4(nullReplacement(pairedDevice.friendlyName)));
617                         html.append("</td><td>");
618                         html.append(HtmlEscape.escapeHtml4(nullReplacement(pairedDevice.address)));
619                         html.append("</td></tr>");
620                     }
621                     html.append("</table>");
622                 } else {
623                     html.append(HtmlEscape.escapeHtml4("No bluetooth devices paired"));
624                 }
625             }
626         }
627     }
628
629     void handleProxyRequest(Connection connection, HttpServletResponse resp, String verb, String url,
630             @Nullable String referer, @Nullable String postData, boolean json, String site) throws IOException {
631         HttpsURLConnection urlConnection;
632         try {
633             Map<String, String> headers = null;
634             if (referer != null) {
635                 headers = new HashMap<>();
636                 headers.put("Referer", referer);
637             }
638
639             urlConnection = connection.makeRequest(verb, url, postData, json, false, headers, 0);
640             if (urlConnection.getResponseCode() == 302) {
641                 {
642                     String location = urlConnection.getHeaderField("location");
643                     if (location.contains("/ap/maplanding")) {
644                         try {
645                             connection.registerConnectionAsApp(location);
646                             account.setConnection(connection);
647                             handleDefaultPageResult(resp, "Login succeeded", connection);
648                             this.connectionToInitialize = null;
649                             return;
650                         } catch (URISyntaxException | ConnectionException e) {
651                             returnError(resp,
652                                     "Login to '" + connection.getAmazonSite() + "' failed: " + e.getLocalizedMessage());
653                             this.connectionToInitialize = null;
654                             return;
655                         }
656                     }
657
658                     String startString = "https://www." + connection.getAmazonSite() + "/";
659                     String newLocation = null;
660                     if (location.startsWith(startString) && connection.getIsLoggedIn()) {
661                         newLocation = servletUrl + PROXY_URI_PART + location.substring(startString.length());
662                     } else if (location.startsWith(startString)) {
663                         newLocation = servletUrl + FORWARD_URI_PART + location.substring(startString.length());
664                     } else {
665                         startString = "/";
666                         if (location.startsWith(startString)) {
667                             newLocation = servletUrl + FORWARD_URI_PART + location.substring(startString.length());
668                         }
669                     }
670                     if (newLocation != null) {
671                         logger.debug("Redirect mapped from {} to {}", location, newLocation);
672
673                         resp.sendRedirect(newLocation);
674                         return;
675                     }
676                     returnError(resp, "Invalid redirect to '" + location + "'");
677                     return;
678                 }
679             }
680         } catch (URISyntaxException | ConnectionException | InterruptedException e) {
681             returnError(resp, e.getLocalizedMessage());
682             return;
683         }
684         String response = connection.convertStream(urlConnection);
685         returnHtml(connection, resp, response, site);
686     }
687
688     private void returnHtml(Connection connection, HttpServletResponse resp, String html) {
689         returnHtml(connection, resp, html, connection.getAmazonSite());
690     }
691
692     private void returnHtml(Connection connection, HttpServletResponse resp, String html, String amazonSite) {
693         String resultHtml = html.replace("action=\"/", "action=\"" + servletUrl + "/")
694                 .replace("action=\"&#x2F;", "action=\"" + servletUrl + "/")
695                 .replace("https://www." + amazonSite + "/", servletUrl + "/")
696                 .replace("https://www." + amazonSite + ":443" + "/", servletUrl + "/")
697                 .replace("https:&#x2F;&#x2F;www." + amazonSite + "&#x2F;", servletUrl + "/")
698                 .replace("https:&#x2F;&#x2F;www." + amazonSite + ":443" + "&#x2F;", servletUrl + "/")
699                 .replace("http://www." + amazonSite + "/", servletUrl + "/")
700                 .replace("http:&#x2F;&#x2F;www." + amazonSite + "&#x2F;", servletUrl + "/");
701
702         resp.addHeader("content-type", "text/html;charset=UTF-8");
703         try {
704             resp.getWriter().write(resultHtml);
705         } catch (IOException e) {
706             logger.warn("return html failed with IO error", e);
707         }
708     }
709
710     void returnError(HttpServletResponse resp, @Nullable String errorMessage) {
711         try {
712             String message = errorMessage != null ? errorMessage : "null";
713             resp.getWriter().write("<html>" + HtmlEscape.escapeHtml4(message) + "<br><a href='" + servletUrl
714                     + "'>Try again</a></html>");
715         } catch (IOException e) {
716             logger.info("Returning error message failed", e);
717         }
718     }
719 }