diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..b6f433df --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,178 @@ +Copyright 2015-2016 Amazon.com, Inc. or its affiliates ("Amazon"). All Rights Reserved. + +Amazon materials are licensed as "Alexa Materials" under the Alexa Voice Service Agreement (the "License") of the Alexa Voice Service Program, which is available at https://developer.amazon.com/edw/avs_agreement.html. See the License for the specific language governing permissions and limitations under the License. + +These materials may also include software licensed as "Content" under the Login with Amazon Service Agreement (the "Agreement") which is available at http://login.amazon.com/services-agreement. See the Agreement for the specific language governing permissions and limitations under the Agreement. + +These materials may also include third party software that is copyrighted by other parties and is subject to separate license terms. Information on that software and the applicable copyright notices and license terms are included below or in files accompanying these materials. + +These materials are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +##### + +Legal Notices for Certain Third-Party Software + +----- + +Portions of InputStreamResponseListener.java are available under the Apache License, version 2.0, or the Eclipse Public License. + +----- + +Legal Notices for Jetty: + +============================================================== + Jetty Web Container + Copyright 1995-2016 Mort Bay Consulting Pty Ltd. +============================================================== + +The Jetty Web Container is Copyright Mort Bay Consulting Pty Ltd +unless otherwise noted. + +Jetty is dual licensed under both + + * The Apache 2.0 License + http://www.apache.org/licenses/LICENSE-2.0.html + + and + + * The Eclipse Public 1.0 License + http://www.eclipse.org/legal/epl-v10.html + +Jetty may be distributed under either license. + +##### + +Full text of the Apache license: + +----- + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +##### + +Full text of the Eclipse Public License: + +----- + +Eclipse Public License - v 1.0 + +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and documentation distributed under this Agreement, and +b) in the case of each subsequent Contributor: +i) changes to the Program, and +ii) additions to the Program; +where such changes and/or additions to the Program originate from and are distributed by that particular Contributor. A Contribution 'originates' from a Contributor if it was added to the Program by such Contributor itself or anyone acting on such Contributor's behalf. Contributions do not include additions to the Program which: (i) are separate modules of software distributed in conjunction with the Program under their own license agreement, and (ii) are not derivative works of the Program. +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which are necessarily infringed by the use or sale of its Contribution alone or when combined with the Program. + +"Program" means the Contributions distributed in accordance with this Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, including all Contributors. + +2. GRANT OF RIGHTS + +a) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, distribute and sublicense the Contribution of such Contributor, if any, and such derivative works, in source code and object code form. +b) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed Patents to make, use, sell, offer to sell, import and otherwise transfer the Contribution of such Contributor, if any, in source code and object code form. This patent license shall apply to the combination of the Contribution and the Program if, at the time the Contribution is added by the Contributor, such addition of the Contribution causes such combination to be covered by the Licensed Patents. The patent license shall not apply to any other combinations which include the Contribution. No hardware per se is licensed hereunder. +c) Recipient understands that although each Contributor grants the licenses to its Contributions set forth herein, no assurances are provided by any Contributor that the Program does not infringe the patent or other intellectual property rights of any other entity. Each Contributor disclaims any liability to Recipient for claims brought by any other entity based on infringement of intellectual property rights or otherwise. As a condition to exercising the rights and licenses granted hereunder, each Recipient hereby assumes sole responsibility to secure any other intellectual property rights needed, if any. For example, if a third party patent license is required to allow Recipient to distribute the Program, it is Recipient's responsibility to acquire that license before distributing the Program. +d) Each Contributor represents that to its knowledge it has sufficient copyright rights in its Contribution, if any, to grant the copyright license set forth in this Agreement. +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under its own license agreement, provided that: + +a) it complies with the terms and conditions of this Agreement; and +b) its license agreement: +i) effectively disclaims on behalf of all Contributors all warranties and conditions, express and implied, including warranties or conditions of title and non-infringement, and implied warranties or conditions of merchantability and fitness for a particular purpose; +ii) effectively excludes on behalf of all Contributors all liability for damages, including direct, indirect, special, incidental and consequential damages, such as lost profits; +iii) states that any provisions which differ from this Agreement are offered by that Contributor alone and not by any other party; and +iv) states that source code for the Program is available from such Contributor, and informs licensees how to obtain it in a reasonable manner on or through a medium customarily used for software exchange. +When the Program is made available in source code form: + +a) it must be made available under this Agreement; and +b) a copy of this Agreement must be included with each copy of the Program. +Contributors may not remove or alter any copyright notices contained within the Program. + +Each Contributor must identify itself as the originator of its Contribution, if any, in a manner that reasonably allows subsequent Recipients to identify the originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with respect to end users, business partners and the like. While this license is intended to facilitate the commercial use of the Program, the Contributor who includes the Program in a commercial product offering should do so in a manner which does not create potential liability for other Contributors. Therefore, if a Contributor includes the Program in a commercial product offering, such Contributor ("Commercial Contributor") hereby agrees to defend and indemnify every other Contributor ("Indemnified Contributor") against any losses, damages and costs (collectively "Losses") arising from claims, lawsuits and other legal actions brought by a third party against the Indemnified Contributor to the extent caused by the acts or omissions of such Commercial Contributor in connection with its distribution of the Program in a commercial product offering. The obligations in this section do not apply to any claims or Losses relating to any actual or alleged intellectual property infringement. In order to qualify, an Indemnified Contributor must: a) promptly notify the Commercial Contributor in writing of such claim, and b) allow the Commercial Contributor to control, and cooperate with the Commercial Contributor in, the defense and any related settlement negotiations. The Indemnified Contributor may participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial product offering, Product X. That Contributor is then a Commercial Contributor. If that Commercial Contributor then makes performance claims, or offers warranties related to Product X, those performance claims and warranties are such Commercial Contributor's responsibility alone. Under this section, the Commercial Contributor would have to defend claims against the other Contributors related to those performance claims and warranties, and if a court requires any other Contributor to pay any damages as a result, the Commercial Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the appropriateness of using and distributing the Program and assumes all risks associated with its exercise of rights under this Agreement , including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this Agreement, and without further action by the parties hereto, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Program itself (excluding combinations of the Program with other software or hardware) infringes such Recipient's patent(s), then such Recipient's rights granted under Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to comply with any of the material terms or conditions of this Agreement and does not cure such failure in a reasonable period of time after becoming aware of such noncompliance. If all Recipient's rights under this Agreement terminate, Recipient agrees to cease use and distribution of the Program as soon as reasonably practicable. However, Recipient's obligations under this Agreement and any licenses granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in order to avoid inconsistency the Agreement is copyrighted and may only be modified in the following manner. The Agreement Steward reserves the right to publish new versions (including revisions) of this Agreement from time to time. No one other than the Agreement Steward has the right to modify this Agreement. The Eclipse Foundation is the initial Agreement Steward. The Eclipse Foundation may assign the responsibility to serve as the Agreement Steward to a suitable separate entity. Each new version of the Agreement will be given a distinguishing version number. The Program (including Contributions) may always be distributed subject to the version of the Agreement under which it was received. In addition, after a new version of the Agreement is published, Contributor may elect to distribute the Program (including its Contributions) under the new version. Except as expressly stated in Sections 2(a) and 2(b) above, Recipient receives no rights or licenses to the intellectual property of any Contributor under this Agreement, whether expressly, by implication, estoppel or otherwise. All rights in the Program not expressly granted under this Agreement are reserved. + +This Agreement is governed by the laws of the State of New York and the intellectual property laws of the United States of America. No party to this Agreement will bring a legal action under this Agreement more than one year after the cause of action arose. Each party waives its rights to a jury trial in any resulting litigation. diff --git a/README.md b/README.md new file mode 100644 index 00000000..5bdda99b --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +#Alexa Voice Service Raspberry Pi \ No newline at end of file diff --git a/RELEASE.txt b/RELEASE.txt new file mode 100644 index 00000000..99bc4111 --- /dev/null +++ b/RELEASE.txt @@ -0,0 +1,4 @@ +07/31/2015 - Initial Release. Includes Java reference implementation of device code, and a Node.js reference companion service for authenticating and provisioning the device code. +10/02/2015 - Fixed a bug related to the enabling of TuneIn. Users can now listen to TuneIn radio streams from the reference implementation of device code. +10/15/2015 - Added reference implementations for mobile app authentication and provisioning for iOS and Android phones. Also adds support for play/pause/previous/next buttons in the reference implementation of device code. +03/22/2016 - Updated the reference client to use the new v20160207 APIs over HTTP2. Adds support for timers, alarms, button events, and control via the companion app. diff --git a/samples/androidCompanionApp/README.txt b/samples/androidCompanionApp/README.txt new file mode 100644 index 00000000..fbec920f --- /dev/null +++ b/samples/androidCompanionApp/README.txt @@ -0,0 +1,8 @@ +Please run the setup at libraries/java/README.txt before continuing. + +To build the Android sample you need to import into Android Studio, or run "sh gradlew build". + +To import into Android Studio: +1. From the "Welcome to Android Studio" window select "Open an existing Android Studio project" +2. Navigation to and select $PACKAGE_ROOT/samples/android/ +3. Once loaded hit the green "Play" button diff --git a/samples/androidCompanionApp/app/build.gradle b/samples/androidCompanionApp/app/build.gradle new file mode 100644 index 00000000..762e4be3 --- /dev/null +++ b/samples/androidCompanionApp/app/build.gradle @@ -0,0 +1,67 @@ +apply plugin: 'com.android.application' + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + compile 'com.android.support:appcompat-v7:22.2.0' + compile 'commons-io:commons-io:2.4' + compile 'org.apache.commons:commons-lang3:3.4' +} + +String apiKeyLocation = "./src/main/assets/api_key.txt" +String caCertLocation = "./src/main/res/raw/ca.crt" + +task checkForRequiredFiles << { + if (!file(apiKeyLocation).exists()) { + throw new FileNotFoundException("The API Key file does not exist. Please make sure " + apiKeyLocation + " has been created and populated with the proper values from the Security Profile you created.") + } + + if (!file(caCertLocation).exists()) { + throw new FileNotFoundException("The Certificate Authority public certificate file does not exist. Please make sure " + caCertLocation + " exists and contains the public certificate created during setup.") + } +} +preBuild.dependsOn checkForRequiredFiles + +android { + compileSdkVersion 22 + buildToolsVersion "22.0.1" + + defaultConfig { + applicationId "com.amazon.alexa.avs.companion" + minSdkVersion 8 + targetSdkVersion 22 + versionCode 1 + versionName "1.0" + } + + // Sign both debug and release with a single key so that LWA always works + // In practice you would have separate keys for debug and release + signingConfigs { + lwa { + storeFile file('keystore.jks') + keyAlias 'androiddebugkey' + keyPassword 'android' + storePassword 'android' + } + } + + packagingOptions { + exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/NOTICE.txt' + } + + lintOptions { + abortOnError false + disable 'IconMissingDensityFolder' + } + + buildTypes { + release { + minifyEnabled false + signingConfig signingConfigs.lwa + } + + debug { + signingConfig signingConfigs.lwa + } + } +} diff --git a/samples/androidCompanionApp/app/libs/.COMPONENT_CACHED b/samples/androidCompanionApp/app/libs/.COMPONENT_CACHED new file mode 100644 index 00000000..e69de29b diff --git a/samples/androidCompanionApp/app/libs/login-with-amazon-sdk.jar b/samples/androidCompanionApp/app/libs/login-with-amazon-sdk.jar new file mode 100644 index 00000000..9d82dc90 Binary files /dev/null and b/samples/androidCompanionApp/app/libs/login-with-amazon-sdk.jar differ diff --git a/samples/androidCompanionApp/app/src/main/AndroidManifest.xml b/samples/androidCompanionApp/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a9fac095 --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/AuthConstants.java b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/AuthConstants.java new file mode 100644 index 00000000..5e70c593 --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/AuthConstants.java @@ -0,0 +1,22 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.companion; + +public class AuthConstants { + public static final String SESSION_ID = "sessionId"; + + public static final String CLIENT_ID = "clientId"; + public static final String REDIRECT_URI = "redirectUri"; + public static final String AUTH_CODE = "authCode"; + + public static final String CODE_CHALLENGE = "codeChallenge"; + public static final String CODE_CHALLENGE_METHOD = "codeChallengeMethod"; + public static final String DSN = "dsn"; + public static final String PRODUCT_ID = "productId"; +} diff --git a/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/CompanionProvisioningInfo.java b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/CompanionProvisioningInfo.java new file mode 100644 index 00000000..431470d6 --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/CompanionProvisioningInfo.java @@ -0,0 +1,55 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.companion; + +import org.json.JSONException; +import org.json.JSONObject; + +public class CompanionProvisioningInfo { + private final String sessionId; + private final String clientId; + private final String redirectUri; + private final String authCode; + + public CompanionProvisioningInfo(String sessionId, String clientId, String redirectUri, String authCode) { + this.sessionId = sessionId; + this.clientId = clientId; + this.redirectUri = redirectUri; + this.authCode = authCode; + } + + public String getSessionId() { + return sessionId; + } + + public String getClientId() { + return clientId; + } + + public String getRedirectUri() { + return redirectUri; + } + + public String getAuthCode() { + return authCode; + } + + public JSONObject toJson() { + try { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(AuthConstants.AUTH_CODE, authCode); + jsonObject.put(AuthConstants.CLIENT_ID, clientId); + jsonObject.put(AuthConstants.REDIRECT_URI, redirectUri); + jsonObject.put(AuthConstants.SESSION_ID, sessionId); + return jsonObject; + } catch (JSONException e) { + return null; + } + } +} diff --git a/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/DeviceProvisioningInfo.java b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/DeviceProvisioningInfo.java new file mode 100644 index 00000000..4030ee53 --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/DeviceProvisioningInfo.java @@ -0,0 +1,95 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.companion; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class DeviceProvisioningInfo { + private final String productId; + private final String dsn; + private final String sessionId; + private final String codeChallenge; + private final String codeChallengeMethod; + + public DeviceProvisioningInfo(String productId, String dsn, String sessionId, String codeChallenge, String codeChallengeMethod) { + List missingParameters = new ArrayList(); + if (StringUtils.isBlank(productId)) { + missingParameters.add(AuthConstants.PRODUCT_ID); + } + + if (StringUtils.isBlank(dsn)) { + missingParameters.add(AuthConstants.DSN); + } + + if (StringUtils.isBlank(sessionId)) { + missingParameters.add(AuthConstants.SESSION_ID); + } + + if (StringUtils.isBlank(codeChallenge)) { + missingParameters.add(AuthConstants.CODE_CHALLENGE); + } + + if (StringUtils.isBlank(codeChallengeMethod)) { + missingParameters.add(AuthConstants.CODE_CHALLENGE_METHOD); + } + + if (missingParameters.size() != 0) { + throw new MissingParametersException(missingParameters); + } + + this.productId = productId; + this.dsn = dsn; + this.sessionId = sessionId; + this.codeChallenge = codeChallenge; + this.codeChallengeMethod = codeChallengeMethod; + } + + public String getProductId() { + return productId; + } + + public String getDsn() { + return dsn; + } + + public String getSessionId() { + return sessionId; + } + + public String getCodeChallenge() { + return codeChallenge; + } + + public String getCodeChallengeMethod() { + return codeChallengeMethod; + } + + public static class MissingParametersException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + private final List missingParameters; + + public MissingParametersException(List missingParameters) { + super(); + this.missingParameters = missingParameters; + } + + @Override + public String getMessage() { + return "The following parameters were missing or empty strings: " + + StringUtils.join(missingParameters.toArray(), ", "); + } + + public List getMissingParameters() { + return missingParameters; + } + } +} \ No newline at end of file diff --git a/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/LoginWithAmazonActivity.java b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/LoginWithAmazonActivity.java new file mode 100644 index 00000000..5c1663ba --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/LoginWithAmazonActivity.java @@ -0,0 +1,338 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.companion; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.FragmentManager; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.amazon.identity.auth.device.AuthError; +import com.amazon.identity.auth.device.authorization.api.AmazonAuthorizationManager; +import com.amazon.identity.auth.device.authorization.api.AuthorizationListener; +import com.amazon.identity.auth.device.authorization.api.AuthzConstants; + +import org.json.JSONException; +import org.json.JSONObject; + +public class LoginWithAmazonActivity extends AppCompatActivity { + + private static final String TAG = LoginWithAmazonActivity.class.getName(); + private static final String ALEXA_ALL_SCOPE = "alexa:all"; + private static final String DEVICE_SERIAL_NUMBER = "deviceSerialNumber"; + private static final String PRODUCT_INSTANCE_ATTRIBUTES = "productInstanceAttributes"; + private static final String PRODUCT_ID = "productID"; + + private static final String BUNDLE_KEY_EXCEPTION = "exception"; + + private static final String[] APP_SCOPES= { ALEXA_ALL_SCOPE }; + + private static final int MIN_CONNECT_PROGRESS_TIME_MS = 1*1000; + + private AmazonAuthorizationManager mAuthManager; + private ProvisioningClient mProvisioningClient; + private DeviceProvisioningInfo mDeviceProvisioningInfo; + + private EditText mAddressTextView; + private Button mConnectButton; + private ProgressBar mConnectProgress; + private ProgressBar mLoginProgress; + private ImageButton mLoginButton; + private TextView mLoginMessage; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.lwa_activity); + + mAddressTextView = (EditText) findViewById(R.id.addressTextView); + + mConnectButton = (Button) findViewById(R.id.connectButton); + mConnectProgress = (ProgressBar) findViewById(R.id.connectProgressBar); + + mLoginButton = (ImageButton) findViewById(R.id.loginButton); + mLoginProgress = (ProgressBar) findViewById(R.id.loginProgressBar); + mLoginMessage = (TextView) findViewById(R.id.loginMessage); + + connectCleanState(); + + try { + mAuthManager = new AmazonAuthorizationManager(this, Bundle.EMPTY); + } catch(IllegalArgumentException e) { + connectErrorState(); + showAlertDialog(e); + Log.e(TAG, "Unable to use Amazon Authorization Manager. APIKey is incorrect or does not exist.", e); + } + + try { + mProvisioningClient = new ProvisioningClient(this); + } catch(Exception e) { + connectErrorState(); + showAlertDialog(e); + Log.e(TAG, "Unable to use Provisioning Client. CA Certificate is incorrect or does not exist.", e); + } + + String savedDeviceAddress = getPreferences(Context.MODE_PRIVATE).getString(getString(R.string.saved_device_address), null); + if (savedDeviceAddress != null) { + mAddressTextView.setText(savedDeviceAddress); + } + + mConnectButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + final String address = mAddressTextView.getText().toString(); + mProvisioningClient.setEndpoint(address); + + new AsyncTask() { + private Exception errorInBackground; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + connectInProgressState(); + } + + @Override + protected DeviceProvisioningInfo doInBackground(Void... voids) { + try { + long startTime = System.currentTimeMillis(); + DeviceProvisioningInfo response = mProvisioningClient.getDeviceProvisioningInfo(); + long duration = System.currentTimeMillis() - startTime; + + if (duration < MIN_CONNECT_PROGRESS_TIME_MS) { + try { + Thread.sleep(MIN_CONNECT_PROGRESS_TIME_MS - duration); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + return response; + } catch (Exception e) { + errorInBackground = e; + } + return null; + } + + @Override + protected void onPostExecute(DeviceProvisioningInfo deviceProvisioningInfo) { + super.onPostExecute(deviceProvisioningInfo); + if (deviceProvisioningInfo != null) { + mDeviceProvisioningInfo = deviceProvisioningInfo; + + SharedPreferences.Editor editor = getPreferences(Context.MODE_PRIVATE).edit(); + editor.putString(getString(R.string.saved_device_address), address); + editor.commit(); + + connectSuccessState(); + } else { + connectCleanState(); + showAlertDialog(errorInBackground); + } + } + }.execute(); + } + }); + + mLoginButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + loginInProgressState(); + + Bundle options = new Bundle(); + + JSONObject scopeData = new JSONObject(); + JSONObject productInfo = new JSONObject(); + JSONObject productInstanceAttributes = new JSONObject(); + + try { + productInstanceAttributes.put(DEVICE_SERIAL_NUMBER, mDeviceProvisioningInfo.getDsn()); + productInfo.put(PRODUCT_ID, mDeviceProvisioningInfo.getProductId()); + productInfo.put(PRODUCT_INSTANCE_ATTRIBUTES, productInstanceAttributes); + scopeData.put(ALEXA_ALL_SCOPE, productInfo); + + String codeChallenge = mDeviceProvisioningInfo.getCodeChallenge(); + String codeChallengeMethod = mDeviceProvisioningInfo.getCodeChallengeMethod(); + + options.putString(AuthzConstants.BUNDLE_KEY.SCOPE_DATA.val, scopeData.toString()); + options.putBoolean(AuthzConstants.BUNDLE_KEY.GET_AUTH_CODE.val, true); + options.putString(AuthzConstants.BUNDLE_KEY.CODE_CHALLENGE.val, codeChallenge); + options.putString(AuthzConstants.BUNDLE_KEY.CODE_CHALLENGE_METHOD.val, codeChallengeMethod); + mAuthManager.authorize(APP_SCOPES, options, new AuthListener()); + } catch (JSONException e) { + e.printStackTrace(); + } + } + }); + } + + private void connectCleanState() { + mConnectButton.setVisibility(View.VISIBLE); + mConnectProgress.setVisibility(View.GONE); + + mLoginButton.setVisibility(View.GONE); + mLoginProgress.setVisibility(View.GONE); + mLoginMessage.setVisibility(View.GONE); + } + + private void connectInProgressState() { + mConnectButton.setVisibility(View.GONE); + mConnectProgress.setVisibility(View.VISIBLE); + mConnectProgress.setIndeterminate(true); + + mLoginButton.setVisibility(View.GONE); + mLoginProgress.setVisibility(View.GONE); + mLoginMessage.setVisibility(View.GONE); + } + + private void connectSuccessState() { + mConnectButton.setVisibility(View.VISIBLE); + mConnectProgress.setVisibility(View.GONE); + + mLoginButton.setVisibility(View.VISIBLE); + mLoginProgress.setVisibility(View.GONE); + mLoginMessage.setVisibility(View.GONE); + } + + private void connectErrorState() { + mConnectButton.setVisibility(View.GONE); + mConnectProgress.setVisibility(View.GONE); + + mLoginButton.setVisibility(View.GONE); + mLoginProgress.setVisibility(View.GONE); + mLoginMessage.setVisibility(View.GONE); + } + + private void loginInProgressState() { + mConnectButton.setVisibility(View.VISIBLE); + mConnectProgress.setVisibility(View.GONE); + + mLoginButton.setVisibility(View.GONE); + mLoginProgress.setVisibility(View.VISIBLE); + mLoginMessage.setVisibility(View.GONE); + } + + private void loginSuccessState() { + mConnectButton.setVisibility(View.VISIBLE); + mConnectProgress.setVisibility(View.GONE); + + mLoginButton.setVisibility(View.GONE); + mLoginProgress.setVisibility(View.GONE); + mLoginMessage.setVisibility(View.VISIBLE); + mLoginMessage.setText(R.string.success_message); + } + + protected void showAlertDialog(Exception exception) { + exception.printStackTrace(); + ErrorDialogFragment dialogFragment = new ErrorDialogFragment(); + Bundle args = new Bundle(); + args.putSerializable(BUNDLE_KEY_EXCEPTION, exception); + dialogFragment.setArguments(args); + FragmentManager fm = getSupportFragmentManager(); + dialogFragment.show(fm, "error_dialog"); + } + + private class AuthListener implements AuthorizationListener { + @Override + public void onSuccess(Bundle response) { + try { + final String authorizationCode = response.getString(AuthzConstants.BUNDLE_KEY.AUTHORIZATION_CODE.val); + final String redirectUri = mAuthManager.getRedirectUri(); + final String clientId = mAuthManager.getClientId(); + final String sessionId = mDeviceProvisioningInfo.getSessionId(); + + final CompanionProvisioningInfo companionProvisioningInfo = new CompanionProvisioningInfo(sessionId, clientId, redirectUri, authorizationCode); + + new AsyncTask() { + private Exception errorInBackground; + + @Override + protected void onPreExecute() { + super.onPreExecute(); + loginInProgressState(); + } + + @Override + protected Void doInBackground(Void... voids) { + try { + mProvisioningClient.postCompanionProvisioningInfo(companionProvisioningInfo); + } catch (Exception e) { + errorInBackground = e; + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + super.onPostExecute(result); + if (errorInBackground != null) { + connectCleanState(); + showAlertDialog(errorInBackground); + } else { + loginSuccessState(); + } + } + }.execute(); + } catch (AuthError authError) { + authError.printStackTrace(); + } + } + + @Override + public void onError(final AuthError ae) { + Log.e(TAG, "AuthError during authorization", ae); + runOnUiThread(new Runnable() { + @Override + public void run() { + showAlertDialog(ae); + } + }); + } + + @Override + public void onCancel(Bundle cause) { + Log.e(TAG, "User cancelled authorization"); + } + } + + public static class ErrorDialogFragment extends DialogFragment { + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + Bundle args = getArguments(); + Exception exception = (Exception) args.getSerializable(BUNDLE_KEY_EXCEPTION); + String message = exception.getMessage(); + + return new AlertDialog.Builder(getActivity()) + .setTitle(R.string.error) + .setMessage(message) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + dismiss(); + } + }) + .create(); + } + } +} \ No newline at end of file diff --git a/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/ProvisioningClient.java b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/ProvisioningClient.java new file mode 100644 index 00000000..aee01257 --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/java/com/amazon/alexa/avs/companion/ProvisioningClient.java @@ -0,0 +1,172 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.companion; + +import android.content.Context; + +import org.apache.commons.io.IOUtils; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.ArrayList; +import java.util.List; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; + +public class ProvisioningClient { + private String endpoint; + private SSLSocketFactory pinnedSSLSocketFactory; + + public ProvisioningClient(Context context) throws Exception { + this.pinnedSSLSocketFactory = getPinnedSSLSocketFactory(context); + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public DeviceProvisioningInfo getDeviceProvisioningInfo() throws JSONException, IOException { + URL companionInfoEndpoint = new URL(endpoint + "/provision/deviceInfo"); + + HttpURLConnection connection = (HttpURLConnection) companionInfoEndpoint.openConnection(); + + JSONObject response = doRequest(connection); + + List missingParameters = new ArrayList(); + if (!response.has(AuthConstants.PRODUCT_ID)) { + missingParameters.add(AuthConstants.PRODUCT_ID); + } + + if (!response.has(AuthConstants.DSN)) { + missingParameters.add(AuthConstants.DSN); + } + + if (!response.has(AuthConstants.SESSION_ID)) { + missingParameters.add(AuthConstants.SESSION_ID); + } + + if (!response.has(AuthConstants.CODE_CHALLENGE)) { + missingParameters.add(AuthConstants.CODE_CHALLENGE); + } + + if (!response.has(AuthConstants.CODE_CHALLENGE_METHOD)) { + missingParameters.add(AuthConstants.CODE_CHALLENGE_METHOD); + } + + if (missingParameters.size() != 0) { + throw new DeviceProvisioningInfo.MissingParametersException(missingParameters); + } + + String productId = response.getString(AuthConstants.PRODUCT_ID); + String dsn = response.getString(AuthConstants.DSN); + String sessionId = response.getString(AuthConstants.SESSION_ID); + String codeChallenge = response.getString(AuthConstants.CODE_CHALLENGE); + String codeChallengeMethod = response.getString(AuthConstants.CODE_CHALLENGE_METHOD); + + DeviceProvisioningInfo ret = new DeviceProvisioningInfo(productId, dsn, sessionId, codeChallenge, codeChallengeMethod); + return ret; + } + + public void postCompanionProvisioningInfo(CompanionProvisioningInfo companionProvisioningInfo) throws IOException, JSONException { + String jsonString = companionProvisioningInfo.toJson().toString(); + + URL companionInfoEndpoint = new URL(endpoint + "/provision/companionInfo"); + + HttpURLConnection connection = (HttpURLConnection) companionInfoEndpoint.openConnection(); + + doRequest(connection, jsonString); + } + + JSONObject doRequest(HttpURLConnection connection) throws IOException, JSONException { + return doRequest(connection, null); + } + + JSONObject doRequest(HttpURLConnection connection, String data) throws IOException, JSONException { + int responseCode = -1; + InputStream response = null; + DataOutputStream outputStream = null; + + try { + if (connection instanceof HttpsURLConnection) { + ((HttpsURLConnection) connection).setSSLSocketFactory(pinnedSSLSocketFactory); + } + + connection.setRequestProperty("Content-Type", "application/json"); + if (data != null) { + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + + outputStream = new DataOutputStream(connection.getOutputStream()); + outputStream.write(data.getBytes()); + outputStream.flush(); + outputStream.close(); + } else { + connection.setRequestMethod("GET"); + } + + responseCode = connection.getResponseCode(); + response = connection.getInputStream(); + + if (responseCode != 204) { + String responseString = IOUtils.toString(response); + JSONObject jsonObject = new JSONObject(responseString); + return jsonObject; + } else { + return null; + } + } catch (IOException e) { + if (responseCode < 200 || responseCode >= 300) { + response = connection.getErrorStream(); + if (response != null) { + String responseString = IOUtils.toString(response); + throw new RuntimeException(responseString); + } + } + throw e; + } finally { + IOUtils.closeQuietly(outputStream); + IOUtils.closeQuietly(response); + } + } + + private SSLSocketFactory getPinnedSSLSocketFactory(Context context) throws Exception { + InputStream caCertInputStream = null; + try { + caCertInputStream = context.getResources().openRawResource(R.raw.ca); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Certificate caCert = cf.generateCertificate(caCertInputStream); + + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("myca", caCert); + + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + return sslContext.getSocketFactory(); + } finally { + IOUtils.closeQuietly(caCertInputStream); + } + } + +} diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-hdpi/btnlwa_gold_loginwithamazon.png b/samples/androidCompanionApp/app/src/main/res/drawable-hdpi/btnlwa_gold_loginwithamazon.png new file mode 100644 index 00000000..5864637a Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-hdpi/btnlwa_gold_loginwithamazon.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-hdpi/btnlwa_gold_loginwithamazon_pressed.png b/samples/androidCompanionApp/app/src/main/res/drawable-hdpi/btnlwa_gold_loginwithamazon_pressed.png new file mode 100644 index 00000000..6602154d Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-hdpi/btnlwa_gold_loginwithamazon_pressed.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-mdpi/btnlwa_gold_loginwithamazon.png b/samples/androidCompanionApp/app/src/main/res/drawable-mdpi/btnlwa_gold_loginwithamazon.png new file mode 100644 index 00000000..7b5d1ae1 Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-mdpi/btnlwa_gold_loginwithamazon.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-mdpi/btnlwa_gold_loginwithamazon_pressed.png b/samples/androidCompanionApp/app/src/main/res/drawable-mdpi/btnlwa_gold_loginwithamazon_pressed.png new file mode 100644 index 00000000..0d9e9f75 Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-mdpi/btnlwa_gold_loginwithamazon_pressed.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-xhdpi/btnlwa_gold_loginwithamazon.png b/samples/androidCompanionApp/app/src/main/res/drawable-xhdpi/btnlwa_gold_loginwithamazon.png new file mode 100644 index 00000000..aa0fc264 Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-xhdpi/btnlwa_gold_loginwithamazon.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-xhdpi/btnlwa_gold_loginwithamazon_pressed.png b/samples/androidCompanionApp/app/src/main/res/drawable-xhdpi/btnlwa_gold_loginwithamazon_pressed.png new file mode 100644 index 00000000..969b349a Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-xhdpi/btnlwa_gold_loginwithamazon_pressed.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-xxhdpi/btnlwa_gold_loginwithamazon.png b/samples/androidCompanionApp/app/src/main/res/drawable-xxhdpi/btnlwa_gold_loginwithamazon.png new file mode 100644 index 00000000..de137340 Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-xxhdpi/btnlwa_gold_loginwithamazon.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable-xxhdpi/btnlwa_gold_loginwithamazon_pressed.png b/samples/androidCompanionApp/app/src/main/res/drawable-xxhdpi/btnlwa_gold_loginwithamazon_pressed.png new file mode 100644 index 00000000..2d879580 Binary files /dev/null and b/samples/androidCompanionApp/app/src/main/res/drawable-xxhdpi/btnlwa_gold_loginwithamazon_pressed.png differ diff --git a/samples/androidCompanionApp/app/src/main/res/drawable/login_button.xml b/samples/androidCompanionApp/app/src/main/res/drawable/login_button.xml new file mode 100644 index 00000000..08f678bb --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/res/drawable/login_button.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/androidCompanionApp/app/src/main/res/layout/lwa_activity.xml b/samples/androidCompanionApp/app/src/main/res/layout/lwa_activity.xml new file mode 100644 index 00000000..1ead8841 --- /dev/null +++ b/samples/androidCompanionApp/app/src/main/res/layout/lwa_activity.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/iOSCompanionApp/Application/en.lproj/MainStoryboard_iPhone.storyboard b/samples/iOSCompanionApp/Application/en.lproj/MainStoryboard_iPhone.storyboard new file mode 100644 index 00000000..31c0942b --- /dev/null +++ b/samples/iOSCompanionApp/Application/en.lproj/MainStoryboard_iPhone.storyboard @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/iOSCompanionApp/Application/main.m b/samples/iOSCompanionApp/Application/main.m new file mode 100644 index 00000000..69bae8c3 --- /dev/null +++ b/samples/iOSCompanionApp/Application/main.m @@ -0,0 +1,20 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +#import + +#import "AVSApplicationDelegate.h" + +int main(int argc, char *argv[]) +{ + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AVSApplicationDelegate class])); + } +} + \ No newline at end of file diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIAuthenticationDelegate.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIAuthenticationDelegate.h new file mode 100644 index 00000000..9cd012c4 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIAuthenticationDelegate.h @@ -0,0 +1,125 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +@class AIError; + +#pragma mark - API + +/** + These constants identify which API succeeded or failed when calling AIAuthenticationDelegate. The value identifying + the API is passed in the APIResult and APIError objects. + + @since 1.0 +*/ +typedef NS_ENUM(NSUInteger, API) { + /** Refers to `[AIMobileLib authorizeUserForScopes:delegate:]` */ + kAPIAuthorizeUser = 1, + /** Refers to `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]` */ + kAPIGetAccessToken = 2, + /** Refers to `[AIMobileLib clearAuthorizationState:]` */ + kAPIClearAuthorizationState = 3, + /** Refers to `[AIMobileLib getProfile:]` */ + kAPIGetProfile = 4, + /** Refers to `[AIMobileLib authorizeUserForScopes:delegate:options]` */ + kAPIGetAuthorizationCode = 5 +}; + +#pragma mark - APIResult +/** + This class encapsulates success information from an AIMobileLib API call. +*/ +@interface APIResult : NSObject + +- (id)initResultForAPI:(API)anAPI andResult:(id)theResult; + +/** + The result object returned from the API on success. The API result can be `nil`, an `NSDictionary`, or an `NSString` + depending upon which API created the APIResult. + +- `[AIMobileLib authorizeUserForScopes:delegate:]` : Passes `nil` as the result to the delegate. +- `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]` : Passes an access token as an `NSString` object + to the delegate. +- `[AIMobileLib clearAuthorizationState:]` : Passes nil as the result to the delegate. +- `[AIMobileLib getProfile:]` : Passes profile data in an `NSDictionary` object to the delegate. See the API description + for information on the key:value pairs expected in profile dictionary. + + @since 1.0 + */ +@property (retain) id result; + +/** + The API returning the result. + + @since 1.0 +*/ +@property API api; + +@end + +#pragma mark - APIError + +/** + This class encapsulates the failure result from an AIMobileLib API call. +*/ +@interface APIError : NSObject + +- (id)initErrorForAPI:(API)anAPI andError:(id)theErrorObject; + +/** + The error object returned from the API on failure. + + @see See AIError for more details. + + @since 1.0 +*/ +@property (retain) AIError *error; + +/** + The API which is returning the error. + + @since 1.0 +*/ +@property API api; + +@end + +#pragma mark - AIAuthenticationDelegate +/** + Applications calling AIMobileLib APIs must implement the methods of this protocol to receive success and failure + information. +*/ +@protocol AIAuthenticationDelegate + +@required + +/** + The APIs call this delegate method with the result when it completes successfully. + + @param apiResult An APIResult object containing the information about the calling API and the result generated. + @see See APIResult for more information on the content of the apiResult. + @since 1.0 +*/ +- (void)requestDidSucceed:(APIResult *)apiResult; + + +/** + The APIs call this delegate method with the result when it fails. + + @param errorResponse An APIResult object containing the information about the API and the error that occurred. + @see See APIError for more information on the content of the result. + @since 1.0 +*/ +- (void)requestDidFail:(APIError *)errorResponse; + +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIError.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIError.h new file mode 100644 index 00000000..cea67032 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIError.h @@ -0,0 +1,146 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#pragma mark - Error codes + +/** + No error, initial value. +*/ +extern const NSUInteger kAINoError; + +/** + A valid refresh token was not found. + + The refresh token stored in the SDK is invalid, expired, revoked, does not match + the redirection URI used in the authorization request, does not have the required scopes, or was issued to another + client. `[AIMobileLib getProfile:]` and `[getAccessTokenForScopes:withOverrideParams:delegate:]` can return this + error. + + Generally with this type of error, the app can call `[AIMobileLib authorizeUserForScopes:delegate:]` to request + authorization, or call `[AIMobileLib clearAuthorizationState:]` to logout the user. +*/ +extern const NSUInteger kAIApplicationNotAuthorized; + +/** + An error occurred on the server. + + The server encountered an error while completing the request, or the SDK received an unknown response from the server. + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can allow the user to retry the last action. +*/ +extern const NSUInteger kAIServerError; + +/** + The user canceled the login page. + + The user pressed cancel while on the login or the consent page. `[AIMobileLib authorizeUserForScopes:delegate:]` can + return this error. + + Generally with this type of error, the app can allow the user to login again. +*/ +extern const NSUInteger kAIErrorUserInterrupted; + +/** + The resource owner or authorization server denied the request. + + The user declined to authorize the app on the consent page. `[AIMobileLib authorizeUserForScopes:delegate:]` can + return this error. + + Generally with this type of error, the app can call `[AIMobileLib authorizeUserForScopes:delegate:]` to + request authorization. +*/ +extern const NSUInteger kAIAccessDenied; + +/** + The SDK encountered an error on the device. + + The SDK returns this when there is a problem with the Keychain. `[AIMobileLib getProfile:]`, + `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can call `[AIMobileLib clearAuthorizationState:]` to reset the Keychain. +*/ +extern const NSUInteger kAIDeviceError; + +/** + One of the API parameters is invalid. + + The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, + or is otherwise malformed. + + Check your method parameters and try again. All APIs can return this error. +*/ +extern const NSUInteger kAIInvalidInput; + +/** + A network error occurred, possibly due to the user being offline. + + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can ask the user to check their network connections. +*/ +extern const NSUInteger kAINetworkError; + +/** + The client is not authorized to request an authorization code using this method. + + The app is not authorized to make this call. Make sure the registered Bundle identifier matches your app, and that you + have a valid APIKey property in the app property list. + `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. +*/ +extern const NSUInteger kAIUnauthorizedClient; + +/** + An internal error occurred in the SDK. + + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally these errors cannot be handled by app. Please contact us to report recurring internal errors. +*/ +extern const NSUInteger kAIInternalError; + +/** + An version error occurred while the SDK version is not supported for LWA SSO. + Only `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + */ +extern const NSUInteger kAIVersionDenied; + +#pragma mark - AIError + +/** + This class encapsulates the error information generated by the SDK. An AIError object includes the error code and a + meaningful error message. The error code constants are available in the header file. +*/ +@interface AIError : NSObject + +/** + The error code for the error encountered by the API. + + @since 1.0 +*/ +@property NSUInteger code; + +/** + The readable message corresponding to the error code. + + @since 1.0 +*/ +@property (retain) NSString *message; + +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIMobileLib.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIMobileLib.h new file mode 100644 index 00000000..64fd1da2 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/AIMobileLib.h @@ -0,0 +1,276 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#import "AIError.h" +#import "AIAuthenticationDelegate.h" + +/** + Key name for defining whether to force a refresh of the access token. + + Pass this key with a string value of "YES" to `getAccessTokenForScopes:withOverrideParams:delegate:` to force the + method to refresh the access token. +*/ +extern const NSString *kForceRefresh; + +/** + Key name for defining whether sandbox mode is on. + + Pass this key with a string value of "YES" to `authorizeUserForScopes:delegate:options:` to switch to the sandbox mode. + */ +extern const NSString *kAIOptionSandboxMode; + +/** + Key name for defining the scope data parameter. + + Pass this key with a Json encoded string of scope data to`authorizeUserForScopes:delegate:options` as required by certain + types of Amazon services. +*/ +extern const NSString *kAIOptionScopeData; + +/** + Key name for defining whether to return authorization code. + + Pass this key with a BOOL value of `YES` to `authorizeUserForScopes:delegate:options` to get back an authorization code. +*/ +extern const NSString *kAIOptionReturnAuthCode; + +/** + Key name for defining the SPOP code challenge parameter. + + Pass this key with a string value into the `options` object used when calling `authorizeUserForScopes:delegate:options` + with kAIOptionReturnAuthCode as `YES`. +*/ +extern const NSString *kAIOptionCodeChallenge; + +/** + Key name for defining the SPOP code challenge method parameter. (Optional) + + Pass this key with a string value into the `options` object used when calling `authorizeUserForScopes:delegate:options` + with kAIOptionReturnAuthCode as `YES`. + */ +extern const NSString *kAIOptionCodeChallengeMethod; + +/** + AIMobileLib is a static class that contains Login with Amazon APIs. + + This class provides APIs for getting authorization from users, getting profile information, clearing authorization + state, and getting authorization tokens to access secure data. +*/ +@interface AIMobileLib : NSObject + +/** + Allows the user to login and, if necessary, authorize the app for the requested scopes. + + Use this method to request authorization from the user for the required scopes. If the user has not logged in, they + will see a login page. Afterward, if they have not previously approved these scopes for your app, they will see a + consent page. + + The sign-in page is displayed in Safari, so there will be a visible switch from the app to Safari. After the user + signs in on the browser, they are redirected back to the app. The app must define + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` in the app delegate and call the + `handleOpenURL:sourceApplication:` API from that delegate method. This allows the SDK to get the login information + from the Safari web browser. + + Scopes that can be used with this API are: + + - "profile": This scope enables an app to request profile information from the backend server. The profile + information includes customer's name, email and user_id. + - "postal_code": This scope enables an app to request the postal code registered to the user's account. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The app can now call `getProfile:` to retrieve the user's profile data, or + `getAccessTokenForScopes:withOverrideParams:delegate:` to retrieve the raw access token. On failure, + `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are passed to the method + in the APIError object. Error codes that can be returned by this API are: + + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIErrorUserInterrupted` : The user canceled the login page. You can allow the user to login again. + - `kAIAccessDenied` : The user did not consent to the requested scopes. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIUnauthorizedClient` : The app is not authorized to make this call. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param scopes The profile scopes that the app is requesting from the user. The first scope must be "profile". + "postal_code" is an optional second scope. + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param options An optional dictionary of options. + @since 1.0 +*/ ++ (void)authorizeUserForScopes:(NSArray *)scopes + delegate:(id )authenticationDelegate + options:(NSDictionary *)options; + ++ (void)authorizeUserForScopes:(NSArray *)scopes delegate:(id )authenticationDelegate; + +/** + Once the user has logged in, this method will return a valid access token for the requested scopes. + + This method returns a valid access token, if necessary by exchanging the current refresh token for a new access token. + If the method is successful, this access token is valid for the requested scopes. + + Scopes that can be used with this API are: + + - "profile": This scope enables an app to request profile information from the backend server. The profile + information includes customer's name, email and user_id. + - "postal_code": This scope enables an app to request the postal code registered to the user's account. + + Values that can be used in `overrideParams`: + + - `kForceRefresh` - Forces the SDK to refresh the access token, discarding the current one and retrieving a new one. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The new access token is passed in the result property of the APIResult parameter. The app can then use the + access token directly with services that support it. On failure, `[AIAuthenticationDelegate requestDidFail:]` is + called. The error code and an error message are passed to the method in the APIError object. Error codes that can be + returned by this API are: + + - `kAIApplicationNotAuthorized` : The app is not authorized for scopes requested. Call + `authorizeUserForScopes:delegate:` to allow the user to authorize the app. + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIUnauthorizedClient` : The app is not authorized to make this call. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param scopes The profile scopes that the app is requesting from the user. The first scope must be "profile". + "postal_code" is an optional second scope. + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param overrideParams Dictionary of optional keys to alter behavior of this function. + @since 1.0 +*/ ++ (void)getAccessTokenForScopes:(NSArray *)scopes + withOverrideParams:(NSDictionary *)overrideParams + delegate:(id )authenticationDelegate; + +/** + Deletes cached user tokens and other data. Use this method to logout a user. + + This method removes the authorization tokens from the Keychain. It also clears the cookies from the local cookie + storage to clear the authorization state of the users who checked the "Remember me" checkbox. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. On failure, `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are + passed to the method in the APIError object. Error codes that can be returned by this API are: + + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @since 1.0 +*/ ++ (void)clearAuthorizationState:(id )authenticationDelegate; + +/** + Use this method to get the profile of the current authorized user. + + This method gets profile information for the current authorized user. The app should make sure it is authorized for + the "profile" scope prior to calling this method. If the app is authorized for the "postal_code" scope, + getProfile will return that information as well. This profile information is cached for 60 minutes. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The user profile is passed in the result property of the APIResult parameter as an NSDictionary. The following + keys are used: + + - "name" : The name of the user. + - "email" : The registered email address of the user. + - "user_id" : The used id of the user, in the form of "amzn1.user.VALUE". The user id is unique to the user. + - "postal_code" : The registered postal code of the user. + + On failure, `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are passed to + the method in the APIError object. Error codes that can be returned by this API are: + + - `kAIApplicationNotAuthorized` : The app is not authorized for scopes requested. Call + `authorizeUserForScopes:delegate:` to allow the user to authorize the app. + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param options An optional dictionary of options. + @since 1.0 +*/ ++ (void)getProfile:(id )authenticationDelegate withOptions:(NSDictionary *)options; + ++ (void)getProfile:(id )authenticationDelegate; + +/** + Helper function for `authorizeUserForScopes:delegate:`. + + Call this function from your implementation of the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate. This method handles the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` call from the Safari web browser. The app + should be calling this function when it receives a call to + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]`, passing in the `url` and the + `sourceApplication`. If the app fails to do so, the SDK will not be able to complete the login flow. + + The SDK validates the `url` parameter to see if it is valid for the SDK. It is possible the app may want to handle the + `url` as well, in which case the app should first call the SDK to see if this `url` is a callback from Safari and if + the SDK wants to process it. After processing, the SDK will return its preference and the app can then process the + `url` if it chooses. Any error arising from this API is reported through the failure delegate used for the + `authorizeUserForScopes:delegate:` call. + + @param url The url received in the `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate + method. + @param sourceApplication The sourceApplication received in the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate method. + @return Returns YES if the url passed in was a valid url for the SDK and NO if the url was not valid. + @see See `authorizeUserForScopes:delegate:` for more discussion on how to work with this API to complement the login + work flow. + @since 1.0 +*/ ++ (BOOL)handleOpenURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication; + +/** + Helper function for `authorizeUserForScopes:delegate:options:`. + + Use this function to get the clientID encoded in the API key your app uses to configure Login with Amazon SDK + for iOS. This clientId is your client identifier that Login with Amazon SDK uses to authorize customers for your application. + If you are requesting to get an authorization code in return from the `[authorizeUserForScopes:delegate:options:]` + API, you will need this value to call Login with Amazon Authorize Service in exchange for refresh and access tokens. + + @return Return the clientId in need for calling Login with Amazon Authorize Service in exchange for refresh and access tokens. + @since 2.0 +*/ + ++ (NSString *) getClientId; + +/** + Helper function for `authorizeUserForScopes:delegate:options:`. + + Use this function to get the redirect_uri that Login with Amazon SDK uses in the `[authorizeUserForScopes:delegate:options]` + API. If you are requesting to get an authorization code in return, this value is required to call Login with Amazon Authorize + service in exchange for refresh and access tokens. + + @return Return the redirect_uri used in the `[authorizeUserForScopes:delegate:options]` API. + @since 2.0 +*/ + ++ (NSString *) getRedirectUri; +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/LoginWithAmazon.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/LoginWithAmazon.h new file mode 100644 index 00000000..590ba3d9 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Headers/LoginWithAmazon.h @@ -0,0 +1,17 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#import "AIMobileLib.h" +#import "AIAuthenticationDelegate.h" +#import "AIError.h" diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/LoginWithAmazon b/samples/iOSCompanionApp/LoginWithAmazon.framework/LoginWithAmazon new file mode 100644 index 00000000..f83c0828 Binary files /dev/null and b/samples/iOSCompanionApp/LoginWithAmazon.framework/LoginWithAmazon differ diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIAuthenticationDelegate.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIAuthenticationDelegate.h new file mode 100644 index 00000000..9cd012c4 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIAuthenticationDelegate.h @@ -0,0 +1,125 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +@class AIError; + +#pragma mark - API + +/** + These constants identify which API succeeded or failed when calling AIAuthenticationDelegate. The value identifying + the API is passed in the APIResult and APIError objects. + + @since 1.0 +*/ +typedef NS_ENUM(NSUInteger, API) { + /** Refers to `[AIMobileLib authorizeUserForScopes:delegate:]` */ + kAPIAuthorizeUser = 1, + /** Refers to `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]` */ + kAPIGetAccessToken = 2, + /** Refers to `[AIMobileLib clearAuthorizationState:]` */ + kAPIClearAuthorizationState = 3, + /** Refers to `[AIMobileLib getProfile:]` */ + kAPIGetProfile = 4, + /** Refers to `[AIMobileLib authorizeUserForScopes:delegate:options]` */ + kAPIGetAuthorizationCode = 5 +}; + +#pragma mark - APIResult +/** + This class encapsulates success information from an AIMobileLib API call. +*/ +@interface APIResult : NSObject + +- (id)initResultForAPI:(API)anAPI andResult:(id)theResult; + +/** + The result object returned from the API on success. The API result can be `nil`, an `NSDictionary`, or an `NSString` + depending upon which API created the APIResult. + +- `[AIMobileLib authorizeUserForScopes:delegate:]` : Passes `nil` as the result to the delegate. +- `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]` : Passes an access token as an `NSString` object + to the delegate. +- `[AIMobileLib clearAuthorizationState:]` : Passes nil as the result to the delegate. +- `[AIMobileLib getProfile:]` : Passes profile data in an `NSDictionary` object to the delegate. See the API description + for information on the key:value pairs expected in profile dictionary. + + @since 1.0 + */ +@property (retain) id result; + +/** + The API returning the result. + + @since 1.0 +*/ +@property API api; + +@end + +#pragma mark - APIError + +/** + This class encapsulates the failure result from an AIMobileLib API call. +*/ +@interface APIError : NSObject + +- (id)initErrorForAPI:(API)anAPI andError:(id)theErrorObject; + +/** + The error object returned from the API on failure. + + @see See AIError for more details. + + @since 1.0 +*/ +@property (retain) AIError *error; + +/** + The API which is returning the error. + + @since 1.0 +*/ +@property API api; + +@end + +#pragma mark - AIAuthenticationDelegate +/** + Applications calling AIMobileLib APIs must implement the methods of this protocol to receive success and failure + information. +*/ +@protocol AIAuthenticationDelegate + +@required + +/** + The APIs call this delegate method with the result when it completes successfully. + + @param apiResult An APIResult object containing the information about the calling API and the result generated. + @see See APIResult for more information on the content of the apiResult. + @since 1.0 +*/ +- (void)requestDidSucceed:(APIResult *)apiResult; + + +/** + The APIs call this delegate method with the result when it fails. + + @param errorResponse An APIResult object containing the information about the API and the error that occurred. + @see See APIError for more information on the content of the result. + @since 1.0 +*/ +- (void)requestDidFail:(APIError *)errorResponse; + +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIError.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIError.h new file mode 100644 index 00000000..cea67032 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIError.h @@ -0,0 +1,146 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#pragma mark - Error codes + +/** + No error, initial value. +*/ +extern const NSUInteger kAINoError; + +/** + A valid refresh token was not found. + + The refresh token stored in the SDK is invalid, expired, revoked, does not match + the redirection URI used in the authorization request, does not have the required scopes, or was issued to another + client. `[AIMobileLib getProfile:]` and `[getAccessTokenForScopes:withOverrideParams:delegate:]` can return this + error. + + Generally with this type of error, the app can call `[AIMobileLib authorizeUserForScopes:delegate:]` to request + authorization, or call `[AIMobileLib clearAuthorizationState:]` to logout the user. +*/ +extern const NSUInteger kAIApplicationNotAuthorized; + +/** + An error occurred on the server. + + The server encountered an error while completing the request, or the SDK received an unknown response from the server. + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can allow the user to retry the last action. +*/ +extern const NSUInteger kAIServerError; + +/** + The user canceled the login page. + + The user pressed cancel while on the login or the consent page. `[AIMobileLib authorizeUserForScopes:delegate:]` can + return this error. + + Generally with this type of error, the app can allow the user to login again. +*/ +extern const NSUInteger kAIErrorUserInterrupted; + +/** + The resource owner or authorization server denied the request. + + The user declined to authorize the app on the consent page. `[AIMobileLib authorizeUserForScopes:delegate:]` can + return this error. + + Generally with this type of error, the app can call `[AIMobileLib authorizeUserForScopes:delegate:]` to + request authorization. +*/ +extern const NSUInteger kAIAccessDenied; + +/** + The SDK encountered an error on the device. + + The SDK returns this when there is a problem with the Keychain. `[AIMobileLib getProfile:]`, + `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can call `[AIMobileLib clearAuthorizationState:]` to reset the Keychain. +*/ +extern const NSUInteger kAIDeviceError; + +/** + One of the API parameters is invalid. + + The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, + or is otherwise malformed. + + Check your method parameters and try again. All APIs can return this error. +*/ +extern const NSUInteger kAIInvalidInput; + +/** + A network error occurred, possibly due to the user being offline. + + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can ask the user to check their network connections. +*/ +extern const NSUInteger kAINetworkError; + +/** + The client is not authorized to request an authorization code using this method. + + The app is not authorized to make this call. Make sure the registered Bundle identifier matches your app, and that you + have a valid APIKey property in the app property list. + `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. +*/ +extern const NSUInteger kAIUnauthorizedClient; + +/** + An internal error occurred in the SDK. + + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally these errors cannot be handled by app. Please contact us to report recurring internal errors. +*/ +extern const NSUInteger kAIInternalError; + +/** + An version error occurred while the SDK version is not supported for LWA SSO. + Only `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + */ +extern const NSUInteger kAIVersionDenied; + +#pragma mark - AIError + +/** + This class encapsulates the error information generated by the SDK. An AIError object includes the error code and a + meaningful error message. The error code constants are available in the header file. +*/ +@interface AIError : NSObject + +/** + The error code for the error encountered by the API. + + @since 1.0 +*/ +@property NSUInteger code; + +/** + The readable message corresponding to the error code. + + @since 1.0 +*/ +@property (retain) NSString *message; + +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIMobileLib.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIMobileLib.h new file mode 100644 index 00000000..64fd1da2 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/AIMobileLib.h @@ -0,0 +1,276 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#import "AIError.h" +#import "AIAuthenticationDelegate.h" + +/** + Key name for defining whether to force a refresh of the access token. + + Pass this key with a string value of "YES" to `getAccessTokenForScopes:withOverrideParams:delegate:` to force the + method to refresh the access token. +*/ +extern const NSString *kForceRefresh; + +/** + Key name for defining whether sandbox mode is on. + + Pass this key with a string value of "YES" to `authorizeUserForScopes:delegate:options:` to switch to the sandbox mode. + */ +extern const NSString *kAIOptionSandboxMode; + +/** + Key name for defining the scope data parameter. + + Pass this key with a Json encoded string of scope data to`authorizeUserForScopes:delegate:options` as required by certain + types of Amazon services. +*/ +extern const NSString *kAIOptionScopeData; + +/** + Key name for defining whether to return authorization code. + + Pass this key with a BOOL value of `YES` to `authorizeUserForScopes:delegate:options` to get back an authorization code. +*/ +extern const NSString *kAIOptionReturnAuthCode; + +/** + Key name for defining the SPOP code challenge parameter. + + Pass this key with a string value into the `options` object used when calling `authorizeUserForScopes:delegate:options` + with kAIOptionReturnAuthCode as `YES`. +*/ +extern const NSString *kAIOptionCodeChallenge; + +/** + Key name for defining the SPOP code challenge method parameter. (Optional) + + Pass this key with a string value into the `options` object used when calling `authorizeUserForScopes:delegate:options` + with kAIOptionReturnAuthCode as `YES`. + */ +extern const NSString *kAIOptionCodeChallengeMethod; + +/** + AIMobileLib is a static class that contains Login with Amazon APIs. + + This class provides APIs for getting authorization from users, getting profile information, clearing authorization + state, and getting authorization tokens to access secure data. +*/ +@interface AIMobileLib : NSObject + +/** + Allows the user to login and, if necessary, authorize the app for the requested scopes. + + Use this method to request authorization from the user for the required scopes. If the user has not logged in, they + will see a login page. Afterward, if they have not previously approved these scopes for your app, they will see a + consent page. + + The sign-in page is displayed in Safari, so there will be a visible switch from the app to Safari. After the user + signs in on the browser, they are redirected back to the app. The app must define + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` in the app delegate and call the + `handleOpenURL:sourceApplication:` API from that delegate method. This allows the SDK to get the login information + from the Safari web browser. + + Scopes that can be used with this API are: + + - "profile": This scope enables an app to request profile information from the backend server. The profile + information includes customer's name, email and user_id. + - "postal_code": This scope enables an app to request the postal code registered to the user's account. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The app can now call `getProfile:` to retrieve the user's profile data, or + `getAccessTokenForScopes:withOverrideParams:delegate:` to retrieve the raw access token. On failure, + `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are passed to the method + in the APIError object. Error codes that can be returned by this API are: + + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIErrorUserInterrupted` : The user canceled the login page. You can allow the user to login again. + - `kAIAccessDenied` : The user did not consent to the requested scopes. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIUnauthorizedClient` : The app is not authorized to make this call. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param scopes The profile scopes that the app is requesting from the user. The first scope must be "profile". + "postal_code" is an optional second scope. + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param options An optional dictionary of options. + @since 1.0 +*/ ++ (void)authorizeUserForScopes:(NSArray *)scopes + delegate:(id )authenticationDelegate + options:(NSDictionary *)options; + ++ (void)authorizeUserForScopes:(NSArray *)scopes delegate:(id )authenticationDelegate; + +/** + Once the user has logged in, this method will return a valid access token for the requested scopes. + + This method returns a valid access token, if necessary by exchanging the current refresh token for a new access token. + If the method is successful, this access token is valid for the requested scopes. + + Scopes that can be used with this API are: + + - "profile": This scope enables an app to request profile information from the backend server. The profile + information includes customer's name, email and user_id. + - "postal_code": This scope enables an app to request the postal code registered to the user's account. + + Values that can be used in `overrideParams`: + + - `kForceRefresh` - Forces the SDK to refresh the access token, discarding the current one and retrieving a new one. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The new access token is passed in the result property of the APIResult parameter. The app can then use the + access token directly with services that support it. On failure, `[AIAuthenticationDelegate requestDidFail:]` is + called. The error code and an error message are passed to the method in the APIError object. Error codes that can be + returned by this API are: + + - `kAIApplicationNotAuthorized` : The app is not authorized for scopes requested. Call + `authorizeUserForScopes:delegate:` to allow the user to authorize the app. + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIUnauthorizedClient` : The app is not authorized to make this call. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param scopes The profile scopes that the app is requesting from the user. The first scope must be "profile". + "postal_code" is an optional second scope. + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param overrideParams Dictionary of optional keys to alter behavior of this function. + @since 1.0 +*/ ++ (void)getAccessTokenForScopes:(NSArray *)scopes + withOverrideParams:(NSDictionary *)overrideParams + delegate:(id )authenticationDelegate; + +/** + Deletes cached user tokens and other data. Use this method to logout a user. + + This method removes the authorization tokens from the Keychain. It also clears the cookies from the local cookie + storage to clear the authorization state of the users who checked the "Remember me" checkbox. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. On failure, `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are + passed to the method in the APIError object. Error codes that can be returned by this API are: + + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @since 1.0 +*/ ++ (void)clearAuthorizationState:(id )authenticationDelegate; + +/** + Use this method to get the profile of the current authorized user. + + This method gets profile information for the current authorized user. The app should make sure it is authorized for + the "profile" scope prior to calling this method. If the app is authorized for the "postal_code" scope, + getProfile will return that information as well. This profile information is cached for 60 minutes. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The user profile is passed in the result property of the APIResult parameter as an NSDictionary. The following + keys are used: + + - "name" : The name of the user. + - "email" : The registered email address of the user. + - "user_id" : The used id of the user, in the form of "amzn1.user.VALUE". The user id is unique to the user. + - "postal_code" : The registered postal code of the user. + + On failure, `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are passed to + the method in the APIError object. Error codes that can be returned by this API are: + + - `kAIApplicationNotAuthorized` : The app is not authorized for scopes requested. Call + `authorizeUserForScopes:delegate:` to allow the user to authorize the app. + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param options An optional dictionary of options. + @since 1.0 +*/ ++ (void)getProfile:(id )authenticationDelegate withOptions:(NSDictionary *)options; + ++ (void)getProfile:(id )authenticationDelegate; + +/** + Helper function for `authorizeUserForScopes:delegate:`. + + Call this function from your implementation of the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate. This method handles the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` call from the Safari web browser. The app + should be calling this function when it receives a call to + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]`, passing in the `url` and the + `sourceApplication`. If the app fails to do so, the SDK will not be able to complete the login flow. + + The SDK validates the `url` parameter to see if it is valid for the SDK. It is possible the app may want to handle the + `url` as well, in which case the app should first call the SDK to see if this `url` is a callback from Safari and if + the SDK wants to process it. After processing, the SDK will return its preference and the app can then process the + `url` if it chooses. Any error arising from this API is reported through the failure delegate used for the + `authorizeUserForScopes:delegate:` call. + + @param url The url received in the `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate + method. + @param sourceApplication The sourceApplication received in the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate method. + @return Returns YES if the url passed in was a valid url for the SDK and NO if the url was not valid. + @see See `authorizeUserForScopes:delegate:` for more discussion on how to work with this API to complement the login + work flow. + @since 1.0 +*/ ++ (BOOL)handleOpenURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication; + +/** + Helper function for `authorizeUserForScopes:delegate:options:`. + + Use this function to get the clientID encoded in the API key your app uses to configure Login with Amazon SDK + for iOS. This clientId is your client identifier that Login with Amazon SDK uses to authorize customers for your application. + If you are requesting to get an authorization code in return from the `[authorizeUserForScopes:delegate:options:]` + API, you will need this value to call Login with Amazon Authorize Service in exchange for refresh and access tokens. + + @return Return the clientId in need for calling Login with Amazon Authorize Service in exchange for refresh and access tokens. + @since 2.0 +*/ + ++ (NSString *) getClientId; + +/** + Helper function for `authorizeUserForScopes:delegate:options:`. + + Use this function to get the redirect_uri that Login with Amazon SDK uses in the `[authorizeUserForScopes:delegate:options]` + API. If you are requesting to get an authorization code in return, this value is required to call Login with Amazon Authorize + service in exchange for refresh and access tokens. + + @return Return the redirect_uri used in the `[authorizeUserForScopes:delegate:options]` API. + @since 2.0 +*/ + ++ (NSString *) getRedirectUri; +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/LoginWithAmazon.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/LoginWithAmazon.h new file mode 100644 index 00000000..590ba3d9 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/Headers/LoginWithAmazon.h @@ -0,0 +1,17 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#import "AIMobileLib.h" +#import "AIAuthenticationDelegate.h" +#import "AIError.h" diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/LoginWithAmazon b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/LoginWithAmazon new file mode 100644 index 00000000..f83c0828 Binary files /dev/null and b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/A/LoginWithAmazon differ diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIAuthenticationDelegate.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIAuthenticationDelegate.h new file mode 100644 index 00000000..9cd012c4 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIAuthenticationDelegate.h @@ -0,0 +1,125 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +@class AIError; + +#pragma mark - API + +/** + These constants identify which API succeeded or failed when calling AIAuthenticationDelegate. The value identifying + the API is passed in the APIResult and APIError objects. + + @since 1.0 +*/ +typedef NS_ENUM(NSUInteger, API) { + /** Refers to `[AIMobileLib authorizeUserForScopes:delegate:]` */ + kAPIAuthorizeUser = 1, + /** Refers to `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]` */ + kAPIGetAccessToken = 2, + /** Refers to `[AIMobileLib clearAuthorizationState:]` */ + kAPIClearAuthorizationState = 3, + /** Refers to `[AIMobileLib getProfile:]` */ + kAPIGetProfile = 4, + /** Refers to `[AIMobileLib authorizeUserForScopes:delegate:options]` */ + kAPIGetAuthorizationCode = 5 +}; + +#pragma mark - APIResult +/** + This class encapsulates success information from an AIMobileLib API call. +*/ +@interface APIResult : NSObject + +- (id)initResultForAPI:(API)anAPI andResult:(id)theResult; + +/** + The result object returned from the API on success. The API result can be `nil`, an `NSDictionary`, or an `NSString` + depending upon which API created the APIResult. + +- `[AIMobileLib authorizeUserForScopes:delegate:]` : Passes `nil` as the result to the delegate. +- `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]` : Passes an access token as an `NSString` object + to the delegate. +- `[AIMobileLib clearAuthorizationState:]` : Passes nil as the result to the delegate. +- `[AIMobileLib getProfile:]` : Passes profile data in an `NSDictionary` object to the delegate. See the API description + for information on the key:value pairs expected in profile dictionary. + + @since 1.0 + */ +@property (retain) id result; + +/** + The API returning the result. + + @since 1.0 +*/ +@property API api; + +@end + +#pragma mark - APIError + +/** + This class encapsulates the failure result from an AIMobileLib API call. +*/ +@interface APIError : NSObject + +- (id)initErrorForAPI:(API)anAPI andError:(id)theErrorObject; + +/** + The error object returned from the API on failure. + + @see See AIError for more details. + + @since 1.0 +*/ +@property (retain) AIError *error; + +/** + The API which is returning the error. + + @since 1.0 +*/ +@property API api; + +@end + +#pragma mark - AIAuthenticationDelegate +/** + Applications calling AIMobileLib APIs must implement the methods of this protocol to receive success and failure + information. +*/ +@protocol AIAuthenticationDelegate + +@required + +/** + The APIs call this delegate method with the result when it completes successfully. + + @param apiResult An APIResult object containing the information about the calling API and the result generated. + @see See APIResult for more information on the content of the apiResult. + @since 1.0 +*/ +- (void)requestDidSucceed:(APIResult *)apiResult; + + +/** + The APIs call this delegate method with the result when it fails. + + @param errorResponse An APIResult object containing the information about the API and the error that occurred. + @see See APIError for more information on the content of the result. + @since 1.0 +*/ +- (void)requestDidFail:(APIError *)errorResponse; + +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIError.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIError.h new file mode 100644 index 00000000..cea67032 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIError.h @@ -0,0 +1,146 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#pragma mark - Error codes + +/** + No error, initial value. +*/ +extern const NSUInteger kAINoError; + +/** + A valid refresh token was not found. + + The refresh token stored in the SDK is invalid, expired, revoked, does not match + the redirection URI used in the authorization request, does not have the required scopes, or was issued to another + client. `[AIMobileLib getProfile:]` and `[getAccessTokenForScopes:withOverrideParams:delegate:]` can return this + error. + + Generally with this type of error, the app can call `[AIMobileLib authorizeUserForScopes:delegate:]` to request + authorization, or call `[AIMobileLib clearAuthorizationState:]` to logout the user. +*/ +extern const NSUInteger kAIApplicationNotAuthorized; + +/** + An error occurred on the server. + + The server encountered an error while completing the request, or the SDK received an unknown response from the server. + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can allow the user to retry the last action. +*/ +extern const NSUInteger kAIServerError; + +/** + The user canceled the login page. + + The user pressed cancel while on the login or the consent page. `[AIMobileLib authorizeUserForScopes:delegate:]` can + return this error. + + Generally with this type of error, the app can allow the user to login again. +*/ +extern const NSUInteger kAIErrorUserInterrupted; + +/** + The resource owner or authorization server denied the request. + + The user declined to authorize the app on the consent page. `[AIMobileLib authorizeUserForScopes:delegate:]` can + return this error. + + Generally with this type of error, the app can call `[AIMobileLib authorizeUserForScopes:delegate:]` to + request authorization. +*/ +extern const NSUInteger kAIAccessDenied; + +/** + The SDK encountered an error on the device. + + The SDK returns this when there is a problem with the Keychain. `[AIMobileLib getProfile:]`, + `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can call `[AIMobileLib clearAuthorizationState:]` to reset the Keychain. +*/ +extern const NSUInteger kAIDeviceError; + +/** + One of the API parameters is invalid. + + The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, + or is otherwise malformed. + + Check your method parameters and try again. All APIs can return this error. +*/ +extern const NSUInteger kAIInvalidInput; + +/** + A network error occurred, possibly due to the user being offline. + + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally with this type of error, the app can ask the user to check their network connections. +*/ +extern const NSUInteger kAINetworkError; + +/** + The client is not authorized to request an authorization code using this method. + + The app is not authorized to make this call. Make sure the registered Bundle identifier matches your app, and that you + have a valid APIKey property in the app property list. + `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. +*/ +extern const NSUInteger kAIUnauthorizedClient; + +/** + An internal error occurred in the SDK. + + `[AIMobileLib getProfile:]`, `[AIMobileLib getAccessTokenForScopes:withOverrideParams:delegate:]`, and + `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + + Generally these errors cannot be handled by app. Please contact us to report recurring internal errors. +*/ +extern const NSUInteger kAIInternalError; + +/** + An version error occurred while the SDK version is not supported for LWA SSO. + Only `[AIMobileLib authorizeUserForScopes:delegate:]` can return this error. + */ +extern const NSUInteger kAIVersionDenied; + +#pragma mark - AIError + +/** + This class encapsulates the error information generated by the SDK. An AIError object includes the error code and a + meaningful error message. The error code constants are available in the header file. +*/ +@interface AIError : NSObject + +/** + The error code for the error encountered by the API. + + @since 1.0 +*/ +@property NSUInteger code; + +/** + The readable message corresponding to the error code. + + @since 1.0 +*/ +@property (retain) NSString *message; + +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIMobileLib.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIMobileLib.h new file mode 100644 index 00000000..64fd1da2 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/AIMobileLib.h @@ -0,0 +1,276 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#import "AIError.h" +#import "AIAuthenticationDelegate.h" + +/** + Key name for defining whether to force a refresh of the access token. + + Pass this key with a string value of "YES" to `getAccessTokenForScopes:withOverrideParams:delegate:` to force the + method to refresh the access token. +*/ +extern const NSString *kForceRefresh; + +/** + Key name for defining whether sandbox mode is on. + + Pass this key with a string value of "YES" to `authorizeUserForScopes:delegate:options:` to switch to the sandbox mode. + */ +extern const NSString *kAIOptionSandboxMode; + +/** + Key name for defining the scope data parameter. + + Pass this key with a Json encoded string of scope data to`authorizeUserForScopes:delegate:options` as required by certain + types of Amazon services. +*/ +extern const NSString *kAIOptionScopeData; + +/** + Key name for defining whether to return authorization code. + + Pass this key with a BOOL value of `YES` to `authorizeUserForScopes:delegate:options` to get back an authorization code. +*/ +extern const NSString *kAIOptionReturnAuthCode; + +/** + Key name for defining the SPOP code challenge parameter. + + Pass this key with a string value into the `options` object used when calling `authorizeUserForScopes:delegate:options` + with kAIOptionReturnAuthCode as `YES`. +*/ +extern const NSString *kAIOptionCodeChallenge; + +/** + Key name for defining the SPOP code challenge method parameter. (Optional) + + Pass this key with a string value into the `options` object used when calling `authorizeUserForScopes:delegate:options` + with kAIOptionReturnAuthCode as `YES`. + */ +extern const NSString *kAIOptionCodeChallengeMethod; + +/** + AIMobileLib is a static class that contains Login with Amazon APIs. + + This class provides APIs for getting authorization from users, getting profile information, clearing authorization + state, and getting authorization tokens to access secure data. +*/ +@interface AIMobileLib : NSObject + +/** + Allows the user to login and, if necessary, authorize the app for the requested scopes. + + Use this method to request authorization from the user for the required scopes. If the user has not logged in, they + will see a login page. Afterward, if they have not previously approved these scopes for your app, they will see a + consent page. + + The sign-in page is displayed in Safari, so there will be a visible switch from the app to Safari. After the user + signs in on the browser, they are redirected back to the app. The app must define + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` in the app delegate and call the + `handleOpenURL:sourceApplication:` API from that delegate method. This allows the SDK to get the login information + from the Safari web browser. + + Scopes that can be used with this API are: + + - "profile": This scope enables an app to request profile information from the backend server. The profile + information includes customer's name, email and user_id. + - "postal_code": This scope enables an app to request the postal code registered to the user's account. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The app can now call `getProfile:` to retrieve the user's profile data, or + `getAccessTokenForScopes:withOverrideParams:delegate:` to retrieve the raw access token. On failure, + `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are passed to the method + in the APIError object. Error codes that can be returned by this API are: + + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIErrorUserInterrupted` : The user canceled the login page. You can allow the user to login again. + - `kAIAccessDenied` : The user did not consent to the requested scopes. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIUnauthorizedClient` : The app is not authorized to make this call. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param scopes The profile scopes that the app is requesting from the user. The first scope must be "profile". + "postal_code" is an optional second scope. + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param options An optional dictionary of options. + @since 1.0 +*/ ++ (void)authorizeUserForScopes:(NSArray *)scopes + delegate:(id )authenticationDelegate + options:(NSDictionary *)options; + ++ (void)authorizeUserForScopes:(NSArray *)scopes delegate:(id )authenticationDelegate; + +/** + Once the user has logged in, this method will return a valid access token for the requested scopes. + + This method returns a valid access token, if necessary by exchanging the current refresh token for a new access token. + If the method is successful, this access token is valid for the requested scopes. + + Scopes that can be used with this API are: + + - "profile": This scope enables an app to request profile information from the backend server. The profile + information includes customer's name, email and user_id. + - "postal_code": This scope enables an app to request the postal code registered to the user's account. + + Values that can be used in `overrideParams`: + + - `kForceRefresh` - Forces the SDK to refresh the access token, discarding the current one and retrieving a new one. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The new access token is passed in the result property of the APIResult parameter. The app can then use the + access token directly with services that support it. On failure, `[AIAuthenticationDelegate requestDidFail:]` is + called. The error code and an error message are passed to the method in the APIError object. Error codes that can be + returned by this API are: + + - `kAIApplicationNotAuthorized` : The app is not authorized for scopes requested. Call + `authorizeUserForScopes:delegate:` to allow the user to authorize the app. + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIUnauthorizedClient` : The app is not authorized to make this call. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param scopes The profile scopes that the app is requesting from the user. The first scope must be "profile". + "postal_code" is an optional second scope. + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param overrideParams Dictionary of optional keys to alter behavior of this function. + @since 1.0 +*/ ++ (void)getAccessTokenForScopes:(NSArray *)scopes + withOverrideParams:(NSDictionary *)overrideParams + delegate:(id )authenticationDelegate; + +/** + Deletes cached user tokens and other data. Use this method to logout a user. + + This method removes the authorization tokens from the Keychain. It also clears the cookies from the local cookie + storage to clear the authorization state of the users who checked the "Remember me" checkbox. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. On failure, `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are + passed to the method in the APIError object. Error codes that can be returned by this API are: + + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @since 1.0 +*/ ++ (void)clearAuthorizationState:(id )authenticationDelegate; + +/** + Use this method to get the profile of the current authorized user. + + This method gets profile information for the current authorized user. The app should make sure it is authorized for + the "profile" scope prior to calling this method. If the app is authorized for the "postal_code" scope, + getProfile will return that information as well. This profile information is cached for 60 minutes. + + The result of this API is sent to the `delegate`. On success, `[AIAuthenticationDelegate requestDidSucceed:]` is + called. The user profile is passed in the result property of the APIResult parameter as an NSDictionary. The following + keys are used: + + - "name" : The name of the user. + - "email" : The registered email address of the user. + - "user_id" : The used id of the user, in the form of "amzn1.user.VALUE". The user id is unique to the user. + - "postal_code" : The registered postal code of the user. + + On failure, `[AIAuthenticationDelegate requestDidFail:]` is called. The error code and an error message are passed to + the method in the APIError object. Error codes that can be returned by this API are: + + - `kAIApplicationNotAuthorized` : The app is not authorized for scopes requested. Call + `authorizeUserForScopes:delegate:` to allow the user to authorize the app. + - `kAIServerError` : The server encountered an error while completing the request, or the SDK received an unknown + response from the server. You can allow the user to login again. + - `kAIDeviceError` : The SDK encountered an error on the device. The SDK returns this when there is a problem with the + Keychain. Calling `clearAuthorizationState:` will help. + - `kAIInvalidInput` : One of the API parameters is invalid. See the error message for more information. + - `kAINetworkError` : A network error occurred, possibly due to the user being offline. + - `kAIInternalError` : An internal error occurred in the SDK. You can allow the user to login again. + + @param authenticationDelegate A delegate implementing the `AIAuthenticationDelegate` protocol to receive success and + failure messages. + @param options An optional dictionary of options. + @since 1.0 +*/ ++ (void)getProfile:(id )authenticationDelegate withOptions:(NSDictionary *)options; + ++ (void)getProfile:(id )authenticationDelegate; + +/** + Helper function for `authorizeUserForScopes:delegate:`. + + Call this function from your implementation of the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate. This method handles the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` call from the Safari web browser. The app + should be calling this function when it receives a call to + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]`, passing in the `url` and the + `sourceApplication`. If the app fails to do so, the SDK will not be able to complete the login flow. + + The SDK validates the `url` parameter to see if it is valid for the SDK. It is possible the app may want to handle the + `url` as well, in which case the app should first call the SDK to see if this `url` is a callback from Safari and if + the SDK wants to process it. After processing, the SDK will return its preference and the app can then process the + `url` if it chooses. Any error arising from this API is reported through the failure delegate used for the + `authorizeUserForScopes:delegate:` call. + + @param url The url received in the `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate + method. + @param sourceApplication The sourceApplication received in the + `[UIApplicationDelegate application:openURL:sourceApplication:annotation]` delegate method. + @return Returns YES if the url passed in was a valid url for the SDK and NO if the url was not valid. + @see See `authorizeUserForScopes:delegate:` for more discussion on how to work with this API to complement the login + work flow. + @since 1.0 +*/ ++ (BOOL)handleOpenURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication; + +/** + Helper function for `authorizeUserForScopes:delegate:options:`. + + Use this function to get the clientID encoded in the API key your app uses to configure Login with Amazon SDK + for iOS. This clientId is your client identifier that Login with Amazon SDK uses to authorize customers for your application. + If you are requesting to get an authorization code in return from the `[authorizeUserForScopes:delegate:options:]` + API, you will need this value to call Login with Amazon Authorize Service in exchange for refresh and access tokens. + + @return Return the clientId in need for calling Login with Amazon Authorize Service in exchange for refresh and access tokens. + @since 2.0 +*/ + ++ (NSString *) getClientId; + +/** + Helper function for `authorizeUserForScopes:delegate:options:`. + + Use this function to get the redirect_uri that Login with Amazon SDK uses in the `[authorizeUserForScopes:delegate:options]` + API. If you are requesting to get an authorization code in return, this value is required to call Login with Amazon Authorize + service in exchange for refresh and access tokens. + + @return Return the redirect_uri used in the `[authorizeUserForScopes:delegate:options]` API. + @since 2.0 +*/ + ++ (NSString *) getRedirectUri; +@end diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/LoginWithAmazon.h b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/LoginWithAmazon.h new file mode 100644 index 00000000..590ba3d9 --- /dev/null +++ b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/Headers/LoginWithAmazon.h @@ -0,0 +1,17 @@ +/** + * Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy + * of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +#import + +#import "AIMobileLib.h" +#import "AIAuthenticationDelegate.h" +#import "AIError.h" diff --git a/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/LoginWithAmazon b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/LoginWithAmazon new file mode 100644 index 00000000..f83c0828 Binary files /dev/null and b/samples/iOSCompanionApp/LoginWithAmazon.framework/Versions/Current/LoginWithAmazon differ diff --git a/samples/iOSCompanionApp/Resources/App/Default-568h@2x.png b/samples/iOSCompanionApp/Resources/App/Default-568h@2x.png new file mode 100644 index 00000000..0891b7aa Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/Default-568h@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/App/btnLWA_gold_157x36.png b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_157x36.png new file mode 100644 index 00000000..51b14ffd Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_157x36.png differ diff --git a/samples/iOSCompanionApp/Resources/App/btnLWA_gold_157x36_pressed.png b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_157x36_pressed.png new file mode 100644 index 00000000..43d4380f Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_157x36_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/App/btnLWA_gold_209x48.png b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_209x48.png new file mode 100644 index 00000000..2c6e7830 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_209x48.png differ diff --git a/samples/iOSCompanionApp/Resources/App/btnLWA_gold_209x48_pressed.png b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_209x48_pressed.png new file mode 100644 index 00000000..a4aa1af5 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_209x48_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/App/btnLWA_gold_314x72.png b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_314x72.png new file mode 100644 index 00000000..23faf977 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_314x72.png differ diff --git a/samples/iOSCompanionApp/Resources/App/btnLWA_gold_314x72_pressed.png b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_314x72_pressed.png new file mode 100644 index 00000000..f2956b2a Binary files /dev/null and b/samples/iOSCompanionApp/Resources/App/btnLWA_gold_314x72_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_34x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_34x36.png new file mode 100644 index 00000000..f9915e73 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_34x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_34x36_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_34x36_Pressed.png new file mode 100644 index 00000000..c3388c89 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_34x36_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_77x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_77x36.png new file mode 100644 index 00000000..c83e02da Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_77x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_77x36_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_77x36_Pressed.png new file mode 100644 index 00000000..2cafb344 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Drkgry_77x36_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_34x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_34x36.png new file mode 100644 index 00000000..3efb20bb Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_34x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_34x36_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_34x36_Pressed.png new file mode 100644 index 00000000..ed0e83d6 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_34x36_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_77x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_77x36.png new file mode 100644 index 00000000..65cd2040 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_77x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_77x36_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_77x36_Pressed.png new file mode 100644 index 00000000..16002a94 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gold_77x36_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_34x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_34x36.png new file mode 100644 index 00000000..00fb7861 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_34x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_34x36_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_34x36_Pressed.png new file mode 100644 index 00000000..a99a29c7 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_34x36_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_77x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_77x36.png new file mode 100644 index 00000000..b0a116fa Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_77x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_77x36_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_77x36_Pressed.png new file mode 100644 index 00000000..653cb046 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_Gry_77x36_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_drkgry_157x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_drkgry_157x36.png new file mode 100644 index 00000000..0865a9c7 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_drkgry_157x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_drkgry_157x36_pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_drkgry_157x36_pressed.png new file mode 100644 index 00000000..891ba50b Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_drkgry_157x36_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gold_157x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gold_157x36.png new file mode 100644 index 00000000..51b14ffd Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gold_157x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gold_157x36_pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gold_157x36_pressed.png new file mode 100644 index 00000000..43d4380f Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gold_157x36_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gry_157x36.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gry_157x36.png new file mode 100644 index 00000000..a2a827ba Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gry_157x36.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gry_157x36_pressed.png b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gry_157x36_pressed.png new file mode 100644 index 00000000..e6cc917c Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/1x/btnLWA_gry_157x36_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Drkgry_77x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Drkgry_77x36@2x.png new file mode 100644 index 00000000..b46a1119 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Drkgry_77x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Drkgry_77x36_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Drkgry_77x36_Pressed@2x.png new file mode 100644 index 00000000..34f5084c Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Drkgry_77x36_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gold_77x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gold_77x36@2x.png new file mode 100644 index 00000000..6b39a6eb Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gold_77x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gold_77x36_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gold_77x36_Pressed@2x.png new file mode 100644 index 00000000..66b1d158 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gold_77x36_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gry_77x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gry_77x36@2x.png new file mode 100644 index 00000000..377bfbc3 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gry_77x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gry_77x36_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gry_77x36_Pressed@2x.png new file mode 100644 index 00000000..d7839ddb Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_Gry_77x36_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgray_157x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgray_157x36@2x.png new file mode 100644 index 00000000..1b41a711 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgray_157x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgray_157x36_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgray_157x36_pressed@2x.png new file mode 100644 index 00000000..e7514600 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgray_157x36_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgry_34x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgry_34x36@2x.png new file mode 100644 index 00000000..9b7d8fe9 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgry_34x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgry_34x36_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgry_34x36_pressed@2x.png new file mode 100644 index 00000000..2c9102dc Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_drkgry_34x36_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_157x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_157x36@2x.png new file mode 100644 index 00000000..23faf977 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_157x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_157x36_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_157x36_pressed@2x.png new file mode 100644 index 00000000..f2956b2a Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_157x36_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_34x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_34x36@2x.png new file mode 100644 index 00000000..fd6c5617 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_34x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_34x36_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_34x36_pressed@2x.png new file mode 100644 index 00000000..44d72d40 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gold_34x36_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_157x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_157x36@2x.png new file mode 100644 index 00000000..342d6f51 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_157x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_157x36_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_157x36_pressed@2x.png new file mode 100644 index 00000000..3660f4c3 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_157x36_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_34x36@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_34x36@2x.png new file mode 100644 index 00000000..29ffcd23 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_34x36@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_34x36_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_34x36_pressed@2x.png new file mode 100644 index 00000000..044bb22e Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/32dp/2x/btnLWA_gry_34x36_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_102x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_102x48.png new file mode 100644 index 00000000..dcab6b44 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_102x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_102x48_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_102x48_Pressed.png new file mode 100644 index 00000000..dceeb9b2 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_102x48_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_46x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_46x48.png new file mode 100644 index 00000000..80c8780b Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_46x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_46x48_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_46x48_Pressed.png new file mode 100644 index 00000000..47367078 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Drkgry_46x48_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_102x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_102x48.png new file mode 100644 index 00000000..2f6169d5 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_102x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_102x48_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_102x48_Pressed.png new file mode 100644 index 00000000..42d40fb5 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_102x48_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_46x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_46x48.png new file mode 100644 index 00000000..af4e6455 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_46x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_46x48_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_46x48_Pressed.png new file mode 100644 index 00000000..8138cb19 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gold_46x48_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_102x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_102x48.png new file mode 100644 index 00000000..02e88192 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_102x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_102x48_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_102x48_Pressed.png new file mode 100644 index 00000000..a9905eca Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_102x48_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_46x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_46x48.png new file mode 100644 index 00000000..37a07ce5 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_46x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_46x48_Pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_46x48_Pressed.png new file mode 100644 index 00000000..128b6a7c Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_Gry_46x48_Pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_drkgry_209x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_drkgry_209x48.png new file mode 100644 index 00000000..e5e8f87a Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_drkgry_209x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_drkgry_209x48_pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_drkgry_209x48_pressed.png new file mode 100644 index 00000000..ecf439ed Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_drkgry_209x48_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_gry_209x48.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_gry_209x48.png new file mode 100644 index 00000000..95ebd661 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_gry_209x48.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_gry_209x48_pressed.png b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_gry_209x48_pressed.png new file mode 100644 index 00000000..3f80060d Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/1x/btnLWA_gry_209x48_pressed.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_102x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_102x48@2x.png new file mode 100644 index 00000000..58624d6e Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_102x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_102x48_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_102x48_Pressed@2x.png new file mode 100644 index 00000000..c99de047 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_102x48_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_46x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_46x48@2x.png new file mode 100644 index 00000000..e7bbb26d Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_46x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_46x48_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_46x48_Pressed@2x.png new file mode 100644 index 00000000..4e4a03bf Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Drkgry_46x48_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_102x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_102x48@2x.png new file mode 100644 index 00000000..34abbf41 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_102x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_102x48_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_102x48_Pressed@2x.png new file mode 100644 index 00000000..59d1daf9 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_102x48_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_46x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_46x48@2x.png new file mode 100644 index 00000000..72754de7 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_46x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_46x48_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_46x48_Pressed@2x.png new file mode 100644 index 00000000..972317c2 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gold_46x48_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_102x486@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_102x486@2x.png new file mode 100644 index 00000000..12e8d01d Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_102x486@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_102x48_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_102x48_Pressed@2x.png new file mode 100644 index 00000000..1b32df1a Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_102x48_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_46x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_46x48@2x.png new file mode 100644 index 00000000..4e589751 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_46x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_46x48_Pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_46x48_Pressed@2x.png new file mode 100644 index 00000000..194e41c6 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_Gry_46x48_Pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_drkgry_209x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_drkgry_209x48@2x.png new file mode 100644 index 00000000..36a2dc97 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_drkgry_209x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_drkgry_209x48_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_drkgry_209x48_pressed@2x.png new file mode 100644 index 00000000..4c497b8e Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_drkgry_209x48_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gold_209x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gold_209x48@2x.png new file mode 100644 index 00000000..0bf7cbe6 Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gold_209x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gold_209x48_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gold_209x48_pressed@2x.png new file mode 100644 index 00000000..24e1efff Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gold_209x48_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gry_209x48@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gry_209x48@2x.png new file mode 100644 index 00000000..dcd2d9fe Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gry_209x48@2x.png differ diff --git a/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gry_209x48_pressed@2x.png b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gry_209x48_pressed@2x.png new file mode 100644 index 00000000..b2eddf0b Binary files /dev/null and b/samples/iOSCompanionApp/Resources/iOS/44dp/2x/btnLWA_gry_209x48_pressed@2x.png differ diff --git a/samples/iOSCompanionApp/btnLWA_Gold_94x96.png b/samples/iOSCompanionApp/btnLWA_Gold_94x96.png new file mode 100644 index 00000000..44b46c1e Binary files /dev/null and b/samples/iOSCompanionApp/btnLWA_Gold_94x96.png differ diff --git a/samples/iOSCompanionApp/btnLWA_gold_68x72.png b/samples/iOSCompanionApp/btnLWA_gold_68x72.png new file mode 100644 index 00000000..83c61c83 Binary files /dev/null and b/samples/iOSCompanionApp/btnLWA_gold_68x72.png differ diff --git a/samples/javaclient/README.txt b/samples/javaclient/README.txt new file mode 100644 index 00000000..8fe60c18 --- /dev/null +++ b/samples/javaclient/README.txt @@ -0,0 +1,18 @@ +Refer to https://developer.amazon.com/public/solutions/alexa/alexa-voice-service/ for the most up to date documentation on how to +use this client and how to provision with your account and test device information. Please note that in order to use this client you +must also use one of the companion samples for authentication. + +Then do the following: + +First, check what version of Java you have. Only 1.8 is supported: + + $ java -version + java version "1.8.0_74" + Java(TM) SE Runtime Environment (build 1.8.0_74-b02) + Java HotSpot(TM) 64-Bit Server VM (build 25.74-b02, mixed mode) + +Then, consult the table at this URL: http://www.eclipse.org/jetty/documentation/current/alpn-chapter.html#alpn-versions +Copy the version of ALPN that you require for your version of the JDK. + +When you run the app you'll need to run it like so: +mvn exec:exec -Dalpn-boot.version=YOUR_VERSION \ No newline at end of file diff --git a/samples/javaclient/config.json b/samples/javaclient/config.json new file mode 100644 index 00000000..01a9e290 --- /dev/null +++ b/samples/javaclient/config.json @@ -0,0 +1,17 @@ +{ + "productId":"", + "dsn":"", + "provisioningMethod":"", + "companionApp":{ + "localPort":8443, + "sslKeyStore":"", + "sslKeyStorePassphrase":"", + "lwaUrl":"https://api.amazon.com" + }, + "companionService":{ + "serviceUrl":"https://localhost:3000", + "sslClientKeyStore":"", + "sslClientKeyStorePassphrase":"", + "sslCaCert":"" + } +} diff --git a/samples/javaclient/generate.bat b/samples/javaclient/generate.bat new file mode 100644 index 00000000..ecff1db8 --- /dev/null +++ b/samples/javaclient/generate.bat @@ -0,0 +1,51 @@ +@echo off +pushd %~dp0 + +set /p productId="Product ID: " +set /p dsn="Serial Number: " + +set "psCommand=powershell -Command "$pword = read-host 'Password for Keystores' -AsSecureString ; ^ + $BSTR=[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($pword); ^ + [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)"" +for /f "usebackq delims=" %%p in (`%psCommand%`) do set password=%%p + +setlocal enableextensions +if not exist ".\certs\ca\" mkdir .\certs\ca\ +if not exist ".\certs\server\" mkdir .\certs\server\ +if not exist ".\certs\client\" mkdir .\certs\client\ +if not exist "..\androidCompanionApp\app\src\main\res\raw\" mkdir ..\androidCompanionApp\app\src\main\res\raw\ +if not exist "..\iOSCompanionApp\Resources\App\" mkdir ..\iOSCompanionApp\Resources\App\ +endlocal + +REM Create CA +openssl genrsa -out certs\ca\ca.key 4096 +set COMMON_NAME=My CA +openssl req -new -x509 -days 365 -key certs\ca\ca.key -out certs\ca\ca.crt -config ./ssl.cnf -sha256 + +REM Create the Client KeyPair for the Device Code +openssl genrsa -out certs\client\client.key 2048 +set COMMON_NAME=%productId%:%dsn% +openssl req -new -key certs\client\client.key -out certs\client\client.csr -config ssl.cnf -sha256 +openssl x509 -req -days 365 -in certs\client\client.csr -CA certs\ca\ca.crt -CAkey certs\ca\ca.key -set_serial 01 -out certs\client\client.crt -sha256 +openssl pkcs12 -inkey certs\client\client.key -in certs\client\client.crt -export -out certs\client\client.pkcs12 -password pass:%password% + +REM Create the KeyPair for the Node.js Companion Service +openssl genrsa -out certs\server\node.key 2048 +set COMMON_NAME=localhost +openssl req -new -key certs\server\node.key -out certs\server\node.csr -config ssl.cnf -sha256 +openssl x509 -req -days 365 -in certs\server\node.csr -CA certs\ca\ca.crt -CAkey certs\ca\ca.key -set_serial 02 -out certs\server\node.crt -sha256 + +REM Create the KeyPair for the Jetty server running on the Device Code in companionApp mode +openssl genrsa -out certs\server\jetty.key 2048 +set COMMON_NAME=localhost +openssl req -new -key certs\server\jetty.key -out certs\server\jetty.csr -config ssl.cnf -sha256 +set COMMON_NAME=localhost +openssl x509 -req -days 365 -in certs\server\jetty.csr -CA certs\ca\ca.crt -CAkey certs\ca\ca.key -set_serial 03 -out certs\server\jetty.crt -extensions v3_req -extfile ssl.cnf -sha256 +openssl pkcs12 -inkey certs\server\jetty.key -in certs\server\jetty.crt -export -out certs\server\jetty.pkcs12 -password pass:%password% + +REM Copy the CA certificate to Android +xcopy /Y certs\ca\ca.crt ..\androidCompanionApp\app\src\main\res\raw\ + +REM Copy the CA certificate in the correct format to iOS +openssl x509 -outform der -in certs\ca\ca.crt -out certs\ca\ca.der +xcopy /Y certs\ca\ca.der ..\iOSCompanionApp\Resources\App\ diff --git a/samples/javaclient/generate.sh b/samples/javaclient/generate.sh new file mode 100644 index 00000000..c665ce1a --- /dev/null +++ b/samples/javaclient/generate.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd $SCRIPT_DIR + +echo -n "Product ID: " +read productId + +echo -n "Serial Number: " +read dsn + +echo -n "Password for Keystores (won't echo): " +read -s password + +mkdir -p certs/ca/ +mkdir -p certs/server/ +mkdir -p certs/client/ +mkdir -p ../iOSCompanionApp/Resources/App/ +mkdir -p ../androidCompanionApp/app/src/main/res/raw/ + +openssl genrsa -out certs/ca/ca.key 4096 +COMMON_NAME="My CA" openssl req -new -x509 -days 365 -key certs/ca/ca.key -out certs/ca/ca.crt -config ssl.cnf -sha256 + +# Create the Client KeyPair for the Device Code +openssl genrsa -out certs/client/client.key 2048 +COMMON_NAME="$productId:$dsn" openssl req -new -key certs/client/client.key -out certs/client/client.csr -config ssl.cnf -sha256 +openssl x509 -req -days 365 -in certs/client/client.csr -CA certs/ca/ca.crt -CAkey certs/ca/ca.key -set_serial 01 -out certs/client/client.crt -sha256 +openssl pkcs12 -inkey certs/client/client.key -in certs/client/client.crt -export -out certs/client/client.pkcs12 -password pass:$password + +# Create the KeyPair for the Node.js Companion Service +openssl genrsa -out certs/server/node.key 2048 +COMMON_NAME="localhost" openssl req -new -key certs/server/node.key -out certs/server/node.csr -config ssl.cnf -sha256 +openssl x509 -req -days 365 -in certs/server/node.csr -CA certs/ca/ca.crt -CAkey certs/ca/ca.key -set_serial 02 -out certs/server/node.crt -sha256 + +# Create the KeyPair for the Jetty server running on the Device Code in companionApp mode +openssl genrsa -out certs/server/jetty.key 2048 +COMMON_NAME="localhost" openssl req -new -key certs/server/jetty.key -out certs/server/jetty.csr -config ssl.cnf -sha256 +COMMON_NAME="localhost" openssl x509 -req -days 365 -in certs/server/jetty.csr -CA certs/ca/ca.crt -CAkey certs/ca/ca.key -set_serial 03 -out certs/server/jetty.crt -extensions v3_req -extfile ssl.cnf -sha256 +openssl pkcs12 -inkey certs/server/jetty.key -in certs/server/jetty.crt -export -out certs/server/jetty.pkcs12 -password pass:$password + +# Copy the CA certificate to Android +cp certs/ca/ca.crt ../androidCompanionApp/app/src/main/res/raw/ + +# Copy the CA certificate in the correct format to iOS +openssl x509 -outform der -in certs/ca/ca.crt -out certs/ca/ca.der +cp certs/ca/ca.der ../iOSCompanionApp/Resources/App/ diff --git a/samples/javaclient/install-java8.sh b/samples/javaclient/install-java8.sh new file mode 100644 index 00000000..60da8a18 --- /dev/null +++ b/samples/javaclient/install-java8.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Ensure we are running on Raspbian +lsb_release -a 2>/dev/null | grep Raspbian +if [ "$?" -ne "0" ]; then + echo "This OS is not Raspbian. Exiting..." + exit 1 +fi + +# Determine which version of Raspbian we are running on +VERSION=`lsb_release -c 2>/dev/null | awk '{print $2}'` +echo "Version of Raspbian determined to be: $VERSION" + +if [ "$VERSION" == "jessie" ]; then + UBUNTU_VERSION="trusty" +elif [ "$VERSION" == "wheezy" ]; then + UBUNTU_VERSION="precise" +else + echo "Not running Raspbian Wheezy or Jessie. Exiting..." + exit 1; +fi + +# Remove any existing Java +sudo apt-get remove --purge oracle-java8-jdk oracle-java7-jdk openjdk-7-jre openjdk-8-jre + +# Install Java from Ubuntu's PPA +# http://linuxg.net/how-to-install-the-oracle-java-8-on-debian-wheezy-and-debian-jessie-via-repository/ +sudo sh -c "echo \"deb http://ppa.launchpad.net/webupd8team/java/ubuntu $UBUNTU_VERSION main\" >> /etc/apt/sources.list" +sudo sh -c "echo \"deb-src http://ppa.launchpad.net/webupd8team/java/ubuntu $UBUNTU_VERSION main\" >> /etc/apt/sources.list" +sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys EEA14886 +sudo apt-get update +sudo apt-get install oracle-java8-installer +sudo apt-get install oracle-java8-set-default diff --git a/samples/javaclient/log4j2.xml b/samples/javaclient/log4j2.xml new file mode 100644 index 00000000..48383c1d --- /dev/null +++ b/samples/javaclient/log4j2.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/javaclient/pom.xml b/samples/javaclient/pom.xml new file mode 100644 index 00000000..7cfe494a --- /dev/null +++ b/samples/javaclient/pom.xml @@ -0,0 +1,197 @@ + + + 4.0.0 + com.amazon.alexa.avs + sample-java-client + 20160207.0 + jar + Alexa Voice Service Sample Java Client + https://developer.amazon.com/appsandservices/solutions/alexa/alexa-voice-service + + + 9.3.7.v20160115 + + + + 8.1.7.v20160121 + 1.7.10 + 2.3 + + + + commons-codec + commons-codec + 1.9 + + + commons-io + commons-io + 2.4 + + + org.apache.commons + commons-lang3 + 3.4 + + + commons-fileupload + commons-fileupload + 1.3.1 + + + uk.co.caprica + vlcj + 2.4.1 + + + org.glassfish + javax.json + 1.0.4 + + + javazoom + jlayer + 1.0.1 + + + org.mortbay.jetty.alpn + alpn-boot + ${alpn-boot.version} + + + org.codehaus.jackson + jackson-mapper-asl + 1.9.13 + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.apache.logging.log4j + log4j-slf4j-impl + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + + org.eclipse.jetty + jetty-alpn-client + ${jetty.version} + + + org.eclipse.jetty + jetty-http + ${jetty.version} + + + org.eclipse.jetty.http2 + http2-client + ${jetty.version} + + + org.eclipse.jetty.http2 + http2-http-client-transport + ${jetty.version} + + + org.eclipse.jetty.http2 + http2-hpack + ${jetty.version} + + + org.eclipse.jetty.http2 + http2-common + ${jetty.version} + + + org.eclipse.jetty + jetty-server + ${jetty.version} + + + org.eclipse.jetty + jetty-security + ${jetty.version} + + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + + + org.eclipse.jetty + jetty-util + ${jetty.version} + + + org.eclipse.jetty + jetty-io + ${jetty.version} + + + org.eclipse.jetty + jetty-client + ${jetty.version} + + + javax.servlet + javax.servlet-api + 3.1.0 + + + + + + src/main/resources + true + + **/*.properties + + + + + src/main/resources + false + + **/*.mp3 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.3 + + 1.8 + 1.8 + + + + org.codehaus.mojo + exec-maven-plugin + 1.2.1 + + java + + -Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/alpn/alpn-boot/${alpn-boot.version}/alpn-boot-${alpn-boot.version}.jar + -Dlog4j.configurationFile=file:///${basedir}/log4j2.xml + -classpath + + -Djna.library.path=${env.VLC_PATH} + com.amazon.alexa.avs.AVSApp + + + + + + + diff --git a/samples/javaclient/src/main/java/.gitkeep b/samples/javaclient/src/main/java/.gitkeep new file mode 100644 index 00000000..74a6ce44 --- /dev/null +++ b/samples/javaclient/src/main/java/.gitkeep @@ -0,0 +1 @@ +Feel free to delete this file as soon as actual Java code is added to this directory. diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAPIConstants.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAPIConstants.java new file mode 100644 index 00000000..52d10d50 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAPIConstants.java @@ -0,0 +1,285 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +package com.amazon.alexa.avs; + +public final class AVSAPIConstants { + + public static final class AudioPlayer { + public static final String NAMESPACE = AudioPlayer.class.getSimpleName(); + + public static final class Events { + public static final class PlaybackStarted { + public static final String NAME = PlaybackStarted.class.getSimpleName(); + } + + public static final class PlaybackNearlyFinished { + public static final String NAME = PlaybackNearlyFinished.class.getSimpleName(); + } + + public static final class PlaybackStutterStarted { + public static final String NAME = PlaybackStutterStarted.class.getSimpleName(); + } + + public static final class PlaybackStutterFinished { + public static final String NAME = PlaybackStutterFinished.class.getSimpleName(); + } + + public static final class PlaybackFinished { + public static final String NAME = PlaybackFinished.class.getSimpleName(); + } + + public static final class PlaybackFailed { + public static final String NAME = PlaybackFailed.class.getSimpleName(); + } + + public static final class PlaybackStopped { + public static final String NAME = PlaybackStopped.class.getSimpleName(); + } + + public static final class PlaybackPaused { + public static final String NAME = PlaybackPaused.class.getSimpleName(); + } + + public static final class PlaybackResumed { + public static final String NAME = PlaybackResumed.class.getSimpleName(); + } + + public static final class PlaybackQueueCleared { + public static final String NAME = PlaybackQueueCleared.class.getSimpleName(); + } + + public static final class ProgressReportDelayElapsed { + public static final String NAME = ProgressReportDelayElapsed.class.getSimpleName(); + } + + public static final class ProgressReportIntervalElapsed { + public static final String NAME = + ProgressReportIntervalElapsed.class.getSimpleName(); + } + + public static final class PlaybackState { + public static final String NAME = PlaybackState.class.getSimpleName(); + } + } + + public static final class Directives { + + public static final class Play { + public static final String NAME = Play.class.getSimpleName(); + } + + public static final class Stop { + public static final String NAME = Stop.class.getSimpleName(); + } + + public static final class ClearQueue { + public static final String NAME = ClearQueue.class.getSimpleName(); + } + } + } + + public static final class PlaybackController { + public static final String NAMESPACE = PlaybackController.class.getSimpleName(); + + public static final class Events { + + public static final class NextCommandIssued { + public static final String NAME = NextCommandIssued.class.getSimpleName(); + } + + public static final class PreviousCommandIssued { + public static final String NAME = PreviousCommandIssued.class.getSimpleName(); + } + + public static final class PlayCommandIssued { + public static final String NAME = PlayCommandIssued.class.getSimpleName(); + } + + public static final class PauseCommandIssued { + public static final String NAME = PauseCommandIssued.class.getSimpleName(); + } + } + } + + public static final class SpeechSynthesizer { + public static final String NAMESPACE = SpeechSynthesizer.class.getSimpleName(); + + public static final class Events { + + public static final class SpeechStarted { + public static final String NAME = SpeechStarted.class.getSimpleName(); + } + + public static final class SpeechFinished { + public static final String NAME = SpeechFinished.class.getSimpleName(); + } + + public static final class SpeechState { + public static final String NAME = SpeechState.class.getSimpleName(); + } + } + + public static final class Directives { + + public static final class Speak { + public static final String NAME = Speak.class.getSimpleName(); + } + } + } + + public static final class SpeechRecognizer { + public static final String NAMESPACE = SpeechRecognizer.class.getSimpleName(); + + public static final class Events { + + public static final class Recognize { + public static final String NAME = Recognize.class.getSimpleName(); + } + + public static final class ExpectSpeechTimedOut { + public static final String NAME = ExpectSpeechTimedOut.class.getSimpleName(); + } + } + + public static final class Directives { + + public static final class ExpectSpeech { + public static final String NAME = ExpectSpeech.class.getSimpleName(); + } + + public static final class StopCapture { + public static final String NAME = StopCapture.class.getSimpleName(); + } + + public static final class RequestProcessingStarted { + public static final String NAME = RequestProcessingStarted.class.getSimpleName(); + } + } + } + + public static class Alerts { + public static final String NAMESPACE = Alerts.class.getSimpleName(); + + public static final class Events { + + public static final class SetAlertSucceeded { + public static final String NAME = SetAlertSucceeded.class.getSimpleName(); + } + + public static final class SetAlertFailed { + public static final String NAME = SetAlertFailed.class.getSimpleName(); + } + + public static final class DeleteAlertSucceeded { + public static final String NAME = DeleteAlertSucceeded.class.getSimpleName(); + } + + public static final class DeleteAlertFailed { + public static final String NAME = DeleteAlertFailed.class.getSimpleName(); + } + + public static final class AlertStarted { + public static final String NAME = AlertStarted.class.getSimpleName(); + } + + public static final class AlertStopped { + public static final String NAME = AlertStopped.class.getSimpleName(); + } + + public static final class AlertsState { + public static final String NAME = AlertsState.class.getSimpleName(); + } + + public static final class AlertEnteredForeground { + public static final String NAME = AlertEnteredForeground.class.getSimpleName(); + } + + public static final class AlertEnteredBackground { + public static final String NAME = AlertEnteredBackground.class.getSimpleName(); + } + } + + public static final class Directives { + + public static final class SetAlert { + public static final String NAME = SetAlert.class.getSimpleName(); + } + + public static final class DeleteAlert { + public static final String NAME = DeleteAlert.class.getSimpleName(); + } + } + } + + public static final class Speaker { + + public static final String NAMESPACE = Speaker.class.getSimpleName(); + + public static final class Events { + + public static final class VolumeChanged { + public static final String NAME = VolumeChanged.class.getSimpleName(); + } + + public static final class MuteChanged { + public static final String NAME = MuteChanged.class.getSimpleName(); + } + + public static final class VolumeState { + public static final String NAME = VolumeState.class.getSimpleName(); + } + } + + public static final class Directives { + + public static final class SetVolume { + public static final String NAME = SetVolume.class.getSimpleName(); + } + + public static final class AdjustVolume { + public static final String NAME = AdjustVolume.class.getSimpleName(); + } + + public static final class SetMute { + public static final String NAME = SetMute.class.getSimpleName(); + } + } + } + + public static final class System { + public static final String NAMESPACE = System.class.getSimpleName(); + + public static final class Exception { + public static final String NAME = Exception.class.getSimpleName(); + } + + public static final class Events { + + public static final class SynchronizeState { + public static final String NAME = SynchronizeState.class.getSimpleName(); + } + + public static final class ExceptionEncountered { + public static final String NAME = ExceptionEncountered.class.getSimpleName(); + } + + public static final class UserInactivityReport { + public static final String NAME = UserInactivityReport.class.getSimpleName(); + } + } + + public static final class Directives { + + public static final class ResetUserInactivity { + public static final String NAME = ResetUserInactivity.class.getSimpleName(); + } + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSApp.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSApp.java new file mode 100644 index 00000000..6a4227c2 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSApp.java @@ -0,0 +1,363 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.auth.AccessTokenListener; +import com.amazon.alexa.avs.auth.AuthSetup; +import com.amazon.alexa.avs.auth.companionservice.RegCodeDisplayHandler; +import com.amazon.alexa.avs.config.DeviceConfig; +import com.amazon.alexa.avs.config.DeviceConfigUtils; +import com.amazon.alexa.avs.http.AVSClientFactory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GridLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +import javax.swing.Box; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingWorker; + +@SuppressWarnings("serial") +public class AVSApp extends JFrame implements ExpectSpeechListener, RecordingRMSListener, + RegCodeDisplayHandler, AccessTokenListener { + + private static final Logger log = LoggerFactory.getLogger(AVSApp.class); + + private static final String APP_TITLE = "Alexa Voice Service"; + private static final String START_LABEL = "Start Listening"; + private static final String STOP_LABEL = "Stop Listening"; + private static final String PROCESSING_LABEL = "Processing"; + private static final String PREVIOUS_LABEL = "\u21E4"; + private static final String NEXT_LABEL = "\u21E5"; + private static final String PAUSE_LABEL = "\u275A\u275A"; + private static final String PLAY_LABEL = "\u25B6"; + private final AVSController controller; + private JButton actionButton; + private JButton playPauseButton; + private JTextField tokenTextField; + private JProgressBar visualizer; + private Thread autoEndpoint = null; // used to auto-endpoint while listening + private final DeviceConfig deviceConfig; + // minimum audio level threshold under which is considered silence + private static final int ENDPOINT_THRESHOLD = 5; + private static final int ENDPOINT_SECONDS = 2; // amount of silence time before endpointing + private String accessToken; + + private AuthSetup authSetup; + + public static void main(String[] args) throws Exception { + if (args.length == 1) { + new AVSApp(args[0]); + } else { + new AVSApp(); + } + } + + public AVSApp() throws Exception { + this(DeviceConfigUtils.readConfigFile()); + } + + public AVSApp(String configName) throws Exception { + this(DeviceConfigUtils.readConfigFile(configName)); + } + + private AVSApp(DeviceConfig config) throws Exception { + deviceConfig = config; + controller = new AVSController(this, new AVSAudioPlayerFactory(), new AlertManagerFactory(), + getAVSClientFactory(deviceConfig), DialogRequestIdAuthority.getInstance()); + + authSetup = new AuthSetup(config, this); + authSetup.addAccessTokenListener(this); + authSetup.addAccessTokenListener(controller); + authSetup.startProvisioningThread(); + + addDeviceField(); + addTokenField(); + addVisualizerField(); + addActionField(); + addPlaybackButtons(); + + getContentPane().setLayout(new GridLayout(0, 1)); + setTitle(getAppTitle()); + setDefaultCloseOperation(EXIT_ON_CLOSE); + setSize(400, 200); + setVisible(true); + controller.startHandlingDirectives(); + } + + private String getAppVersion() { + final Properties properties = new Properties(); + try (final InputStream stream = getClass().getResourceAsStream("/res/version.properties")) { + properties.load(stream); + if (properties.containsKey("version")) { + return properties.getProperty("version"); + } + } catch (IOException e) { + log.warn("version.properties file not found on classpath"); + } + return null; + } + + private String getAppTitle() { + String version = getAppVersion(); + String title = APP_TITLE; + if (version != null) { + title += " - v" + version; + } + return title; + } + + protected AVSClientFactory getAVSClientFactory(DeviceConfig config) { + return new AVSClientFactory(config); + } + + private void addDeviceField() { + JLabel productIdLabel = new JLabel(deviceConfig.getProductId()); + JLabel dsnLabel = new JLabel(deviceConfig.getDsn()); + productIdLabel.setFont(productIdLabel.getFont().deriveFont(Font.PLAIN)); + dsnLabel.setFont(dsnLabel.getFont().deriveFont(Font.PLAIN)); + + FlowLayout flowLayout = new FlowLayout(FlowLayout.LEFT); + flowLayout.setHgap(0); + JPanel devicePanel = new JPanel(flowLayout); + devicePanel.add(new JLabel("Device: ")); + devicePanel.add(productIdLabel); + devicePanel.add(Box.createRigidArea(new Dimension(5, 0))); + devicePanel.add(new JLabel("DSN: ")); + devicePanel.add(dsnLabel); + getContentPane().add(devicePanel); + } + + private void addTokenField() { + getContentPane().add(new JLabel("Bearer Token:")); + tokenTextField = new JTextField(50); + tokenTextField.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + controller.onUserActivity(); + authSetup.onAccessTokenReceived(tokenTextField.getText()); + } + }); + getContentPane().add(tokenTextField); + + if (accessToken != null) { + tokenTextField.setText(accessToken); + accessToken = null; + } + } + + private void addVisualizerField() { + visualizer = new JProgressBar(0, 100); + getContentPane().add(visualizer); + } + + private void addActionField() { + final RecordingRMSListener rmsListener = this; + actionButton = new JButton(START_LABEL); + actionButton.setEnabled(true); + actionButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + controller.onUserActivity(); + if (actionButton.getText().equals(START_LABEL)) { // if in idle mode + actionButton.setText(STOP_LABEL); + + RequestListener requestListener = new RequestListener() { + + @Override + public void onRequestSuccess() { + finishProcessing(); + } + + @Override + public void onRequestError(Throwable e) { + log.error("An error occured creating speech request", e); + JOptionPane.showMessageDialog(getContentPane(), e.getMessage(), "Error", + JOptionPane.ERROR_MESSAGE); + actionButton.doClick(); + finishProcessing(); + } + }; + + controller.startRecording(rmsListener, requestListener); + } else { // else we must already be in listening + actionButton.setText(PROCESSING_LABEL); // go into processing mode + actionButton.setEnabled(false); + visualizer.setIndeterminate(true); + controller.stopRecording(); // stop the recording so the request can complete + } + } + }); + + getContentPane().add(actionButton); + } + + /** + * Respond to a music button press event + * + * @param action + * Playback action to handle + */ + private void musicButtonPressedEventHandler(final PlaybackAction action) { + SwingWorker alexaCall = new SwingWorker() { + @Override + public Void doInBackground() throws Exception { + visualizer.setIndeterminate(true); + controller.handlePlaybackAction(action); + return null; + } + + @Override + public void done() { + visualizer.setIndeterminate(false); + } + }; + alexaCall.execute(); + } + + private void createMusicButton(JPanel container, String label, final PlaybackAction action) { + JButton button = new JButton(label); + button.setEnabled(true); + button.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + controller.onUserActivity(); + musicButtonPressedEventHandler(action); + } + }); + container.add(button); + } + + /** + * Add music control buttons + */ + private void addPlaybackButtons() { + JPanel container = new JPanel(); + container.setLayout(new GridLayout(1, 5)); + + playPauseButton = new JButton(PLAY_LABEL + "/" + PAUSE_LABEL); + playPauseButton.setEnabled(true); + playPauseButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + controller.onUserActivity(); + if (controller.isPlaying()) { + musicButtonPressedEventHandler(PlaybackAction.PAUSE); + } else { + musicButtonPressedEventHandler(PlaybackAction.PLAY); + } + } + }); + + createMusicButton(container, PREVIOUS_LABEL, PlaybackAction.PREVIOUS); + container.add(playPauseButton); + + createMusicButton(container, NEXT_LABEL, PlaybackAction.NEXT); + getContentPane().add(container); + } + + public void finishProcessing() { + actionButton.setText(START_LABEL); + actionButton.setEnabled(true); + visualizer.setIndeterminate(false); + controller.processingFinished(); + + } + + @Override + public void rmsChanged(int rms) { // AudioRMSListener callback + // if greater than threshold or not recording, kill the autoendpoint thread + if ((rms == 0) || (rms > ENDPOINT_THRESHOLD)) { + if (autoEndpoint != null) { + autoEndpoint.interrupt(); + autoEndpoint = null; + } + } else if (rms < ENDPOINT_THRESHOLD) { + // start the autoendpoint thread if it isn't already running + if (autoEndpoint == null) { + autoEndpoint = new Thread() { + @Override + public void run() { + try { + Thread.sleep(ENDPOINT_SECONDS * 1000); + actionButton.doClick(); // hit stop if we get through the autoendpoint + // time + } catch (InterruptedException e) { + return; + } + } + }; + autoEndpoint.start(); + } + } + + visualizer.setValue(rms); // update the visualizer + } + + @Override + public void onExpectSpeechDirective() { + Thread thread = new Thread() { + @Override + public void run() { + while (!actionButton.isEnabled() || !actionButton.getText().equals(START_LABEL) + || controller.isSpeaking()) { + try { + Thread.sleep(500); + } catch (Exception e) { + } + } + actionButton.doClick(); + } + }; + thread.start(); + + } + + public void showDialog(String message) { + JTextArea textMessage = new JTextArea(message); + textMessage.setEditable(false); + JOptionPane.showMessageDialog(getContentPane(), textMessage, "Information", + JOptionPane.INFORMATION_MESSAGE); + } + + @Override + public void displayRegCode(String regCode) { + String regUrl = + deviceConfig.getCompanionServiceInfo().getServiceUrl() + "/provision/" + regCode; + showDialog("Please register your device by visiting the following website on " + + "any system and following the instructions:\n" + regUrl + + "\n\n Hit OK once completed."); + } + + @Override + public synchronized void onAccessTokenReceived(String accessToken) { + if (tokenTextField == null) { + this.accessToken = accessToken; + } else { + tokenTextField.setText(accessToken); + } + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAudioPlayer.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAudioPlayer.java new file mode 100644 index 00000000..1aef6468 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAudioPlayer.java @@ -0,0 +1,896 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.AudioPlayerStateMachine.AudioPlayerState; +import com.amazon.alexa.avs.exception.DirectiveHandlingException; +import com.amazon.alexa.avs.exception.DirectiveHandlingException.ExceptionType; +import com.amazon.alexa.avs.message.request.RequestFactory; +import com.amazon.alexa.avs.message.request.context.PlaybackStatePayload; +import com.amazon.alexa.avs.message.request.context.SpeechStatePayload; +import com.amazon.alexa.avs.message.request.context.VolumeStatePayload; +import com.amazon.alexa.avs.message.response.audioplayer.AudioItem; +import com.amazon.alexa.avs.message.response.audioplayer.ClearQueue; +import com.amazon.alexa.avs.message.response.audioplayer.Play; +import com.amazon.alexa.avs.message.response.audioplayer.Stream; +import com.amazon.alexa.avs.message.response.speaker.SetMute; +import com.amazon.alexa.avs.message.response.speaker.VolumePayload; +import com.amazon.alexa.avs.message.response.speechsynthesizer.Speak; + +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; + +import javazoom.jl.player.Player; +import uk.co.caprica.vlcj.component.AudioMediaPlayerComponent; +import uk.co.caprica.vlcj.player.MediaPlayer; +import uk.co.caprica.vlcj.player.MediaPlayerEventAdapter; + +public class AVSAudioPlayer { + + private static final Logger log = LoggerFactory.getLogger(AVSAudioPlayer.class); + + // callback to send audio events + private final AVSController controller; + // vlc instance to play media + private AudioMediaPlayerComponent audioPlayer; + // queue of listen directive media + private final Queue playQueue; + // queue of speak directive media + private final Queue speakQueue; + // Cache of URLs associated with the current AVSPlayItem/stream + private Set streamUrls; + // Urls associated with the current stream that we've already tried to play + private Set attemptedUrls; + // Alarm thread + private Thread alarmThread; + // Speaker thread + private Thread playThread; + // Object on which to lock + private Object playLock = new Object(); + // How long the thread should block on waiting for audio to finish playing + private static final int TIMEOUT_IN_MS = 3000; + + // VLCJ volumes are between 0-200. Alexa volumes are from 0-100. These constants are used to + // convert and limit volume values. + private static final long VLCJ_VOLUME_SCALAR = 2; + private static final int VLCJ_MIN_VOLUME = 0; + private static final int VLCJ_MAX_VOLUME = 200; + + private long stopOffset; + // track the last progressReport sent time + private boolean waitForPlaybackFinished; + // used for speak directives and earcons + private Player speaker = null; + private final ClassLoader resLoader; // used to load resource files + + private String latestStreamToken = ""; + + private String latestToken = ""; + + /* + * The AudioPlayerStateMachine is used to keep track of local audio playback state changes, + * ensuring the PlaybackEvents are sent at the right time, in the correct order, and only once. + */ + private final AudioPlayerStateMachine audioPlayerStateMachine; + + private int currentVolume; + + private long playbackStutterStartedOffsetInMilliseconds; + + private final Set listeners; + + private final AudioPlayerProgressReporter progressReporter; + + private enum SpeechState { + PLAYING, + FINISHED; + } + + private enum AlertState { + PLAYING, + INTERRUPTED, + FINISHED; + } + + private volatile AlertState alertState = AlertState.FINISHED; + + private volatile SpeechState speechState = SpeechState.FINISHED; + + private boolean currentlyMuted; + + public AVSAudioPlayer(AVSController controller) { + this.controller = controller; + resLoader = Thread.currentThread().getContextClassLoader(); + stopOffset = -1; + waitForPlaybackFinished = false; + playQueue = new LinkedList(); + speakQueue = new LinkedList(); + streamUrls = new HashSet(); + attemptedUrls = new HashSet(); + setupAudioPlayer(); + + currentVolume = audioPlayer.getMediaPlayer().getVolume(); + currentlyMuted = audioPlayer.getMediaPlayer().isMute(); + + audioPlayerStateMachine = new AudioPlayerStateMachine(this, controller); + + progressReporter = new AudioPlayerProgressReporter( + new ProgressReportDelayEventRunnable(audioPlayerStateMachine), + new ProgressReportIntervalEventRunnable(audioPlayerStateMachine)); + + listeners = new HashSet<>(); + } + + public void registerAlexaSpeechListener(AlexaSpeechListener listener) { + listeners.add(listener); + } + + public void handleSpeak(Speak speak) { + SpeakItem speakItem = new SpeakItem(speak.getToken(), speak.getAttachedContent()); + + speakQueue.add(speakItem); + // if not already speaking, start speech + if (speakQueue.size() == 1) { + startSpeech(); + } + } + + public void handlePlay(Play play) throws DirectiveHandlingException { + AudioItem item = play.getAudioItem(); + if (play.getPlayBehavior() == Play.PlayBehavior.REPLACE_ALL) { + clearAll(); + } else if (play.getPlayBehavior() == Play.PlayBehavior.REPLACE_ENQUEUED) { + clearEnqueued(); + } + + Stream stream = item.getStream(); + String streamUrl = stream.getUrl(); + String streamId = stream.getToken(); + long offset = stream.getOffsetInMilliseconds(); + log.info("URL: {}", streamUrl); + log.info("StreamId: {}", streamId); + log.info("Offset: {}", offset); + + if (stream.hasAttachedContent()) { + try { + File tmp = File.createTempFile(UUID.randomUUID().toString(), ".mp3"); + Files.copy(stream.getAttachedContent(), tmp.toPath(), + StandardCopyOption.REPLACE_EXISTING); + + stream.setUrl(tmp.getAbsolutePath()); + add(stream); + } catch (IOException e) { + log.error("Error while saving audio to a file", e); + throw new DirectiveHandlingException(ExceptionType.INTERNAL_ERROR, + "Error saving attached content to disk, unable to handle Play directive."); + } + } else { + add(stream); + } + } + + public void handleStop() { + stop(); + audioPlayerStateMachine.playbackStopped(); + } + + public void handleClearQueue(ClearQueue clearQueue) { + if (clearQueue.getClearBehavior() == ClearQueue.ClearBehavior.CLEAR_ALL) { + audioPlayerStateMachine.clearQueueAll(); + clearAll(); + } else { + audioPlayerStateMachine.clearQueueEnqueued(); + clearEnqueued(); + } + } + + public void handleSetVolume(VolumePayload volumePayload) { + currentVolume = (int) (volumePayload.getVolume() * VLCJ_VOLUME_SCALAR); + audioPlayer.getMediaPlayer().setVolume(currentVolume); + controller.sendRequest( + RequestFactory.createSpeakerVolumeChangedEvent(getVolume(), isMuted())); + } + + public void handleAdjustVolume(VolumePayload volumePayload) { + int adjustVolumeBy = (int) (volumePayload.getVolume() * VLCJ_VOLUME_SCALAR); + currentVolume = Math.min(VLCJ_MAX_VOLUME, + Math.max(VLCJ_MIN_VOLUME, currentVolume + adjustVolumeBy)); + audioPlayer.getMediaPlayer().setVolume(currentVolume); + controller.sendRequest( + RequestFactory.createSpeakerVolumeChangedEvent(getVolume(), isMuted())); + } + + public void handleSetMute(SetMute setMutePayload) { + currentlyMuted = setMutePayload.getMute(); + audioPlayer.getMediaPlayer().mute(currentlyMuted); + controller + .sendRequest(RequestFactory.createSpeakerMuteChangedEvent(getVolume(), isMuted())); + } + + private void setupAudioPlayer() { + audioPlayer = new AudioMediaPlayerComponent(); + + audioPlayer.getMediaPlayer().addMediaPlayerEventListener(new MediaPlayerEventAdapter() { + + private boolean playbackStartedSuccessully; + + private boolean bufferUnderrunInProgress; + + private boolean isPaused; + + @Override + public void newMedia(MediaPlayer mediaPlayer) { + log.debug("newMedia: {}", mediaPlayer.mrl()); + playbackStartedSuccessully = false; + bufferUnderrunInProgress = false; + } + + @Override + public void stopped(MediaPlayer mediaPlayer) { + log.debug("stopped: {}", mediaPlayer.mrl()); + } + + @Override + public void playing(MediaPlayer mediaPlayer) { + log.debug("playing: {}", mediaPlayer.mrl()); + long length = audioPlayer.getMediaPlayer().getLength(); + log.debug(" length: {}", length); + + if (isPaused && playbackStartedSuccessully) { + audioPlayerStateMachine.playbackResumed(); + isPaused = false; + } + } + + @Override + public void buffering(MediaPlayer mediaPlayer, float newCache) { + if (playbackStartedSuccessully && !bufferUnderrunInProgress) { + // We started buffering mid playback + bufferUnderrunInProgress = true; + playbackStutterStartedOffsetInMilliseconds = getCurrentOffsetInMilliseconds(); + audioPlayerStateMachine.playbackStutterStarted(); + } + + if (bufferUnderrunInProgress && newCache >= 100.0f) { + // We are fully buffered after a buffer underrun event + bufferUnderrunInProgress = false; + audioPlayerStateMachine.playbackStutterFinished(); + } + + if (!playbackStartedSuccessully && newCache >= 100.0f) { + // We have successfully buffered the first time and started playback + playbackStartedSuccessully = true; + audioPlayerStateMachine.playbackStarted(); + + if (isPaused) { + audioPlayerStateMachine.playbackPaused(); + } + } + } + + @Override + public void paused(MediaPlayer mediaPlayer) { + log.debug("paused: {}", mediaPlayer.mrl()); + if (playbackStartedSuccessully) { + audioPlayerStateMachine.playbackPaused(); + } + isPaused = true; + } + + @Override + public void finished(MediaPlayer mediaPlayer) { + log.info("Finished playing {}", mediaPlayer.mrl()); + List items = mediaPlayer.subItems(); + // Remember the url we just tried + attemptedUrls.add(mediaPlayer.mrl()); + + if ((items.size() > 0) || (streamUrls.size() > 0)) { + // Add to the set of URLs to attempt playback + streamUrls.addAll(items); + + // Play any url associated with this play item that + // we haven't already tried + for (String mrl : streamUrls) { + if (!attemptedUrls.contains(mrl)) { + log.info("Playing {}", mrl); + mediaPlayer.playMedia(mrl); + return; + } + } + } + + // wait for any pending events to finish(playbackStarted/progressReport) + while (controller.eventRunning()) { + try { + Thread.sleep(100); + } catch (Exception e) { + } + } + + // remove the item from the queue since it has finished playing + playQueue.poll(); + + progressReporter.stop(); + audioPlayerStateMachine.playbackNearlyFinished(); + audioPlayerStateMachine.playbackFinished(); + + // unblock playback now that playbackFinished has been sent + waitForPlaybackFinished = false; + if (!playQueue.isEmpty()) { + // start playback if it wasn't the last item + startPlayback(); + } + } + + @Override + public void error(MediaPlayer mediaPlayer) { + log.error("Error playing: {}", mediaPlayer.mrl()); + + attemptedUrls.add(mediaPlayer.mrl()); + // If there are any urls left to try, don't throw an error + for (String mrl : streamUrls) { + if (!attemptedUrls.contains(mrl)) { + mediaPlayer.playMedia(mrl); + return; + } + } + + // wait for any pending events to finish(playbackStarted/progressReport) + while (controller.eventRunning()) { + try { + Thread.sleep(100); + } catch (Exception e) { + } + } + progressReporter.stop(); + playQueue.clear(); + audioPlayerStateMachine.playbackFailed(); + + } + }); + } + + /** + * Returns true if Alexa is currently speaking + */ + public boolean isSpeaking() { + return speechState == SpeechState.PLAYING; + } + + /** + * Returns true if Alexa is currently playing media + */ + public boolean isPlaying() { + return (audioPlayerStateMachine.getState() == AudioPlayerState.PLAYING + || audioPlayerStateMachine.getState() == AudioPlayerState.PAUSED); + } + + /** + * Returns true if Alexa is currently playing an alarm sound + */ + public boolean isAlarming() { + return alertState == AlertState.PLAYING; + } + + /** + * Interrupt all audio - Alarms, speech, and media + */ + public void interruptAllAlexaOutput() { + if (isSpeaking()) { + // Then we are interrupting some speech + interruptCurrentlyPlaying(); + } + speakQueue.clear(); + + interruptAlertsAndContent(); + } + + /** + * Interrupt only alerts and content + */ + private void interruptAlertsAndContent() { + if (isAlarming()) { + alertState = AlertState.INTERRUPTED; + } + + interruptContent(); + } + + /** + * Interrupt only content + */ + private void interruptContent() { + + synchronized (audioPlayer.getMediaPlayer()) { + if (!playQueue.isEmpty() && (stopOffset == -1) + && audioPlayer.getMediaPlayer().isPlaying()) { + progressReporter.pause(); + audioPlayer.getMediaPlayer().pause(); + } + } + } + + /** + * Resume all audio from interrupted state. Since the speech queue is cleared when interrupted, + * resuming speech is not necessary + */ + public void resumeAllAlexaOutput() { + if (speakQueue.isEmpty() && !resumeAlerts()) { + resumeContent(); + } + } + + /** + * Resume alert audio + */ + private boolean resumeAlerts() { + if (alertState == AlertState.INTERRUPTED) { + startAlert(); + return true; + } + return false; + } + + /** + * Resume any content + */ + private void resumeContent() { + synchronized (audioPlayer.getMediaPlayer()) { + if (!playQueue.isEmpty() && (stopOffset == -1) + && !audioPlayer.getMediaPlayer().isPlaying()) { + progressReporter.resume(); + // Pause toggles the pause state of the media player, if it was previously paused it + // will be resumed. + audioPlayer.getMediaPlayer().pause(); + } + } + } + + /** + * Add audio to be played by the media player. This is triggered by the play directive + * + * @param stream + * Stream to add to the play queue + */ + private void add(Stream stream) { + String expectedPreviousToken = stream.getExpectedPreviousToken(); + + boolean startPlaying = playQueue.isEmpty(); + + if (expectedPreviousToken == null || latestStreamToken.isEmpty() + || latestStreamToken.equals(expectedPreviousToken)) { + playQueue.add(stream); + } + + if (startPlaying) { + startPlayback(); + } + } + + /** + * Play media in the play queue + */ + private void startPlayback() { + if (playQueue.isEmpty()) { + return; + } + + Thread thread = new Thread() { + + @Override + public void run() { + // wait for any speech to complete before starting playback + // also wait for playbackFinished to be called after getNextItem + while (!speakQueue.isEmpty() || waitForPlaybackFinished) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + log.error("Interupted while waiting to start playback", e); + } + } + + Stream stream = playQueue.peek(); + + if (stream == null) { + // if a stop/clearQueue came down before we started + return; + } + + latestStreamToken = stream.getToken(); + + if (!playItem(stream.getUrl(), stream.getOffsetInMilliseconds())) { + // an error will be reported from the vlcj listener + return; + } + + if (stream.getProgressReportRequired()) { + progressReporter.stop(); + progressReporter.start(stream.getProgressReport()); + } + + if (isSpeaking() || isAlarming()) { + // pause if Alexa is speaking or there is an active alert. + interruptContent(); + } + } + }; + thread.start(); + } + + /** + * Play the media from the given url, at the given offset + * + * @param url + * Media item to play + * @param offset + * Offset from the start to play at in milliseconds + * @return true if played successfully, false otherwise + */ + private boolean playItem(final String url, final long offset) { + synchronized (audioPlayer.getMediaPlayer()) { + // we are no longer in "PAUSED" state + stopOffset = -1; + + // Reset url caches and state information + streamUrls = new HashSet(); + attemptedUrls = new HashSet(); + + setupAudioPlayer(); + + log.debug("playing {}", url); + + if (audioPlayer.getMediaPlayer().startMedia(url)) { + audioPlayer.getMediaPlayer().setVolume(currentVolume); + audioPlayer.getMediaPlayer().mute(currentlyMuted); + if (offset > 0) { + audioPlayer.getMediaPlayer().setTime(offset); + } + + return true; + } + return false; + } + } + + /** + * Stop all media playback + */ + public void stop() { + synchronized (audioPlayer.getMediaPlayer()) { + if (!playQueue.isEmpty() && (stopOffset == -1)) { + stopOffset = getProgress(); + + progressReporter.stop(); + audioPlayer.getMediaPlayer().stop(); + } + } + } + + /** + * Play items from the speech play queue + */ + private void startSpeech() { + notifyAlexaSpeechStarted(); + final SpeakItem speak = speakQueue.peek(); + speechState = SpeechState.PLAYING; + latestToken = speak.getToken(); + + controller + .sendRequest(RequestFactory.createSpeechSynthesizerSpeechStartedEvent(latestToken)); + + interruptAlertsAndContent(); + + Thread thread = new Thread() { + @Override + public void run() { + synchronized (playLock) { + try { + InputStream inpStream = speak.getAudio(); + play(inpStream); + while (inpStream.available() > 0) { + playLock.wait(TIMEOUT_IN_MS); + } + } catch (InterruptedException | IOException e) { + } + + finishedSpeechItem(); + } + } + }; + thread.start(); + } + + /** + * When a speech item is finished, perform the necessary actions + */ + private void finishedSpeechItem() { + // remove the finished item + speakQueue.poll(); + + if (speakQueue.isEmpty()) { + speechState = SpeechState.FINISHED; + controller.sendRequest( + RequestFactory.createSpeechSynthesizerSpeechFinishedEvent(latestToken)); + + notifyAlexaSpeechFinished(); + } else { + // if not done start the next speech + startSpeech(); + } + } + + /** + * Clear the queue of items to play, but keep the most recent item. + */ + public void clearEnqueued() { + // save the top item + Stream top = playQueue.poll(); + // clear the queue and re-add the top item + playQueue.clear(); + if (top != null) { + playQueue.add(top); + } + } + + /** + * Clear all media scheduled to play, including items currently playing + */ + public void clearAll() { + // stop playback and clear all + stop(); + playQueue.clear(); + } + + /** + * Get the position of the currently playing media item + * + * @return The position in milliseconds of the stream + */ + private long getProgress() { + synchronized (audioPlayer.getMediaPlayer()) { + return audioPlayer.getMediaPlayer().getTime(); + } + } + + /** + * Get the playback state of the media player + */ + public PlaybackStatePayload getPlaybackState() { + AudioPlayerState playerState = audioPlayerStateMachine.getState(); + + long offset = getCurrentOffsetInMilliseconds(); + + return new PlaybackStatePayload(latestStreamToken, offset, playerState.toString()); + } + + public String getCurrentStreamToken() { + return latestStreamToken; + } + + public long getPlaybackStutterStartedOffsetInMilliseconds() { + return playbackStutterStartedOffsetInMilliseconds; + } + + public long getCurrentOffsetInMilliseconds() { + AudioPlayerState playerActivity = audioPlayerStateMachine.getState(); + + long offset = 0; + + if (playerActivity == AudioPlayerState.PLAYING + || playerActivity == AudioPlayerState.PAUSED) { + offset = getProgress(); + } else if (playerActivity == AudioPlayerState.STOPPED + || playerActivity == AudioPlayerState.FINISHED) { + offset = stopOffset; + } + + return Math.max(0, offset); + } + + /** + * Get the speech state + */ + public SpeechStatePayload getSpeechState() { + String contentId = latestToken; + return new SpeechStatePayload(contentId, getPlayerPosition(), speechState.name()); + } + + public VolumeStatePayload getVolumeState() { + return new VolumeStatePayload(getVolume(), isMuted()); + } + + public long getVolume() { + return currentVolume / VLCJ_VOLUME_SCALAR; + } + + public boolean isMuted() { + return currentlyMuted; + } + + /** + * Returns the offset in milliseconds of the default audio player. If there is no player + * position, this function defaults to 0 + * + * @return Player offset in milliseconds + */ + private synchronized long getPlayerPosition() { + long offsetInMilliseconds = 0; + if (speaker != null) { + offsetInMilliseconds = speaker.getPosition(); + } + return offsetInMilliseconds; + } + + /** + * plays MP3 data from a resource asynchronously. will stop any previous playback and start the + * new audio + */ + public synchronized void playMp3FromResource(String resource) { + final InputStream inpStream = resLoader.getResourceAsStream(resource); + play(inpStream); + } + + /** + * Play the alarm sound + */ + public void startAlert() { + if (!isAlarming()) { + interruptContent(); + if (isSpeaking()) { + // alerts are in the background when Alexa is speaking + alertState = AlertState.INTERRUPTED; + } else { + alertState = AlertState.PLAYING; + + alarmThread = new Thread() { + @Override + public void run() { + while (isAlarming() && !isSpeaking()) { + if (Thread.interrupted()) { + break; + } + InputStream inpStream = resLoader.getResourceAsStream("res/alarm.mp3"); + synchronized (playLock) { + try { + play(inpStream); + while (inpStream.available() > 0) { + playLock.wait(TIMEOUT_IN_MS); + } + } catch (InterruptedException | IOException e) { + } + } + } + } + }; + alarmThread.start(); + } + } + } + + /** + * Stop the alarm + */ + public void stopAlert() { + interruptCurrentlyPlaying(); + alertState = AlertState.FINISHED; + } + + /** + * Interrupt whatever audio is currently playing through the default audio player + */ + private synchronized void interruptCurrentlyPlaying() { + if (playThread != null) { + playThread.interrupt(); + } + stopPlayer(); + } + + /** + * Ends playback of the default audio player + */ + private synchronized void stopPlayer() { + if (speaker != null) { + speaker.close(); + speaker = null; + if (isSpeaking()) { + speechState = SpeechState.FINISHED; + notifyAlexaSpeechFinished(); + } + } + } + + /** + * Play a generic input stream through the default audio player without blocking + */ + private synchronized void play(final InputStream inpStream) { + play(inpStream, false); + } + + /** + * Play a generic input stream through the default audio player + */ + private synchronized void play(final InputStream inpStream, boolean block) { + playThread = new Thread() { + @Override + public void run() { + synchronized (playLock) { + try { + speaker = new Player(inpStream); + speaker.play(); + } catch (Exception e) { + log.error("An error occurred while trying to play audio", e); + } finally { + IOUtils.closeQuietly(inpStream); + } + playLock.notifyAll(); + } + } + }; + playThread.start(); + } + + private void notifyAlexaSpeechStarted() { + for (AlexaSpeechListener listener : listeners) { + listener.onAlexaSpeechStarted(); + } + } + + private void notifyAlexaSpeechFinished() { + for (AlexaSpeechListener listener : listeners) { + listener.onAlexaSpeechFinished(); + } + } + + private static class ProgressReportDelayEventRunnable implements Runnable { + + private final AudioPlayerStateMachine playbackStateMachine; + + public ProgressReportDelayEventRunnable(AudioPlayerStateMachine playbackStateMachine) { + this.playbackStateMachine = playbackStateMachine; + } + + @Override + public void run() { + playbackStateMachine.reportProgressDelay(); + } + }; + + private static class ProgressReportIntervalEventRunnable implements Runnable { + + private final AudioPlayerStateMachine playbackStateMachine; + + public ProgressReportIntervalEventRunnable(AudioPlayerStateMachine playbackStateMachine) { + this.playbackStateMachine = playbackStateMachine; + } + + @Override + public void run() { + playbackStateMachine.reportProgressInterval(); + } + } + + public interface AlexaSpeechListener { + void onAlexaSpeechStarted(); + + void onAlexaSpeechFinished(); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAudioPlayerFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAudioPlayerFactory.java new file mode 100644 index 00000000..f80f7fb0 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSAudioPlayerFactory.java @@ -0,0 +1,16 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public class AVSAudioPlayerFactory { + + public AVSAudioPlayer getAudioPlayer(AVSController controller) { + return new AVSAudioPlayer(controller); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSController.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSController.java new file mode 100644 index 00000000..bbfd27ab --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSController.java @@ -0,0 +1,466 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.AVSAudioPlayer.AlexaSpeechListener; +import com.amazon.alexa.avs.AlertManager.ResultListener; +import com.amazon.alexa.avs.auth.AccessTokenListener; +import com.amazon.alexa.avs.exception.DirectiveHandlingException; +import com.amazon.alexa.avs.exception.DirectiveHandlingException.ExceptionType; +import com.amazon.alexa.avs.http.AVSClient; +import com.amazon.alexa.avs.http.AVSClientFactory; +import com.amazon.alexa.avs.http.ParsingFailedHandler; +import com.amazon.alexa.avs.message.request.RequestBody; +import com.amazon.alexa.avs.message.request.RequestFactory; +import com.amazon.alexa.avs.message.response.Directive; +import com.amazon.alexa.avs.message.response.alerts.DeleteAlert; +import com.amazon.alexa.avs.message.response.alerts.SetAlert; +import com.amazon.alexa.avs.message.response.alerts.SetAlert.AlertType; +import com.amazon.alexa.avs.message.response.audioplayer.ClearQueue; +import com.amazon.alexa.avs.message.response.audioplayer.Play; +import com.amazon.alexa.avs.message.response.speaker.SetMute; +import com.amazon.alexa.avs.message.response.speaker.VolumePayload; +import com.amazon.alexa.avs.message.response.speechsynthesizer.Speak; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +public class AVSController + implements RecordingStateListener, AlertHandler, AlertEventListener, AccessTokenListener, + DirectiveDispatcher, AlexaSpeechListener, ParsingFailedHandler, UserActivityListener { + private final AudioCapture microphone; + private final AVSClient avsClient; + private final DialogRequestIdAuthority dialogRequestIdAuthority; + private AlertManager alertManager; + + private boolean eventRunning = false; // is an event currently being sent + + private static final AudioInputFormat AUDIO_TYPE = AudioInputFormat.LPCM; + private static final String START_SOUND = "res/start.mp3"; + private static final String END_SOUND = "res/stop.mp3"; + private static final String ERROR_SOUND = "res/error.mp3"; + private static final SpeechProfile PROFILE = SpeechProfile.CLOSE_TALK; + private static final String FORMAT = "AUDIO_L16_RATE_16000_CHANNELS_1"; + + private static final Logger log = LoggerFactory.getLogger(AVSController.class); + private static final long MILLISECONDS_PER_SECOND = 1000; + private static final long USER_INACTIVITY_REPORT_PERIOD_HOURS = 1; + + private final AVSAudioPlayer player; + private BlockableDirectiveThread dependentDirectiveThread; + private BlockableDirectiveThread independentDirectiveThread; + private BlockingQueue dependentQueue; + private BlockingQueue independentQueue; + public SpeechRequestAudioPlayerPauseController speechRequestAudioPlayerPauseController; + + private ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(1); + + private AtomicLong lastUserInteractionTimestampSeconds; + + private final Set expectSpeechListeners; + + public AVSController(ExpectSpeechListener listenHandler, AVSAudioPlayerFactory audioFactory, + AlertManagerFactory alarmFactory, AVSClientFactory avsClientFactory, + DialogRequestIdAuthority dialogRequestIdAuthority) throws Exception { + + this.microphone = AudioCapture.getAudioHardware(AUDIO_TYPE.getAudioFormat(), + new MicrophoneLineFactory()); + this.player = audioFactory.getAudioPlayer(this); + this.player.registerAlexaSpeechListener(this); + this.dialogRequestIdAuthority = dialogRequestIdAuthority; + speechRequestAudioPlayerPauseController = + new SpeechRequestAudioPlayerPauseController(player); + + expectSpeechListeners = new HashSet( + Arrays.asList(listenHandler, speechRequestAudioPlayerPauseController)); + dependentQueue = new LinkedBlockingDeque<>(); + + independentQueue = new LinkedBlockingDeque<>(); + + DirectiveEnqueuer directiveEnqueuer = + new DirectiveEnqueuer(dialogRequestIdAuthority, dependentQueue, independentQueue); + + avsClient = avsClientFactory.getAVSClient(directiveEnqueuer, this); + + alertManager = alarmFactory.getAlertManager(this, this, AlertsFileDataStore.getInstance()); + + // Ensure that we have attempted to finish loading all alarms from file before sending + // synchronize state + alertManager.loadFromDisk(new ResultListener() { + @Override + public void onSuccess() { + sendSynchronizeStateEvent(); + } + + @Override + public void onFailure() { + sendSynchronizeStateEvent(); + } + }); + + // ensure we notify AVS of playbackStopped on app exit + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + player.stop(); + avsClient.shutdown(); + } + }); + + dependentDirectiveThread = + new BlockableDirectiveThread(dependentQueue, this, "DependentDirectiveThread"); + independentDirectiveThread = + new BlockableDirectiveThread(independentQueue, this, "IndependentDirectiveThread"); + + lastUserInteractionTimestampSeconds = + new AtomicLong(System.currentTimeMillis() / MILLISECONDS_PER_SECOND); + scheduledExecutor.scheduleAtFixedRate(new UserInactivityReport(), + USER_INACTIVITY_REPORT_PERIOD_HOURS, USER_INACTIVITY_REPORT_PERIOD_HOURS, + TimeUnit.HOURS); + } + + public void startHandlingDirectives() { + dependentDirectiveThread.start(); + independentDirectiveThread.start(); + } + + public void sendSynchronizeStateEvent() { + sendRequest(RequestFactory.createSystemSynchronizeStateEvent(player.getPlaybackState(), + player.getSpeechState(), alertManager.getState(), player.getVolumeState())); + } + + @Override + public void onAccessTokenReceived(String accessToken) { + avsClient.setAccessToken(accessToken); + } + + // start the recording process and send to server + // takes an optional RMS callback and an optional request callback + public void startRecording(RecordingRMSListener rmsListener, RequestListener requestListener) { + try { + String dialogRequestId = dialogRequestIdAuthority.createNewDialogRequestId(); + + RequestBody body = RequestFactory.createSpeechRegonizerRecognizeRequest(dialogRequestId, + PROFILE, FORMAT, player.getPlaybackState(), player.getSpeechState(), + alertManager.getState(), player.getVolumeState()); + + dependentQueue.clear(); + + InputStream inputStream = microphone.getAudioInputStream(this, rmsListener); + + avsClient.sendEvent(body, inputStream, requestListener, AUDIO_TYPE); + + speechRequestAudioPlayerPauseController.startSpeechRequest(); + + } catch (Exception e) { + player.playMp3FromResource(ERROR_SOUND); + requestListener.onRequestError(e); + } + } + + public void handlePlaybackAction(PlaybackAction action) { + switch (action) { + case PLAY: + if (alertManager.hasActiveAlerts()) { + alertManager.stopActiveAlert(); + } else { + sendRequest(RequestFactory.createPlaybackControllerPlayEvent( + player.getPlaybackState(), player.getSpeechState(), + alertManager.getState(), player.getVolumeState())); + } + break; + case PAUSE: + if (alertManager.hasActiveAlerts()) { + alertManager.stopActiveAlert(); + } else { + sendRequest(RequestFactory.createPlaybackControllerPauseEvent( + player.getPlaybackState(), player.getSpeechState(), + alertManager.getState(), player.getVolumeState())); + } + break; + case PREVIOUS: + sendRequest(RequestFactory.createPlaybackControllerPreviousEvent( + player.getPlaybackState(), player.getSpeechState(), alertManager.getState(), + player.getVolumeState())); + break; + case NEXT: + sendRequest(RequestFactory.createPlaybackControllerNextEvent( + player.getPlaybackState(), player.getSpeechState(), alertManager.getState(), + player.getVolumeState())); + break; + default: + log.error("Failed to handle playback action"); + } + } + + public void sendRequest(RequestBody body) { + eventRunning = true; + try { + avsClient.sendEvent(body); + } catch (Exception e) { + log.error("Failed to send request", e); + } + eventRunning = false; + } + + public boolean eventRunning() { + return eventRunning; + } + + @Override + public synchronized void dispatch(Directive directive) { + String directiveNamespace = directive.getNamespace(); + + String directiveName = directive.getName(); + log.info("Handling directive: {}.{}", directiveNamespace, directiveName); + if (dialogRequestIdAuthority.isCurrentDialogRequestId(directive.getDialogRequestId())) { + speechRequestAudioPlayerPauseController.dispatchDirective(); + } + try { + if (directiveNamespace.equals(AVSAPIConstants.SpeechRecognizer.NAMESPACE)) { + handleSpeechRecognizerDirective(directive); + } else if (directiveNamespace.equals(AVSAPIConstants.SpeechSynthesizer.NAMESPACE)) { + handleSpeechSynthesizerDirective(directive); + } else if (directiveNamespace.equals(AVSAPIConstants.AudioPlayer.NAMESPACE)) { + handleAudioPlayerDirective(directive); + } else if (directiveNamespace.equals(AVSAPIConstants.Alerts.NAMESPACE)) { + handleAlertsDirective(directive); + } else if (directiveNamespace.equals(AVSAPIConstants.Speaker.NAMESPACE)) { + handleSpeakerDirective(directive); + } else if (directiveNamespace.equals(AVSAPIConstants.System.NAMESPACE)) { + handleSystemDirective(directive); + } else { + throw new DirectiveHandlingException(ExceptionType.UNSUPPORTED_OPERATION, + "No device side component to handle the directive."); + } + } catch (DirectiveHandlingException e) { + sendExceptionEncounteredEvent(directive.getRawMessage(), e.getType(), e); + } catch (Exception e) { + sendExceptionEncounteredEvent(directive.getRawMessage(), ExceptionType.INTERNAL_ERROR, + e); + throw e; + } + + } + + private void sendExceptionEncounteredEvent(String directiveJson, ExceptionType type, + Exception e) { + sendRequest(RequestFactory.createSystemExceptionEncounteredEvent(directiveJson, type, + e.getMessage(), player.getPlaybackState(), player.getSpeechState(), + alertManager.getState(), player.getVolumeState())); + log.error("{} error handling directive: {}", type, directiveJson, e); + } + + private void handleAudioPlayerDirective(Directive directive) throws DirectiveHandlingException { + String directiveName = directive.getName(); + if (directiveName.equals(AVSAPIConstants.AudioPlayer.Directives.Play.NAME)) { + player.handlePlay((Play) directive.getPayload()); + } else if (directiveName.equals(AVSAPIConstants.AudioPlayer.Directives.Stop.NAME)) { + player.handleStop(); + } else if (directiveName.equals(AVSAPIConstants.AudioPlayer.Directives.ClearQueue.NAME)) { + player.handleClearQueue((ClearQueue) directive.getPayload()); + } + + } + + private void handleSpeechSynthesizerDirective(Directive directive) + throws DirectiveHandlingException { + if (directive.getName().equals(AVSAPIConstants.SpeechSynthesizer.Directives.Speak.NAME)) { + player.handleSpeak((Speak) directive.getPayload()); + } + } + + private void handleSpeechRecognizerDirective(Directive directive) { + if (directive + .getName() + .equals(AVSAPIConstants.SpeechRecognizer.Directives.ExpectSpeech.NAME)) { + + // If your device cannot handle automatically starting to listen, you must + // implement a listen timeout event, as described here: + // https://developer.amazon.com/public/solutions/alexa/alexa-voice-service/rest/speechrecognizer-listentimeout-request + notifyExpectSpeechDirective(); + } + } + + private void handleAlertsDirective(Directive directive) { + String directiveName = directive.getName(); + if (directiveName.equals(AVSAPIConstants.Alerts.Directives.SetAlert.NAME)) { + SetAlert payload = (SetAlert) directive.getPayload(); + String alertToken = payload.getToken(); + ZonedDateTime scheduledTime = payload.getScheduledTime(); + AlertType type = payload.getType(); + + if (alertManager.hasAlert(alertToken)) { + AlertScheduler scheduler = alertManager.getScheduler(alertToken); + if (scheduler.getAlert().getScheduledTime().equals(scheduledTime)) { + return; + } else { + scheduler.cancel(); + } + } + + Alert alert = new Alert(alertToken, type, scheduledTime); + alertManager.add(alert); + } else if (directiveName.equals(AVSAPIConstants.Alerts.Directives.DeleteAlert.NAME)) { + DeleteAlert payload = (DeleteAlert) directive.getPayload(); + alertManager.delete(payload.getToken()); + } + } + + private void handleSpeakerDirective(Directive directive) { + String directiveName = directive.getName(); + if (directiveName.equals(AVSAPIConstants.Speaker.Directives.SetVolume.NAME)) { + player.handleSetVolume((VolumePayload) directive.getPayload()); + } else if (directiveName.equals(AVSAPIConstants.Speaker.Directives.AdjustVolume.NAME)) { + player.handleAdjustVolume((VolumePayload) directive.getPayload()); + } else if (directiveName.equals(AVSAPIConstants.Speaker.Directives.SetMute.NAME)) { + player.handleSetMute((SetMute) directive.getPayload()); + } + } + + private void handleSystemDirective(Directive directive) { + if (directive + .getName() + .equals(AVSAPIConstants.System.Directives.ResetUserInactivity.NAME)) { + onUserActivity(); + } + } + + private void notifyExpectSpeechDirective() { + for (ExpectSpeechListener listener : expectSpeechListeners) { + listener.onExpectSpeechDirective(); + } + } + + public void stopRecording() { + speechRequestAudioPlayerPauseController.finishedListening(); + microphone.stopCapture(); + } + + // audio state callback for when recording has started + @Override + public void recordingStarted() { + player.playMp3FromResource(START_SOUND); + } + + // audio state callback for when recording has completed + @Override + public void recordingCompleted() { + player.playMp3FromResource(END_SOUND); + } + + public boolean isSpeaking() { + return player.isSpeaking(); + } + + public boolean isPlaying() { + return player.isPlaying(); + } + + @Override + public void onAlertStarted(String alertToken) { + sendRequest(RequestFactory.createAlertsAlertStartedEvent(alertToken)); + + if (player.isSpeaking()) { + sendRequest(RequestFactory.createAlertsAlertEnteredBackgroundEvent(alertToken)); + } else { + sendRequest(RequestFactory.createAlertsAlertEnteredForegroundEvent(alertToken)); + } + } + + @Override + public void onAlertStopped(String alertToken) { + sendRequest(RequestFactory.createAlertsAlertStoppedEvent(alertToken)); + } + + @Override + public void onAlertSet(String alertToken, boolean success) { + sendRequest(RequestFactory.createAlertsSetAlertEvent(alertToken, success)); + } + + @Override + public void onAlertDelete(String alertToken, boolean success) { + sendRequest(RequestFactory.createAlertsDeleteAlertEvent(alertToken, success)); + } + + @Override + public void startAlert(String alertToken) { + player.startAlert(); + } + + @Override + public void stopAlert(String alertToken) { + if (!alertManager.hasActiveAlerts()) { + player.stopAlert(); + } + } + + public void processingFinished() { + speechRequestAudioPlayerPauseController + .speechRequestProcessingFinished(dependentQueue.size()); + } + + @Override + public void onAlexaSpeechStarted() { + dependentDirectiveThread.block(); + + if (alertManager.hasActiveAlerts()) { + for (String alertToken : alertManager.getActiveAlerts()) { + sendRequest(RequestFactory.createAlertsAlertEnteredBackgroundEvent(alertToken)); + } + } + } + + @Override + public void onAlexaSpeechFinished() { + dependentDirectiveThread.unblock(); + + if (alertManager.hasActiveAlerts()) { + for (String alertToken : alertManager.getActiveAlerts()) { + sendRequest(RequestFactory.createAlertsAlertEnteredForegroundEvent(alertToken)); + } + } + } + + @Override + public void onParsingFailed(String unparseable) { + String message = "Failed to parse message from AVS"; + sendRequest(RequestFactory.createSystemExceptionEncounteredEvent(unparseable, + ExceptionType.UNEXPECTED_INFORMATION_RECEIVED, message, player.getPlaybackState(), + player.getSpeechState(), alertManager.getState(), player.getVolumeState())); + } + + @Override + public void onUserActivity() { + lastUserInteractionTimestampSeconds + .set(System.currentTimeMillis() / MILLISECONDS_PER_SECOND); + } + + private class UserInactivityReport implements Runnable { + + @Override + public void run() { + sendRequest(RequestFactory.createSystemUserInactivityReportEvent( + (System.currentTimeMillis() / MILLISECONDS_PER_SECOND) + - lastUserInteractionTimestampSeconds.get())); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSRequest.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSRequest.java new file mode 100644 index 00000000..cbcf03fa --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AVSRequest.java @@ -0,0 +1,57 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.http.AVSClient.Resource; +import com.amazon.alexa.avs.http.MultipartParser; +import com.amazon.alexa.avs.http.RetryPolicy; + +import org.eclipse.jetty.client.api.ContentProvider; + +import java.util.Optional; + +public class AVSRequest { + private final Resource resource; + private final ContentProvider contentProvider; + private final RetryPolicy retryPolicy; + private final MultipartParser multipartParser; + private final RequestListener requestListener; + + public AVSRequest(Resource resource, ContentProvider contentProvider, RetryPolicy retryPolicy, MultipartParser multipartParser, RequestListener requestListener) { + this.resource = resource; + this.contentProvider = contentProvider; + this.retryPolicy = retryPolicy; + this.multipartParser = multipartParser; + this.requestListener = requestListener; + } + + public AVSRequest(Resource resource, ContentProvider contentProvider, RetryPolicy retryPolicy, MultipartParser multipartParser) { + this(resource, contentProvider, retryPolicy, multipartParser, null); + } + + public Resource getResource() { + return resource; + } + + public ContentProvider getContentProvider() { + return contentProvider; + } + + public RetryPolicy getRetryPolicy() { + return retryPolicy; + } + + public MultipartParser getMultipartParser() { + return multipartParser; + } + + public Optional getRequestListener() { + return Optional.ofNullable(requestListener); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/Alert.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/Alert.java new file mode 100644 index 00000000..e7de4c44 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/Alert.java @@ -0,0 +1,96 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.message.response.alerts.SetAlert.AlertType; + +import org.codehaus.jackson.JsonGenerator; +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.annotate.JsonCreator; +import org.codehaus.jackson.annotate.JsonProperty; +import org.codehaus.jackson.map.JsonSerializer; +import org.codehaus.jackson.map.SerializerProvider; +import org.codehaus.jackson.map.annotate.JsonSerialize; + +import java.io.IOException; +import java.time.ZonedDateTime; + +/** + * Represents an alert (timer/alarm) + */ +public class Alert { + private final String token; + private final AlertType type; + private final ZonedDateTime scheduledTime; + + public Alert(String token, AlertType type, ZonedDateTime scheduledTime) { + this.token = token; + this.type = type; + this.scheduledTime = scheduledTime; + } + + @JsonCreator + public Alert(@JsonProperty("token") String token, @JsonProperty("type") AlertType type, + @JsonProperty("scheduledTime") String scheduledTime) { + this.token = token; + this.type = type; + this.scheduledTime = ZonedDateTime.parse(scheduledTime, DateUtils.AVS_ISO_OFFSET_DATE_TIME); + } + + public String getToken() { + return this.token; + } + + public AlertType getType() { + return this.type; + } + + @JsonSerialize(using = ZonedDateTimeSerializer.class) + public ZonedDateTime getScheduledTime() { + return scheduledTime; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = (prime * result) + ((token == null) ? 0 : token.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Alert other = (Alert) obj; + if (token == null) { + if (other.token != null) { + return false; + } + } else if (!token.equals(other.token)) { + return false; + } + return true; + } + + public static class ZonedDateTimeSerializer extends JsonSerializer { + @Override + public void serialize(ZonedDateTime zonedDateTime, JsonGenerator jsonGenerator, + SerializerProvider serializerProvider) throws IOException, JsonProcessingException { + jsonGenerator.writeString(zonedDateTime.format(DateUtils.AVS_ISO_OFFSET_DATE_TIME)); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertEventListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertEventListener.java new file mode 100644 index 00000000..5d963b87 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertEventListener.java @@ -0,0 +1,21 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface AlertEventListener { + + void onAlertStarted(String alertToken); + + void onAlertStopped(String alertToken); + + void onAlertSet(String alertToken, boolean success); + + void onAlertDelete(String alertToken, boolean success); + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertHandler.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertHandler.java new file mode 100644 index 00000000..3a512a0a --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertHandler.java @@ -0,0 +1,17 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface AlertHandler { + + void startAlert(String alertToken); + + void stopAlert(String alertToken); + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertManager.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertManager.java new file mode 100644 index 00000000..5d963bbc --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertManager.java @@ -0,0 +1,174 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.message.request.context.AlertsStatePayload; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class AlertManager implements AlertHandler { + private final AlertEventListener listener; + private final AlertHandler handler; + private final Map schedulers; + private final Set activeAlerts; + private final AlertsDataStore dataStore; + + private static final Logger log = LoggerFactory.getLogger(AlertManager.class); + + public AlertManager(AlertEventListener listener, AlertHandler handler, + AlertsDataStore dataStore) { + this.listener = listener; + this.handler = handler; + this.schedulers = new HashMap(); + this.activeAlerts = new HashSet(); + this.dataStore = dataStore; + } + + void loadFromDisk(final ResultListener listener) { + dataStore.loadFromDisk(AlertManager.this, listener); + + } + + public synchronized boolean hasAlert(String alertToken) { + return schedulers.containsKey(alertToken); + } + + public synchronized boolean hasActiveAlerts() { + return activeAlerts.size() > 0; + } + + public synchronized Set getActiveAlerts() { + return activeAlerts; + } + + public synchronized List getAllAlerts() { + List list = new ArrayList(schedulers.size()); + for (AlertScheduler scheduler : schedulers.values()) { + list.add(scheduler.getAlert()); + } + return list; + } + + public synchronized AlertScheduler getScheduler(String alertToken) { + return schedulers.get(alertToken); + } + + public void add(final Alert alert) { + add(alert, false); + } + + // When re-adding alerts by reading them from disk, suppressEvent + // should be set to true. We only want to trigger events the first time + // a alert is set + public synchronized void add(final Alert alert, final boolean suppressEvent) { + final AlertScheduler scheduler = new AlertScheduler(alert, this); + schedulers.put(alert.getToken(), scheduler); + log.debug("Adding alert with token {}", alert.getToken()); + writeCurrentAlertsToDisk(new ResultListener() { + @Override + public void onSuccess() { + if (!suppressEvent) { + listener.onAlertSet(alert.getToken(), true); + } + } + + @Override + public void onFailure() { + if (!suppressEvent) { + listener.onAlertSet(alert.getToken(), false); + } + schedulers.remove(alert.getToken()); + scheduler.cancel(); + } + }); + } + + public synchronized void delete(final String alertToken) { + final AlertScheduler scheduler = schedulers.remove(alertToken); + log.debug("Deleting alert with token {}", alertToken); + if (scheduler != null) { + final Alert alert = scheduler.getAlert(); + writeCurrentAlertsToDisk(new ResultListener() { + @Override + public void onSuccess() { + scheduler.cancel(); + listener.onAlertDelete(alert.getToken(), true); + } + + @Override + public void onFailure() { + listener.onAlertDelete(alert.getToken(), false); + } + }); + } + } + + public void drop(final Alert alert) { + listener.onAlertStopped(alert.getToken()); + } + + private void writeCurrentAlertsToDisk(final ResultListener l) { + dataStore.writeToDisk(getAllAlerts(), l); + } + + @Override + public synchronized void startAlert(String alertToken) { + activeAlerts.add(alertToken); + listener.onAlertStarted(alertToken); + handler.startAlert(alertToken); + } + + @Override + public synchronized void stopAlert(String alertToken) { + activeAlerts.remove(alertToken); + schedulers.remove(alertToken); + listener.onAlertStopped(alertToken); + handler.stopAlert(alertToken); + } + + /** + * Stops an active alert + */ + public synchronized void stopActiveAlert() { + if (hasActiveAlerts()) { + for (String alertToken : activeAlerts) { + stopAlert(alertToken); + return; + } + } + } + + public synchronized AlertsStatePayload getState() { + List all = new ArrayList<>(schedulers.size()); + List active = new ArrayList<>(activeAlerts.size()); + for (AlertScheduler scheduler : schedulers.values()) { + Alert alert = scheduler.getAlert(); + all.add(alert); + + if (activeAlerts.contains(alert.getToken())) { + active.add(alert); + } + } + return new AlertsStatePayload(all, active); + } + + interface ResultListener { + void onSuccess(); + + void onFailure(); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertManagerFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertManagerFactory.java new file mode 100644 index 00000000..0733b513 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertManagerFactory.java @@ -0,0 +1,17 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public class AlertManagerFactory { + + public AlertManager getAlertManager(AlertEventListener listener, AlertHandler handler, + AlertsDataStore dataStore) { + return new AlertManager(listener, handler, dataStore); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertScheduler.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertScheduler.java new file mode 100644 index 00000000..c0e870a5 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertScheduler.java @@ -0,0 +1,56 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import java.util.Date; +import java.util.Timer; +import java.util.TimerTask; + +/** + * A timer used to trigger AVS alerts on schedule + */ +public class AlertScheduler extends Timer { + private final Alert alert; + private final AlertHandler handler; + private boolean active = false; + + public AlertScheduler(final Alert alert, final AlertHandler handler) { + super(); + schedule(new TimerTask() { + @Override + public void run() { + setActive(true); + handler.startAlert(alert.getToken()); + } + }, Date.from(alert.getScheduledTime().toInstant())); + this.alert = alert; + this.handler = handler; + } + + public synchronized boolean isActive() { + return active; + } + + public synchronized void setActive(boolean active) { + this.active = active; + } + + @Override + public void cancel() { + super.cancel(); + if (isActive()) { + handler.stopAlert(alert.getToken()); + setActive(false); + } + } + + public Alert getAlert() { + return alert; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertsDataStore.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertsDataStore.java new file mode 100644 index 00000000..2a070bc5 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertsDataStore.java @@ -0,0 +1,20 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.AlertManager.ResultListener; + +import java.util.List; + +public interface AlertsDataStore { + + void loadFromDisk(AlertManager manager, ResultListener listener); + + void writeToDisk(List alerts, ResultListener listener); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertsFileDataStore.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertsFileDataStore.java new file mode 100644 index 00000000..f44da2e5 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AlertsFileDataStore.java @@ -0,0 +1,122 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.AlertManager.ResultListener; +import com.amazon.alexa.avs.config.ObjectMapperFactory; + +import org.apache.commons.io.IOUtils; +import org.codehaus.jackson.map.ObjectReader; +import org.codehaus.jackson.map.ObjectWriter; +import org.codehaus.jackson.type.TypeReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.time.ZonedDateTime; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import javax.json.JsonReader; + +/** + * A file-backed data store for AVS Alerts + */ +public class AlertsFileDataStore implements AlertsDataStore { + private static final Logger log = LoggerFactory.getLogger(AlertsFileDataStore.class); + private static final String ALARM_FILE = "alarms.json"; + private static final int MINUTES_AFTER_PAST_ALERT_EXPIRES = 30; + private static AlertsFileDataStore sInstance = new AlertsFileDataStore(); + private static final ExecutorService sExecutor = Executors.newSingleThreadExecutor(); + + private AlertsFileDataStore() { + } + + public synchronized static AlertsFileDataStore getInstance() { + return sInstance; + } + + @Override + public synchronized void loadFromDisk(AlertManager manager, final ResultListener listener) { + sExecutor.execute(new Runnable() { + @Override + public void run() { + FileReader fis = null; + BufferedReader br = null; + JsonReader parser = null; + + ObjectReader reader = ObjectMapperFactory + .getObjectReader() + .withType(new TypeReference>() { + }); + List droppedAlerts = new LinkedList(); + try { + fis = new FileReader(ALARM_FILE); + br = new BufferedReader(fis); + + List alerts = reader.readValue(br); + for (Alert alert : alerts) { + // Only add alerts that are within the expiration window + if (alert.getScheduledTime().isAfter(ZonedDateTime + .now() + .minusMinutes(MINUTES_AFTER_PAST_ALERT_EXPIRES))) { + manager.add(alert, true); + } else { + droppedAlerts.add(alert); + } + } + // Now that all the valid alerts have been re-added to the alarm manager, + // go through and explicitly drop all the alerts that were not added + for (Alert alert : droppedAlerts) { + manager.drop(alert); + } + listener.onSuccess(); + } catch (FileNotFoundException e) { + // This is not a fatal error + // The alarm file might not have been created yet + listener.onSuccess(); + } catch (IOException e) { + log.error("Failed to load alerts from disk.", e); + listener.onFailure(); + } finally { + IOUtils.closeQuietly(parser); + IOUtils.closeQuietly(br); + } + } + }); + } + + @Override + public synchronized void writeToDisk(List alerts, final ResultListener listener) { + sExecutor.execute(new Runnable() { + @Override + public void run() { + ObjectWriter writer = ObjectMapperFactory.getObjectWriter(); + PrintWriter out = null; + try { + out = new PrintWriter(ALARM_FILE); + out.print(writer.writeValueAsString(alerts)); + out.flush(); + listener.onSuccess(); + } catch (IOException e) { + log.error("Failed to write to disk", e); + listener.onFailure(); + } finally { + IOUtils.closeQuietly(out); + } + } + }); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioCapture.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioCapture.java new file mode 100644 index 00000000..18db6761 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioCapture.java @@ -0,0 +1,120 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; + +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.TargetDataLine; + +public class AudioCapture { + private static AudioCapture sAudioCapture; + private final TargetDataLine microphoneLine; + private AudioFormat audioFormat; + private AudioBufferThread thread; + + private static final int BUFFER_SIZE_IN_SECONDS = 6; + + private final int BUFFER_SIZE_IN_BYTES; + + private static final Logger log = LoggerFactory.getLogger(AudioCapture.class); + + public static AudioCapture getAudioHardware(final AudioFormat audioFormat, + MicrophoneLineFactory microphoneLineFactory) { + if (sAudioCapture == null) { + sAudioCapture = new AudioCapture(audioFormat, microphoneLineFactory); + } + return sAudioCapture; + } + + private AudioCapture(final AudioFormat audioFormat, + MicrophoneLineFactory microphoneLineFactory) { + super(); + this.audioFormat = audioFormat; + microphoneLine = microphoneLineFactory.getMicrophone(); + + BUFFER_SIZE_IN_BYTES = + (int) ((audioFormat.getSampleSizeInBits() * audioFormat.getSampleRate()) / 8 + * BUFFER_SIZE_IN_SECONDS); + } + + public InputStream getAudioInputStream(final RecordingStateListener stateListener, + final RecordingRMSListener rmsListener) throws LineUnavailableException, IOException { + try { + startCapture(); + PipedInputStream inputStream = new PipedInputStream(BUFFER_SIZE_IN_BYTES); + thread = new AudioBufferThread(inputStream, stateListener, rmsListener); + thread.start(); + return inputStream; + } catch (LineUnavailableException | IOException e) { + stopCapture(); + throw e; + } + } + + public void stopCapture() { + microphoneLine.stop(); + microphoneLine.close(); + + } + + private void startCapture() throws LineUnavailableException { + microphoneLine.open(audioFormat); + microphoneLine.start(); + } + + public int getAudioBufferSizeInBytes() { + return BUFFER_SIZE_IN_BYTES; + } + + private class AudioBufferThread extends Thread { + + private final AudioStateOutputStream audioStateOutputStream; + + public AudioBufferThread(PipedInputStream inputStream, + RecordingStateListener recordingStateListener, RecordingRMSListener rmsListener) + throws IOException { + audioStateOutputStream = + new AudioStateOutputStream(inputStream, recordingStateListener, rmsListener); + } + + @Override + public void run() { + while (microphoneLine.isOpen()) { + copyAudioBytesFromInputToOutput(); + } + closePipedOutputStream(); + } + + private void copyAudioBytesFromInputToOutput() { + byte[] data = new byte[microphoneLine.getBufferSize() / 5]; + int numBytesRead = microphoneLine.read(data, 0, data.length); + try { + audioStateOutputStream.write(data, 0, numBytesRead); + } catch (IOException e) { + stopCapture(); + } + } + + private void closePipedOutputStream() { + try { + audioStateOutputStream.close(); + } catch (IOException e) { + log.error("Failed to close audio stream ", e); + } + } + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioInputFormat.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioInputFormat.java new file mode 100644 index 00000000..f321c3ac --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioInputFormat.java @@ -0,0 +1,50 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import javax.sound.sampled.AudioFormat; + +public enum AudioInputFormat { + LPCM(Constants.LPCM_CHUNK_SIZE_BYTES, Constants.LPCM_CHUNK_SIZE_MS, Constants.LPCM_AUDIO_FORMAT, Constants.LPCM_CONTENT_TYPE); + + private final int chunkSizeBytes; + private final int chunkSizeMs; + private final AudioFormat audioFormat; + private final String contentType; + + private AudioInputFormat(final int chunkSizeBytes, final int chunkSizeMs, AudioFormat audioFormat, final String contentType) { + this.chunkSizeBytes = chunkSizeBytes; + this.chunkSizeMs = chunkSizeMs; + this.audioFormat = audioFormat; + this.contentType = contentType; + } + + public int getChunkSizeBytes() { + return chunkSizeBytes; + } + + public int getChunkSizeMs() { + return chunkSizeMs; + } + + public AudioFormat getAudioFormat() { + return audioFormat; + } + + public String getContentType() { + return contentType; + } + + private static final class Constants { + private static final int LPCM_CHUNK_SIZE_BYTES = 320; + private static final int LPCM_CHUNK_SIZE_MS = 10; + private static final AudioFormat LPCM_AUDIO_FORMAT = new AudioFormat(16000f, 16, 1, true, false); + private static final String LPCM_CONTENT_TYPE = "audio/L16; rate=16000; channels=1"; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerActivity.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerActivity.java new file mode 100644 index 00000000..1d8b3ae1 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerActivity.java @@ -0,0 +1,13 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public enum AudioPlayerActivity { + PLAYING, PAUSED, IDLE; +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerEventPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerEventPayload.java new file mode 100644 index 00000000..20e27316 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerEventPayload.java @@ -0,0 +1,16 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public class AudioPlayerEventPayload { + + AudioPlayerEventPayload(String streamId, String activity, String errorType, + String errorMessage) { + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerProgressReporter.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerProgressReporter.java new file mode 100644 index 00000000..c28008f1 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerProgressReporter.java @@ -0,0 +1,106 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.message.response.ProgressReport; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public class AudioPlayerProgressReporter { + private final ScheduledExecutorService eventScheduler = Executors.newScheduledThreadPool(1); + + private ScheduledFuture progressReportDelayFuture; + private ScheduledFuture progressReportIntervalFuture; + + private final Runnable progressReportDelayRunnable; + private final Runnable progressReportIntervalRunnable; + + private long progressReportDelay; + private long progressReportInterval; + + private long activeTimestampMs; + private long totalActiveTimeElapsedMs; + + public AudioPlayerProgressReporter(Runnable progressReportDelayRunnable, + Runnable progressReportIntervalRunnable) { + this.progressReportDelayRunnable = progressReportDelayRunnable; + this.progressReportIntervalRunnable = progressReportIntervalRunnable; + } + + public synchronized void start(ProgressReport progressReport) { + if (progressReport == null) { + throw new IllegalArgumentException("ProgressReport must not be null."); + } + + progressReportDelay = progressReport.getProgressReportDelayInMilliseconds(); + progressReportInterval = progressReport.getProgressReportIntervalInMilliseconds(); + + scheduleBothEvents(progressReportDelay, 0, progressReportInterval); + } + + public synchronized void resume() { + long remainingDelay = Math.max(0, progressReportDelay - totalActiveTimeElapsedMs); + long remainingIntervalDelay = progressReportInterval == 0 ? 0 + : progressReportInterval - totalActiveTimeElapsedMs % progressReportInterval; + scheduleBothEvents(remainingDelay, remainingIntervalDelay, progressReportInterval); + } + + /** + * Schedules both events. + * + * @param delay + * Delay in ms for the progress report delay event. + * @param intervalDelay + * Delay in ms for the progress report interval event. + * @param interval + * Period in ms of the progress report interval event. + */ + private void scheduleBothEvents(long delay, long intervalDelay, long interval) { + if (delay != 0) { + progressReportDelayFuture = eventScheduler.schedule(progressReportDelayRunnable, delay, + TimeUnit.MILLISECONDS); + } + + if (interval != 0) { + progressReportIntervalFuture = eventScheduler.scheduleAtFixedRate( + progressReportIntervalRunnable, intervalDelay, interval, TimeUnit.MILLISECONDS); + } + + if (isStarted()) { + activeTimestampMs = System.currentTimeMillis(); + } + } + + private boolean isStarted() { + return progressReportDelayFuture != null || progressReportIntervalFuture != null; + } + + public synchronized void stop() { + cancelEvents(); + totalActiveTimeElapsedMs = 0; + } + + public synchronized void pause() { + cancelEvents(); + totalActiveTimeElapsedMs += System.currentTimeMillis() - activeTimestampMs; + } + + private void cancelEvents() { + if (progressReportDelayFuture != null && !progressReportDelayFuture.isDone()) { + progressReportDelayFuture.cancel(false); + } + + if (progressReportIntervalFuture != null && !progressReportIntervalFuture.isDone()) { + progressReportIntervalFuture.cancel(false); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerStateMachine.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerStateMachine.java new file mode 100644 index 00000000..0b3e0f34 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioPlayerStateMachine.java @@ -0,0 +1,452 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.message.request.RequestBody; +import com.amazon.alexa.avs.message.request.RequestFactory; +import com.amazon.alexa.avs.message.request.audioplayer.PlaybackFailedPayload.ErrorType; +import com.amazon.alexa.avs.message.request.context.PlaybackStatePayload; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.EnumSet; +import java.util.Set; + +/** + * The AudioPlayerStateMachine enforces the correct ordering and number of PlaybackEvents sent + * during audio playback, and keeps track of the audio player state. The audio player state has four + * supported states. + * + *
    + *
  • Idle, the state which the machine starts out in (no media playback has happened).
  • + *
  • Playing, state corresponding to the client handle a media item and playing it
  • + *
  • Stopped, state corresponding to the client previously playing a media item and was stopped + *
  • Finished, state corresponding to when the client has finished playing a media item
  • + *
+ * + * AudioPlayerStateMachine provides all the transitions between these states, and sends any playback + * events required. + */ +public class AudioPlayerStateMachine { + + private static final Logger log = LoggerFactory.getLogger(AudioPlayerStateMachine.class); + + // Current State + private State state; + + // State Transitions + private final PlaybackStarted playbackStarted; + private final DelayProgressReport delayReport; + private final IntervalProgressReport intervalReport; + private final PlaybackFailed playbackFailed; + private final PlaybackNearlyFinished playbackNearlyFinished; + private final PlaybackStutterStarted playbackStutterStarted; + private final PlaybackStutterFinished playbackStutterFinished; + private final PlaybackFinished playbackFinished; + private final PlaybackStopped playbackStopped; + private final ClearQueueEnqueued clearQueueEnqueued; + private final ClearQueueAll clearQueueAll; + private final PlaybackPaused playbackPaused; + private final PlaybackResumed playbackResumed; + + public AudioPlayerStateMachine(AVSAudioPlayer audioPlayer, AVSController controller) { + state = new State(AudioPlayerState.IDLE); + + playbackFinished = + new PlaybackFinished(EnumSet.of(AudioPlayerState.PLAYING), audioPlayer, controller); + clearQueueEnqueued = new ClearQueueEnqueued(EnumSet.allOf(AudioPlayerState.class), + audioPlayer, controller); + clearQueueAll = + new ClearQueueAll(EnumSet.allOf(AudioPlayerState.class), audioPlayer, controller); + playbackStarted = new PlaybackStarted( + EnumSet.of(AudioPlayerState.STOPPED, AudioPlayerState.FINISHED, + AudioPlayerState.IDLE, AudioPlayerState.PAUSED, AudioPlayerState.PLAYING), + audioPlayer, controller); + delayReport = new DelayProgressReport(EnumSet.of(AudioPlayerState.PLAYING), audioPlayer, + controller); + intervalReport = new IntervalProgressReport(EnumSet.of(AudioPlayerState.PLAYING), + audioPlayer, controller); + playbackFailed = + new PlaybackFailed(EnumSet.allOf(AudioPlayerState.class), audioPlayer, controller); + playbackNearlyFinished = new PlaybackNearlyFinished(EnumSet.allOf(AudioPlayerState.class), + audioPlayer, controller); + playbackStopped = + new PlaybackStopped(EnumSet.allOf(AudioPlayerState.class), audioPlayer, controller); + playbackStutterStarted = new PlaybackStutterStarted(EnumSet.of(AudioPlayerState.PLAYING), + audioPlayer, controller); + playbackStutterFinished = new PlaybackStutterFinished( + EnumSet.of(AudioPlayerState.BUFFER_UNDERRUN), audioPlayer, controller); + playbackPaused = new PlaybackPaused( + EnumSet.of(AudioPlayerState.PLAYING, AudioPlayerState.STOPPED, + AudioPlayerState.IDLE, AudioPlayerState.BUFFER_UNDERRUN), + audioPlayer, controller); + playbackResumed = + new PlaybackResumed(EnumSet.of(AudioPlayerState.PAUSED), audioPlayer, controller); + } + + /** + * Transitions into the playing state sending playback started events + */ + public synchronized void playbackStarted() { + log.debug(PlaybackStarted.class.getSimpleName()); + playbackStarted.transition(state); + } + + /** + * Transitions into the buffer underrun state sending playback stutter started events + */ + public synchronized void playbackStutterStarted() { + log.debug(PlaybackStutterStarted.class.getSimpleName()); + playbackStutterStarted.transition(state); + } + + /** + * Transitions into the playing state sending playback stutter finished events + */ + public synchronized void playbackStutterFinished() { + log.debug(PlaybackStutterFinished.class.getSimpleName()); + playbackStutterFinished.transition(state); + } + + /** + * Transitions from playing state into the stopped state sending playback stopped events. + * Alternatively if the player is in IDLE, it will remain in idle + */ + public synchronized void playbackStopped() { + log.debug(PlaybackStopped.class.getSimpleName()); + playbackStopped.transition(state); + } + + /** + * Transitions to the appropriate state, sending playback queue cleared events. + */ + public synchronized void clearQueueEnqueued() { + log.debug(ClearQueueEnqueued.class.getSimpleName()); + clearQueueEnqueued.transition(state); + } + + /** + * Transitions to the appropriate state, sending playback queue cleared events. + */ + public synchronized void clearQueueAll() { + log.debug(ClearQueueAll.class.getSimpleName()); + clearQueueAll.transition(state); + } + + /** + * Transitions from playing to playing, sending playback error events. + */ + public synchronized void playbackFailed() { + log.debug(PlaybackFailed.class.getSimpleName()); + playbackFailed.transition(state); + } + + /** + * Transitions from playing to playing, sending playback progress delay report events. + */ + public synchronized void reportProgressDelay() { + log.debug(DelayProgressReport.class.getSimpleName()); + delayReport.transition(state); + } + + /** + * Transitions from playing to playing, sending playback progress interval report events. + */ + public synchronized void reportProgressInterval() { + log.debug(IntervalProgressReport.class.getSimpleName()); + intervalReport.transition(state); + } + + /** + * Transitions from current state to current state sending playback nearly finished events. + */ + public synchronized void playbackNearlyFinished() { + log.debug(PlaybackNearlyFinished.class.getSimpleName()); + playbackNearlyFinished.transition(state); + } + + /** + * Transitions from playing to finished, sending playback finished events. + */ + public synchronized void playbackFinished() { + log.debug(PlaybackFinished.class.getSimpleName()); + playbackFinished.transition(state); + } + + /** + * Transitions from playing to paused. + */ + public synchronized void playbackPaused() { + log.debug(PlaybackPaused.class.getSimpleName()); + playbackPaused.transition(state); + } + + /** + * Transitions from paused to playing. + */ + public synchronized void playbackResumed() { + log.debug(PlaybackResumed.class.getSimpleName()); + playbackResumed.transition(state); + } + + public AudioPlayerState getState() { + return state.get(); + } + + public enum AudioPlayerState { + IDLE, + PLAYING, + PAUSED, + FINISHED, + STOPPED, + BUFFER_UNDERRUN; + } + + private static abstract class AudioPlayerStateTransition + extends StateTransition { + + private final AVSAudioPlayer audioPlayer; + private final AVSController controller; + + public AudioPlayerStateTransition(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates); + this.audioPlayer = audioPlayer; + this.controller = controller; + } + + protected final void sendRequest(RequestBody requestBody) { + controller.sendRequest(requestBody); + } + + protected final PlaybackStatePayload getCurrentPlaybackState() { + return audioPlayer.getPlaybackState(); + } + + protected final String getCurrentStreamToken() { + return audioPlayer.getCurrentStreamToken(); + } + + protected final long getCurrentOffsetInMilliseconds() { + return audioPlayer.getCurrentOffsetInMilliseconds(); + } + + protected final long getPlaybackStutterStartedTimestampMs() { + return audioPlayer.getPlaybackStutterStartedOffsetInMilliseconds(); + } + + @Override + protected void onInvalidStartState(State startState) { + log.error("Invalid {} from {}.", this.getClass().getSimpleName(), startState.get()); + } + } + + private static class PlaybackStarted extends AudioPlayerStateTransition { + + public PlaybackStarted(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.PLAYING); + sendRequest(RequestFactory.createAudioPlayerPlaybackStartedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + + private static class PlaybackStopped extends AudioPlayerStateTransition { + + public PlaybackStopped(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + if (state.get() != AudioPlayerState.IDLE) { + state.set(AudioPlayerState.STOPPED); + sendRequest(RequestFactory.createAudioPlayerPlaybackStoppedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + } + + private static class DelayProgressReport extends AudioPlayerStateTransition { + + public DelayProgressReport(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.PLAYING); + sendRequest(RequestFactory.createAudioPlayerProgressReportDelayElapsedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + + private static class IntervalProgressReport extends AudioPlayerStateTransition { + + public IntervalProgressReport(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.PLAYING); + sendRequest(RequestFactory.createAudioPlayerProgressReportIntervalElapsedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + + private static class PlaybackFailed extends AudioPlayerStateTransition { + + public PlaybackFailed(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.STOPPED); + sendRequest(RequestFactory.createAudioPlayerPlaybackFailedEvent(getCurrentStreamToken(), + getCurrentPlaybackState(), ErrorType.MEDIA_ERROR_UNKNOWN)); + } + } + + private static class PlaybackNearlyFinished extends AudioPlayerStateTransition { + + public PlaybackNearlyFinished(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + sendRequest(RequestFactory.createAudioPlayerPlaybackNearlyFinishedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + + private static class PlaybackFinished extends AudioPlayerStateTransition { + + public PlaybackFinished(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.FINISHED); + sendRequest(RequestFactory.createAudioPlayerPlaybackFinishedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + + private static class ClearQueueEnqueued extends AudioPlayerStateTransition { + + public ClearQueueEnqueued(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + sendRequest(RequestFactory.createAudioPlayerPlaybackQueueClearedEvent()); + } + } + + private static class ClearQueueAll extends AudioPlayerStateTransition { + + public ClearQueueAll(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + sendRequest(RequestFactory.createAudioPlayerPlaybackQueueClearedEvent()); + + AudioPlayerState currentState = state.get(); + if (currentState == AudioPlayerState.PLAYING || currentState == AudioPlayerState.PAUSED + || currentState == AudioPlayerState.BUFFER_UNDERRUN) { + state.set(AudioPlayerState.STOPPED); + sendRequest(RequestFactory.createAudioPlayerPlaybackStoppedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + + } + } + + private static class PlaybackStutterStarted extends AudioPlayerStateTransition { + + public PlaybackStutterStarted(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.BUFFER_UNDERRUN); + sendRequest(RequestFactory.createAudioPlayerPlaybackStutterStartedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } + + private static class PlaybackStutterFinished extends AudioPlayerStateTransition { + + public PlaybackStutterFinished(Set validStartStates, + AVSAudioPlayer audioPlayer, AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.PLAYING); + sendRequest(RequestFactory.createAudioPlayerPlaybackStutterFinishedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds(), + getCurrentOffsetInMilliseconds() - getPlaybackStutterStartedTimestampMs())); + } + } + + private static class PlaybackPaused extends AudioPlayerStateTransition { + + public PlaybackPaused(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.PAUSED); + sendRequest(RequestFactory.createAudioPlayerPlaybackPausedEvent(getCurrentStreamToken(), + getCurrentOffsetInMilliseconds())); + } + } + + private static class PlaybackResumed extends AudioPlayerStateTransition { + + public PlaybackResumed(Set validStartStates, AVSAudioPlayer audioPlayer, + AVSController controller) { + super(validStartStates, audioPlayer, controller); + } + + @Override + protected void onTransition(State state) { + state.set(AudioPlayerState.PLAYING); + sendRequest(RequestFactory.createAudioPlayerPlaybackResumedEvent( + getCurrentStreamToken(), getCurrentOffsetInMilliseconds())); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioStateOutputStream.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioStateOutputStream.java new file mode 100644 index 00000000..26f45a93 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/AudioStateOutputStream.java @@ -0,0 +1,95 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * A PipedOutputStream that call the appropriate listeners when the bytes from the audio source are + * written and updates decibel values. This output stream should be connected to a input stream with + * a large buffer to avoid dropping audio bytes while waiting for a connection to AVS + */ +public class AudioStateOutputStream extends PipedOutputStream { + private RecordingStateListener stateListener; + private RecordingRMSListener rmsListener; + + protected AudioStateOutputStream(PipedInputStream inputStream, + RecordingStateListener stateListener, final RecordingRMSListener rmsListener) + throws IOException { + super(inputStream); + this.stateListener = stateListener; + this.rmsListener = rmsListener; + notifyRecordingStarted(); + + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + super.write(b, off, len); + calculateDB(b, len); + } + + @Override + public void close() throws IOException { + super.close(); + notifyRecordingCompleted(); + clearRMS(); + } + + private void notifyRecordingStarted() { + if (stateListener != null) { + stateListener.recordingStarted(); + } + } + + private void notifyRecordingCompleted() { + if (stateListener != null) { + stateListener.recordingCompleted(); + } + } + + private void clearRMS() { + if (rmsListener != null) { + rmsListener.rmsChanged(0); + } + } + + // rmsListener is the AudioRMSListener callback for audio visualizer(optional - can be null) + // assuming 16bit samples, 1 channel, little endian + private void calculateDB(byte[] data, int cnt) { + if ((rmsListener == null) || (cnt < 2)) { + return; + } + + final int bytesPerSample = 2; + int len = cnt / bytesPerSample; + double avg = 0; + + for (int i = 0; i < cnt; i += bytesPerSample) { + ByteBuffer bb = ByteBuffer.allocate(bytesPerSample); + bb.order(ByteOrder.LITTLE_ENDIAN); + bb.put(data[i]); + bb.put(data[i + 1]); + // generate the signed 16 bit number from the 2 bytes + double dVal = java.lang.Math.abs(bb.getShort(0)); + // scale it from 1 to 100. Use max/2 as values tend to be low + dVal = ((100 * dVal) / (Short.MAX_VALUE / 2.0)) + 1; + avg += dVal * dVal; // add the square to the running average + } + avg /= len; + avg = java.lang.Math.sqrt(avg); + // update the AudioRMSListener callback with the scaled root-mean-squared power value + rmsListener.rmsChanged((int) avg); + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/BlockableDirectiveThread.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/BlockableDirectiveThread.java new file mode 100644 index 00000000..ca366f46 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/BlockableDirectiveThread.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.message.response.Directive; + +import java.util.concurrent.BlockingQueue; + +/** + * This thread takes a queue which will be filled with directives and dispatches them to the given + * {@link DirectiveDispatcher} as they are added to the queue. This thread also supports blocking + * the dispatching of directives. + */ +public class BlockableDirectiveThread extends Thread { + private final BlockingQueue directiveQueue; + private final DirectiveDispatcher directiveDispatcher; + private volatile boolean block; + + public BlockableDirectiveThread(BlockingQueue directiveQueue, + DirectiveDispatcher directiveDispatcher) { + this(directiveQueue, directiveDispatcher, BlockableDirectiveThread.class.getSimpleName()); + } + + public BlockableDirectiveThread(BlockingQueue directiveQueue, + DirectiveDispatcher directiveDispatcher, String name) { + this.directiveQueue = directiveQueue; + this.directiveDispatcher = directiveDispatcher; + setName(name); + } + + public synchronized void block() { + block = true; + } + + public synchronized void unblock() { + block = false; + notify(); + } + + public synchronized void clear() { + directiveQueue.clear(); + } + + @Override + public void run() { + while (true) { + try { + synchronized (this) { + if (block) { + wait(); + } + } + Directive directive = directiveQueue.take(); + directiveDispatcher.dispatch(directive); + } catch (InterruptedException e) { + } + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/DateUtils.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DateUtils.java new file mode 100644 index 00000000..c14c932c --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DateUtils.java @@ -0,0 +1,20 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; + +public class DateUtils { + public static final DateTimeFormatter AVS_ISO_OFFSET_DATE_TIME = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .appendOffset("+HHmm", "+0000") + .toFormatter(); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/DialogRequestIdAuthority.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DialogRequestIdAuthority.java new file mode 100644 index 00000000..e7159e4b --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DialogRequestIdAuthority.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import java.util.UUID; + +/** + * DialogRequestIdAuthority creates and keeps track of the active dialogRequestId. + */ +public class DialogRequestIdAuthority { + + private static final DialogRequestIdAuthority instance; + + static { + instance = new DialogRequestIdAuthority(); + } + + private String currentDialogRequestId; + + private DialogRequestIdAuthority() { + } + + public static DialogRequestIdAuthority getInstance() { + return instance; + } + + public String createNewDialogRequestId() { + currentDialogRequestId = UUID.randomUUID().toString(); + return currentDialogRequestId; + } + + public boolean isCurrentDialogRequestId(String candidateRequestId) { + return currentDialogRequestId != null && currentDialogRequestId.equals(candidateRequestId); + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/DirectiveDispatcher.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DirectiveDispatcher.java new file mode 100644 index 00000000..1b69ed6f --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DirectiveDispatcher.java @@ -0,0 +1,15 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.message.response.Directive; + +public interface DirectiveDispatcher { + void dispatch(Directive directive); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/DirectiveEnqueuer.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DirectiveEnqueuer.java new file mode 100644 index 00000000..ab487d10 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/DirectiveEnqueuer.java @@ -0,0 +1,112 @@ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.http.MultipartParser.MultipartParserConsumer; +import com.amazon.alexa.avs.message.Payload; +import com.amazon.alexa.avs.message.response.AttachedContentPayload; +import com.amazon.alexa.avs.message.response.Directive; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Map; +import java.util.Queue; + +/** + * The DirectiveEnqueuer takes parts parsed from a multipart parser, combines directves with their + * attached content, and triages those directives into either the dependent directive queue or + * independent directive queue. + * + * Any directive with the current dialogRequestID is dependent on all the directives with that id + * which came before it. These directives are added to the dependent directive queue. Any directive + * with no dialogRequestId is dependent on nothing and is added to the independent directive queue. + */ +public class DirectiveEnqueuer implements MultipartParserConsumer { + + // The authority for the current dialogRequestId. + private final DialogRequestIdAuthority dialogRequestIdAuthority; + + // Queue made up of all dependent directives for the current dialogRequestId + private final Queue dependentQueue; + + // Queue made up of all directives without a dialogRequestId + private final Queue independentQueue; + + // Queue for incomplete directives. A directive is incomplete if it still needs some attached + // content to be associated with it. + private final Queue incompleteDirectiveQueue; + + // Map of all attachments which have not yet been matched with directives. + private final Map attachments; + + public DirectiveEnqueuer(DialogRequestIdAuthority dialogRequestIdAuthority, + Queue dependentQueue, Queue independentQueue) { + this.dialogRequestIdAuthority = dialogRequestIdAuthority; + this.dependentQueue = dependentQueue; + this.independentQueue = independentQueue; + incompleteDirectiveQueue = new LinkedList<>(); + attachments = new HashMap<>(); + } + + @Override + public synchronized void onDirective(Directive directive) { + incompleteDirectiveQueue.add(directive); + matchAttachementsWithDirectives(); + } + + @Override + public synchronized void onDirectiveAttachment(String contentId, + InputStream attachmentContent) { + attachments.put(contentId, attachmentContent); + matchAttachementsWithDirectives(); + } + + private void matchAttachementsWithDirectives() { + for (Directive directive : incompleteDirectiveQueue) { + Payload payload = directive.getPayload(); + if (payload instanceof AttachedContentPayload) { + AttachedContentPayload attachedContentPayload = (AttachedContentPayload) payload; + String contentId = attachedContentPayload.getAttachedContentId(); + + InputStream attachment = attachments.remove(contentId); + if (attachment != null) { + attachedContentPayload.setAttachedContent(contentId, attachment); + } + } + } + + findCompleteDirectives(); + } + + private void findCompleteDirectives() { + Iterator iterator = incompleteDirectiveQueue.iterator(); + while (iterator.hasNext()) { + Directive directive = iterator.next(); + Payload payload = directive.getPayload(); + if (payload instanceof AttachedContentPayload) { + AttachedContentPayload attachedContentPayload = (AttachedContentPayload) payload; + + if (!attachedContentPayload.requiresAttachedContent()) { + // The front most directive IS complete. + enqueueDirective(directive); + iterator.remove(); + } else { + break; + } + } else { + // Immediately enqueue any directive which does not contain audio content + enqueueDirective(directive); + iterator.remove(); + } + } + } + + private void enqueueDirective(Directive directive) { + String dialogRequestId = directive.getDialogRequestId(); + if (dialogRequestId == null) { + independentQueue.add(directive); + } else if (dialogRequestIdAuthority.isCurrentDialogRequestId(dialogRequestId)) { + dependentQueue.add(directive); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/ExpectSpeechListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/ExpectSpeechListener.java new file mode 100644 index 00000000..009fe5b9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/ExpectSpeechListener.java @@ -0,0 +1,13 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface ExpectSpeechListener { + void onExpectSpeechDirective(); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/MicrophoneLineFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/MicrophoneLineFactory.java new file mode 100644 index 00000000..2b7773ac --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/MicrophoneLineFactory.java @@ -0,0 +1,42 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.Line; +import javax.sound.sampled.Mixer; +import javax.sound.sampled.TargetDataLine; + +public class MicrophoneLineFactory { + // get the system default microphone + public TargetDataLine getMicrophone() { + Mixer.Info[] mixers = AudioSystem.getMixerInfo(); + for (Mixer.Info mixerInfo : mixers) { + Mixer m = AudioSystem.getMixer(mixerInfo); + try { + m.open(); + m.close(); + } catch (Exception e) { + continue; + } + + Line.Info[] lines = m.getTargetLineInfo(); + for (Line.Info li : lines) { + try { + TargetDataLine temp = (TargetDataLine) AudioSystem.getLine(li); + if (temp != null) { + return temp; + } + } catch (Exception e) { + } + } + } + return null; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/PlaybackAction.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/PlaybackAction.java new file mode 100644 index 00000000..04c8f1d5 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/PlaybackAction.java @@ -0,0 +1,13 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public enum PlaybackAction { + PLAY, PAUSE, PREVIOUS, NEXT; +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/RecordingRMSListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/RecordingRMSListener.java new file mode 100644 index 00000000..8dd40894 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/RecordingRMSListener.java @@ -0,0 +1,13 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface RecordingRMSListener { + void rmsChanged(int rms); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/RecordingStateListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/RecordingStateListener.java new file mode 100644 index 00000000..8aefa100 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/RecordingStateListener.java @@ -0,0 +1,14 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface RecordingStateListener { + void recordingStarted(); + void recordingCompleted(); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/RequestListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/RequestListener.java new file mode 100644 index 00000000..9fc7ad4e --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/RequestListener.java @@ -0,0 +1,15 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface RequestListener { + void onRequestSuccess(); + + void onRequestError(Throwable e); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/SimpleStateChangeTransition.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SimpleStateChangeTransition.java new file mode 100644 index 00000000..f2ec0c88 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SimpleStateChangeTransition.java @@ -0,0 +1,37 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import org.slf4j.Logger; + +import java.util.Set; + +public class SimpleStateChangeTransition extends StateTransition { + + private final E endState; + + private final Logger errorLogger; + + public SimpleStateChangeTransition(Set validStartStates, E endState, Logger errorLogger) { + super(validStartStates); + this.endState = endState; + this.errorLogger = errorLogger; + } + + @Override + protected final void onTransition(State state) { + state.set(endState); + } + + @Override + protected final void onInvalidStartState(State currentState) { + errorLogger.debug("Invalid {} from {}.", this.getClass().getSimpleName(), + currentState.get()); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeakItem.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeakItem.java new file mode 100644 index 00000000..295eac2f --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeakItem.java @@ -0,0 +1,29 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import java.io.InputStream; + +public class SpeakItem { + private final String token; + private final InputStream audio; + + public SpeakItem(String token, InputStream audio) { + this.token = token; + this.audio = audio; + } + + public String getToken() { + return token; + } + + public InputStream getAudio() { + return audio; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeechProfile.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeechProfile.java new file mode 100644 index 00000000..53334c1b --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeechProfile.java @@ -0,0 +1,25 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public enum SpeechProfile { + + CLOSE_TALK("CLOSE_TALK"); + + private final String profileName; + + SpeechProfile(String profileName) { + this.profileName = profileName; + } + + @Override + public String toString() { + return this.profileName; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeechRequestAudioPlayerPauseController.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeechRequestAudioPlayerPauseController.java new file mode 100644 index 00000000..58ec4056 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/SpeechRequestAudioPlayerPauseController.java @@ -0,0 +1,122 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import com.amazon.alexa.avs.AVSAudioPlayer.AlexaSpeechListener; + +import java.util.Optional; +import java.util.concurrent.CountDownLatch; + +/** + * This class keeps track of running speech requests and whether the device is listening/speaking to + * appropriately manage the pause state of the player. + */ +public class SpeechRequestAudioPlayerPauseController + implements AlexaSpeechListener, ExpectSpeechListener { + private final AVSAudioPlayer audioPlayer; + private Optional outstandingDirectiveCount = Optional.empty(); + private Optional resumeAudioThread = Optional.empty(); + private Optional alexaSpeaking = Optional.empty(); + private Optional alexaListening = Optional.empty(); + boolean speechRequestRunning = false; + + public SpeechRequestAudioPlayerPauseController(AVSAudioPlayer audioPlayer) { + this.audioPlayer = audioPlayer; + audioPlayer.registerAlexaSpeechListener(this); + } + + /** + * Called when the starting a speech request to alexa voice service + */ + public void startSpeechRequest() { + alexaListening = Optional.of(new CountDownLatch(1)); + audioPlayer.interruptAllAlexaOutput(); + resumeAudioThread.ifPresent(t -> t.interrupt()); + speechRequestRunning = true; + } + + /** + * Called when finished Listening + */ + public void finishedListening() { + alexaListening.ifPresent(c -> c.countDown()); + if (!speechRequestRunning) { + audioPlayer.resumeAllAlexaOutput(); + } + } + + /** + * Called each time a directive is dispatched + */ + public void dispatchDirective() { + outstandingDirectiveCount.ifPresent(c -> c.countDown()); + } + + @Override + public void onAlexaSpeechStarted() { + alexaSpeaking = Optional.of(new CountDownLatch(1)); + } + + @Override + public void onAlexaSpeechFinished() { + alexaSpeaking.ifPresent(c -> c.countDown()); + if (!speechRequestRunning) { + audioPlayer.resumeAllAlexaOutput(); + } + } + + @Override + public void onExpectSpeechDirective() { + alexaListening = Optional.of(new CountDownLatch(1)); + } + + /** + * A speech request has been finished processing + * + * @param directiveCount + * the number of outstanding directives that correspond to the speech request that + * just finished + */ + public void speechRequestProcessingFinished(int directiveCount) { + resumeAudioThread.ifPresent(t -> t.interrupt()); + outstandingDirectiveCount = Optional.of(new CountDownLatch(directiveCount)); + resumeAudioThread = Optional.of(new Thread() { + + boolean isInterrupted = false; + + @Override + public void run() { + outstandingDirectiveCount.ifPresent(c -> awaitOnLatch(c)); + if (alexaListening.isPresent() || alexaSpeaking.isPresent()) { + alexaSpeaking.ifPresent(c -> awaitOnLatch(c)); + alexaListening.ifPresent(c -> awaitOnLatch(c)); + } + if (!isInterrupted) { + speechRequestRunning = false; + audioPlayer.resumeAllAlexaOutput(); + } + + } + + private void awaitOnLatch(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + // If another speech request is kicked off while we're processing the + // current request we expect this thread to be interrupted + isInterrupted = true; + } + } + + }); + resumeAudioThread.ifPresent(t -> t.start()); + + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/State.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/State.java new file mode 100644 index 00000000..0590e398 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/State.java @@ -0,0 +1,17 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located + * the "LICENSE.txt" file accompanying this source. This file is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package com.amazon.alexa.avs; + +import java.util.concurrent.atomic.AtomicReference; + +public class State extends AtomicReference { + public State(V startState) { + set(startState); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/StateTransition.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/StateTransition.java new file mode 100644 index 00000000..43d8fe88 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/StateTransition.java @@ -0,0 +1,33 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +import java.util.Collections; +import java.util.Set; + +public abstract class StateTransition { + + protected Set validStartStates; + + public StateTransition(Set validStartStates) { + this.validStartStates = Collections.unmodifiableSet(validStartStates); + } + + public final void transition(State currentState) { + if (validStartStates.contains(currentState.get())) { + onTransition(currentState); + } else { + onInvalidStartState(currentState); + } + } + + protected abstract void onTransition(State state); + + protected abstract void onInvalidStartState(State currentState); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/UserActivityListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/UserActivityListener.java new file mode 100644 index 00000000..0834e0b7 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/UserActivityListener.java @@ -0,0 +1,13 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs; + +public interface UserActivityListener { + void onUserActivity(); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AccessTokenListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AccessTokenListener.java new file mode 100644 index 00000000..4e6671a9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AccessTokenListener.java @@ -0,0 +1,19 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth; + +/** + * Interface for listening when the accessToken is received. + */ +public interface AccessTokenListener { + /** + * @param accessToken + */ + void onAccessTokenReceived(String accessToken); +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AuthConstants.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AuthConstants.java new file mode 100644 index 00000000..7f13b4c2 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AuthConstants.java @@ -0,0 +1,51 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth; + +/** + * Constants related to authentication and device provisioning. + */ +@SuppressWarnings("javadoc") +public class AuthConstants { + public static final String SESSION_ID = "sessionId"; + + public static final String CLIENT_ID = "clientId"; + public static final String REDIRECT_URI = "redirectUri"; + public static final String AUTH_CODE = "authCode"; + + public static final String CODE_CHALLENGE = "codeChallenge"; + public static final String CODE_CHALLENGE_METHOD = "codeChallengeMethod"; + public static final String DSN = "dsn"; + public static final String PRODUCT_ID = "productId"; + + public static final String REG_CODE = "regCode"; + + // ERRORS + public static final String ERROR = "error"; + public static final String MESSAGE = "message"; + public static final String INVALID_PARAM_ERROR = "INVALID_PARAM"; + public static final String INCORRECT_SESSION_ID_ERROR = "INCORRECT_SESSION_ID"; + public static final String LWA_ERROR = "LWA_ERROR"; + + /** + * Constants related specifically to OAuth 2.0 (http://tools.ietf.org/html/rfc6749) and draft 10 + * of Proof Key for Code Exchange by OAuth (https://tools.ietf.org/html/draft-ietf-oauth-spop-10). + */ + public static class OAuth2 { + public static final String AUTHORIZATION_CODE = "authorization_code"; + public static final String GRANT_TYPE = "grant_type"; + public static final String REDIRECT_URI = "redirect_uri"; + public static final String CODE = "code"; + public static final String CLIENT_ID = "client_id"; + public static final String CODE_VERIFIER = "code_verifier"; + public static final String ACCESS_TOKEN = "access_token"; + public static final String REFRESH_TOKEN = "refresh_token"; + public static final String EXPIRES_IN = "expires_in"; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AuthSetup.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AuthSetup.java new file mode 100644 index 00000000..ce00aa8c --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/AuthSetup.java @@ -0,0 +1,104 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth; + +import com.amazon.alexa.avs.auth.companionapp.CodeChallengeWorkflow; +import com.amazon.alexa.avs.auth.companionapp.CompanionAppAuthManager; +import com.amazon.alexa.avs.auth.companionapp.OAuth2ClientForPkce; +import com.amazon.alexa.avs.auth.companionapp.server.CompanionAppProvisioningServer; +import com.amazon.alexa.avs.auth.companionservice.CompanionServiceAuthManager; +import com.amazon.alexa.avs.auth.companionservice.CompanionServiceClient; +import com.amazon.alexa.avs.auth.companionservice.RegCodeDisplayHandler; +import com.amazon.alexa.avs.config.DeviceConfig; +import com.amazon.alexa.avs.config.DeviceConfig.ProvisioningMethod; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.Set; + +/** + * Initializes and owns the two ways to provision this device: via a companion service where this + * device acts as a client, and via a companion application where this device acts as a server. + */ +public class AuthSetup implements AccessTokenListener { + + private static final Logger log = LoggerFactory.getLogger(AuthSetup.class); + + private final DeviceConfig deviceConfig; + private final RegCodeDisplayHandler regCodeDisplayHandler; + private final Set accessTokenListeners = new HashSet<>(); + + /** + * Creates an {@link AuthSetup} object. + * + * @param deviceConfig + * Information about this device. + * @param regCodeDisplayHandler + */ + public AuthSetup(final DeviceConfig deviceConfig, final RegCodeDisplayHandler regCodeDisplayHandler) { + this.deviceConfig = deviceConfig; + this.regCodeDisplayHandler = regCodeDisplayHandler; + } + + public void addAccessTokenListener(AccessTokenListener accessTokenListener) { + accessTokenListeners.add(accessTokenListener); + } + + /** + * Initializes threads for the {@link CompanionAppProvisioningServer} and the + * {@link CompanionServiceClient}, depending on which is selected by the user. + */ + public void startProvisioningThread() { + if (deviceConfig.getProvisioningMethod() == ProvisioningMethod.COMPANION_APP) { + OAuth2ClientForPkce oAuthClient = + new OAuth2ClientForPkce(deviceConfig.getCompanionAppInfo().getLwaUrl()); + CompanionAppAuthManager authManager = new CompanionAppAuthManager(deviceConfig, + oAuthClient, CodeChallengeWorkflow.getInstance(), this); + + final CompanionAppProvisioningServer registrationServer = + new CompanionAppProvisioningServer(authManager, deviceConfig); + + Thread provisioningThread = new Thread() { + @Override + public void run() { + try { + registrationServer.startServer(); + } catch (Exception e) { + log.error("Failed to start companion app provisioning server", e); + } + } + }; + provisioningThread.start(); + } else if (deviceConfig.getProvisioningMethod() == ProvisioningMethod.COMPANION_SERVICE) { + CompanionServiceClient remoteProvisioningClient = + new CompanionServiceClient(deviceConfig); + final CompanionServiceAuthManager authManager = new CompanionServiceAuthManager( + deviceConfig, remoteProvisioningClient, regCodeDisplayHandler, this); + + Thread provisioningThread = new Thread() { + @Override + public void run() { + try { + authManager.startRemoteProvisioning(); + } catch (Exception e) { + log.error("Failed to start companion service client", e); + } + } + }; + provisioningThread.start(); + } + } + + @Override + public void onAccessTokenReceived(String accessToken) { + accessTokenListeners.stream().forEach(listener -> listener.onAccessTokenReceived(accessToken)); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/MissingParameterException.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/MissingParameterException.java new file mode 100644 index 00000000..65500c07 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/MissingParameterException.java @@ -0,0 +1,29 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth; + +@SuppressWarnings("javadoc") +public class MissingParameterException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + private final String missingParameter; + + public MissingParameterException(String missingParameter) { + super(); + this.missingParameter = missingParameter; + } + + @Override + public String getMessage() { + return "The following parameter was missing or an empty string: " + this.missingParameter; + } + + public String getMissingParameter() { + return this.missingParameter; + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/OAuth2AccessToken.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/OAuth2AccessToken.java new file mode 100644 index 00000000..e83f2738 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/OAuth2AccessToken.java @@ -0,0 +1,63 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth; + +import java.util.Calendar; +import java.util.Date; + +import org.apache.commons.lang3.StringUtils; + +/** + * Holds relevent accessToken information from LWA. + */ +public class OAuth2AccessToken { + + private final String accessToken; + private final long expiresTime; + + /** + * Creates an {@link OAuth2AccessToken} object. + * + * @param accessToken The accessToken returned from LWA. + * @param expiresIn Time in seconds that the accessToken expires in. + */ + public OAuth2AccessToken(String accessToken, int expiresIn) { + if (StringUtils.isBlank(accessToken)) { + throw new IllegalArgumentException("Missing " + AuthConstants.OAuth2.ACCESS_TOKEN + " parameter"); + } + + if (expiresIn < 0) { + throw new IllegalArgumentException("Invalid " + AuthConstants.OAuth2.EXPIRES_IN + + " value. Must be a positive number."); + } + + Date currentDate = new Date(); + Calendar calendar = Calendar.getInstance(); + calendar.setTime(currentDate); + calendar.add(Calendar.SECOND, expiresIn); + + this.accessToken = accessToken; + this.expiresTime = calendar.getTime().getTime(); + } + + /** + * @return accessToken + */ + public String getAccessToken() { + return accessToken; + } + + /** + * The time in milliseconds that the accessToken expires. + * @return time in milliseconds that the accessToken expires. + */ + public long getExpiresTime() { + return expiresTime; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CodeChallengeWorkflow.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CodeChallengeWorkflow.java new file mode 100644 index 00000000..1dec3fa7 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CodeChallengeWorkflow.java @@ -0,0 +1,107 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; + +import org.apache.commons.codec.binary.Base64; + +@SuppressWarnings("javadoc") +public class CodeChallengeWorkflow { + private static final String SHA_256 = "S256"; + private static final String ALORITHM_SHA_256 = "SHA-256"; + + private String codeVerifier; + private String codeChallengeMethod; + private String codeChallenge; + private static CodeChallengeWorkflow instance = new CodeChallengeWorkflow(); + + private CodeChallengeWorkflow() { + } + + /** + * @return the {@link CodeChallengeWorkflow} instance + */ + public static CodeChallengeWorkflow getInstance() { + return instance; + } + + /** + * CodeChallenge parameter generation logic goes here. We are implementing version 10 of the specification. + * Design doc: https://w.amazon.com/index.php/IdentityServices/LWA/Projects/LWA_3P_SSO_Launch + * SPOP Protocol specification version 10: https://tools.ietf.org/html/draft-ietf-oauth-spop-02 + */ + public void generateProofKeyParameters() { + try { + codeVerifier = generateCodeVerifier(); + codeChallengeMethod = SHA_256; + codeChallenge = generateCodeChallenge(codeVerifier, codeChallengeMethod); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Your JRE does not support the required " + + CodeChallengeWorkflow.ALORITHM_SHA_256 + " algorithm.", e); + } + } + + /** + * @return the codeVerifier generated. + */ + public String getCodeVerifier() { + return this.codeVerifier; + } + + /** + * @return the codeChallenge generated. + */ + public String getCodeChallenge() { + return this.codeChallenge; + } + + /** + * @return the codeChallengeMethod used. Defaults to {@value #SHA_256} + */ + public String getCodeChallengeMethod() { + return this.codeChallengeMethod; + } + + private String generateCodeChallenge(String codeVerifier, String codeChallengeMethod) + throws NoSuchAlgorithmException { + String codeChallenge = + base64UrlEncode(MessageDigest.getInstance(ALORITHM_SHA_256).digest(codeVerifier.getBytes())); + return codeChallenge; + } + + private String generateCodeVerifier() { + byte[] randomOctetSequence = generateRandomOctetSequence(); + String codeVerifier = base64UrlEncode(randomOctetSequence); + return codeVerifier; + } + + /** + * As per Proof Key/SPOP protocol Version 10 + * @return a random 32 sized octet sequence from allowed range + */ + private byte[] generateRandomOctetSequence() { + SecureRandom random = new SecureRandom(); + byte[] octetSequence = new byte[32]; + random.nextBytes(octetSequence); + + return octetSequence; + } + + /** + * This method is borrowed from the SPOP protocol spec version 10 here : http://datatracker.ietf.org/doc/draft-ietf-oauth-spop/?include_text=1 + * @param arg the string to convert + * @return base64 URL encoded string value as specified by spec. + */ + private String base64UrlEncode(byte[] arg) { + return Base64.encodeBase64URLSafeString(arg); + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CompanionAppAuthManager.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CompanionAppAuthManager.java new file mode 100644 index 00000000..fbe99566 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CompanionAppAuthManager.java @@ -0,0 +1,258 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +import com.amazon.alexa.avs.auth.AccessTokenListener; +import com.amazon.alexa.avs.config.DeviceConfig; +import com.amazon.alexa.avs.config.DeviceConfig.CompanionAppInformation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.sql.Date; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Entry points for dealing with authentication and provisioning. Owns exchanging credentials for + * tokens and managing those tokens. + */ +public class CompanionAppAuthManager { + + private static final Logger log = LoggerFactory.getLogger(CompanionAppAuthManager.class); + + /** + * How many times to retry exchanging refreshToken for an accessToken. + */ + private static final int TOKEN_REFRESH_RETRY_COUNT = 3; + + /** + * How long in seconds before trying again to exchange refreshToken for an accessToken. + */ + private static final int TOKEN_REFRESH_RETRY_INTERVAL_IN_S = 2; + + /** + * A map from sessionId to codeVerifier. + * + * New sessionId and codeVerifiers are generated by {@link #getDeviceProvisioningInfo()} and + * mapped together for easy recall in the future. + */ + private final Map sessionIdToCodeVerifier = + new ConcurrentHashMap(); + + /** + * Device configuration information. + */ + private final DeviceConfig deviceConfig; + + /** + * Client for exchanging credentials (authCode, clientId, refreshToken, etc) for accessTokens. + */ + private final OAuth2ClientForPkce pkceOAuth2Client; + + /** + * Handles generating codeVerifier, codeChallenge, and the codeChallengeMethod. + */ + private final CodeChallengeWorkflow codeChallengeWorkflow; + + /** + * The current tokens being used to make requests to AVS. + */ + private OAuth2TokensForPkce tokens; + + private final AccessTokenListener accessTokenListener; + + private final Timer refreshTimer; + + /** + * Creates an {@link CompanionAppAuthManager} object. + * + * @param deviceConfig + * @param oAuth2Client + * @param codeChallengeWorkflow + * @param accessTokenListener + */ + public CompanionAppAuthManager(DeviceConfig deviceConfig, OAuth2ClientForPkce oAuth2Client, + CodeChallengeWorkflow codeChallengeWorkflow, AccessTokenListener accessTokenListener) { + this.deviceConfig = deviceConfig; + this.pkceOAuth2Client = oAuth2Client; + this.codeChallengeWorkflow = codeChallengeWorkflow; + this.accessTokenListener = accessTokenListener; + this.refreshTimer = new Timer(); + + if (deviceConfig.getCompanionAppInfo() != null + && deviceConfig.getCompanionAppInfo().getClientId() != null + && deviceConfig.getCompanionAppInfo().getRefreshToken() != null) { + this.refreshTimer.schedule(new RefreshTokenTimerTask(), 0); + } + } + + /** + * Return a {@link DeviceProvisioningInfo} populated with the necessary codeChallenge + * information and device information, including productId and dsn. + * + * @return The information necessary to start the device provisioning process. + */ + public DeviceProvisioningInfo getDeviceProvisioningInfo() { + codeChallengeWorkflow.generateProofKeyParameters(); + + // Get everything that we need from the CodeChallengeWorkflow. + String codeChallenge = codeChallengeWorkflow.getCodeChallenge(); + String codeChallengeMethod = codeChallengeWorkflow.getCodeChallengeMethod(); + String codeVerifier = codeChallengeWorkflow.getCodeVerifier(); + String sessionId = UUID.randomUUID().toString(); + + // Map sessionId back to codeVerifier so that we can retrieve it later given a sessionId. + sessionIdToCodeVerifier.put(sessionId, codeVerifier); + + // Return the object will all the necessary information that can be serialized by the client + // or server later. + DeviceProvisioningInfo deviceProvisioningInfo = + new DeviceProvisioningInfo(deviceConfig.getProductId(), deviceConfig.getDsn(), + sessionId, codeChallenge, codeChallengeMethod); + return deviceProvisioningInfo; + } + + /** + * Requests accessToken and refreshToken from LWA by exchanging information provided by the + * companion, and the codeVerifier, for tokens. + * + * @param companionProvisioningInfo + * The information provided by the companion application or service. + * @throws IOException + * If an I/O exception occurs. + */ + public void exchangeCompanionInfoForTokens( + CompanionAppProvisioningInfo companionProvisioningInfo) throws IOException { + // Pull out all information from the companion app + String sessionId = companionProvisioningInfo.getSessionId(); + String clientId = companionProvisioningInfo.getClientId(); + String authCode = companionProvisioningInfo.getAuthCode(); + String redirectUri = companionProvisioningInfo.getRedirectUri(); + String codeVerifier = sessionIdToCodeVerifier.get(sessionId); + + // If we're unable to pull a valid codeVerifier from the map of sessionId->codeVerifier, + // then the passed sessionId is invalid + if (codeVerifier == null) { + throw new InvalidSessionIdException(sessionId); + } + + // Exchange the authCode and codeVerifier for refreshToken and accessToken + OAuth2TokensForPkce tokens = pkceOAuth2Client.exchangeAuthCodeForTokens(authCode, + redirectUri, clientId, codeVerifier); + setTokens(tokens); + } + + /** + * Set tokens returned from the {@link OAuth2ClientForPkce} where they need to go. + * + * @param tokens + * Retrieved from the {@link OAuth2ClientForPkce}. + */ + private synchronized void setTokens(OAuth2TokensForPkce tokens) { + this.tokens = tokens; + + CompanionAppInformation info = deviceConfig.getCompanionAppInfo(); + info.setClientId(tokens.getClientId()); + info.setRefreshToken(tokens.getRefreshToken()); + deviceConfig.saveConfig(); + + refreshTimer.schedule(new RefreshTokenTimerTask(), new Date(tokens.getExpiresTime())); + + accessTokenListener.onAccessTokenReceived(tokens.getAccessToken()); + } + + /** + * Exchanges a refreshToken for an accessToken. + * + * @throws IOException + * If an I/O exception occurs. + */ + public void refreshTokens() throws IOException { + if (deviceConfig.getCompanionAppInfo() != null) { + String refreshToken = deviceConfig.getCompanionAppInfo().getRefreshToken(); + String clientId = deviceConfig.getCompanionAppInfo().getClientId(); + refreshTokens(refreshToken, clientId); + } + } + + /** + * Exchanges a refreshToken for an accessToken. + * + * @param refreshToken + * The refreshToken. + * @param clientId + * The clientId of the companion application/service used. + * @throws IOException + * If an I/O exception occurs. + */ + private void refreshTokens(String refreshToken, String clientId) throws IOException { + OAuth2TokensForPkce tokens = + pkceOAuth2Client.exchangeRefreshTokenForTokens(refreshToken, clientId); + setTokens(tokens); + } + + /** + * @return the most recent tokens, or null. + */ + public OAuth2TokensForPkce getTokens() { + return tokens; + } + + /** + * @return whether or not there are tokens + */ + public boolean hasTokens() { + return tokens != null; + } + + @SuppressWarnings("javadoc") + public static class InvalidSessionIdException extends IllegalArgumentException { + private static final long serialVersionUID = 1L; + + /** + * @param sessionId + * the invalid sessionId + */ + public InvalidSessionIdException(String sessionId) { + super("The sessionId that you passed is incorrect or invalid: " + sessionId); + } + } + + /** + * TimerTask for refreshing accessTokens every hour. + */ + private class RefreshTokenTimerTask extends TimerTask { + @Override + public void run() { + int tries = 0; + while (tries < TOKEN_REFRESH_RETRY_COUNT) { + try { + refreshTokens(); + break; + } catch (IOException e) { + try { + log.error( + "There was a problem connecting to the LWA service. Trying again in {} seconds", + TOKEN_REFRESH_RETRY_INTERVAL_IN_S); + Thread.sleep(TOKEN_REFRESH_RETRY_INTERVAL_IN_S); + } catch (InterruptedException ie) { + log.error("Interrupted while waiting to retry connecting to LWA", ie); + } + tries++; + } + } + } + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CompanionAppProvisioningInfo.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CompanionAppProvisioningInfo.java new file mode 100644 index 00000000..fee969d8 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/CompanionAppProvisioningInfo.java @@ -0,0 +1,86 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +import org.apache.commons.lang3.StringUtils; + +import com.amazon.alexa.avs.auth.AuthConstants; +import com.amazon.alexa.avs.auth.MissingParameterException; + +/** + * A container for the necessary provisioning information from the companion app/service. + */ +public class CompanionAppProvisioningInfo { + private final String sessionId; + private final String clientId; + private final String redirectUri; + private final String authCode; + + /** + * Creates a {@link CompanionAppProvisioningInfo} object. + * + * @param sessionId The sessionId used to initiate this information. + * @param clientId The clientId of the companion. + * @param redirectUri The redirectUri used by the companion. + * @param authCode The authCode from the companion. + */ + public CompanionAppProvisioningInfo(String sessionId, String clientId, String redirectUri, String authCode) { + super(); + + if (StringUtils.isBlank(sessionId)) { + throw new MissingParameterException(AuthConstants.SESSION_ID); + } + + if (StringUtils.isBlank(clientId)) { + throw new MissingParameterException(AuthConstants.CLIENT_ID); + } + + if (StringUtils.isBlank(redirectUri)) { + throw new MissingParameterException(AuthConstants.REDIRECT_URI); + } + + if (StringUtils.isBlank(authCode)) { + throw new MissingParameterException(AuthConstants.AUTH_CODE); + } + + this.sessionId = sessionId; + this.clientId = clientId; + this.redirectUri = redirectUri; + this.authCode = authCode; + } + + /** + * @return sessionId. + */ + public String getSessionId() { + return sessionId; + } + + /** + * @return clientId. + */ + public String getClientId() { + return clientId; + } + + /** + * @return redirectUri. + */ + public String getRedirectUri() { + return redirectUri; + } + + /** + * @return authCode. + */ + public String getAuthCode() { + return authCode; + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/DeviceProvisioningInfo.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/DeviceProvisioningInfo.java new file mode 100644 index 00000000..eeebf008 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/DeviceProvisioningInfo.java @@ -0,0 +1,109 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; + +import com.amazon.alexa.avs.auth.AuthConstants; + +/** + * A container for the necessary provisioning information about this device. + */ +public class DeviceProvisioningInfo { + private final String productId; + private final String dsn; + private final String sessionId; + private final String codeChallenge; + private final String codeChallengeMethod; + + /** + * Creates a {@link DeviceProvisioningInfo} object. + * + * @param productId The productId of this device. + * @param dsn The dsn of this device. + * @param sessionId The sessionId associated with this information. + * @param codeChallenge The codeChallenge for this request. + * @param codeChallengeMethod The codeChallengeMethod for this request. + */ + public DeviceProvisioningInfo(String productId, String dsn, String sessionId, String codeChallenge, String codeChallengeMethod) { + this.productId = productId; + this.dsn = dsn; + this.sessionId = sessionId; + this.codeChallenge = codeChallenge; + this.codeChallengeMethod = codeChallengeMethod; + } + + /** + * @return productId. + */ + public String getProductId() { + return productId; + } + + /** + * @return dsn. + */ + public String getDsn() { + return dsn; + } + + /** + * @return sessionId. + */ + public String getSessionId() { + return sessionId; + } + + /** + * @return codeChallenge. + */ + public String getCodeChallenge() { + return codeChallenge; + } + + /** + * @return codeChallengeMethod. + */ + public String getCodeChallengeMethod() { + return codeChallengeMethod; + } + + /** + * Serialize this object to JSON. + * + * @return A JSON representation of this object. + */ + public JsonObject toJson() { + return toJson(false); + } + + /** + * Serialize this object to JSON. + * + * @param removeSessionId Whether or not to remove sessionId in the serialization. + * @return A JSON representation of this object. + */ + public JsonObject toJson(boolean removeSessionId) { + JsonObjectBuilder builder = + Json.createObjectBuilder() + .add(AuthConstants.PRODUCT_ID, productId) + .add(AuthConstants.DSN, dsn) + .add(AuthConstants.CODE_CHALLENGE, codeChallenge) + .add(AuthConstants.CODE_CHALLENGE_METHOD, codeChallengeMethod); + + if (!removeSessionId) { + builder.add(AuthConstants.SESSION_ID, sessionId); + } + + JsonObject object = builder.build(); + return object; + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/LWAException.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/LWAException.java new file mode 100644 index 00000000..d3c7f233 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/LWAException.java @@ -0,0 +1,26 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +@SuppressWarnings({ + "serial", + "javadoc" +}) +public class LWAException extends RuntimeException { + private final int responseCode; + + public LWAException(String message, int responseCode) { + super(message); + this.responseCode = responseCode; + } + + public int getResponseCode() { + return responseCode; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/OAuth2ClientForPkce.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/OAuth2ClientForPkce.java new file mode 100644 index 00000000..c5ce93a9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/OAuth2ClientForPkce.java @@ -0,0 +1,164 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; + +import org.apache.commons.io.IOUtils; + +import com.amazon.alexa.avs.auth.AuthConstants; +import com.amazon.alexa.avs.auth.OAuth2AccessToken; + +/** + * Device side implementation of http://tools.ietf.org/html/draft-ietf-oauth-spop-10#section-4.4.1. + * Uses the Login With Amazon OAuth2 API to facilitate the exchange of an authCode for access/refresh + * tokens. + */ +public class OAuth2ClientForPkce { + private static final String TOKEN_PATH = "/auth/o2/token"; + + private final URL tokenEndpoint; + + /** + * Creates an {@link OAuth2ClientForPkce} given an endpoint. + * @param endpoint + */ + public OAuth2ClientForPkce(URL endpoint) { + try { + this.tokenEndpoint = new URL(endpoint, TOKEN_PATH); + } catch (MalformedURLException e) { + // Convert to a RuntimeException because we've already validated that endpoint is correct when reading in + // DeviceConfig + throw new RuntimeException(e); + } + } + + /** + * Uses the LWA service to fetch an access token and refresh token in exchange for a refresh token and clientId. + * Expected use case of this method: refreshing tokens once the initial provisioning is complete and normal + * usage of the device is ready to commence. + * + * @param refreshToken received from the initial provisioning request + * @param clientId of the security profile associated with the companion app + * @return {@link OAuth2AccessToken} object containing the access token and refresh token + * @throws IOException + */ + public OAuth2TokensForPkce exchangeRefreshTokenForTokens(String refreshToken, String clientId) throws IOException { + HttpURLConnection connection = (HttpURLConnection) tokenEndpoint.openConnection(); + + JsonObject data = prepareExchangeRefreshTokenForTokensData(refreshToken, clientId); + + JsonObject jsonObject = postRequest(connection, data.toString()); + + String newAccessToken = jsonObject.getString(AuthConstants.OAuth2.ACCESS_TOKEN); + String newRefreshToken = jsonObject.getString(AuthConstants.OAuth2.REFRESH_TOKEN); + int expiresIn = jsonObject.getInt(AuthConstants.OAuth2.EXPIRES_IN); + + return new OAuth2TokensForPkce(clientId, newAccessToken, newRefreshToken, expiresIn); + } + + /** + * Uses the LWA service to fetch an access token and refresh token in exchange for an auth code + * (and a few other relevant parameter). Expected use case of this method: once we receive a + * message/notification from the companion app with the authCode, this method will be used to + * hit LWA and return tokens. These tokens can then be used to access AVS. + * + * @param authCode provided by the companion application + * @param redirectUri corresponding to the companion application + * @param clientId of the security profile associated with the companion app + * @param codeVerifier unique value known to the device + * @return {@link OAuth2AccessToken} object containing the access token and refresh token + * @throws IOException + */ + public OAuth2TokensForPkce exchangeAuthCodeForTokens(String authCode, String redirectUri, String clientId, + String codeVerifier) throws IOException { + HttpURLConnection connection = (HttpURLConnection) tokenEndpoint.openConnection(); + + JsonObject data = prepareExchangeAuthCodeForTokensData(authCode, redirectUri, clientId, codeVerifier); + + JsonObject jsonObject = postRequest(connection, data.toString()); + + String newAccessToken = jsonObject.getString(AuthConstants.OAuth2.ACCESS_TOKEN); + String newRefreshToken = jsonObject.getString(AuthConstants.OAuth2.REFRESH_TOKEN); + int expiresIn = jsonObject.getInt(AuthConstants.OAuth2.EXPIRES_IN); + + return new OAuth2TokensForPkce(clientId, newAccessToken, newRefreshToken, expiresIn); + } + + // Helper method used by the class to make HTTP request to LWA service + JsonObject postRequest(HttpURLConnection connection, String data) throws IOException { + int responseCode = -1; + InputStream error = null; + InputStream response = null; + DataOutputStream outputStream = null; + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + + outputStream = new DataOutputStream(connection.getOutputStream()); + outputStream.write(data.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + outputStream.close(); + responseCode = connection.getResponseCode(); + + try { + response = connection.getInputStream(); + JsonReader reader = Json.createReader(new InputStreamReader(response, StandardCharsets.UTF_8)); + return reader.readObject(); + + } catch (IOException ioException) { + error = connection.getErrorStream(); + if (error != null) { + LWAException lwaException = new LWAException(IOUtils.toString(error), responseCode); + throw lwaException; + } else { + throw ioException; + } + } finally { + IOUtils.closeQuietly(error); + IOUtils.closeQuietly(outputStream); + IOUtils.closeQuietly(response); + } + } + + // Helper method used by this class to prepare string representation of JSON request data + JsonObject prepareExchangeAuthCodeForTokensData(String authCode, String redirectUri, String clientId, + String codeVerifier) { + return Json + .createObjectBuilder() + .add(AuthConstants.OAuth2.GRANT_TYPE, AuthConstants.OAuth2.AUTHORIZATION_CODE) + .add(AuthConstants.OAuth2.CODE, authCode) + .add(AuthConstants.OAuth2.REDIRECT_URI, redirectUri) + .add(AuthConstants.OAuth2.CLIENT_ID, clientId) + .add(AuthConstants.OAuth2.CODE_VERIFIER, codeVerifier) + .build(); + } + + // Helper method used by this class to prepare string representation of JSON request data + JsonObject prepareExchangeRefreshTokenForTokensData(String refreshToken, String clientId) { + return Json + .createObjectBuilder() + .add(AuthConstants.OAuth2.GRANT_TYPE, AuthConstants.OAuth2.REFRESH_TOKEN) + .add(AuthConstants.OAuth2.CLIENT_ID, clientId) + .add(AuthConstants.OAuth2.REFRESH_TOKEN, refreshToken) + .build(); + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/OAuth2TokensForPkce.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/OAuth2TokensForPkce.java new file mode 100644 index 00000000..2dd0e0d4 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/OAuth2TokensForPkce.java @@ -0,0 +1,60 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp; + +import org.apache.commons.lang3.StringUtils; + +import com.amazon.alexa.avs.auth.AuthConstants; +import com.amazon.alexa.avs.auth.OAuth2AccessToken; + +/** + * Container for information regarding accessTokens and refreshTokens. + */ +public class OAuth2TokensForPkce extends OAuth2AccessToken { + + private final String clientId; + private final String refreshToken; + + /** + * Creates an {@link OAuth2TokensForPkce} object. + * + * @param clientId The clientId of the companion app/service that initiated the workflow. + * @param accessToken The accessToken returned from LWA. + * @param refreshToken The refreshToken returned from LWA. + * @param expiresIn Time in seconds that the accessToken expires in. + */ + public OAuth2TokensForPkce(String clientId, String accessToken, String refreshToken, int expiresIn) { + super(accessToken, expiresIn); + + if (StringUtils.isBlank(clientId)) { + throw new IllegalArgumentException("Missing or empty " + AuthConstants.OAuth2.CLIENT_ID + " parameter"); + } + + if (StringUtils.isBlank(refreshToken)) { + throw new IllegalArgumentException("Missing " + AuthConstants.OAuth2.REFRESH_TOKEN + " parameter"); + } + + this.clientId = clientId; + this.refreshToken = refreshToken; + } + + /** + * @return clientId. + */ + public String getClientId() { + return clientId; + } + + /** + * @return refreshToken. + */ + public String getRefreshToken() { + return refreshToken; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/CompanionAppProvisioningServer.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/CompanionAppProvisioningServer.java new file mode 100644 index 00000000..03243cfb --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/CompanionAppProvisioningServer.java @@ -0,0 +1,100 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp.server; + +import com.amazon.alexa.avs.auth.companionapp.CompanionAppAuthManager; +import com.amazon.alexa.avs.config.DeviceConfig; + +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.server.handler.ContextHandlerCollection; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +/** + * A Jetty server for handling local device provisioning. + */ +public class CompanionAppProvisioningServer { + private final CompanionAppAuthManager authManager; + private final DeviceConfig deviceConfig; + + /** + * Creates a {@link CompanionAppProvisioningServer} object. + * + * @param authManager + * @param deviceConfig + */ + public CompanionAppProvisioningServer(CompanionAppAuthManager authManager, + DeviceConfig deviceConfig) { + this.authManager = authManager; + this.deviceConfig = deviceConfig; + } + + /** + * Start the Jetty server and setup port information, resources, etc. + * + * @throws Exception + */ + public void startServer() throws Exception { + int localPort = deviceConfig.getCompanionAppInfo().getLocalPort(); + Server jettyServer = new Server(); + + ContextHandler beginContext = new ContextHandler("/provision/deviceInfo"); + beginContext.setAllowNullPathInfo(true); + beginContext.setHandler(new DeviceInfoHandler(authManager)); + + ContextHandler finishContext = new ContextHandler("/provision/companionInfo"); + finishContext.setAllowNullPathInfo(true); + finishContext.setHandler(new CompanionInfoHandler(authManager)); + + ContextHandlerCollection contexts = new ContextHandlerCollection(); + contexts.setHandlers(new Handler[] { + beginContext, + finishContext, + }); + jettyServer.setHandler(contexts); + + HttpConfiguration http_config = new HttpConfiguration(); + http_config.setSecureScheme("https"); + http_config.setSecurePort(localPort); + + SslContextFactory sslContextFactory = new SslContextFactory(); + sslContextFactory.setKeyStorePath(deviceConfig.getCompanionAppInfo().getSslKeyStore()); + sslContextFactory.setKeyStorePassword(deviceConfig + .getCompanionAppInfo() + .getSslKeyStorePassphrase()); + sslContextFactory.setKeyStoreType("PKCS12"); + + // SSL HTTP Configuration + HttpConfiguration https_config = new HttpConfiguration(http_config); + https_config.addCustomizer(new SecureRequestCustomizer()); + + // SSL Connector + ServerConnector sslConnector = + new ServerConnector(jettyServer, new SslConnectionFactory(sslContextFactory, + HttpVersion.HTTP_1_1.asString()), new HttpConnectionFactory(https_config)); + sslConnector.setPort(localPort); + jettyServer.setConnectors(new Connector[] { sslConnector + }); + + try { + jettyServer.start(); + jettyServer.join(); + } finally { + jettyServer.destroy(); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/CompanionInfoHandler.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/CompanionInfoHandler.java new file mode 100644 index 00000000..220bbfab --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/CompanionInfoHandler.java @@ -0,0 +1,108 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp.server; + +import java.io.IOException; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; + +import com.amazon.alexa.avs.auth.AuthConstants; +import com.amazon.alexa.avs.auth.MissingParameterException; +import com.amazon.alexa.avs.auth.companionapp.CompanionAppAuthManager; +import com.amazon.alexa.avs.auth.companionapp.CompanionAppAuthManager.InvalidSessionIdException; +import com.amazon.alexa.avs.auth.companionapp.CompanionAppProvisioningInfo; +import com.amazon.alexa.avs.auth.companionapp.LWAException; + +/** + * A Jetty Handler for receiving {@link CompanionAppProvisioningInfo} from companion applications. + */ +public class CompanionInfoHandler extends AbstractHandler { + private final CompanionAppAuthManager authManager; + + /** + * Creates a {@link CompanionInfoHandler} object. + * @param authManager + */ + public CompanionInfoHandler(CompanionAppAuthManager authManager) { + this.authManager = authManager; + } + + /** + * Writes an error message to the response. + * + * @param response The response object to write to. + * @param error The error type to write. + * @param message The error message to write. + * @param statusCode The HTTP status code to use. + * @throws IOException If an I/O exception occurs. + */ + public void errorMessage(HttpServletResponse response, String error, String message, int statusCode) + throws IOException { + JsonObject object = + Json.createObjectBuilder().add(AuthConstants.ERROR, error).add(AuthConstants.MESSAGE, message).build(); + + response.setStatus(statusCode); + response.getWriter().println(object.toString()); + } + + /** + * Handle receiving the necessary information from the companion application to finish provisioning. + * + * {@inheritDoc} + */ + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) + throws IOException { + // Only handle this as a POST request. + if (!request.getMethod().equals("POST")) { + baseRequest.setHandled(false); + return; + } + + // Setup the response. We'll always return JSON. + baseRequest.setHandled(true); + response.setContentType("application/json"); + + // Read in the JSON and parse it. + JsonReader reader = Json.createReader(request.getInputStream()); + JsonObject jsonRequest = reader.readObject(); + + String sessionId = jsonRequest.getString(AuthConstants.SESSION_ID, null); + String clientId = jsonRequest.getString(AuthConstants.CLIENT_ID, null); + String redirectUri = jsonRequest.getString(AuthConstants.REDIRECT_URI, null); + String authCode = jsonRequest.getString(AuthConstants.AUTH_CODE, null); + + try { + CompanionAppProvisioningInfo companionProvisioningInfo = + new CompanionAppProvisioningInfo(sessionId, clientId, redirectUri, authCode); + authManager.exchangeCompanionInfoForTokens(companionProvisioningInfo); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } catch (MissingParameterException e) { + errorMessage(response, AuthConstants.INVALID_PARAM_ERROR, e.getMessage(), HttpServletResponse.SC_BAD_REQUEST); + return; + } catch (InvalidSessionIdException e) { + errorMessage(response, AuthConstants.INCORRECT_SESSION_ID_ERROR, e.getMessage(), + HttpServletResponse.SC_BAD_REQUEST); + return; + } catch (LWAException e) { + errorMessage(response, AuthConstants.LWA_ERROR, e.getMessage(), e.getResponseCode()); + return; + } + + return; + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/DeviceInfoHandler.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/DeviceInfoHandler.java new file mode 100644 index 00000000..21d5f8a6 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionapp/server/DeviceInfoHandler.java @@ -0,0 +1,61 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionapp.server; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; + +import com.amazon.alexa.avs.auth.companionapp.CompanionAppAuthManager; +import com.amazon.alexa.avs.auth.companionapp.DeviceProvisioningInfo; + +/** + * A Jetty Handler for sending {@link DeviceProvisioningInfo} to companion applications. + */ +public class DeviceInfoHandler extends AbstractHandler { + private final CompanionAppAuthManager authManager; + + /** + * Creates a {@link DeviceInfoHandler} object. + * @param authManager + */ + public DeviceInfoHandler(CompanionAppAuthManager authManager) { + this.authManager = authManager; + } + + /** + * Handle sending the necessary device information to the companion application to start provisioning. + * + * {@inheritDoc} + */ + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + // Only handle this as a GET request. + if (!request.getMethod().equals("GET")) { + baseRequest.setHandled(false); + return; + } + + // Setup the response. We'll always return JSON. + baseRequest.setHandled(true); + response.setContentType("application/json"); + + DeviceProvisioningInfo deviceProvisioningInfo = authManager.getDeviceProvisioningInfo(); + + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().print(deviceProvisioningInfo.toJson().toString()); + return; + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceAuthManager.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceAuthManager.java new file mode 100644 index 00000000..83cd1925 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceAuthManager.java @@ -0,0 +1,135 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionservice; + +import com.amazon.alexa.avs.auth.AccessTokenListener; +import com.amazon.alexa.avs.auth.OAuth2AccessToken; +import com.amazon.alexa.avs.auth.companionservice.CompanionServiceClient.RemoteServiceException; +import com.amazon.alexa.avs.config.DeviceConfig; +import com.amazon.alexa.avs.config.DeviceConfig.CompanionServiceInformation; + +import java.io.IOException; +import java.util.Date; +import java.util.Timer; +import java.util.TimerTask; + +public class CompanionServiceAuthManager { + /** + * How long in seconds before trying again to exchange refreshToken for an accessToken. + */ + private static final int TOKEN_REFRESH_RETRY_INTERVAL_IN_S = 2; + + private final DeviceConfig deviceConfig; + + private final CompanionServiceClient companionServiceClient; + + private final RegCodeDisplayHandler regCodeDisplayHandler; + + private final AccessTokenListener accessTokenListener; + + private final Timer refreshTimer; + + private OAuth2AccessToken token; + + public CompanionServiceAuthManager(DeviceConfig deviceConfig, + CompanionServiceClient remoteProvisioningClient, + RegCodeDisplayHandler regCodeDisplayHandler, AccessTokenListener accessTokenListener) { + this.deviceConfig = deviceConfig; + this.companionServiceClient = remoteProvisioningClient; + this.regCodeDisplayHandler = regCodeDisplayHandler; + this.accessTokenListener = accessTokenListener; + this.refreshTimer = new Timer(); + } + + public void startRemoteProvisioning() { + if (deviceConfig.getCompanionServiceInfo() != null + && deviceConfig.getCompanionServiceInfo().getSessionId() != null) { + try { + refreshTokens(); + } catch (RemoteServiceException e) { + startNewProvisioningRequest(); + } + } else { + startNewProvisioningRequest(); + } + } + + private void startNewProvisioningRequest() { + CompanionServiceRegCodeResponse response = requestRegistrationCode(); + requestAccessToken(response.getSessionId()); + } + + public CompanionServiceRegCodeResponse requestRegistrationCode() { + while (true) { + try { + CompanionServiceRegCodeResponse regCodeResponse = + companionServiceClient.getRegistrationCode(); + + String regCode = regCodeResponse.getRegCode(); + + regCodeDisplayHandler.displayRegCode(regCode); + return regCodeResponse; + } catch (IOException e) { + try { + System.err + .println("There was a problem connecting to the Companion Service. Trying again in " + + TOKEN_REFRESH_RETRY_INTERVAL_IN_S + + " seconds. Please make sure it is up and running."); + Thread.sleep(TOKEN_REFRESH_RETRY_INTERVAL_IN_S * 1000); + } catch (InterruptedException ie) { + } + } + } + } + + public void requestAccessToken(String sessionId) { + if (deviceConfig.getCompanionServiceInfo() != null) { + while (true) { + try { + token = companionServiceClient.getAccessToken(sessionId); + + CompanionServiceInformation info = deviceConfig.getCompanionServiceInfo(); + info.setSessionId(sessionId); + deviceConfig.saveConfig(); + + refreshTimer.schedule(new RefreshTokenTimerTask(), + new Date(token.getExpiresTime())); + + accessTokenListener.onAccessTokenReceived(token.getAccessToken()); + break; + } catch (IOException e) { + try { + System.err + .println("There was a problem connecting to the Companion Service. Trying again in " + + TOKEN_REFRESH_RETRY_INTERVAL_IN_S + + " seconds. Please make sure it is up and running."); + Thread.sleep(TOKEN_REFRESH_RETRY_INTERVAL_IN_S * 1000); + } catch (InterruptedException ie) { + } + } + } + } + } + + private void refreshTokens() { + if (deviceConfig.getCompanionServiceInfo() != null) { + requestAccessToken(deviceConfig.getCompanionServiceInfo().getSessionId()); + } + } + + /** + * TimerTask for refreshing accessTokens every hour. + */ + private class RefreshTokenTimerTask extends TimerTask { + @Override + public void run() { + refreshTokens(); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceClient.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceClient.java new file mode 100644 index 00000000..8cef16c4 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceClient.java @@ -0,0 +1,265 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionservice; + +import com.amazon.alexa.avs.auth.AuthConstants; +import com.amazon.alexa.avs.auth.OAuth2AccessToken; +import com.amazon.alexa.avs.auth.companionapp.CompanionAppProvisioningInfo; +import com.amazon.alexa.avs.config.DeviceConfig; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.HashMap; +import java.util.Map; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; + +/** + * Client for communicating with the companion service and exchanging information for provisioning. + */ +public class CompanionServiceClient { + + private final DeviceConfig deviceConfig; + private SSLSocketFactory pinnedSSLSocketFactory; + + private static final Logger log = LoggerFactory.getLogger(CompanionServiceClient.class); + + /** + * Creates an {@link CompanionServiceClient} object. + * + * @param deviceConfig + */ + public CompanionServiceClient(DeviceConfig deviceConfig) { + this.deviceConfig = deviceConfig; + this.pinnedSSLSocketFactory = getPinnedSSLSocketFactory(); + } + + /** + * Creates an {@link CompanionServiceClient} object. + * + * @param deviceConfig + * @param sslSocketFactory + */ + protected CompanionServiceClient(DeviceConfig deviceConfig, SSLSocketFactory sslSocketFactory) { + this.deviceConfig = deviceConfig; + this.pinnedSSLSocketFactory = sslSocketFactory; + } + + /** + * Loads the CA certificate into an in-memory keystore and creates an {@link SSLSocketFactory}. + * + * @return SSLSocketFactory + */ + public SSLSocketFactory getPinnedSSLSocketFactory() { + InputStream caCertInputStream = null; + InputStream clientKeyPair = null; + try { + // Load the CA certificate into memory + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + caCertInputStream = + new FileInputStream(deviceConfig.getCompanionServiceInfo().getSslCaCert()); + Certificate caCert = cf.generateCertificate(caCertInputStream); + + // Load the CA certificate into the trusted KeyStore + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("myca", caCert); + + // Create a TrustManagerFactory with the trusted KeyStore + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + + // Load the client certificate and private key into another KeyStore + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + clientKeyPair = new FileInputStream( + deviceConfig.getCompanionServiceInfo().getSslClientKeyStore()); + keyStore.load(clientKeyPair, deviceConfig + .getCompanionServiceInfo() + .getSslClientKeyStorePassphrase() + .toCharArray()); + + // Create a TrustManagerFactory with the client key pair KeyStore + KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, deviceConfig + .getCompanionServiceInfo() + .getSslClientKeyStorePassphrase() + .toCharArray()); + + // Initialize the SSLContext and return an SSLSocketFactory; + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), + null); + + return sc.getSocketFactory(); + } catch (CertificateException | KeyStoreException | UnrecoverableKeyException + | NoSuchAlgorithmException | IOException | KeyManagementException e) { + throw new RuntimeException( + "The KeyStore for contacting the Companion Service could not be loaded.", e); + } finally { + IOUtils.closeQuietly(caCertInputStream); + IOUtils.closeQuietly(clientKeyPair); + } + } + + /** + * Send the device's provisioning information to the companion service, and receive back + * {@link CompanionServiceRegCodeResponse} which has a regCode to display to the user. + * + * @return Information from the companion service to begin the provisioning process. + * @throws IOException + * If an I/O exception occurs. + */ + public CompanionServiceRegCodeResponse getRegistrationCode() throws IOException { + Map queryParameters = new HashMap(); + queryParameters.put(AuthConstants.PRODUCT_ID, deviceConfig.getProductId()); + queryParameters.put(AuthConstants.DSN, deviceConfig.getDsn()); + + JsonObject response = callService("/provision/regCode", queryParameters); + + // The sessionId created from the 3pService + String sessionId = response.getString(AuthConstants.SESSION_ID, null); + String regCode = response.getString(AuthConstants.REG_CODE, null); + + return new CompanionServiceRegCodeResponse(sessionId, regCode); + } + + /** + * Request the companion service's information once the user has registered. Once the user has + * registered and we've received the {@link CompanionAppProvisioningInfo} we can then exchange + * that information for tokens. + * + * @param sessionId + * @return accessToken + * @throws IOException + * If an I/O exception occurs. + */ + public OAuth2AccessToken getAccessToken(String sessionId) throws IOException { + Map queryParameters = new HashMap(); + queryParameters.put(AuthConstants.SESSION_ID, sessionId); + + JsonObject response = callService("/provision/accessToken", queryParameters); + + String accessToken = response.getString(AuthConstants.OAuth2.ACCESS_TOKEN, null); + int expiresIn = response.getInt(AuthConstants.OAuth2.EXPIRES_IN, -1); + + return new OAuth2AccessToken(accessToken, expiresIn); + } + + JsonObject callService(String path, Map parameters) throws IOException { + HttpURLConnection con = null; + InputStream response = null; + try { + String queryString = mapToQueryString(parameters); + URL obj = new URL(deviceConfig.getCompanionServiceInfo().getServiceUrl(), + path + queryString); + con = (HttpURLConnection) obj.openConnection(); + + if (con instanceof HttpsURLConnection) { + ((HttpsURLConnection) con).setSSLSocketFactory(pinnedSSLSocketFactory); + } + + con.setRequestProperty("Content-Type", "application/json"); + con.setRequestMethod("GET"); + + if ((con.getResponseCode() >= 200) || (con.getResponseCode() < 300)) { + response = con.getInputStream(); + } + + if (response != null) { + String responsestring = IOUtils.toString(response); + log.info("Received response from companion service: {}", responsestring); + JsonReader reader = Json + .createReader(new ByteArrayInputStream(responsestring.getBytes(StandardCharsets.UTF_8))); + return reader.readObject(); + } + return Json.createObjectBuilder().build(); + } catch (IOException e) { + if (con != null) { + response = con.getErrorStream(); + + if (response != null) { + String responsestring = IOUtils.toString(response); + JsonReader reader = Json.createReader( + new ByteArrayInputStream(responsestring.getBytes(StandardCharsets.UTF_8))); + JsonObject error = reader.readObject(); + + String errorName = error.getString("error", null); + String errorMessage = error.getString("message", null); + + if (!StringUtils.isBlank(errorName) && !StringUtils.isBlank(errorMessage)) { + throw new RemoteServiceException(errorName + ": " + errorMessage); + } + } + } + throw e; + } finally { + if (response != null) { + IOUtils.closeQuietly(response); + } + } + } + + private String mapToQueryString(Map parameters) + throws UnsupportedEncodingException { + StringBuilder queryBuilder = new StringBuilder(); + if ((parameters != null) && (parameters.size() > 0)) { + queryBuilder.append("?"); + for (Map.Entry entry : parameters.entrySet()) { + if (queryBuilder.length() > 1) { + queryBuilder.append("&"); + } + queryBuilder.append(URLEncoder.encode(entry.getKey().toString(), + StandardCharsets.UTF_8.name())); + queryBuilder.append("="); + queryBuilder.append(URLEncoder.encode(entry.getValue().toString(), + StandardCharsets.UTF_8.name())); + } + } + return queryBuilder.toString(); + } + + @SuppressWarnings("javadoc") + public static class RemoteServiceException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public RemoteServiceException(String s) { + super(s); + } + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceRegCodeResponse.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceRegCodeResponse.java new file mode 100644 index 00000000..1b71bf2c --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/CompanionServiceRegCodeResponse.java @@ -0,0 +1,54 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionservice; + +import org.apache.commons.lang3.StringUtils; + +import com.amazon.alexa.avs.auth.AuthConstants; + +/** + * A container for the necessary provisioning information from the companion service to start the provisioning process. + */ +public class CompanionServiceRegCodeResponse { + private final String sessionId; + private final String regCode; + + /** + * Creates a {@link CompanionServiceRegCodeResponse} object. + * + * @param sessionId The sessionId from the companion service. + * @param regCode The registration code to be shown to the user to register on the companion service. + */ + public CompanionServiceRegCodeResponse(String sessionId, String regCode) { + if (StringUtils.isBlank(sessionId)) { + throw new IllegalArgumentException("Missing " + AuthConstants.SESSION_ID + " parameter"); + } + + if (StringUtils.isBlank(regCode)) { + throw new IllegalArgumentException("Missing " + AuthConstants.REG_CODE + " parameter"); + } + + this.sessionId = sessionId; + this.regCode = regCode; + } + + /** + * @return sessionId. + */ + public String getSessionId() { + return sessionId; + } + + /** + * @return regCode. + */ + public String getRegCode() { + return regCode; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/RegCodeDisplayHandler.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/RegCodeDisplayHandler.java new file mode 100644 index 00000000..e983a236 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/auth/companionservice/RegCodeDisplayHandler.java @@ -0,0 +1,19 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.auth.companionservice; + +/** + * Interface for handling displaying the regCode to the customer. + */ +public interface RegCodeDisplayHandler { + /** + * @param regCode + */ + void displayRegCode(String regCode); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/DeviceConfig.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/DeviceConfig.java new file mode 100644 index 00000000..21ad7bc9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/DeviceConfig.java @@ -0,0 +1,518 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.config; + +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; + +/** + * Container that encapsulates all the information that exists in the config file. + */ +public class DeviceConfig { + private static final String DEFAULT_HOST = "https://avs-alexa-na.amazon.com"; + public static final String FILE_NAME = "config.json"; + + public static final String PRODUCT_ID = "productId"; + public static final String DSN = "dsn"; + public static final String COMPANION_APP = "companionApp"; + public static final String COMPANION_SERVICE = "companionService"; + public static final String PROVISIONING_METHOD = "provisioningMethod"; + public static final String AVS_HOST = "avsHost"; + + /* + * Required parameters from the config file. + */ + private final String productId; + private final String dsn; + private final ProvisioningMethod provisioningMethod; + private final URL avsHost; + + /* + * Optional parameters from the config file. + */ + private CompanionAppInformation companionAppInfo; + private CompanionServiceInformation companionServiceInfo; + + @SuppressWarnings("javadoc") + public enum ProvisioningMethod { + COMPANION_APP(DeviceConfig.COMPANION_APP), COMPANION_SERVICE( + DeviceConfig.COMPANION_SERVICE); + + private String name; + + ProvisioningMethod(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + + public static ProvisioningMethod fromString(String method) { + if (ProvisioningMethod.COMPANION_APP.toString().equals(method)) { + return COMPANION_APP; + } else if (ProvisioningMethod.COMPANION_SERVICE.toString().equals(method)) { + return COMPANION_SERVICE; + } + throw new IllegalArgumentException("Invalid provisioning method"); + } + } + + /** + * Creates a {@link DeviceConfig} object. + * + * @param productId + * The productId of this device. + * @param dsn + * The dsn of this device. + * @param provisioningMethod + * The provisioningMethod to use. One of: {@value #COMPANION_APP}, + * {@value #COMPANION_SERVICE} + * @param companionAppInfo + * The information necessary for the Companion App method of provisioning. + * @param companionServiceInfo + * The information necessary for the Companion Service method of provisioning. + * @param avsHost + * (optional) AVS host override + */ + public DeviceConfig(String productId, String dsn, String provisioningMethod, + CompanionAppInformation companionAppInfo, + CompanionServiceInformation companionServiceInfo, String avsHost) { + + if (StringUtils.isBlank(productId)) { + throw new MalformedConfigException(PRODUCT_ID + " is blank in your config file."); + } + + if (StringUtils.isBlank(dsn)) { + throw new MalformedConfigException(DSN + " is blank in your config file."); + } + + ProvisioningMethod method; + try { + method = ProvisioningMethod.fromString(provisioningMethod); + } catch (IllegalArgumentException e) { + throw new MalformedConfigException(PROVISIONING_METHOD + " should be either \"" + + COMPANION_APP + "\" or \"" + COMPANION_SERVICE + "\"."); + } + + if (method == ProvisioningMethod.COMPANION_APP + && (companionAppInfo == null || !companionAppInfo.isValid())) { + throw new MalformedConfigException("Your " + PROVISIONING_METHOD + " is set to \"" + + COMPANION_APP + "\" but you do not have a valid \"" + COMPANION_APP + + "\" section in your config file."); + } else if (method == ProvisioningMethod.COMPANION_SERVICE + && (companionServiceInfo == null || !companionServiceInfo.isValid())) { + throw new MalformedConfigException("Your " + PROVISIONING_METHOD + " is set to \"" + + COMPANION_SERVICE + "\" but you do not have a valid \"" + COMPANION_SERVICE + + "\" section in your config file."); + } + + this.provisioningMethod = method; + this.productId = productId; + this.dsn = dsn; + this.companionServiceInfo = companionServiceInfo; + this.companionAppInfo = companionAppInfo; + avsHost = StringUtils.isBlank(avsHost) ? DEFAULT_HOST : avsHost; + try { + this.avsHost = new URL(avsHost); + } catch (MalformedURLException e) { + throw new MalformedConfigException(AVS_HOST + " is malformed in your config file.", e); + } + } + + public DeviceConfig(String productId, String dsn, String provisioningMethod, + CompanionAppInformation companionAppInfo, + CompanionServiceInformation companionServiceInfo) { + this(productId, dsn, provisioningMethod, companionAppInfo, companionServiceInfo, + DEFAULT_HOST); + } + + /** + * @return avsHost. + */ + public URL getAvsHost() { + return avsHost; + } + + /** + * @return productId. + */ + public String getProductId() { + return productId; + } + + /** + * @return dsn. + */ + public String getDsn() { + return dsn; + } + + /** + * @return provisioningMethod. + */ + public ProvisioningMethod getProvisioningMethod() { + return provisioningMethod; + } + + /** + * @return companionAppInfo. + */ + public CompanionAppInformation getCompanionAppInfo() { + return companionAppInfo; + } + + /** + * @param companionAppInfo + */ + public void setCompanionAppInfo(CompanionAppInformation companionAppInfo) { + this.companionAppInfo = companionAppInfo; + } + + /** + * @return companionServiceInfo. + */ + public CompanionServiceInformation getCompanionServiceInfo() { + return companionServiceInfo; + } + + /** + * @param companionServiceInfo + */ + public void setCompanionServiceInfo(CompanionServiceInformation companionServiceInfo) { + this.companionServiceInfo = companionServiceInfo; + } + + /** + * Save this file back to disk. + */ + public void saveConfig() { + DeviceConfigUtils.updateConfigFile(this); + } + + /** + * Serialize this object to JSON. + * + * @return A JSON representation of this object. + */ + public JsonObject toJson() { + JsonObjectBuilder builder = Json + .createObjectBuilder() + .add(PRODUCT_ID, productId) + .add(DSN, dsn) + .add(PROVISIONING_METHOD, provisioningMethod.toString()) + .add(AVS_HOST, avsHost.toString()); + + if (companionAppInfo != null) { + builder.add(COMPANION_APP, companionAppInfo.toJson()); + } + + if (companionServiceInfo != null) { + builder.add(COMPANION_SERVICE, companionServiceInfo.toJson()); + } + + return builder.build(); + } + + /** + * Describes the information necessary for the Companion App method of provisioning. + */ + public static class CompanionAppInformation { + public static final String LOCAL_PORT = "localPort"; + public static final String LWA_URL = "lwaUrl"; + public static final String SSL_KEYSTORE = "sslKeyStore"; + public static final String SSL_KEYSTORE_PASSPHRASE = "sslKeyStorePassphrase"; + public static final String REFRESH_TOKEN = "refreshToken"; + public static final String CLIENT_ID = "clientId"; + + private final int localPort; + private final String lwaUrl; + private final String sslKeyStore; + private final String sslKeyStorePassphrase; + + private URL loginWithAmazonUrl; + private String clientId; + private String refreshToken; + + /** + * Creates a {@link CompanionAppInformation} object. + * + * @param localPort + * @param lwaUrl + */ + public CompanionAppInformation(int localPort, String lwaUrl, String sslKeyStore, + String sslKeyStorePassphrase) { + this.localPort = localPort; + this.sslKeyStore = sslKeyStore; + this.sslKeyStorePassphrase = sslKeyStorePassphrase; + this.lwaUrl = lwaUrl; + } + + /** + * @return clientId. + */ + public String getClientId() { + return clientId; + } + + /** + * @param clientId + */ + public void setClientId(String clientId) { + this.clientId = clientId; + } + + /** + * @return refreshToken. + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * @param refreshToken + */ + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + /** + * @return localPort. + */ + public int getLocalPort() { + return localPort; + } + + /** + * @return lwaUrl. + */ + public URL getLwaUrl() { + if (loginWithAmazonUrl == null) { + if (StringUtils.isBlank(lwaUrl)) { + throw new MalformedConfigException(LWA_URL + " is blank in your config file."); + } else { + try { + loginWithAmazonUrl = new URL(lwaUrl); + } catch (MalformedURLException e) { + throw new MalformedConfigException( + LWA_URL + " is malformed in your config file.", e); + } + } + } + return loginWithAmazonUrl; + } + + /** + * @return sslKeyStore. + */ + public String getSslKeyStore() { + return sslKeyStore; + } + + /** + * @return sslKeyStorePassphrase. + */ + public String getSslKeyStorePassphrase() { + return sslKeyStorePassphrase; + } + + /** + * Serialize this object to JSON. + * + * @return A JSON representation of this object. + */ + public JsonObject toJson() { + JsonObjectBuilder builder = Json + .createObjectBuilder() + .add(LOCAL_PORT, localPort) + .add(LWA_URL, getLwaUrl().toString()) + .add(SSL_KEYSTORE, sslKeyStore) + .add(SSL_KEYSTORE_PASSPHRASE, sslKeyStorePassphrase); + + if ((clientId != null) && (refreshToken != null)) { + builder.add(CLIENT_ID, clientId); + builder.add(REFRESH_TOKEN, refreshToken); + } + + return builder.build(); + } + + public boolean isValid() { + if (localPort < 1 || localPort > 65535) { + throw new MalformedConfigException( + LOCAL_PORT + " is invalid. Value port values are 1-65535."); + } + + getLwaUrl(); // Verifies that the url is valid + if (StringUtils.isBlank(sslKeyStore)) { + throw new MalformedConfigException(SSL_KEYSTORE + " is blank in your config file."); + } else { + File sslKeyStoreFile = new File(sslKeyStore); + if (!sslKeyStoreFile.exists()) { + throw new MalformedConfigException( + sslKeyStore + " " + SSL_KEYSTORE + " does not exist."); + } + } + return true; + } + } + + /** + * Describes the information necessary for the Companion Service method of provisioning. + */ + public static class CompanionServiceInformation { + public static final String SESSION_ID = "sessionId"; + public static final String SERVICE_URL = "serviceUrl"; + public static final String SSL_CLIENT_KEYSTORE = "sslClientKeyStore"; + public static final String SSL_CLIENT_KEYSTORE_PASSPHRASE = "sslClientKeyStorePassphrase"; + public static final String SSL_CA_CERT = "sslCaCert"; + + private final String serviceUrlString; + private final String sslClientKeyStore; + private final String sslClientKeyStorePassphrase; + private final String sslCaCert; + + private URL serviceUrl; + private String sessionId; + + /** + * Creates a {@link CompanionServiceInformation} object. + * + * @param serviceUrl + */ + public CompanionServiceInformation(String serviceUrl, String sslClientKeyStore, + String sslClientKeyStorePassphrase, String sslCaCert) { + this.serviceUrlString = serviceUrl; + this.sslClientKeyStore = sslClientKeyStore; + this.sslClientKeyStorePassphrase = sslClientKeyStorePassphrase; + this.sslCaCert = sslCaCert; + } + + /** + * @return serviceUrl. + */ + public URL getServiceUrl() { + if (serviceUrl == null) { + if (StringUtils.isBlank(serviceUrlString)) { + throw new MalformedConfigException( + SERVICE_URL + " is blank in your config file."); + } else { + try { + this.serviceUrl = new URL(serviceUrlString); + } catch (MalformedURLException e) { + throw new MalformedConfigException( + SERVICE_URL + " is malformed in your config file.", e); + } + } + } + return serviceUrl; + } + + /** + * @param sessionId + */ + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + /** + * @return sessionId. + */ + public String getSessionId() { + return sessionId; + } + + /** + * @return sslClientKeyStore. + */ + public String getSslClientKeyStore() { + return sslClientKeyStore; + } + + /** + * @return sslClientKeyStorePassphrase. + */ + public String getSslClientKeyStorePassphrase() { + return sslClientKeyStorePassphrase; + } + + /** + * @return sslCaCert. + */ + public String getSslCaCert() { + return sslCaCert; + } + + /** + * Serialize this object to JSON. + * + * @return A JSON representation of this object. + */ + public JsonObject toJson() { + JsonObjectBuilder builder = Json + .createObjectBuilder() + .add(SERVICE_URL, getServiceUrl().toString()) + .add(SSL_CLIENT_KEYSTORE, sslClientKeyStore) + .add(SSL_CLIENT_KEYSTORE_PASSPHRASE, sslClientKeyStorePassphrase) + .add(SSL_CA_CERT, sslCaCert); + + if (sessionId != null) { + builder.add(SESSION_ID, sessionId); + } + + return builder.build(); + } + + public boolean isValid() { + getServiceUrl(); // Verifies that the URL is valid + if (StringUtils.isBlank(sslClientKeyStore)) { + throw new MalformedConfigException( + SSL_CLIENT_KEYSTORE + " is blank in your config file."); + } else { + File sslClientKeyStoreFile = new File(sslClientKeyStore); + if (!sslClientKeyStoreFile.exists()) { + throw new MalformedConfigException( + sslClientKeyStore + " " + SSL_CLIENT_KEYSTORE + " does not exist."); + } + } + + if (StringUtils.isBlank(sslCaCert)) { + throw new MalformedConfigException(SSL_CA_CERT + " is blank in your config file."); + } else { + File sslCaCertFile = new File(sslCaCert); + if (!sslCaCertFile.exists()) { + throw new MalformedConfigException( + sslCaCertFile + " " + SSL_CA_CERT + " does not exist."); + } + } + return true; + } + } + + @SuppressWarnings("javadoc") + public static class MalformedConfigException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public MalformedConfigException(String message, Throwable cause) { + super(message, cause); + } + + public MalformedConfigException(String s) { + super(s); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/DeviceConfigUtils.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/DeviceConfigUtils.java new file mode 100644 index 00000000..4781d6cc --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/DeviceConfigUtils.java @@ -0,0 +1,156 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.config; + +import com.amazon.alexa.avs.config.DeviceConfig.CompanionAppInformation; +import com.amazon.alexa.avs.config.DeviceConfig.CompanionServiceInformation; + +import org.apache.commons.io.IOUtils; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.json.JsonWriter; +import javax.json.JsonWriterFactory; +import javax.json.stream.JsonGenerator; + +/** + * A utility class for interacting with the config file. This class is used for creating + * {@link DeviceConfig}, and also for persisting changes. + * + * @see DeviceConfig + */ +public final class DeviceConfigUtils { + private static String deviceConfigName = DeviceConfig.FILE_NAME; + + /** + * Reads the {@link DeviceConfig} from disk. + * + * @return The configuration. + */ + public static DeviceConfig readConfigFile() { + return readConfigFile(DeviceConfig.FILE_NAME); + } + + /** + * Reads the {@link DeviceConfig} from disk. Pass in the name of the config file + * + * @return The configuration. + */ + public static DeviceConfig readConfigFile(String filename) { + FileInputStream file = null; + try { + deviceConfigName = filename.trim(); + file = new FileInputStream(deviceConfigName); + JsonReader json = Json.createReader(file); + JsonObject configObject = json.readObject(); + + JsonObject companionServiceObject = + configObject.getJsonObject(DeviceConfig.COMPANION_SERVICE); + CompanionServiceInformation companionServiceInfo = null; + if (companionServiceObject != null) { + String serviceUrl = companionServiceObject + .getString(DeviceConfig.CompanionServiceInformation.SERVICE_URL, null); + String sessionId = companionServiceObject + .getString(DeviceConfig.CompanionServiceInformation.SESSION_ID, null); + String sslClientKeyStore = companionServiceObject.getString( + DeviceConfig.CompanionServiceInformation.SSL_CLIENT_KEYSTORE, null); + String sslClientKeyStorePassphrase = companionServiceObject.getString( + DeviceConfig.CompanionServiceInformation.SSL_CLIENT_KEYSTORE_PASSPHRASE, + null); + String sslCaCert = companionServiceObject + .getString(DeviceConfig.CompanionServiceInformation.SSL_CA_CERT, null); + + companionServiceInfo = new CompanionServiceInformation(serviceUrl, + sslClientKeyStore, sslClientKeyStorePassphrase, sslCaCert); + companionServiceInfo.setSessionId(sessionId); + } + + JsonObject companionAppObject = configObject.getJsonObject(DeviceConfig.COMPANION_APP); + CompanionAppInformation companionAppInfo = null; + if (companionAppObject != null) { + int localPort = companionAppObject + .getInt(DeviceConfig.CompanionAppInformation.LOCAL_PORT, -1); + String lwaUrl = companionAppObject + .getString(DeviceConfig.CompanionAppInformation.LWA_URL, null); + String clientId = companionAppObject + .getString(DeviceConfig.CompanionAppInformation.CLIENT_ID, null); + String refreshToken = companionAppObject + .getString(DeviceConfig.CompanionAppInformation.REFRESH_TOKEN, null); + String sslKeyStore = companionAppObject + .getString(DeviceConfig.CompanionAppInformation.SSL_KEYSTORE, null); + String sslKeyStorePassphrase = companionAppObject.getString( + DeviceConfig.CompanionAppInformation.SSL_KEYSTORE_PASSPHRASE, null); + + companionAppInfo = new CompanionAppInformation(localPort, lwaUrl, sslKeyStore, + sslKeyStorePassphrase); + companionAppInfo.setClientId(clientId); + companionAppInfo.setRefreshToken(refreshToken); + } + + String productId = configObject.getString(DeviceConfig.PRODUCT_ID, null); + String dsn = configObject.getString(DeviceConfig.DSN, null); + String provisioningMethod = + configObject.getString(DeviceConfig.PROVISIONING_METHOD, null); + String avsHost = configObject.getString(DeviceConfig.AVS_HOST, null); + + DeviceConfig deviceConfig = new DeviceConfig(productId, dsn, provisioningMethod, + companionAppInfo, companionServiceInfo, avsHost); + + return deviceConfig; + } catch (FileNotFoundException e) { + throw new RuntimeException( + "The required file " + deviceConfigName + " could not be opened.", e); + } finally { + IOUtils.closeQuietly(file); + } + } + + /** + * Writes the {@link DeviceConfig} back to disk. + * + * @param config + */ + public static void updateConfigFile(DeviceConfig config) { + FileOutputStream file = null; + try { + file = new FileOutputStream(deviceConfigName); + StringWriter stringWriter = new StringWriter(); + + Map properties = new HashMap(1); + properties.put(JsonGenerator.PRETTY_PRINTING, true); + + JsonWriterFactory writerFactory = Json.createWriterFactory(properties); + JsonWriter jsonWriter = writerFactory.createWriter(stringWriter); + jsonWriter.writeObject(config.toJson()); + jsonWriter.close(); + + // We have to write to a separate StringWriter and trim() it because the pretty-printing + // generator adds a newline at the beginning of the file. + file.write(stringWriter.toString().trim().getBytes()); + } catch (FileNotFoundException e) { + throw new RuntimeException( + "The required file " + deviceConfigName + " could not be updated.", e); + } catch (IOException e) { + throw new RuntimeException( + "The required file " + deviceConfigName + " could not be updated.", e); + } finally { + IOUtils.closeQuietly(file); + } + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/ObjectMapperFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/ObjectMapperFactory.java new file mode 100644 index 00000000..2f408e63 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/config/ObjectMapperFactory.java @@ -0,0 +1,47 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.config; + +import org.codehaus.jackson.map.DeserializationConfig; +import org.codehaus.jackson.map.ObjectMapper; +import org.codehaus.jackson.map.ObjectReader; +import org.codehaus.jackson.map.ObjectWriter; + +public class ObjectMapperFactory { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationConfig.Feature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final ObjectWriter OBJECT_WRITER = OBJECT_MAPPER.writer(); + private static final ObjectReader OBJECT_READER = OBJECT_MAPPER.reader(); + + private ObjectMapperFactory() { + } + + /** + * + * @return A generic object reader + */ + public static ObjectReader getObjectReader() { + return OBJECT_READER; + } + + /** + * Get an ObjectReader that can parse JSON to type clazz + * + * @param clazz + * Type of class to parse the JSON into + * @return + */ + public static ObjectReader getObjectReader(Class clazz) { + return OBJECT_READER.withType(clazz); + } + + public static ObjectWriter getObjectWriter() { + return OBJECT_WRITER; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AVSException.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AVSException.java new file mode 100644 index 00000000..993dc4fa --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AVSException.java @@ -0,0 +1,16 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.exception; + +@SuppressWarnings("serial") +public class AVSException extends Exception { + public AVSException(String message) { + super(message); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AVSJsonProcessingException.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AVSJsonProcessingException.java new file mode 100644 index 00000000..8344986a --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AVSJsonProcessingException.java @@ -0,0 +1,25 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.exception; + +import org.codehaus.jackson.JsonProcessingException; + +public class AVSJsonProcessingException extends JsonProcessingException { + + private final String unparseable; + + public AVSJsonProcessingException(String message, JsonProcessingException e, String unparseable) { + super(message, e); + this.unparseable = unparseable; + } + + public String getUnparseable() { + return unparseable; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AlexaSystemException.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AlexaSystemException.java new file mode 100644 index 00000000..8edfd11f --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/AlexaSystemException.java @@ -0,0 +1,31 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.exception; + +/** + * This exception is only for exceptions returned from the Server as a System.Exception message + */ +@SuppressWarnings("serial") +public class AlexaSystemException extends AVSException { + private final String code; + + public AlexaSystemException(String code, String description) { + super(description); + this.code = code; + } + + @Override + public String toString() { + return "" + code + ": " + getMessage(); + } + + public String getDescription() { + return getMessage(); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/DirectiveHandlingException.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/DirectiveHandlingException.java new file mode 100644 index 00000000..c38ce41b --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/exception/DirectiveHandlingException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.exception; + +public class DirectiveHandlingException extends Exception { + + private ExceptionType type; + + public DirectiveHandlingException(ExceptionType type, String message) { + super(message); + this.type = type; + } + + public ExceptionType getType() { + return type; + } + + public enum ExceptionType { + UNEXPECTED_INFORMATION_RECEIVED, + UNSUPPORTED_OPERATION, + INTERNAL_ERROR; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AVSClient.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AVSClient.java new file mode 100644 index 00000000..d4a6b88d --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AVSClient.java @@ -0,0 +1,533 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import com.amazon.alexa.avs.AVSRequest; +import com.amazon.alexa.avs.AudioInputFormat; +import com.amazon.alexa.avs.RequestListener; +import com.amazon.alexa.avs.config.ObjectMapperFactory; +import com.amazon.alexa.avs.exception.AVSException; +import com.amazon.alexa.avs.exception.AVSJsonProcessingException; +import com.amazon.alexa.avs.exception.AlexaSystemException; +import com.amazon.alexa.avs.http.MultipartParser.MultipartParserConsumer; +import com.amazon.alexa.avs.http.jetty.InputStreamResponseListener; +import com.amazon.alexa.avs.http.jetty.PingSendingHttpClientTransportOverHTTP2; +import com.amazon.alexa.avs.http.jetty.PingSendingHttpClientTransportOverHTTP2.ConnectionListener; +import com.amazon.alexa.avs.message.Message; +import com.amazon.alexa.avs.message.request.RequestBody; +import com.amazon.alexa.avs.message.response.AlexaExceptionResponse; + +import org.apache.commons.fileupload.MultipartStream; +import org.apache.commons.fileupload.MultipartStream.MalformedStreamException; +import org.apache.commons.io.IOUtils; +import org.codehaus.jackson.JsonGenerationException; +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.map.JsonMappingException; +import org.codehaus.jackson.map.ObjectWriter; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentProvider; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.component.LifeCycle.Listener; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; + +public class AVSClient implements ConnectionListener { + private static final Logger log = LoggerFactory.getLogger(AVSClient.class); + + private static final int REQUEST_TIMEOUT_IN_S = 10; + private static final int REQUEST_ATTEMPTS = 3; + private static final long REQUEST_RETRY_DELAY_MS = 1000; + + private static final String EVENTS_ENDPOINT = "/v20160207/events"; + private static final String DIRECTIVES_ENDPOINT = "/v20160207/directives"; + private static final BlockingQueue requestQueue = new LinkedBlockingDeque<>(); + + static final String METADATA_NAME = "metadata"; + static final String AUDIO_NAME = "audio"; + + public enum Resource { + EVENTS(EVENTS_ENDPOINT, HttpMethod.POST), + DIRECTIVES(DIRECTIVES_ENDPOINT, HttpMethod.GET); + + private final String path; + private final HttpMethod method; + + Resource(String path, HttpMethod method) { + this.path = path; + this.method = method; + } + + public String getPath() { + return path; + } + + public HttpMethod getMethod() { + return method; + } + } + + private HttpClient httpClient; + private URL host; + private SslContextFactory sslContextFactory; + private String accessToken = ""; + private DownchannelRequestThread downchannelThread; + private RequestThread requestThread; + private MultipartParser requestResponseParser; + private MultipartParser downchannelParser; + private HTTP2Client http2Client; + private ParsingFailedHandler parsingFailedHandler; + + /** + * Constructor that takes a host, a {@link DirectiveQueue}, and a {@link SslContextFactory} . + * The provided {@link SslContextFactory} may allow bypassing server certificates, or handling + * TLS/SSL in different ways. + * + * @param host + * The URL of the AVS host. + * @param directiveEnqueuer + * The {@link DirectiveQueue} where {@link DirectiveGroup}s will be passed to be + * processed. + * @param sslContextFactory + * The {@link SslContextFactory} to use for validating certificates. + * @param parsingFailedHandler + * The handler for handling parse failures. + * @throws Exception + */ + public AVSClient(URL host, MultipartParserConsumer multipartParserConsumer, + SslContextFactory sslContextFactory, ParsingFailedHandler parsingFailedHandler) + throws Exception { + http2Client = new HTTP2Client(); + + this.host = host; + this.sslContextFactory = sslContextFactory; + + requestResponseParser = new MultipartParser(multipartParserConsumer); + downchannelParser = new MultipartParser(multipartParserConsumer); + + this.parsingFailedHandler = parsingFailedHandler; + + createNewHttpClient(); + + requestThread = new RequestThread(requestQueue); + } + + private void createNewHttpClient() throws Exception { + if ((httpClient != null) && httpClient.isStarted()) { + try { + httpClient.stop(); + } catch (Exception e) { + log.error("There was a problem stopping the HTTP client", e); + throw e; + } + } + + // Sets up an HttpClient that sends HTTP/1.1 requests over an HTTP/2 transport + httpClient = new HttpClient(new PingSendingHttpClientTransportOverHTTP2(http2Client, this), + sslContextFactory); + httpClient.addLifeCycleListener(new Listener() { + + @Override + public void lifeCycleFailure(LifeCycle arg0, Throwable arg1) { + log.error("HttpClient failed", arg1); + StackTraceElement st[] = Thread.currentThread().getStackTrace(); + log.info(String.join(System.lineSeparator(), Arrays.toString(st))); + } + + @Override + public void lifeCycleStarted(LifeCycle arg0) { + log.info("HttpClient started"); + } + + @Override + public void lifeCycleStarting(LifeCycle arg0) { + log.info("HttpClient starting"); + } + + @Override + public void lifeCycleStopped(LifeCycle arg0) { + log.info("HttpClient stopped"); + } + + @Override + public void lifeCycleStopping(LifeCycle arg0) { + log.info("HttpClient stopping"); + StackTraceElement st[] = Thread.currentThread().getStackTrace(); + log.info(String.join(System.lineSeparator(), Arrays.toString(st))); + } + + }); + httpClient.start(); + } + + private Request createRequest(Resource resource, ContentProvider content) throws Exception { + if (!httpClient.isStarted()) { + log.error("HttpClient is stopped when it should be started"); + createNewHttpClient(); + } + Request request = httpClient + .newRequest(host.toString() + resource.getPath()) + .method(resource.getMethod()); + + if (content != null) { + request = request.content(content); + } + + return request; + } + + /** + * Execute a request. + * + * @param request + */ + private void doRequest(AVSRequest avsRequest) { + Callable task = new Callable() { + @Override + public Void call() throws Exception { + Request request = createRequest(avsRequest.getResource(), avsRequest.getContentProvider()); + doRequestActual(request, avsRequest.getMultipartParser()); + return null; + } + }; + + try { + avsRequest.getRetryPolicy().tryCall(task, RequestException.class); + } catch (MultipartStream.MalformedStreamException e) { + if (!e.getMessage().equals("Stream ended unexpectedly")) { + log.error("Malformed stream exception", e); + } + } catch (Exception e) { + log.error("There was a problem with the request.", e); + avsRequest.getRequestListener().ifPresent(l -> l.onRequestError(e)); + } + } + + /** + * Execute the actual request to the server, wait for the response, and handle it. + * + * @param request + * The request to make. + * @param multipartParser + * The {@link MultipartParser} to use for parsing the response to this request. + * @throws AVSException + * is thrown when we get a non-2xx HTTP status code. + * @throws IOException + * is thrown when parsing the multipart stream, and reading from the + * {@link PipedChannelResponseListener}. + */ + private void doRequestActual(Request request, MultipartParser multipartParser) + throws AVSException, IOException { + request.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + + InputStreamResponseListener responseListener = new InputStreamResponseListener(); + Response response; + InputStream inputStream; + + try { + // We have a request queue that maintains correct sequencing of events to appease the + // server needing no events to happen in parallel. However, Downchannel requests don't + // happen on that queue, they happen separately. By synchronizing here we can ensure + // that no requests on the request queue will happen in parallel with the downchannel + // requests. + synchronized (this) { + request.send(responseListener); + response = responseListener.get(REQUEST_TIMEOUT_IN_S, TimeUnit.SECONDS); + } + inputStream = responseListener.getInputStream(); + } catch (Exception e) { + throw new RequestException(e); + } + + int statusCode = response.getStatus(); + log.info("Response code: {}", statusCode); + log.info("Response headers: {}", response.getHeaders()); + + if (statusCode == HttpStatus.NO_CONTENT_204) { + log.info("This response successfully had no content."); + return; + } + + String contentType = response.getHeaders().get(HttpHeader.CONTENT_TYPE); + Optional boundary = + getHeaderParameter(contentType, HttpHeaders.Parameters.BOUNDARY); + + try { + if (!boundary.isPresent()) { + // This code assumes that System.Exception is only sent as a non-multipart response + // This should throw an exception + parseException(inputStream, multipartParser); + + // If the above doesn't throw the expected exception, + // throw this exception instead + throw new MalformedStreamException( + "A boundary is missing from the response headers. " + + "Unable to parse multipart stream."); + } + + multipartParser.parseStream(inputStream, boundary.get()); + } catch (AVSJsonProcessingException e) { + parsingFailedHandler.onParsingFailed(e.getUnparseable()); + } catch (JsonProcessingException e) { + String unparseable = IOUtils.toString(inputStream); + parsingFailedHandler.onParsingFailed(unparseable); + } + } + + /** + * Parses an exception in the given byte array + * + * @throws AlexaSystemException + * Special case when the server message is itself an Exception. + */ + public void parseException(InputStream inputStream, MessageParser parser) + throws IOException, AlexaSystemException { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + IOUtils.copy(inputStream, data); + Message message = parser.parseServerMessage(data.toByteArray()); + if (message instanceof AlexaExceptionResponse) { + ((AlexaExceptionResponse) message).throwException(); + } + } + + /** + * Send an event with a {@link RequestBody}. + * + * @param body + * @throws JsonMappingException + * @throws JsonGenerationException + * @throws IOException + */ + public void sendEvent(RequestBody body) + throws JsonGenerationException, JsonMappingException, IOException { + sendEvent(body, null); + } + + /** + * Send an event with a {@link RequestBody}. + * + * @param body + * @param listener + * @throws JsonMappingException + * @throws JsonGenerationException + * @throws IOException + */ + public void sendEvent(RequestBody body, RequestListener listener) + throws JsonGenerationException, JsonMappingException, IOException { + MultipartContentProvider multipartContent = new MultipartContentProvider(); + multipartContent.addPart(METADATA_NAME, createMetadataContent(body)); + + enqueueRequest( + new AVSRequest(Resource.EVENTS, multipartContent, new LinearRetryPolicy(REQUEST_RETRY_DELAY_MS, REQUEST_ATTEMPTS), requestResponseParser, listener)); + } + + /** + * Send a speech recognition event with a {@link RequestBody}. + * + * @param body + * @param inputStream + * @param listener + * @param audiotype + * @throws IOException + */ + public void sendEvent(RequestBody body, InputStream inputStream, RequestListener listener, + AudioInputFormat audiotype) + throws JsonGenerationException, JsonMappingException, IOException { + + AudioInputStreamContentProvider audioContent = + new AudioInputStreamContentProvider(audiotype, inputStream); + + CachingContentProvider cachableContent = new CachingContentProvider(audioContent); + + MultipartContentProvider multipartContent = new MultipartContentProvider(); + multipartContent.addPart(METADATA_NAME, createMetadataContent(body)); + multipartContent.addPart(AUDIO_NAME, cachableContent); + + enqueueRequest( + new AVSRequest(Resource.EVENTS, multipartContent, new LinearRetryPolicy(REQUEST_RETRY_DELAY_MS, REQUEST_ATTEMPTS), requestResponseParser, listener)); + } + + private StringContentProvider createMetadataContent(RequestBody body) + throws JsonGenerationException, JsonMappingException, IOException { + ObjectWriter writer = ObjectMapperFactory.getObjectWriter(); + log.info("Request metadata: \n{}", + writer.withDefaultPrettyPrinter().writeValueAsString(body)); + String metadata = writer.writeValueAsString(body); + StringContentProvider metadataContent = + new StringContentProvider(ContentTypes.JSON, metadata, StandardCharsets.UTF_8); + return metadataContent; + } + + private void enqueueRequest(AVSRequest request) { + if (!requestQueue.offer(request)) { + log.error("Failed to enqueue request"); + } + } + + private static Optional getHeaderParameter(final String headerValue, final String key) { + if ((headerValue == null) || (key == null)) { + return Optional.ofNullable(null); + } + + String[] parts = headerValue.split(";"); + for (String part : parts) { + part = part.trim(); + if (part.startsWith(key)) { + return Optional + .of(part.substring(key.length() + 1).replaceAll("(^\")|(\"$)", "").trim()); + } + } + + return Optional.ofNullable(null); + } + + /** + * Set the access token to use for all requests to AVS. + * + * @param accessToken + */ + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + startRequestThread(); + startDownchannelThread(); + } + + void startRequestThread() { + if (!requestThread.isAlive()) { + requestThread.start(); + } + } + + void startDownchannelThread() { + if (downchannelThread != null) { + downchannelThread.shutdownGracefully(); + } + + downchannelThread = new DownchannelRequestThread(); + downchannelThread.start(); + } + + /** + * When the application shuts down make sure to clean up the HTTPClient + */ + public void shutdown() { + try { + downchannelThread.shutdownGracefully(); + httpClient.stop(); + } catch (Exception e) { + } + } + + /** + * Thread for handling the long-lived response from the server for the downchannel communication + * of directives. + */ + private class DownchannelRequestThread extends Thread { + private boolean running = true; + + public DownchannelRequestThread() { + setName(this.getClass().getSimpleName()); + } + + public void shutdownGracefully() { + downchannelParser.shutdownGracefully(); + running = false; + } + + @Override + public void run() { + openConnection(); + } + + private void openConnection() { + while (running) { + log.info("Establishing downchannel"); + AVSRequest avsRequest = + new AVSRequest(Resource.DIRECTIVES, null, new ExponentialRetryPolicy(REQUEST_RETRY_DELAY_MS, REQUEST_ATTEMPTS), downchannelParser); + doRequest(avsRequest); + log.info("Finishing downchannel"); + } + } + } + + private class RequestThread extends Thread { + private BlockingQueue queue; + + public RequestThread(BlockingQueue queue) { + this.queue = queue; + setName(this.getClass().getSimpleName()); + } + + @Override + public void run() { + while (true) { + try { + AVSRequest request = queue.take(); + doRequest(request); + request.getRequestListener().ifPresent(l -> l.onRequestSuccess()); + } catch (InterruptedException e) { + log.error("Exception in the request thread", e); + } + } + } + } + + private static class RequestException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public RequestException(Throwable cause) { + super(cause); + } + } + + public static class MalformedResponseException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public MalformedResponseException(String message, Throwable cause) { + super(message, cause); + } + + public MalformedResponseException(String message) { + super(message); + } + + public MalformedResponseException(Throwable cause) { + super(cause); + } + } + + @Override + public void onConnected() { + downchannelParser.onConnected(); + } + + @Override + public void onDisconnected() { + downchannelParser.onDisconnected(); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AVSClientFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AVSClientFactory.java new file mode 100644 index 00000000..7be609a4 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AVSClientFactory.java @@ -0,0 +1,28 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import com.amazon.alexa.avs.DirectiveEnqueuer; +import com.amazon.alexa.avs.config.DeviceConfig; + +import org.eclipse.jetty.util.ssl.SslContextFactory; + +public class AVSClientFactory { + private DeviceConfig config; + + public AVSClientFactory(DeviceConfig config) { + this.config = config; + } + + public AVSClient getAVSClient(DirectiveEnqueuer directiveEnqueuer, + ParsingFailedHandler parsingFailedHandler) throws Exception { + return new AVSClient(config.getAvsHost(), directiveEnqueuer, new SslContextFactory(), + parsingFailedHandler); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AbstractRetryPolicy.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AbstractRetryPolicy.java new file mode 100644 index 00000000..591d61a0 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AbstractRetryPolicy.java @@ -0,0 +1,50 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import java.util.concurrent.Callable; + +public abstract class AbstractRetryPolicy implements RetryPolicy { + private int maxAttempts; + + public AbstractRetryPolicy(int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + /** + * {@inheritDoc} + */ + @Override + public void tryCall(Callable callable, Class exception) + throws Exception { + int attempts = 0; + while (attempts < maxAttempts) { + try { + callable.call(); + break; + } catch (Exception e) { + attempts++; + if ((exception != null) && (exception.isAssignableFrom(e.getClass())) + && !(attempts >= maxAttempts)) { + Thread.sleep(getDelay(attempts)); + } else { + throw e; + } + } + } + } + + /** + * Get the expected delay in milliseconds. + * + * @param attempts + * @return + */ + protected abstract long getDelay(int attempts); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AudioInputStreamContentProvider.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AudioInputStreamContentProvider.java new file mode 100644 index 00000000..375d97e9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/AudioInputStreamContentProvider.java @@ -0,0 +1,33 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import com.amazon.alexa.avs.AudioInputFormat; + +import org.eclipse.jetty.client.api.ContentProvider; +import org.eclipse.jetty.client.util.InputStreamContentProvider; + +import java.io.InputStream; + +/** + * A {@link ContentProvider} that streams an InputStream in chunks with size provided by + * {@link AudioInputFormat#getChunkSizeBytes()}. + */ +public class AudioInputStreamContentProvider extends InputStreamContentProvider implements + ContentProvider.Typed { + + public AudioInputStreamContentProvider(AudioInputFormat audioType, InputStream stream) { + super(stream, audioType.getChunkSizeBytes()); + } + + @Override + public String getContentType() { + return ContentTypes.AUDIO; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/CachingContentProvider.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/CachingContentProvider.java new file mode 100644 index 00000000..743292ac --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/CachingContentProvider.java @@ -0,0 +1,75 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import org.eclipse.jetty.client.api.ContentProvider; + +import java.nio.ByteBuffer; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +/** + * Decorates a {@link ContentProvider} and adds caching behavior to allow for HTTP request retries. + */ +public class CachingContentProvider implements ContentProvider.Typed { + private ContentProvider contentProvider; + private CachingIterator cachingIterator; + + public CachingContentProvider(ContentProvider contentProvider) { + this.contentProvider = contentProvider; + } + + @Override + public long getLength() { + return contentProvider.getLength(); + } + + @Override + public Iterator iterator() { + if (cachingIterator == null) { + cachingIterator = new CachingIterator(contentProvider.iterator()); + return cachingIterator; + } else { + return cachingIterator.cache.iterator(); + } + } + + @Override + public String getContentType() { + if (contentProvider instanceof ContentProvider.Typed) { + return ((ContentProvider.Typed) contentProvider).getContentType(); + } + return null; + } + + /** + * Keeps a cache of ByteBuffers that come from the original iterator. + */ + public static class CachingIterator implements Iterator { + private Iterator originalIterator; + private List cache = new LinkedList(); + + public CachingIterator(Iterator originalIterator) { + this.originalIterator = originalIterator; + } + + @Override + public boolean hasNext() { + return originalIterator.hasNext(); + } + + @Override + public ByteBuffer next() { + ByteBuffer byteBuffer = originalIterator.next(); + cache.add(byteBuffer.duplicate()); + return byteBuffer; + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ContentTypes.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ContentTypes.java new file mode 100644 index 00000000..73338a58 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ContentTypes.java @@ -0,0 +1,16 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +public class ContentTypes { + public static final String MULTIPART_FORM_DATA = "multipart/form-data"; + public static final String JSON = "application/json"; + public static final String JSON_UTF8 = JSON + "; charset=UTF-8"; + public static final String AUDIO = "application/octet-stream"; +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ExponentialRetryPolicy.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ExponentialRetryPolicy.java new file mode 100644 index 00000000..a586e2e9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ExponentialRetryPolicy.java @@ -0,0 +1,32 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +/** + * Implements a {@link RetryPolicy} with an exponential backoff. + */ +public class ExponentialRetryPolicy extends AbstractRetryPolicy { + private long mulitiplier; + + public ExponentialRetryPolicy(long mulitiplier, int maxAttempts) { + super(maxAttempts); + this.mulitiplier = mulitiplier; + } + + @Override + protected long getDelay(int attempts) { + if (attempts == 0) { + return 0; + } + + attempts = Math.max(0, attempts - 1); + double exp = Math.pow(2, attempts); + return Math.round(exp * mulitiplier); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/HttpHeaders.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/HttpHeaders.java new file mode 100644 index 00000000..39f26b00 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/HttpHeaders.java @@ -0,0 +1,21 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +public class HttpHeaders { + public static final String CONTENT_TYPE = "Content-Type"; + public static final String CONTENT_DISPOSITION = "Content-Disposition"; + public static final String CONTENT_ID = "Content-ID"; + public static final String AUTHORIZATION = "Authorization"; + + public static class Parameters { + public static final String BOUNDARY = "boundary"; + public static final String CHARSET = "charset"; + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/LinearRetryPolicy.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/LinearRetryPolicy.java new file mode 100644 index 00000000..b063ae0a --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/LinearRetryPolicy.java @@ -0,0 +1,26 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +/** + * Implements a {@link RetryPolicy} with a linear backoff. + */ +public class LinearRetryPolicy extends AbstractRetryPolicy { + private long initialDelay; + + public LinearRetryPolicy(long initialDelay, int maxAttempts) { + super(maxAttempts); + this.initialDelay = initialDelay; + } + + @Override + protected long getDelay(int attempts) { + return attempts * initialDelay; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MessageParser.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MessageParser.java new file mode 100644 index 00000000..3106cad8 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MessageParser.java @@ -0,0 +1,51 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import com.amazon.alexa.avs.config.ObjectMapperFactory; +import com.amazon.alexa.avs.exception.AVSJsonProcessingException; +import com.amazon.alexa.avs.message.Message; + +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.map.ObjectReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class MessageParser { + private static final Logger log = LoggerFactory.getLogger(MessageParser.class); + + /** + * Parses a single valid Message in the given byte array + * + * @return Message if the bytes composed a valid Message + * @throws IOException + * Directive parsing failed + */ + protected Message parseServerMessage(byte[] bytes) throws IOException { + return parse(bytes, Message.class); + } + + protected T parse(byte[] bytes, Class clazz) throws IOException { + try { + ObjectReader reader = ObjectMapperFactory.getObjectReader(); + Object logBody = reader.withType(Object.class).readValue(bytes); + log.info("Response metadata: \n{}", ObjectMapperFactory + .getObjectWriter() + .withDefaultPrettyPrinter() + .writeValueAsString(logBody)); + return reader.withType(clazz).readValue(bytes); + } catch (JsonProcessingException e) { + String unparseable = new String(bytes, "UTF-8"); + throw new AVSJsonProcessingException( + String.format("Failed to parse a %1$s", clazz.getSimpleName()), e, unparseable); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MultipartContentProvider.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MultipartContentProvider.java new file mode 100644 index 00000000..f9a28deb --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MultipartContentProvider.java @@ -0,0 +1,193 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import org.eclipse.jetty.client.api.ContentProvider; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A {@link ContentProvider} that formats other {@link ContentProvider}s to conform to RFC 2388 + * [https://www.ietf.org/rfc/rfc2388.txt] on multipart/form-data. + */ +public class MultipartContentProvider implements ContentProvider.Typed { + static final String BOUNDARY = "__BOUNDARY__"; + static final String NEWLINE = "\r\n"; + static final String TWO_DASHES = "--"; + static final String START_DELIMITER = NEWLINE + TWO_DASHES + BOUNDARY + NEWLINE; + static final String END_DELIMITER = NEWLINE + TWO_DASHES + BOUNDARY + TWO_DASHES + NEWLINE; + static final String CONTENT_TYPE = ContentTypes.MULTIPART_FORM_DATA + "; boundary=" + BOUNDARY; + static final String PART_CONTENT_DISPOSITION_FORMAT = HttpHeaders.CONTENT_DISPOSITION + + ": form-data; name=\"%s\"" + NEWLINE; + static final String PART_CONTENT_TYPE_FORMAT = HttpHeaders.CONTENT_TYPE + ": %s" + NEWLINE; + + private String contentType; + private List parts = new ArrayList<>(); + + public MultipartContentProvider() { + this(CONTENT_TYPE); + } + + public MultipartContentProvider(String contentType) { + this.contentType = contentType; + } + + public void addPart(String name, ContentProvider.Typed contentProvider) { + addPart(name, contentProvider.getContentType(), contentProvider); + } + + public void addPart(String name, String contentType, ContentProvider contentProvider) { + PartContentProvider part = new PartContentProvider(contentProvider, contentType, name); + parts.add(part); + } + + @Override + public long getLength() { + long length = 0; + for (PartContentProvider part : parts) { + long subLength = part.getLength(); + if (subLength == -1) { + length = -1; + break; + } else { + length += subLength; + } + } + + if (length > -1) { + length += END_DELIMITER.length(); + } + + return length; + } + + @Override + public Iterator iterator() { + return new MultipartIterator(parts); + } + + @Override + public String getContentType() { + return contentType; + } + + private static class PartContentProvider implements ContentProvider { + private final ContentProvider contentProvider; + private final String contentType; + private final String name; + private final String middleBoundary; + + private PartContentProvider(ContentProvider contentProvider, String contentType, String name) { + this.contentProvider = contentProvider; + this.contentType = contentType; + this.name = name; + this.middleBoundary = getMiddleBoundary(); + } + + private String getMiddleBoundary() { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(START_DELIMITER); + stringBuilder.append(String.format(PART_CONTENT_DISPOSITION_FORMAT, name)); + stringBuilder.append(String.format(PART_CONTENT_TYPE_FORMAT, contentType)); + stringBuilder.append(NEWLINE); + return stringBuilder.toString(); + } + + private Iterator getMiddleBoundaryIterator() { + return Stream.of(ByteBuffer.wrap(middleBoundary.getBytes())).iterator(); + } + + @Override + public long getLength() { + long contentLength = contentProvider.getLength(); + if (contentLength > -1) { + return contentLength + middleBoundary.length(); + } else { + return -1; + } + } + + @Override + public Iterator iterator() { + List> iterators = Arrays.asList(getMiddleBoundaryIterator(), contentProvider.iterator()); + return new IteratorOfIterators<>(iterators); + } + } + + private static class MultipartIterator implements Iterator { + private IteratorOfIterators iteratorOfIterators; + + private MultipartIterator(List parts) { + List> iterators = new ArrayList<>(); + if (!parts.isEmpty()) { + iterators = parts.stream().map(Iterable::iterator).collect(Collectors.toList()); + } + iterators.add(getEndIterator()); + + iteratorOfIterators = new IteratorOfIterators<>(iterators); + } + + private Iterator getEndIterator() { + return Stream.of(ByteBuffer.wrap(END_DELIMITER.getBytes())).iterator(); + } + + @Override + public boolean hasNext() { + return iteratorOfIterators.hasNext(); + } + + @Override + public ByteBuffer next() { + return iteratorOfIterators.next(); + } + } + + private static class IteratorOfIterators implements Iterator { + private final Iterator> iterators; + private Iterator currentIterator; + + private IteratorOfIterators(List> listOfIterators) { + this.iterators = listOfIterators.iterator(); + } + + private Iterator findNextIterator() { + while (iterators.hasNext()) { + currentIterator = iterators.next(); + if (currentIterator.hasNext()) { + return currentIterator; + } + } + return null; + } + + private boolean doesCurrentIteratorHaveNext() { + return ((currentIterator != null) && currentIterator.hasNext()); + } + + @Override + public boolean hasNext() { + if (!doesCurrentIteratorHaveNext()) { + currentIterator = findNextIterator(); + } + + return doesCurrentIteratorHaveNext(); + } + + @Override + public T next() { + return currentIterator.next(); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MultipartParser.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MultipartParser.java new file mode 100644 index 00000000..ca4d0a7a --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/MultipartParser.java @@ -0,0 +1,159 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import com.amazon.alexa.avs.http.jetty.PingSendingHttpClientTransportOverHTTP2.ConnectionListener; +import com.amazon.alexa.avs.message.response.Directive; +import com.amazon.alexa.avs.message.response.ResponseBody; + +import org.apache.commons.fileupload.MultipartStream; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class MultipartParser extends MessageParser implements ConnectionListener { + private static final Logger log = LoggerFactory.getLogger(MultipartParser.class); + private static final int MULTIPART_BUFFER_SIZE = 512; + + private final MultipartParserConsumer consumer; + private final AtomicBoolean shutdown; + private MultipartStream multipartStream; + private Map headers; + + public MultipartParser(MultipartParserConsumer consumer) { + this.consumer = consumer; + this.shutdown = new AtomicBoolean(false); + } + + public void parseStream(InputStream inputStream, String boundary) throws IOException { + shutdown.set(false); + multipartStream = + new MultipartStream(inputStream, boundary.getBytes(), MULTIPART_BUFFER_SIZE, null); + headers = null; + + loopStream(); + } + + public void shutdownGracefully() { + shutdown.set(false); + } + + private ResponseBody parseResponseBody(byte[] bytes) throws IOException { + return parse(bytes, ResponseBody.class); + } + + private void loopStream() throws IOException { + try { + boolean hasNextPart = multipartStream.skipPreamble(); + while (hasNextPart) { + handlePart(); + hasNextPart = multipartStream.readBoundary(); + } + } catch (IOException e) { + if (!shutdown.get()) { + throw e; + } + } + } + + private void handlePart() throws IOException { + headers = getPartHeaders(); + byte[] partBytes = getPartBytes(); + boolean isMetadata = isPartJSON(headers); + + if (isMetadata) { + handleMetadata(partBytes); + } else { + handleAudio(partBytes); + } + } + + private void handleMetadata(byte[] partBytes) throws IOException { + Directive directive = parseResponseBody(partBytes).getDirective(); + if (directive != null) { + consumer.onDirective(directive); + } else { + log.error("Failed to parse a directive."); + } + } + + private void handleAudio(byte[] partBytes) { + String contentId = getMultipartContentId(headers); + InputStream attachmentContent = new ByteArrayInputStream(partBytes); + + consumer.onDirectiveAttachment(contentId, attachmentContent); + } + + private byte[] getPartBytes() throws IOException { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + multipartStream.readBodyData(data); + return data.toByteArray(); + } + + private Map getPartHeaders() throws IOException { + String headers = multipartStream.readHeaders(); + BufferedReader reader = new BufferedReader(new StringReader(headers)); + Map headerMap = new HashMap<>(); + try { + for (String line = reader.readLine(); line != null; line = reader.readLine()) { + line = line.trim(); + if (!StringUtils.isBlank(line) && line.contains(":")) { + int colon = line.indexOf(":"); + String headerName = line.substring(0, colon).trim(); + String headerValue = line.substring(colon + 1).trim(); + headerMap.put(headerName.toLowerCase(), headerValue); + } + } + } catch (Exception e) { + } + + return headerMap; + } + + private String getMultipartHeaderValue(Map headers, String searchHeader) { + return headers.get(searchHeader.toLowerCase()); + } + + private String getMultipartContentId(Map headers) { + String contentId = getMultipartHeaderValue(headers, HttpHeaders.CONTENT_ID); + contentId = contentId.substring(1, contentId.length() - 1); + return contentId; + } + + private boolean isPartJSON(Map headers) { + String contentType = getMultipartHeaderValue(headers, HttpHeaders.CONTENT_TYPE); + return StringUtils.contains(contentType, ContentTypes.JSON); + } + + public interface MultipartParserConsumer { + void onDirective(Directive directive); + + void onDirectiveAttachment(String contentId, InputStream attachmentContent); + } + + @Override + public void onConnected() { + shutdown.set(false); + } + + @Override + public void onDisconnected() { + shutdown.set(true); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ParsingFailedHandler.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ParsingFailedHandler.java new file mode 100644 index 00000000..24c2dc26 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/ParsingFailedHandler.java @@ -0,0 +1,13 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +public interface ParsingFailedHandler { + void onParsingFailed(String unparseable); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/RetryPolicy.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/RetryPolicy.java new file mode 100644 index 00000000..a6b749ce --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/RetryPolicy.java @@ -0,0 +1,28 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http; + +import java.util.concurrent.Callable; + +/** + * A policy for describing how an action should be retried. + */ +public interface RetryPolicy { + /** + * Attempt to execute the {@link Callable}, and retry using the logic of the concrete + * implementation of this interface if we receive an Exception of type exception. + * + * @param callable + * The {@link Callable} to call on each attempt. + * @param exception + * The type of {@link Exception} to cause a retry. + * @throws Exception + */ + void tryCall(Callable callable, Class exception) throws Exception; +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/jetty/InputStreamResponseListener.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/jetty/InputStreamResponseListener.java new file mode 100644 index 00000000..908d482d --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/jetty/InputStreamResponseListener.java @@ -0,0 +1,417 @@ +/** + * Portions of this file were modified by Amazon as indicated in the code. + * The Amazon modifications are copyright 2015-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * The Amazon modifications are subject to the License as defined in the LICENSE.txt file accompanying this source. You may not use this file as a whole except in compliance with the License. A link to the License is located in LICENSE.txt. + * + * This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + * + * The below copyright and license statements apply to the portions of this file other than the Amazon modifications. + */ +// +// ======================================================================== +// Copyright (c) 1995-2015 Mort Bay Consulting Pty. Ltd. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + +package com.amazon.alexa.avs.http.jetty; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.client.api.Response.Listener; +import org.eclipse.jetty.client.api.Result; +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.log.Log; +import org.eclipse.jetty.util.log.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.nio.ByteBuffer; +import java.nio.channels.AsynchronousCloseException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Implementation of {@link Listener} that produces an {@link InputStream} + * that allows applications to read the response content. + *

+ * Typical usage is: + *

+ * InputStreamResponseListener listener = new InputStreamResponseListener();
+ * client.newRequest(...).send(listener);
+ *
+ * // Wait for the response headers to arrive
+ * Response response = listener.get(5, TimeUnit.SECONDS);
+ * if (response.getStatus() == 200)
+ * {
+ *     // Obtain the input stream on the response content
+ *     try (InputStream input = listener.getInputStream())
+ *     {
+ *         // Read the response content
+ *     }
+ * }
+ * 
+ *

+ * The {@link HttpClient} implementation (the producer) will feed the input stream + * asynchronously while the application (the consumer) is reading from it. + * Chunks of content are maintained in a queue, and it is possible to specify a + * maximum buffer size for the bytes held in the queue, by default 16384 bytes. + *

+ * If the consumer is faster than the producer, then the consumer will block + * with the typical {@link InputStream#read()} semantic. + * If the consumer is slower than the producer, then the producer will block + * until the client consumes. + */ +public class InputStreamResponseListener extends Listener.Adapter +{ + private static final Logger LOG = Log.getLogger(InputStreamResponseListener.class); + private static final byte[] EOF = new byte[0]; + private static final byte[] CLOSED = new byte[0]; + private static final byte[] FAILURE = new byte[0]; + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final AtomicLong length = new AtomicLong(); + private final CountDownLatch responseLatch = new CountDownLatch(1); + private final CountDownLatch resultLatch = new CountDownLatch(1); + private final AtomicReference stream = new AtomicReference<>(); + private final long maxBufferSize; + private Response response; + private Result result; + private volatile Throwable failure; + private volatile boolean closed; + + public InputStreamResponseListener() + { + this(16 * 1024L); + } + + public InputStreamResponseListener(long maxBufferSize) + { + this.maxBufferSize = maxBufferSize; + } + + @Override + public void onHeaders(Response response) + { + this.response = response; + responseLatch.countDown(); + } + + @Override + public void onContent(Response response, ByteBuffer content) + { + if (!closed) + { + int remaining = content.remaining(); + if (remaining > 0) + { + + byte[] bytes = new byte[remaining]; + content.get(bytes); + if (LOG.isDebugEnabled()) { + LOG.debug("Queuing {}/{} bytes", bytes, remaining); + } + queue.offer(bytes); + + long newLength = length.addAndGet(remaining); + while (newLength >= maxBufferSize) + { + if (LOG.isDebugEnabled()) { + LOG.debug("Queued bytes limit {}/{} exceeded, waiting", newLength, maxBufferSize); + } + // Block to avoid infinite buffering + if (!await()) { + break; + } + newLength = length.get(); + if (LOG.isDebugEnabled()) { + LOG.debug("Queued bytes limit {}/{} exceeded, woken up", newLength, maxBufferSize); + } + } + } + else + { + if (LOG.isDebugEnabled()) { + LOG.debug("Queuing skipped, empty content {}", content); + } + } + } + else + { + LOG.debug("Queuing skipped, stream already closed"); + } + } + + @Override + public void onSuccess(Response response) + { + if (LOG.isDebugEnabled()) { + LOG.debug("Queuing end of content {}{}", EOF, ""); + } + queue.offer(EOF); + signal(); + } + + @Override + public void onFailure(Response response, Throwable failure) + { + fail(failure); + signal(); + } + + @Override + public void onComplete(Result result) + { + if (result.isFailed() && (failure == null)) { + fail(result.getFailure()); + } + this.result = result; + resultLatch.countDown(); + signal(); + } + + private void fail(Throwable failure) + { + if (LOG.isDebugEnabled()) { + LOG.debug("Queuing failure {} {}", FAILURE, failure); + } + queue.offer(FAILURE); + this.failure = failure; + responseLatch.countDown(); + } + + protected boolean await() + { + try + { + synchronized (this) + { + while ((length.get() >= maxBufferSize) && (failure == null) && !closed) { + wait(); + } + // Re-read the values as they may have changed while waiting. + return (failure == null) && !closed; + } + } + catch (InterruptedException x) + { + Thread.currentThread().interrupt(); + return false; + } + } + + protected void signal() + { + synchronized (this) + { + notifyAll(); + } + } + + /** + * Waits for the given timeout for the response to be available, then returns it. + *

+ * The wait ends as soon as all the HTTP headers have been received, without waiting for the content. + * To wait for the whole content, see {@link #await(long, TimeUnit)}. + * + * @param timeout the time to wait + * @param unit the timeout unit + * @return the response + * @throws InterruptedException if the thread is interrupted + * @throws TimeoutException if the timeout expires + * @throws ExecutionException if a failure happened + */ + public Response get(long timeout, TimeUnit unit) throws InterruptedException, TimeoutException, ExecutionException + { + boolean expired = !responseLatch.await(timeout, unit); + if (expired) { + throw new TimeoutException(); + } + if (failure != null) { + throw new ExecutionException(failure); + } + return response; + } + + /** + * Waits for the given timeout for the whole request/response cycle to be finished, + * then returns the corresponding result. + *

+ * + * @param timeout the time to wait + * @param unit the timeout unit + * @return the result + * @throws InterruptedException if the thread is interrupted + * @throws TimeoutException if the timeout expires + * @see #get(long, TimeUnit) + */ + public Result await(long timeout, TimeUnit unit) throws InterruptedException, TimeoutException + { + boolean expired = !resultLatch.await(timeout, unit); + if (expired) { + throw new TimeoutException(); + } + return result; + } + + /** + * Returns an {@link InputStream} providing the response content bytes. + *

+ * The method may be invoked only once; subsequent invocations will return a closed {@link InputStream}. + * + * @return an input stream providing the response content + */ + public InputStream getInputStream() + { + InputStream result = new Input(); + if (stream.compareAndSet(null, result)) { + return result; + } + return IO.getClosedStream(); + } + + private class Input extends InputStream + { + private byte[] bytes; + private int index; + + @Override + public int read() throws IOException + { + while (true) + { + if (bytes == EOF) + { + // Mark the fact that we saw -1, + // so that in the close case we don't throw + index = -1; + return -1; + } + else if (bytes == FAILURE) + { + throw failure(); + } + else if (bytes == CLOSED) + { + if (index < 0) { + return -1; + } + throw new AsynchronousCloseException(); + } + else if (bytes != null) + { + int result = bytes[index] & 0xFF; + if (++index == bytes.length) + { + length.addAndGet(-index); + bytes = null; + index = 0; + signal(); + } + return result; + } + else + { + bytes = take(); + if (LOG.isDebugEnabled()) { + LOG.debug("Dequeued {}/{} bytes", bytes, bytes.length); + } + } + } + } + + // START AMAZON CHANGES + @Override + public int read(byte buffer[], int offset, int length) throws IOException { + if (buffer == null) { + throw new NullPointerException(); + } else if ((offset < 0) || (length < 0) || (length > (buffer.length - offset))) { + throw new IndexOutOfBoundsException(); + } else if (length == 0) { + return 0; + } + + // Contract specifies must attempt to read at least one byte. If the stream is at end of file: the value -1 is returned + int singleByte = read(); + if (singleByte == -1) { + return -1; + } + buffer[offset] = (byte)singleByte; + + int bytesWritten = 1; + try { + while (bytesWritten < length) { + singleByte = read(); + if (singleByte == -1) { + break; + } + buffer[offset + bytesWritten] = (byte)singleByte; + bytesWritten++; + + if (queue.isEmpty()) { + break; + } + } + } catch (IOException ee) { + } + return bytesWritten; + } + // END AMAZON CHANGES + + private IOException failure() + { + if (failure instanceof IOException) { + return (IOException)failure; + } else { + return new IOException(failure); + } + } + + private byte[] take() throws IOException + { + try + { + return queue.take(); + } + catch (InterruptedException x) + { + throw new InterruptedIOException(); + } + } + + @Override + public void close() throws IOException + { + if (!closed) + { + super.close(); + if (LOG.isDebugEnabled()) { + LOG.debug("Queuing close {}{}", CLOSED, ""); + } + queue.offer(CLOSED); + closed = true; + signal(); + } + } + } +} \ No newline at end of file diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/jetty/PingSendingHttpClientTransportOverHTTP2.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/jetty/PingSendingHttpClientTransportOverHTTP2.java new file mode 100644 index 00000000..27e72bca --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/http/jetty/PingSendingHttpClientTransportOverHTTP2.java @@ -0,0 +1,108 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.http.jetty; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpDestination; +import org.eclipse.jetty.client.Origin; +import org.eclipse.jetty.client.api.Connection; +import org.eclipse.jetty.http2.api.Session; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.http.HttpClientTransportOverHTTP2; +import org.eclipse.jetty.http2.client.http.HttpConnectionOverHTTP2; +import org.eclipse.jetty.http2.client.http.HttpDestinationOverHTTP2; +import org.eclipse.jetty.http2.frames.PingFrame; +import org.eclipse.jetty.util.Callback; + +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Sends an HTTP/2 PING frame every 5 minutes after a connection is opened. + */ +public class PingSendingHttpClientTransportOverHTTP2 extends HttpClientTransportOverHTTP2 { + private static final int PING_INTERVAL_IN_MINUTES = 5; + private static final int INITIAL_PING_DELAY_IN_MINUTES = PING_INTERVAL_IN_MINUTES; + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private Optional connectionListener = Optional.empty(); + private HttpClient httpClient; + + public PingSendingHttpClientTransportOverHTTP2(HTTP2Client client, ConnectionListener connectionListener) { + super(client); + this.connectionListener = Optional.ofNullable(connectionListener); + } + + @Override + public void setHttpClient(HttpClient client) { + super.setHttpClient(client); + httpClient = client; + } + + @Override + protected HttpConnectionOverHTTP2 newHttpConnection(HttpDestination destination, Session session) { + scheduler.scheduleAtFixedRate(new ServerPing(session), INITIAL_PING_DELAY_IN_MINUTES, + PING_INTERVAL_IN_MINUTES, TimeUnit.MINUTES); + return super.newHttpConnection(destination, session); + } + + @Override + public HttpDestination newHttpDestination(Origin origin) { + return new ConnectionStatusHttpDestinationOverHTTP2(httpClient, origin); + } + + /** + * A {@link HttpDestinationOverHTTP2} to let the listener know when the connection is opened or closed. + */ + public class ConnectionStatusHttpDestinationOverHTTP2 extends HttpDestinationOverHTTP2 { + public ConnectionStatusHttpDestinationOverHTTP2(HttpClient client, Origin origin) { + super(client, origin); + } + + @Override + public void close(Connection connection) { + super.close(connection); + connectionListener.ifPresent(l -> l.onDisconnected()); + } + + @Override + public void succeeded(Connection connection) { + super.succeeded(connection); + connectionListener.ifPresent(l -> l.onConnected()); + } + } + + /** + * Task to send a PING frame over an open HTTP/2 Session. + */ + private static class ServerPing implements Runnable { + private Session session; + + private ServerPing(Session session) { + this.session = session; + } + + @Override + public void run() { + if (!session.isClosed()) { + PingFrame frame = new PingFrame(false); + session.ping(frame, Callback.NOOP); + } + } + } + + /** + * Listener to inform others of the connection being opened or closed. + */ + public interface ConnectionListener { + void onConnected(); + void onDisconnected(); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/DialogRequestIdHeader.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/DialogRequestIdHeader.java new file mode 100644 index 00000000..eb7354a5 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/DialogRequestIdHeader.java @@ -0,0 +1,37 @@ + +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message; + +public class DialogRequestIdHeader extends MessageIdHeader { + + private String dialogRequestId; + + public DialogRequestIdHeader() { + // For Jackson + } + + public DialogRequestIdHeader(String namespace, String name, String dialogRequestId) { + super(namespace, name); + this.dialogRequestId = dialogRequestId; + } + + public final void setDialogRequestId(String dialogRequestId) { + this.dialogRequestId = dialogRequestId; + } + + public final String getDialogRequestId() { + return dialogRequestId; + } + + @Override + public String toString() { + return String.format("%1$s dialogRequestId:%2$s", super.toString(), dialogRequestId); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Header.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Header.java new file mode 100644 index 00000000..618f8eab --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Header.java @@ -0,0 +1,50 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message; + +public class Header { + private String namespace; + private String name; + + public Header() { + // For Jackson + } + + public Header(String namespace, String name) { + setNamespace(namespace); + setName(name); + } + + public final void setNamespace(String namespace) { + if (namespace == null) { + throw new IllegalArgumentException("Header namespace must not be null"); + } + this.namespace = namespace; + } + + public final void setName(String name) { + if (name == null) { + throw new IllegalArgumentException("Header name must not be null"); + } + this.name = name; + } + + public final String getNamespace() { + return namespace; + } + + public final String getName() { + return name; + } + + @Override + public String toString() { + return String.format("%1$s:%2$s", namespace, name); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Message.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Message.java new file mode 100644 index 00000000..ce6995e9 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Message.java @@ -0,0 +1,152 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message; + +import com.amazon.alexa.avs.AVSAPIConstants; +import com.amazon.alexa.avs.config.ObjectMapperFactory; +import com.amazon.alexa.avs.message.Message.MessageDeserializer; +import com.amazon.alexa.avs.message.response.AlexaExceptionResponse; +import com.amazon.alexa.avs.message.response.Directive; + +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.JsonParseException; +import org.codehaus.jackson.JsonParser; +import org.codehaus.jackson.JsonProcessingException; +import org.codehaus.jackson.annotate.JsonIgnore; +import org.codehaus.jackson.map.DeserializationContext; +import org.codehaus.jackson.map.JsonDeserializer; +import org.codehaus.jackson.map.JsonMappingException; +import org.codehaus.jackson.map.ObjectReader; +import org.codehaus.jackson.map.annotate.JsonDeserialize; +import org.codehaus.jackson.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Map.Entry; + +/** + * A message from the server. Can be an + * {@link com.amazon.alexa.avs.message.response.system.Exception Exception}, + * {@link com.amazon.alexa.avs.message.request.Event Event} , or {@link Directive} + */ +@JsonDeserialize(using = MessageDeserializer.class) +public abstract class Message { + protected Header header; + protected Payload payload; + + @JsonIgnore + private String rawMessage; + + protected Message(Header header, JsonNode payload, String rawMessage) + throws JsonParseException, JsonMappingException, IOException { + + this.header = header; + try { + ObjectReader reader = ObjectMapperFactory.getObjectReader(); + Class type = Class.forName(getClass().getPackage().getName() + "." + + header.getNamespace().toLowerCase() + "." + header.getName()); + this.payload = (Payload) reader.withType(type).readValue(payload); + } catch (ClassNotFoundException e) { + // Default to empty payload + this.payload = new Payload(); + } + + this.rawMessage = rawMessage; + } + + protected Message(Header header, Payload payload, String rawMessage) { + this.header = header; + this.payload = payload; + this.rawMessage = rawMessage; + } + + @JsonIgnore + public String getName() { + return header.getName(); + } + + @JsonIgnore + public String getNamespace() { + return header.getNamespace(); + } + + public void setHeader(Header header) { + this.header = header; + } + + public Header getHeader() { + return header; + } + + public void setPayload(Payload payload) { + this.payload = payload; + } + + public Payload getPayload() { + return payload; + } + + public String getRawMessage() { + return rawMessage; + } + + @Override + public String toString() { + return header.toString(); + } + + public static class MessageDeserializer extends JsonDeserializer { + private static final Logger log = LoggerFactory.getLogger(MessageDeserializer.class); + + @Override + public Message deserialize(JsonParser jp, DeserializationContext ctx) + throws IOException, JsonProcessingException { + ObjectReader reader = ObjectMapperFactory.getObjectReader(); + ObjectNode obj = (ObjectNode) reader.readTree(jp); + Iterator> elementsIterator = obj.getFields(); + + String rawMessage = obj.toString(); + + DialogRequestIdHeader header = null; + JsonNode payloadNode = null; + ObjectReader headerReader = + ObjectMapperFactory.getObjectReader(DialogRequestIdHeader.class); + while (elementsIterator.hasNext()) { + Entry element = elementsIterator.next(); + if (element.getKey().equals("header")) { + header = headerReader.readValue(element.getValue()); + } + if (element.getKey().equals("payload")) { + payloadNode = element.getValue(); + } + } + if (header == null) { + throw ctx.mappingException("Missing header"); + } + if (payloadNode == null) { + throw ctx.mappingException("Missing payload"); + } + + return createMessage(header, payloadNode, rawMessage); + } + + private Message createMessage(Header header, JsonNode payload, String rawMessage) + throws JsonParseException, JsonMappingException, IOException { + if (AVSAPIConstants.System.NAMESPACE.equals(header.getNamespace()) + && AVSAPIConstants.System.Exception.NAME.equals(header.getName())) { + return new AlexaExceptionResponse(header, payload, rawMessage); + } else { + return new Directive(header, payload, rawMessage); + } + } + + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/MessageIdHeader.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/MessageIdHeader.java new file mode 100644 index 00000000..72b5f540 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/MessageIdHeader.java @@ -0,0 +1,38 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +package com.amazon.alexa.avs.message; + +import java.util.UUID; + +public class MessageIdHeader extends Header { + private String messageId; + + public MessageIdHeader() { + // For Jackson + } + + public MessageIdHeader(String namespace, String name) { + super(namespace, name); + this.messageId = UUID.randomUUID().toString(); + } + + public final void setMessageId(String messageId) { + this.messageId = messageId; + } + + public final String getMessageId() { + return messageId; + } + + @Override + public String toString() { + return String.format("%1$s id:%2$s", super.toString(), messageId); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Payload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Payload.java new file mode 100644 index 00000000..9e31b7d1 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/Payload.java @@ -0,0 +1,16 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message; + +import org.codehaus.jackson.annotate.JsonAutoDetect; + +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.NONE) +public class Payload { + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/ComponentStateFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/ComponentStateFactory.java new file mode 100644 index 00000000..059578a5 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/ComponentStateFactory.java @@ -0,0 +1,40 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request; + +import com.amazon.alexa.avs.AVSAPIConstants; +import com.amazon.alexa.avs.message.Header; +import com.amazon.alexa.avs.message.request.context.AlertsStatePayload; +import com.amazon.alexa.avs.message.request.context.ComponentState; +import com.amazon.alexa.avs.message.request.context.PlaybackStatePayload; +import com.amazon.alexa.avs.message.request.context.SpeechStatePayload; +import com.amazon.alexa.avs.message.request.context.VolumeStatePayload; + +public class ComponentStateFactory { + + public static ComponentState createPlaybackState(PlaybackStatePayload playerState) { + return new ComponentState(new Header(AVSAPIConstants.AudioPlayer.NAMESPACE, + AVSAPIConstants.AudioPlayer.Events.PlaybackState.NAME), playerState); + } + + public static ComponentState createSpeechState(SpeechStatePayload speechState) { + return new ComponentState(new Header(AVSAPIConstants.SpeechSynthesizer.NAMESPACE, + AVSAPIConstants.SpeechSynthesizer.Events.SpeechState.NAME), speechState); + } + + public static ComponentState createAlertState(AlertsStatePayload alertState) { + return new ComponentState(new Header(AVSAPIConstants.Alerts.NAMESPACE, + AVSAPIConstants.Alerts.Events.AlertsState.NAME), alertState); + } + + public static ComponentState createVolumeState(VolumeStatePayload volumeState) { + return new ComponentState(new Header(AVSAPIConstants.Speaker.NAMESPACE, + AVSAPIConstants.Speaker.Events.VolumeState.NAME), volumeState); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/ContextEventRequestBody.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/ContextEventRequestBody.java new file mode 100644 index 00000000..8d418693 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/ContextEventRequestBody.java @@ -0,0 +1,29 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +package com.amazon.alexa.avs.message.request; + +import com.amazon.alexa.avs.message.request.context.ComponentState; + +import java.util.List; + +public class ContextEventRequestBody extends RequestBody { + + private final List context; + + public ContextEventRequestBody(List context, Event event) { + super(event); + this.context = context; + } + + public final List getContext() { + return context; + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/Event.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/Event.java new file mode 100644 index 00000000..c072a008 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/Event.java @@ -0,0 +1,25 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request; + +import com.amazon.alexa.avs.message.Header; +import com.amazon.alexa.avs.message.Message; +import com.amazon.alexa.avs.message.Payload; + +import org.apache.commons.lang3.StringUtils; + +/** + * A message from the client to the server + */ +public class Event extends Message { + + public Event(Header header, Payload payload) { + super(header, payload, StringUtils.EMPTY); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/RequestBody.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/RequestBody.java new file mode 100644 index 00000000..d52f51d7 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/RequestBody.java @@ -0,0 +1,23 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +package com.amazon.alexa.avs.message.request; + +public class RequestBody { + + private final Event event; + + public RequestBody(Event event) { + this.event = event; + } + + public final Event getEvent() { + return event; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/RequestFactory.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/RequestFactory.java new file mode 100644 index 00000000..f38641a2 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/RequestFactory.java @@ -0,0 +1,300 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request; + +import com.amazon.alexa.avs.AVSAPIConstants; +import com.amazon.alexa.avs.SpeechProfile; +import com.amazon.alexa.avs.exception.DirectiveHandlingException.ExceptionType; +import com.amazon.alexa.avs.message.DialogRequestIdHeader; +import com.amazon.alexa.avs.message.Header; +import com.amazon.alexa.avs.message.MessageIdHeader; +import com.amazon.alexa.avs.message.Payload; +import com.amazon.alexa.avs.message.request.alerts.AlertPayload; +import com.amazon.alexa.avs.message.request.audioplayer.AudioPlayerPayload; +import com.amazon.alexa.avs.message.request.audioplayer.PlaybackFailedPayload; +import com.amazon.alexa.avs.message.request.audioplayer.PlaybackFailedPayload.ErrorType; +import com.amazon.alexa.avs.message.request.audioplayer.PlaybackStutterFinishedPayload; +import com.amazon.alexa.avs.message.request.context.AlertsStatePayload; +import com.amazon.alexa.avs.message.request.context.ComponentState; +import com.amazon.alexa.avs.message.request.context.PlaybackStatePayload; +import com.amazon.alexa.avs.message.request.context.SpeechStatePayload; +import com.amazon.alexa.avs.message.request.context.VolumeStatePayload; +import com.amazon.alexa.avs.message.request.speechrecognizer.SpeechRecognizerPayload; +import com.amazon.alexa.avs.message.request.speechsynthesizer.SpeechLifecyclePayload; +import com.amazon.alexa.avs.message.request.system.ExceptionEncounteredPayload; +import com.amazon.alexa.avs.message.request.system.UserInactivityReportPayload; + +import java.util.Arrays; +import java.util.List; + +public class RequestFactory { + + public interface Request { + RequestBody withPlaybackStatePayload(PlaybackStatePayload state); + } + + public static RequestBody createSpeechRegonizerRecognizeRequest(String dialogRequestId, + SpeechProfile profile, String format, PlaybackStatePayload playerState, + SpeechStatePayload speechState, AlertsStatePayload alertState, + VolumeStatePayload volumeState) { + SpeechRecognizerPayload payload = new SpeechRecognizerPayload(profile, format); + Header header = new DialogRequestIdHeader(AVSAPIConstants.SpeechRecognizer.NAMESPACE, + AVSAPIConstants.SpeechRecognizer.Events.Recognize.NAME, dialogRequestId); + Event event = new Event(header, payload); + return createRequestWithAllState(event, playerState, speechState, alertState, volumeState); + } + + public static RequestBody createAudioPlayerPlaybackStartedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent(AVSAPIConstants.AudioPlayer.Events.PlaybackStarted.NAME, + streamToken, offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackNearlyFinishedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent( + AVSAPIConstants.AudioPlayer.Events.PlaybackNearlyFinished.NAME, streamToken, + offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackStutterStartedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent( + AVSAPIConstants.AudioPlayer.Events.PlaybackStutterStarted.NAME, streamToken, + offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackStutterFinishedEvent(String streamToken, + long offsetInMilliseconds, long stutterDurationInMilliseconds) { + Header header = new MessageIdHeader(AVSAPIConstants.AudioPlayer.NAMESPACE, + AVSAPIConstants.AudioPlayer.Events.PlaybackStutterFinished.NAME); + Event event = new Event(header, new PlaybackStutterFinishedPayload(streamToken, + offsetInMilliseconds, stutterDurationInMilliseconds)); + return new RequestBody(event); + } + + public static RequestBody createAudioPlayerPlaybackFinishedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent(AVSAPIConstants.AudioPlayer.Events.PlaybackFinished.NAME, + streamToken, offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackStoppedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent(AVSAPIConstants.AudioPlayer.Events.PlaybackStopped.NAME, + streamToken, offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackPausedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent(AVSAPIConstants.AudioPlayer.Events.PlaybackPaused.NAME, + streamToken, offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackResumedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent(AVSAPIConstants.AudioPlayer.Events.PlaybackResumed.NAME, + streamToken, offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerPlaybackQueueClearedEvent() { + Header header = new MessageIdHeader(AVSAPIConstants.AudioPlayer.NAMESPACE, + AVSAPIConstants.AudioPlayer.Events.PlaybackQueueCleared.NAME); + Event event = new Event(header, new Payload()); + return new RequestBody(event); + } + + public static RequestBody createAudioPlayerPlaybackFailedEvent(String streamToken, + PlaybackStatePayload playbackStatePayload, ErrorType errorType) { + Header header = new MessageIdHeader(AVSAPIConstants.AudioPlayer.NAMESPACE, + AVSAPIConstants.AudioPlayer.Events.PlaybackFailed.NAME); + Event event = new Event(header, + new PlaybackFailedPayload(streamToken, playbackStatePayload, errorType)); + return new RequestBody(event); + } + + public static RequestBody createAudioPlayerProgressReportDelayElapsedEvent(String streamToken, + long offsetInMilliseconds) { + return createAudioPlayerEvent( + AVSAPIConstants.AudioPlayer.Events.ProgressReportDelayElapsed.NAME, streamToken, + offsetInMilliseconds); + } + + public static RequestBody createAudioPlayerProgressReportIntervalElapsedEvent( + String streamToken, long offsetInMilliseconds) { + return createAudioPlayerEvent( + AVSAPIConstants.AudioPlayer.Events.ProgressReportIntervalElapsed.NAME, streamToken, + offsetInMilliseconds); + } + + private static RequestBody createAudioPlayerEvent(String name, String streamToken, + long offsetInMilliseconds) { + Header header = new MessageIdHeader(AVSAPIConstants.AudioPlayer.NAMESPACE, name); + Payload payload = new AudioPlayerPayload(streamToken, offsetInMilliseconds); + Event event = new Event(header, payload); + return new RequestBody(event); + } + + public static RequestBody createPlaybackControllerNextEvent(PlaybackStatePayload playbackState, + SpeechStatePayload speechState, AlertsStatePayload alertState, + VolumeStatePayload volumeState) { + return createPlaybackControllerEvent( + AVSAPIConstants.PlaybackController.Events.NextCommandIssued.NAME, playbackState, + speechState, alertState, volumeState); + } + + public static RequestBody createPlaybackControllerPreviousEvent( + PlaybackStatePayload playbackState, SpeechStatePayload speechState, + AlertsStatePayload alertState, VolumeStatePayload volumeState) { + return createPlaybackControllerEvent( + AVSAPIConstants.PlaybackController.Events.PreviousCommandIssued.NAME, playbackState, + speechState, alertState, volumeState); + } + + public static RequestBody createPlaybackControllerPlayEvent(PlaybackStatePayload playbackState, + SpeechStatePayload speechState, AlertsStatePayload alertState, + VolumeStatePayload volumeState) { + return createPlaybackControllerEvent( + AVSAPIConstants.PlaybackController.Events.PlayCommandIssued.NAME, playbackState, + speechState, alertState, volumeState); + } + + public static RequestBody createPlaybackControllerPauseEvent(PlaybackStatePayload playbackState, + SpeechStatePayload speechState, AlertsStatePayload alertState, + VolumeStatePayload volumeState) { + return createPlaybackControllerEvent( + AVSAPIConstants.PlaybackController.Events.PauseCommandIssued.NAME, playbackState, + speechState, alertState, volumeState); + } + + private static RequestBody createPlaybackControllerEvent(String name, + PlaybackStatePayload playbackState, SpeechStatePayload speechState, + AlertsStatePayload alertState, VolumeStatePayload volumeState) { + Header header = new MessageIdHeader(AVSAPIConstants.PlaybackController.NAMESPACE, name); + Event event = new Event(header, new Payload()); + return createRequestWithAllState(event, playbackState, speechState, alertState, + volumeState); + } + + public static RequestBody createSpeechSynthesizerSpeechStartedEvent(String speakToken) { + return createSpeechSynthesizerEvent( + AVSAPIConstants.SpeechSynthesizer.Events.SpeechStarted.NAME, speakToken); + } + + public static RequestBody createSpeechSynthesizerSpeechFinishedEvent(String speakToken) { + return createSpeechSynthesizerEvent( + AVSAPIConstants.SpeechSynthesizer.Events.SpeechFinished.NAME, speakToken); + } + + private static RequestBody createSpeechSynthesizerEvent(String name, String speakToken) { + Header header = new MessageIdHeader(AVSAPIConstants.SpeechSynthesizer.NAMESPACE, name); + Event event = new Event(header, new SpeechLifecyclePayload(speakToken)); + return new RequestBody(event); + } + + public static RequestBody createAlertsSetAlertEvent(String alertToken, boolean success) { + if (success) { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.SetAlertSucceeded.NAME, + alertToken); + } else { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.SetAlertFailed.NAME, alertToken); + } + } + + public static RequestBody createAlertsDeleteAlertEvent(String alertToken, boolean success) { + if (success) { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.DeleteAlertSucceeded.NAME, + alertToken); + } else { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.DeleteAlertFailed.NAME, + alertToken); + } + } + + public static RequestBody createAlertsAlertStartedEvent(String alertToken) { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.AlertStarted.NAME, alertToken); + } + + public static RequestBody createAlertsAlertStoppedEvent(String alertToken) { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.AlertStopped.NAME, alertToken); + } + + public static RequestBody createAlertsAlertEnteredForegroundEvent(String alertToken) { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.AlertEnteredForeground.NAME, + alertToken); + } + + public static RequestBody createAlertsAlertEnteredBackgroundEvent(String alertToken) { + return createAlertsEvent(AVSAPIConstants.Alerts.Events.AlertEnteredBackground.NAME, + alertToken); + } + + private static RequestBody createAlertsEvent(String name, String alertToken) { + Header header = new MessageIdHeader(AVSAPIConstants.Alerts.NAMESPACE, name); + Payload payload = new AlertPayload(alertToken); + Event event = new Event(header, payload); + return new RequestBody(event); + } + + public static RequestBody createSpeakerVolumeChangedEvent(long volume, boolean muted) { + return createSpeakerEvent(AVSAPIConstants.Speaker.Events.VolumeChanged.NAME, volume, muted); + } + + public static RequestBody createSpeakerMuteChangedEvent(long volume, boolean muted) { + return createSpeakerEvent(AVSAPIConstants.Speaker.Events.MuteChanged.NAME, volume, muted); + } + + public static RequestBody createSpeakerEvent(String name, long volume, boolean muted) { + Header header = new MessageIdHeader(AVSAPIConstants.Speaker.NAMESPACE, name); + + Event event = new Event(header, new VolumeStatePayload(volume, muted)); + return new RequestBody(event); + } + + public static RequestBody createSystemSynchronizeStateEvent(PlaybackStatePayload playerState, + SpeechStatePayload speechState, AlertsStatePayload alertState, + VolumeStatePayload volumeState) { + Header header = new MessageIdHeader(AVSAPIConstants.System.NAMESPACE, + AVSAPIConstants.System.Events.SynchronizeState.NAME); + Event event = new Event(header, new Payload()); + return createRequestWithAllState(event, playerState, speechState, alertState, volumeState); + } + + public static RequestBody createSystemExceptionEncounteredEvent(String directiveJson, + ExceptionType type, String message, PlaybackStatePayload playbackState, + SpeechStatePayload speechState, AlertsStatePayload alertState, + VolumeStatePayload volumeState) { + Header header = new MessageIdHeader(AVSAPIConstants.System.NAMESPACE, + AVSAPIConstants.System.Events.ExceptionEncountered.NAME); + + Event event = + new Event(header, new ExceptionEncounteredPayload(directiveJson, type, message)); + + return createRequestWithAllState(event, playbackState, speechState, alertState, + volumeState); + } + + public static RequestBody createSystemUserInactivityReportEvent(long inactiveTimeInSeconds) { + Header header = new MessageIdHeader(AVSAPIConstants.System.NAMESPACE, + AVSAPIConstants.System.Events.UserInactivityReport.NAME); + Event event = new Event(header, new UserInactivityReportPayload(inactiveTimeInSeconds)); + return new RequestBody(event); + } + + private static RequestBody createRequestWithAllState(Event event, + PlaybackStatePayload playbackState, SpeechStatePayload speechState, + AlertsStatePayload alertState, VolumeStatePayload volumeState) { + List context = + Arrays.asList(ComponentStateFactory.createPlaybackState(playbackState), + ComponentStateFactory.createSpeechState(speechState), + ComponentStateFactory.createAlertState(alertState), + ComponentStateFactory.createVolumeState(volumeState)); + return new ContextEventRequestBody(context, event); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/alerts/AlertPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/alerts/AlertPayload.java new file mode 100644 index 00000000..94c69b9b --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/alerts/AlertPayload.java @@ -0,0 +1,27 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.alerts; + +import com.amazon.alexa.avs.message.Payload; + +import org.codehaus.jackson.map.annotate.JsonSerialize; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +public final class AlertPayload extends Payload { + + private final String token; + + public AlertPayload(String token) { + this.token = token; + } + + public String getToken() { + return token; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/AudioPlayerPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/AudioPlayerPayload.java new file mode 100644 index 00000000..482b1edf --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/AudioPlayerPayload.java @@ -0,0 +1,30 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.audioplayer; + +import com.amazon.alexa.avs.message.Payload; + +public class AudioPlayerPayload extends Payload { + + private final String token; + private final long offsetInMilliseconds; + + public AudioPlayerPayload(String token, long offsetInMilliseconds) { + this.token = token; + this.offsetInMilliseconds = offsetInMilliseconds; + } + + public String getToken() { + return token; + } + + public long getOffsetInMilliseconds() { + return offsetInMilliseconds; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackFailedPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackFailedPayload.java new file mode 100644 index 00000000..e59d2e81 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackFailedPayload.java @@ -0,0 +1,77 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.audioplayer; + +import com.amazon.alexa.avs.message.Payload; +import com.amazon.alexa.avs.message.request.context.PlaybackStatePayload; + +public final class PlaybackFailedPayload extends Payload { + + private final String token; + private final PlaybackStatePayload currentPlaybackState; + private final ErrorStructure error; + + public PlaybackFailedPayload(String token, PlaybackStatePayload playbackState, + ErrorType errorType) { + this.token = token; + this.currentPlaybackState = playbackState; + error = new ErrorStructure(errorType); + + } + + public String getToken() { + return token; + } + + public PlaybackStatePayload getCurrentPlaybackState() { + return currentPlaybackState; + } + + public ErrorStructure getError() { + return error; + } + + private final static class ErrorStructure { + private final ErrorType type; + private final String message; + + public ErrorStructure(ErrorType type) { + this.type = type; + this.message = type.getMessage(); + } + + public ErrorType getType() { + return type; + } + + public String getMessage() { + return message; + } + } + + public enum ErrorType { + MEDIA_ERROR_UNKNOWN("An unknown error occurred"), + MEDIA_ERROR_INVALID_REQUEST( + "The server recognized the request as being malformed (bad request, unauthorized, forbidden, not found, etc)"), + MEDIA_ERROR_SERVICE_UNAVAILABLE("The device was unavailable to reach the service"), + MEDIA_ERROR_INTERNAL_SERVER_ERROR( + "The server accepted the request, but was unable to process it as expected"), + MEDIA_ERROR_INTERNAL_DEVICE_ERROR("There was an internal error on the device"); + + private final String message; + + private ErrorType(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackNearlyFinishedPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackNearlyFinishedPayload.java new file mode 100644 index 00000000..430cb288 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackNearlyFinishedPayload.java @@ -0,0 +1,24 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.audioplayer; + +import com.amazon.alexa.avs.message.Payload; + +public class PlaybackNearlyFinishedPayload extends Payload { + private final String navigationToken; + + public PlaybackNearlyFinishedPayload(String navigationToken) { + this.navigationToken = navigationToken; + } + + public String getNavigationToken() { + return navigationToken; + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackStutterFinishedPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackStutterFinishedPayload.java new file mode 100644 index 00000000..f1c3664e --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/audioplayer/PlaybackStutterFinishedPayload.java @@ -0,0 +1,24 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.audioplayer; + +public class PlaybackStutterFinishedPayload extends AudioPlayerPayload { + + private final long stutterDurationInMilliseconds; + + public PlaybackStutterFinishedPayload(String token, long offsetInMilliseconds, + long stutterDurationInMilliseconds) { + super(token, offsetInMilliseconds); + this.stutterDurationInMilliseconds = stutterDurationInMilliseconds; + } + + public long getStutterDurationInMilliseconds() { + return stutterDurationInMilliseconds; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/AlertsStatePayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/AlertsStatePayload.java new file mode 100644 index 00000000..4a257e14 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/AlertsStatePayload.java @@ -0,0 +1,33 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.context; + +import com.amazon.alexa.avs.Alert; +import com.amazon.alexa.avs.message.Payload; + +import java.util.List; + +public final class AlertsStatePayload extends Payload { + + private final List allAlerts; + private final List activeAlerts; + + public AlertsStatePayload(List all, List active) { + this.allAlerts = all; + this.activeAlerts = active; + } + + public List getAllAlerts() { + return allAlerts; + } + + public List getActiveAlerts() { + return activeAlerts; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/ComponentState.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/ComponentState.java new file mode 100644 index 00000000..6438db63 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/ComponentState.java @@ -0,0 +1,38 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.context; + +import com.amazon.alexa.avs.message.Header; +import com.amazon.alexa.avs.message.Payload; + +public class ComponentState { + private Header header; + private Payload payload; + + public ComponentState(Header header, Payload payload) { + this.header = header; + this.payload = payload; + } + + public Header getHeader() { + return header; + } + + public Payload getPayload() { + return payload; + } + + public void setHeader(Header header) { + this.header = header; + } + + public void setPayload(Payload payload) { + this.payload = payload; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/PlaybackStatePayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/PlaybackStatePayload.java new file mode 100644 index 00000000..bc8f3224 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/PlaybackStatePayload.java @@ -0,0 +1,36 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.context; + +import com.amazon.alexa.avs.message.Payload; + +public final class PlaybackStatePayload extends Payload { + private final String token; + private final long offsetInMilliseconds; + private final String playerActivity; + + public PlaybackStatePayload(String token, long offsetInMilliseconds, String playerActivity) { + this.token = token; + this.offsetInMilliseconds = offsetInMilliseconds; + this.playerActivity = playerActivity; + } + + public String getToken() { + return token; + } + + public long getOffsetInMilliseconds() { + return offsetInMilliseconds; + } + + public String getPlayerActivity() { + return playerActivity; + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/SpeechStatePayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/SpeechStatePayload.java new file mode 100644 index 00000000..aaee82de --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/SpeechStatePayload.java @@ -0,0 +1,36 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.context; + +import com.amazon.alexa.avs.message.Payload; + +public final class SpeechStatePayload extends Payload { + private final String token; + private final long offsetInMilliseconds; + private final String playerActivity; + + public SpeechStatePayload(String token, long offsetInMilliseconds, String playerActivity) { + this.token = token; + this.offsetInMilliseconds = offsetInMilliseconds; + this.playerActivity = playerActivity; + } + + public String getToken() { + return this.token; + } + + public long getOffsetInMilliseconds() { + return this.offsetInMilliseconds; + } + + public String getPlayerActivity() { + return this.playerActivity; + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/VolumeStatePayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/VolumeStatePayload.java new file mode 100644 index 00000000..7a5f576b --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/context/VolumeStatePayload.java @@ -0,0 +1,21 @@ +package com.amazon.alexa.avs.message.request.context; + +import com.amazon.alexa.avs.message.Payload; + +public class VolumeStatePayload extends Payload { + private final long volume; + private final boolean muted; + + public VolumeStatePayload(long volume, boolean muted) { + this.volume = volume; + this.muted = muted; + } + + public long getVolume() { + return volume; + } + + public boolean getMuted() { + return muted; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/speechrecognizer/SpeechRecognizerPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/speechrecognizer/SpeechRecognizerPayload.java new file mode 100644 index 00000000..7df06e17 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/speechrecognizer/SpeechRecognizerPayload.java @@ -0,0 +1,30 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.speechrecognizer; + +import com.amazon.alexa.avs.SpeechProfile; +import com.amazon.alexa.avs.message.Payload; + +public final class SpeechRecognizerPayload extends Payload { + private final String profile; + private final String format; + + public SpeechRecognizerPayload(SpeechProfile profile, String format) { + this.profile = profile.toString(); + this.format = format; + } + + public String getProfile() { + return profile; + } + + public String getFormat() { + return format; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/speechsynthesizer/SpeechLifecyclePayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/speechsynthesizer/SpeechLifecyclePayload.java new file mode 100644 index 00000000..965351c3 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/speechsynthesizer/SpeechLifecyclePayload.java @@ -0,0 +1,27 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ + +package com.amazon.alexa.avs.message.request.speechsynthesizer; + +import com.amazon.alexa.avs.message.Payload; + +import org.codehaus.jackson.map.annotate.JsonSerialize; + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +public final class SpeechLifecyclePayload extends Payload { + private final String token; + + public SpeechLifecyclePayload(String token) { + this.token = token; + } + + public String getToken() { + return token; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/system/ExceptionEncounteredPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/system/ExceptionEncounteredPayload.java new file mode 100644 index 00000000..decdfc1f --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/system/ExceptionEncounteredPayload.java @@ -0,0 +1,49 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.system; + +import com.amazon.alexa.avs.exception.DirectiveHandlingException.ExceptionType; +import com.amazon.alexa.avs.message.Payload; + +public class ExceptionEncounteredPayload extends Payload { + + private String unparsedDirective; + private ErrorStructure error; + + public ExceptionEncounteredPayload(String unparsedDirective, ExceptionType type, String message) { + this.unparsedDirective = unparsedDirective; + error = new ErrorStructure(type, message); + } + + public String getUnparsedDirective() { + return unparsedDirective; + } + + public ErrorStructure getError() { + return error; + } + + private static class ErrorStructure { + private ExceptionType type; + private String message; + + public ErrorStructure(ExceptionType type, String message) { + this.type = type; + this.message = message; + } + + public ExceptionType getType() { + return type; + } + + public String getMessage() { + return message; + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/system/UserInactivityReportPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/system/UserInactivityReportPayload.java new file mode 100644 index 00000000..8f6b0f93 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/request/system/UserInactivityReportPayload.java @@ -0,0 +1,24 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.request.system; + +import com.amazon.alexa.avs.message.Payload; + +public class UserInactivityReportPayload extends Payload { + + private long inactiveTimeInSeconds; + + public UserInactivityReportPayload(long inactiveTimeInSeconds) { + this.inactiveTimeInSeconds = inactiveTimeInSeconds; + } + + public long getInactiveTimeInSeconds() { + return inactiveTimeInSeconds; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/AlexaExceptionResponse.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/AlexaExceptionResponse.java new file mode 100644 index 00000000..201de408 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/AlexaExceptionResponse.java @@ -0,0 +1,36 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response; + +import com.amazon.alexa.avs.exception.AlexaSystemException; +import com.amazon.alexa.avs.message.Header; +import com.amazon.alexa.avs.message.Message; + +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.JsonParseException; +import org.codehaus.jackson.map.JsonMappingException; + +import java.io.IOException; + +public class AlexaExceptionResponse extends Message { + + public AlexaExceptionResponse(Header header, JsonNode payload, String rawMessage) + throws JsonParseException, JsonMappingException, IOException { + super(header, payload, rawMessage); + } + + /** + * @throws AlexaSystemException + */ + public void throwException() throws AlexaSystemException { + com.amazon.alexa.avs.message.response.system.Exception payload = + (com.amazon.alexa.avs.message.response.system.Exception) this.payload; + throw new AlexaSystemException(payload.getCode(), payload.getDescription()); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/AttachedContentPayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/AttachedContentPayload.java new file mode 100644 index 00000000..0cc67056 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/AttachedContentPayload.java @@ -0,0 +1,52 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response; + +import com.amazon.alexa.avs.message.Payload; + +import java.io.InputStream; + +/** + * Specify a type of {@link Payload} that references attached audio content via a Content-ID HTTP + * Header value. + */ +public interface AttachedContentPayload { + + /** + * Returns whether or not this payload requires content to be attached. False means either it + * never required content, or that it has content. + */ + boolean requiresAttachedContent(); + + /** + * Returns whether or not this payload has content attached. + */ + boolean hasAttachedContent(); + + /** + * Returns the content id for the required attached content. + */ + String getAttachedContentId(); + + /** + * Returns the attached content. + */ + InputStream getAttachedContent(); + + /** + * Attaches the given attachment content if the given content id matches the required content + * id. + * + * @param contentId + * - content id of attachementContent + * @param attachmentContent + * - content to attach + */ + void setAttachedContent(String contentId, InputStream attachmentContent); +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/Directive.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/Directive.java new file mode 100644 index 00000000..e4b093a6 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/Directive.java @@ -0,0 +1,45 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response; + +import com.amazon.alexa.avs.message.DialogRequestIdHeader; +import com.amazon.alexa.avs.message.Header; +import com.amazon.alexa.avs.message.Message; + +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.JsonParseException; +import org.codehaus.jackson.annotate.JsonIgnore; +import org.codehaus.jackson.map.JsonMappingException; + +import java.io.IOException; + +public class Directive extends Message { + + @JsonIgnore + private final String dialogRequestId; + + public Directive(Header header, JsonNode payload, String rawMessage) + throws JsonParseException, JsonMappingException, IOException { + super(header, payload, rawMessage); + dialogRequestId = extractDialogRequestId(); + } + + public String getDialogRequestId() { + return dialogRequestId; + } + + private String extractDialogRequestId() { + if (header instanceof DialogRequestIdHeader) { + DialogRequestIdHeader dialogRequestIdHeader = (DialogRequestIdHeader) header; + return dialogRequestIdHeader.getDialogRequestId(); + } else { + return null; + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/ProgressReport.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/ProgressReport.java new file mode 100644 index 00000000..a536e5e2 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/ProgressReport.java @@ -0,0 +1,34 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response; + +public class ProgressReport { + private long progressReportDelayInMilliseconds; + private long progressReportIntervalInMilliseconds; + + public long getProgressReportDelayInMilliseconds() { + return progressReportDelayInMilliseconds; + } + + public long getProgressReportIntervalInMilliseconds() { + return progressReportIntervalInMilliseconds; + } + + public void setProgressReportDelayInMilliseconds(long progressReportDelayInMilliseconds) { + this.progressReportDelayInMilliseconds = progressReportDelayInMilliseconds; + } + + public void setProgressReportIntervalInMilliseconds(long progressReportIntervalInMilliseconds) { + this.progressReportIntervalInMilliseconds = progressReportIntervalInMilliseconds; + } + + public boolean isRequired() { + return progressReportDelayInMilliseconds > 0 || progressReportIntervalInMilliseconds > 0; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/ResponseBody.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/ResponseBody.java new file mode 100644 index 00000000..04e1b8be --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/ResponseBody.java @@ -0,0 +1,21 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response; + +public class ResponseBody { + private Directive directive; + + public Directive getDirective() { + return directive; + } + + void setDirective(Directive directive) { + this.directive = directive; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/alerts/DeleteAlert.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/alerts/DeleteAlert.java new file mode 100644 index 00000000..8dd97b95 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/alerts/DeleteAlert.java @@ -0,0 +1,26 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.alerts; + +import com.amazon.alexa.avs.message.Payload; + +public final class DeleteAlert extends Payload { + + // opaque identifier of the alert + private String token; + + public void setToken(String token) { + this.token = token; + } + + public String getToken() { + return token; + } + +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/alerts/SetAlert.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/alerts/SetAlert.java new file mode 100644 index 00000000..ac3d8e5c --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/alerts/SetAlert.java @@ -0,0 +1,61 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.alerts; + +import com.amazon.alexa.avs.DateUtils; +import com.amazon.alexa.avs.message.Payload; + +import org.codehaus.jackson.annotate.JsonProperty; + +import java.time.ZonedDateTime; + +public final class SetAlert extends Payload { + + public enum AlertType { + ALARM, + TIMER; + } + + // Opaque identifier of the alert + private String token; + + private AlertType type; + + // Time when the alarm or timer is scheduled + private ZonedDateTime scheduledTime; + + public void setToken(String token) { + this.token = token; + } + + public String getToken() { + return token; + } + + public void setType(String type) { + this.type = AlertType.valueOf(type.toUpperCase()); + } + + public AlertType getType() { + return type; + } + + @JsonProperty("scheduledTime") + public void setScheduledTime(String dateTime) { + scheduledTime = ZonedDateTime.parse(dateTime, DateUtils.AVS_ISO_OFFSET_DATE_TIME); + } + + public void setScheduledTime(ZonedDateTime dateTime) { + scheduledTime = dateTime; + } + + public ZonedDateTime getScheduledTime() { + return scheduledTime; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/AudioItem.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/AudioItem.java new file mode 100644 index 00000000..c170fed6 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/AudioItem.java @@ -0,0 +1,30 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.audioplayer; + +public final class AudioItem { + private String audioItemId; + private Stream stream; + + public String getAudioItemId() { + return audioItemId; + } + + public Stream getStream() { + return stream; + } + + public void setAudioItemId(String audioItemId) { + this.audioItemId = audioItemId; + } + + public void setStream(Stream stream) { + this.stream = stream; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/ClearQueue.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/ClearQueue.java new file mode 100644 index 00000000..2c3295c2 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/ClearQueue.java @@ -0,0 +1,29 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.audioplayer; + +import com.amazon.alexa.avs.message.Payload; + +public final class ClearQueue extends Payload { + + public enum ClearBehavior { + CLEAR_ENQUEUED, + CLEAR_ALL; + } + + private ClearBehavior clearBehavior; + + public ClearBehavior getClearBehavior() { + return clearBehavior; + } + + public void setClearBehavior(String clearBehavior) { + this.clearBehavior = ClearBehavior.valueOf(clearBehavior); + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Play.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Play.java new file mode 100644 index 00000000..aae431bc --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Play.java @@ -0,0 +1,80 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.audioplayer; + +import com.amazon.alexa.avs.message.Payload; +import com.amazon.alexa.avs.message.response.AttachedContentPayload; + +import org.codehaus.jackson.annotate.JsonIgnore; + +import java.io.InputStream; + +public final class Play extends Payload implements AttachedContentPayload { + + public enum PlayBehavior { + REPLACE_ALL, + ENQUEUE, + REPLACE_ENQUEUED; + } + + private PlayBehavior playBehavior; + private AudioItem audioItem; + + public PlayBehavior getPlayBehavior() { + return playBehavior; + } + + public AudioItem getAudioItem() { + return audioItem; + } + + public void setPlayBehavior(String playBehavior) { + this.playBehavior = PlayBehavior.valueOf(playBehavior); + } + + public void setAudioItem(AudioItem audioItem) { + this.audioItem = audioItem; + } + + @Override + public boolean requiresAttachedContent() { + return audioItem.getStream().requiresAttachedContent(); + } + + @Override + public boolean hasAttachedContent() { + return audioItem.getStream().hasAttachedContent(); + } + + @Override + public String getAttachedContentId() { + if (requiresAttachedContent()) { + return audioItem.getStream().getUrl(); + } else { + return null; + } + } + + @JsonIgnore + @Override + public InputStream getAttachedContent() { + return audioItem.getStream().getAttachedContent(); + } + + @Override + public void setAttachedContent(String cid, InputStream content) { + if (getAttachedContentId().equals(cid)) { + audioItem.getStream().setAttachedContent(content); + } else { + throw new IllegalArgumentException( + "Tried to add the wrong audio content to a Play directive. This cid: " + + getAttachedContentId() + " other cid: " + cid); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Stop.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Stop.java new file mode 100644 index 00000000..7489db73 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Stop.java @@ -0,0 +1,14 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.audioplayer; + +import com.amazon.alexa.avs.message.Payload; + +public class Stop extends Payload { +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Stream.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Stream.java new file mode 100644 index 00000000..4a54d9a8 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/audioplayer/Stream.java @@ -0,0 +1,102 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.audioplayer; + +import com.amazon.alexa.avs.message.response.ProgressReport; + +import org.codehaus.jackson.annotate.JsonIgnore; + +import java.io.InputStream; + +public final class Stream { + private String url; + private String token; + private String expiryTime; + private long offsetInMilliseconds; + private ProgressReport progressReport; + private boolean urlIsAContentId; + private String expectedPreviousToken; + + @JsonIgnore + private InputStream attachedContent; + + public String getUrl() { + return url; + } + + public String getToken() { + return token; + } + + public String getExpiryTime() { + return expiryTime; + } + + public long getOffsetInMilliseconds() { + return offsetInMilliseconds; + } + + public boolean getProgressReportRequired() { + return progressReport != null && progressReport.isRequired(); + } + + public ProgressReport getProgressReport() { + return progressReport; + } + + public String getExpectedPreviousToken() { + return expectedPreviousToken; + } + + public void setUrl(String url) { + urlIsAContentId = url.startsWith("cid"); + if (urlIsAContentId) { + this.url = url.substring(4); + } else { + this.url = url; + } + } + + public void setToken(String token) { + this.token = token; + } + + public void setExpiryTime(String expiryTime) { + this.expiryTime = expiryTime; + } + + public void setOffsetInMilliseconds(long offsetInMilliseconds) { + this.offsetInMilliseconds = offsetInMilliseconds; + } + + public void setProgressReport(ProgressReport progressReport) { + this.progressReport = progressReport; + } + + public void setExpectedPreviousToken(String expectedPreviousToken) { + this.expectedPreviousToken = expectedPreviousToken; + } + + public boolean requiresAttachedContent() { + return urlIsAContentId && !hasAttachedContent(); + } + + public boolean hasAttachedContent() { + return attachedContent != null; + } + + public void setAttachedContent(InputStream attachedContent) { + this.attachedContent = attachedContent; + } + + @JsonIgnore + public InputStream getAttachedContent() { + return attachedContent; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/AdjustVolume.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/AdjustVolume.java new file mode 100644 index 00000000..2b675b2c --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/AdjustVolume.java @@ -0,0 +1,4 @@ +package com.amazon.alexa.avs.message.response.speaker; + +public class AdjustVolume extends VolumePayload { +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/SetMute.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/SetMute.java new file mode 100644 index 00000000..6a58075d --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/SetMute.java @@ -0,0 +1,15 @@ +package com.amazon.alexa.avs.message.response.speaker; + +import com.amazon.alexa.avs.message.Payload; + +public class SetMute extends Payload { + private boolean mute; + + public final void setMute(boolean mute) { + this.mute = mute; + } + + public final boolean getMute() { + return mute; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/SetVolume.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/SetVolume.java new file mode 100644 index 00000000..e608cfd4 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/SetVolume.java @@ -0,0 +1,4 @@ +package com.amazon.alexa.avs.message.response.speaker; + +public class SetVolume extends VolumePayload { +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/VolumePayload.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/VolumePayload.java new file mode 100644 index 00000000..b8698eea --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speaker/VolumePayload.java @@ -0,0 +1,15 @@ +package com.amazon.alexa.avs.message.response.speaker; + +import com.amazon.alexa.avs.message.Payload; + +public abstract class VolumePayload extends Payload { + private long volume; + + public final void setVolume(long volume) { + this.volume = volume; + } + + public final long getVolume() { + return volume; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechrecognizer/Listen.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechrecognizer/Listen.java new file mode 100644 index 00000000..99d1e091 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechrecognizer/Listen.java @@ -0,0 +1,24 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.speechrecognizer; + +import com.amazon.alexa.avs.message.Payload; + +public class Listen extends Payload { + // duration of wait for the customer to open the microphone before issuing a ListenTimeout event + private String timeoutIntervalInMillis; + + public String getTimeoutIntervalInMillis() { + return timeoutIntervalInMillis; + } + + public void setTimeoutIntervalInMillis(String timeoutIntervalInMillis) { + this.timeoutIntervalInMillis = timeoutIntervalInMillis; + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechsynthesizer/ExpectSpeech.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechsynthesizer/ExpectSpeech.java new file mode 100644 index 00000000..324b6280 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechsynthesizer/ExpectSpeech.java @@ -0,0 +1,14 @@ +/* + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.speechsynthesizer; + +import com.amazon.alexa.avs.message.Payload; + +public class ExpectSpeech extends Payload { +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechsynthesizer/Speak.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechsynthesizer/Speak.java new file mode 100644 index 00000000..c660173c --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/speechsynthesizer/Speak.java @@ -0,0 +1,88 @@ +/** + * Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.speechsynthesizer; + +import com.amazon.alexa.avs.message.Payload; +import com.amazon.alexa.avs.message.response.AttachedContentPayload; + +import org.codehaus.jackson.annotate.JsonIgnore; + +import java.io.InputStream; + +public class Speak extends Payload implements AttachedContentPayload { + private String url; + private String format; + private String token; + + @JsonIgnore + private InputStream attachedContent; + + /** + * Get the Content-ID that this {@link Speak} references. + */ + public String getUrl() { + return url; + } + + public String getFormat() { + return format; + } + + public String getToken() { + return token; + } + + public void setUrl(String url) { + // The format we get from the server has the audioContentId as "cid:%CONTENT_ID%" whereas + // the actual Content-ID HTTP Header value is "%CONTENT_ID%". + // This normalizes that + this.url = url.substring(4); + } + + public void setFormat(String format) { + this.format = format; + } + + public void setToken(String token) { + this.token = token; + } + + @Override + public boolean requiresAttachedContent() { + return !hasAttachedContent(); + } + + @Override + public boolean hasAttachedContent() { + return attachedContent != null; + } + + @JsonIgnore + @Override + public String getAttachedContentId() { + return url; + } + + @JsonIgnore + @Override + public InputStream getAttachedContent() { + return attachedContent; + } + + @Override + public void setAttachedContent(String cid, InputStream content) { + if (getAttachedContentId().equals(cid)) { + this.attachedContent = content; + } else { + throw new IllegalArgumentException( + "Tried to add the wrong audio content to a Speak directive. This cid: " + + getAttachedContentId() + " other cid: " + cid); + } + } +} diff --git a/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/system/Exception.java b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/system/Exception.java new file mode 100644 index 00000000..9e374a11 --- /dev/null +++ b/samples/javaclient/src/main/java/com/amazon/alexa/avs/message/response/system/Exception.java @@ -0,0 +1,35 @@ +/** + * Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * You may not use this file except in compliance with the License. A copy of the License is located the "LICENSE.txt" + * file accompanying this source. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing permissions and limitations + * under the License. + */ +package com.amazon.alexa.avs.message.response.system; + +import com.amazon.alexa.avs.message.Payload; + +/** + * Exception response from the server + */ +public class Exception extends Payload { + private String code; + private String description; + + public void setCode(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public void setDescription(String desc) { + this.description = desc; + } + + public String getDescription() { + return description; + } +} diff --git a/samples/javaclient/src/main/resources/res/alarm.mp3 b/samples/javaclient/src/main/resources/res/alarm.mp3 new file mode 100644 index 00000000..6adebb0f Binary files /dev/null and b/samples/javaclient/src/main/resources/res/alarm.mp3 differ diff --git a/samples/javaclient/src/main/resources/res/error.mp3 b/samples/javaclient/src/main/resources/res/error.mp3 new file mode 100644 index 00000000..4920ad1f Binary files /dev/null and b/samples/javaclient/src/main/resources/res/error.mp3 differ diff --git a/samples/javaclient/src/main/resources/res/start.mp3 b/samples/javaclient/src/main/resources/res/start.mp3 new file mode 100644 index 00000000..dd865e84 Binary files /dev/null and b/samples/javaclient/src/main/resources/res/start.mp3 differ diff --git a/samples/javaclient/src/main/resources/res/stop.mp3 b/samples/javaclient/src/main/resources/res/stop.mp3 new file mode 100644 index 00000000..7cfd9909 Binary files /dev/null and b/samples/javaclient/src/main/resources/res/stop.mp3 differ diff --git a/samples/javaclient/src/main/resources/res/version.properties b/samples/javaclient/src/main/resources/res/version.properties new file mode 100644 index 00000000..defbd482 --- /dev/null +++ b/samples/javaclient/src/main/resources/res/version.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/samples/javaclient/ssl.cnf b/samples/javaclient/ssl.cnf new file mode 100644 index 00000000..c7c03b46 --- /dev/null +++ b/samples/javaclient/ssl.cnf @@ -0,0 +1,19 @@ +[req] +distinguished_name = req_distinguished_name +prompt = no + +[v3_req] +subjectAltName = @alt_names + +[alt_names] +DNS.1 = localhost +IP.1 = 127.0.0.1 +IP.2 = 10.0.2.2 + +[req_distinguished_name] +commonName = $ENV::COMMON_NAME # CN= +countryName = YOUR_COUNTRY_NAME # C= +stateOrProvinceName = YOUR_STATE_OR_PROVINCE # ST= +localityName = YOUR_CITY # L= +organizationName = YOUR_ORGANIZATION # O= +organizationalUnitName = YOUR_ORGANIZATIONAL_UNIT # OU=