Repository: android/uamp Branch: main Commit: 9498d991ab84 Files: 129 Total size: 414.6 KB Directory structure: gitextract_ek5wbsm9/ ├── .github/ │ ├── scripts/ │ │ └── gradlew_recursive.sh │ └── workflows/ │ ├── android.yml │ └── copy-branch.yml ├── .gitignore ├── .google/ │ └── packaging.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── TODO.md ├── app/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── android/ │ │ └── uamp/ │ │ ├── MainActivity.kt │ │ ├── MediaItemAdapter.kt │ │ ├── MediaItemData.kt │ │ ├── cast/ │ │ │ └── UampCastOptionsProvider.kt │ │ ├── fragments/ │ │ │ ├── MediaItemFragment.kt │ │ │ └── NowPlayingFragment.kt │ │ ├── utils/ │ │ │ ├── Event.kt │ │ │ └── InjectorUtils.kt │ │ └── viewmodels/ │ │ ├── MainActivityViewModel.kt │ │ ├── MediaItemFragmentViewModel.kt │ │ └── NowPlayingFragmentViewModel.kt │ └── res/ │ ├── drawable/ │ │ ├── ic_album_black_24dp.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_pause_black_24dp.xml │ │ ├── ic_play_arrow_black_24dp.xml │ │ ├── ic_signal_wifi_off_black_24dp.xml │ │ ├── media_item_background.xml │ │ ├── media_item_mask.xml │ │ └── media_overlay_background.xml │ ├── drawable-v24/ │ │ └── ic_launcher_foreground.xml │ ├── layout/ │ │ ├── activity_main.xml │ │ ├── cast_context_error.xml │ │ ├── fragment_mediaitem.xml │ │ ├── fragment_mediaitem_list.xml │ │ └── fragment_nowplaying.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── xml/ │ └── automotive_app_desc.xml ├── automotive/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── example/ │ │ └── android/ │ │ └── uamp/ │ │ └── automotive/ │ │ └── ExampleInstrumentedTest.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── example/ │ │ │ └── android/ │ │ │ └── uamp/ │ │ │ └── automotive/ │ │ │ ├── AutomotiveMusicService.kt │ │ │ ├── PhoneSignInFragment.kt │ │ │ ├── PinCodeSignInFragment.kt │ │ │ ├── QrCodeSignInFragment.kt │ │ │ ├── SettingsActivity.kt │ │ │ ├── SettingsFragment.kt │ │ │ ├── SignInActivity.kt │ │ │ ├── SignInActivityViewModel.kt │ │ │ ├── SignInLandingPageFragment.kt │ │ │ └── UsernameAndPasswordSignInFragment.kt │ │ └── res/ │ │ ├── color/ │ │ │ ├── car_text_dark.xml │ │ │ └── car_text_light.xml │ │ ├── drawable/ │ │ │ ├── default_button_background.xml │ │ │ ├── google_sign_in_button_background.xml │ │ │ ├── google_sign_in_button_logo.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── pin_background.xml │ │ │ ├── sign_in_button_background.xml │ │ │ ├── sign_in_toolbar_back_icon.xml │ │ │ └── sign_in_toolbar_back_ripple_background.xml │ │ ├── drawable-v24/ │ │ │ └── ic_launcher_foreground.xml │ │ ├── layout/ │ │ │ ├── activity_login.xml │ │ │ ├── activity_settings.xml │ │ │ ├── activity_sign_in.xml │ │ │ ├── phone_sign_in.xml │ │ │ ├── pin_item.xml │ │ │ ├── pin_sign_in.xml │ │ │ ├── preference.xml │ │ │ ├── preference_category.xml │ │ │ ├── qr_sign_in.xml │ │ │ ├── sign_in_landing_page.xml │ │ │ ├── sign_in_landing_page_with_username_and_password.xml │ │ │ └── username_and_password_sign_in.xml │ │ ├── layout-h900dp/ │ │ │ ├── phone_sign_in.xml │ │ │ ├── pin_sign_in.xml │ │ │ ├── qr_sign_in.xml │ │ │ ├── sign_in_landing_page.xml │ │ │ ├── sign_in_landing_page_with_username_and_password.xml │ │ │ └── username_and_password_sign_in.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── values/ │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-h1060dp/ │ │ │ └── dimens.xml │ │ └── xml/ │ │ ├── automotive_app_desc.xml │ │ └── preferences.xml │ └── test/ │ └── java/ │ └── com/ │ └── example/ │ └── android/ │ └── uamp/ │ └── automotive/ │ └── ExampleUnitTest.java ├── build.gradle ├── common/ │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── example/ │ │ │ └── android/ │ │ │ └── uamp/ │ │ │ ├── common/ │ │ │ │ └── MusicServiceConnection.kt │ │ │ └── media/ │ │ │ ├── CastMediaItemConverter.kt │ │ │ ├── MusicService.kt │ │ │ ├── PackageValidator.kt │ │ │ ├── PersistentStorage.kt │ │ │ ├── UampNotificationManager.kt │ │ │ ├── extensions/ │ │ │ │ ├── FileExt.kt │ │ │ │ ├── JavaLangExt.kt │ │ │ │ ├── MediaMetadataCompatExt.kt │ │ │ │ └── PlaybackStateCompatExt.kt │ │ │ └── library/ │ │ │ ├── AlbumArtContentProvider.kt │ │ │ ├── BrowseTree.kt │ │ │ ├── JsonSource.kt │ │ │ └── MusicSource.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_album.xml │ │ │ └── ic_recommended.xml │ │ ├── menu/ │ │ │ └── main_activity_menu.xml │ │ ├── values/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── allowed_media_browser_callers.xml │ └── test/ │ └── java/ │ └── com/ │ └── example/ │ └── android/ │ └── uamp/ │ └── media/ │ └── library/ │ └── MusicSourceTest.kt ├── docs/ │ ├── FAQs.md │ └── FullGuide.md ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/scripts/gradlew_recursive.sh ================================================ #!/bin/bash # Copyright (C) 2020 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License 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. set -xe # Default Gradle settings are not optimal for Android builds, override them # here to make the most out of the GitHub Actions build servers GRADLE_OPTS="$GRADLE_OPTS -Xms4g -Xmx4g" GRADLE_OPTS="$GRADLE_OPTS -XX:+HeapDumpOnOutOfMemoryError" GRADLE_OPTS="$GRADLE_OPTS -Dorg.gradle.daemon=false" GRADLE_OPTS="$GRADLE_OPTS -Dorg.gradle.workers.max=2" GRADLE_OPTS="$GRADLE_OPTS -Dkotlin.incremental=false" GRADLE_OPTS="$GRADLE_OPTS -Dkotlin.compiler.execution.strategy=in-process" GRADLE_OPTS="$GRADLE_OPTS -Dfile.encoding=UTF-8" export GRADLE_OPTS # Crawl all gradlew files which indicate an Android project # You may edit this if your repo has a different project structure for GRADLEW in `find . -name "gradlew"` ; do SAMPLE=$(dirname "${GRADLEW}") # Tell Gradle that this is a CI environment and disable parallel compilation bash "$GRADLEW" -p "$SAMPLE" -Pci --no-parallel --stacktrace $@ done ================================================ FILE: .github/workflows/android.yml ================================================ # Copyright (C) 2020 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License 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. name: Android CI on: workflow_dispatch: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: name: Build runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v1 - name: set up JDK 1.8 uses: actions/setup-java@v1 with: java-version: 1.8 - name: Build project run: .github/scripts/gradlew_recursive.sh assembleDebug - name: Zip artifacts run: zip -r assemble.zip . -i '**/build/*.apk' '**/build/*.aab' '**/build/*.aar' '**/build/*.so' - name: Upload artifacts uses: actions/upload-artifact@v1 with: name: assemble path: assemble.zip ================================================ FILE: .github/workflows/copy-branch.yml ================================================ # Duplicates default main branch to the old master branch name: Duplicates main to old master branch # Controls when the action will run. Triggers the workflow on push or pull request # events but only for the main branch on: workflow_dispatch: push: branches: [ main ] # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "copy-branch" copy-branch: # The type of runner that the job will run on runs-on: ubuntu-latest # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it, # but specifies master branch (old default). - uses: actions/checkout@v2 with: fetch-depth: 0 ref: master - run: | git config user.name github-actions git config user.email github-actions@github.com git merge origin/main git push ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea .DS_Store /captures .externalNativeBuild # Generated files build/ # Extra (custom) settings extra-settings.gradle ================================================ FILE: .google/packaging.yaml ================================================ # GOOGLE SAMPLE PACKAGING DATA # # This file is used by Google as part of our samples packaging process. # End users may safely ignore this file. It has no relevance to other systems. --- status: PUBLISHED technologies: [Android, Android Auto, Android Automotive OS, Android Wear] categories: [Getting Started, Media, UI] languages: [Kotlin] solutions: [Mobile] github: googlesamples/android-UniversalMusicPlayer level: INTERMEDIATE icon: screenshots/icon-web.png apiRefs: - android:android.support.v4.media.session.MediaSessionCompat - android:android.support.v4.media.session.MediaControllerCompat - androidx.media.MediaBrowserServiceCompat - android:android.support.v4.media.MediaBrowserCompat - androidx.media.app.NotificationCompat.MediaStyle - android:com.google.android.exoplayer2.SimpleExoPlayer - android:com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector license: apache2-android ================================================ FILE: CONTRIBUTING.md ================================================ # How to become a contributor and submit your own code ## Contributor License Agreements We'd love to accept your sample apps and patches! Before we can take them, we have to jump a couple of legal hurdles. Please fill out either the individual or corporate Contributor License Agreement (CLA). * If you are an individual writing original source code and you're sure you own the intellectual property, then you'll need to sign an [individual CLA] (https://developers.google.com/open-source/cla/individual). * If you work for a company that wants to allow you to contribute your work, then you'll need to sign a [corporate CLA] (https://developers.google.com/open-source/cla/corporate). Follow either of the two links above to access the appropriate CLA and instructions for how to sign and return it. Once we receive it, we'll be able to accept your pull requests. ## Contributing A Patch 1. Submit an issue describing your proposed change to the repo in question. 1. The repo owner will respond to your issue promptly. 1. If your proposed change is accepted, and you haven't already done so, sign a Contributor License Agreement (see details above). 1. Fork the desired repo, develop and test your code changes. 1. Ensure that your code adheres to the existing style in the sample to which you are contributing. Refer to the [Android Code Style Guide] (https://source.android.com/source/code-style.html) for the recommended coding standards for this organization. 1. Ensure that your code has an appropriate set of unit tests which all pass. 1. Submit a pull request. ================================================ FILE: 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: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) 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 (d) 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 APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2014 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License 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. ================================================ FILE: README.md ================================================ > **Warning** > This sample has been deprecated and is no longer being maintained. > > To find other samples that may be of interest, see [https://developer.android.com/samples](https://developer.android.com/samples). Universal Android Music Player Sample ===================================== The goal of this sample is to show how to implement an audio media app that works across multiple form factors and provides a consistent user experience on Android phones, tablets, Android Auto, Android Wear, Android TV, Google Cast devices, and with the Google Assistant. To get started with UAMP please read the [full guide](docs/FullGuide.md). ![Screenshot showing UAMP's UI for browsing albums and songs](docs/images/1-browse-albums-screenshot.png "Browse albums screenshot") ![Screenshot showing UAMP's UI for playing a song](docs/images/2-play-song-screenshot.png "Play song screenshot") Pre-requisites -------------- - Android Studio 3.x Getting Started --------------- This sample uses the Gradle build system. To build this project, use the "gradlew build" command or use "Import Project" in Android Studio. Support ------- - Check out the [FAQs page](docs/FAQs.md) - Stack Overflow: http://stackoverflow.com/questions/tagged/android If you've found an error in this sample, please [file an issue](https://github.com/android/UAMP/issues) Patches are encouraged and may be submitted by forking this project and submitting a pull request through GitHub. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for more details. Audio ----- Music provided by the [Free Music Archive](http://freemusicarchive.org/). - [Wake Up](http://freemusicarchive.org/music/The_Kyoto_Connection/Wake_Up_1957/) by [The Kyoto Connection](http://freemusicarchive.org/music/The_Kyoto_Connection/). Recordings provided by the [Ambisonic Sound Library](https://library.soundfield.com/). - [Pre Game Marching Band](https://library.soundfield.com/track/163) by Watson Wu - [Chickens on a Farm](https://library.soundfield.com/track/129) by Watson Wu - [Rural Market Busker](https://library.soundfield.com/track/55) by Stephan Schutze - [Steamtrain Interior](https://library.soundfield.com/track/65) by Stephan Schutze - [Rural Road Car Pass](https://library.soundfield.com/track/57) by Stephan Schutze - [10 Feet from Shore](https://library.soundfield.com/track/114) by Watson Wu License ------- Copyright 2025 Google Inc. Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License 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. ================================================ FILE: TODO.md ================================================ TODOs ===== This file captures the high level goals of the project. This provides guidance for anyone who wants to contribute. If you see something in the list that you'd like to work on, the best approach would be to [create an issue](https://github.com/googlesamples/android-UniversalMusicPlayer/issues) first, and then provide a pull request once completed to have your work merged into the project. Service Side Tasks ------------------ - Implement rating (ideally "favorite" vs "thumbs up/down"). - Improve integration with the Google Assistant. UI Tasks -------- - Implement a "now playing" UI with current position and skip forward/back 30s ([BottomSheet](https://material.io/guidelines/components/bottom-sheets.html#bottom-sheets-persistent-bottom-sheets)). ================================================ FILE: app/build.gradle ================================================ /* * Copyright 2017 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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. */ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion rootProject.compileSdkVersion defaultConfig { applicationId "com.example.android.uamp.next" versionCode 1 versionName "1.0" minSdkVersion rootProject.minSdkVersion targetSdkVersion rootProject.targetSdkVersion multiDexEnabled true compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } vectorDrawables { useSupportLibrary true } } buildFeatures { viewBinding true dataBinding true } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } } dependencies { implementation "com.android.support:multidex:$multidex_version" implementation project(':common') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.appcompat:appcompat:$androidx_app_compat_version" implementation "androidx.fragment:fragment-ktx:$fragment_version" implementation "androidx.recyclerview:recyclerview:$recycler_view_version" implementation "androidx.constraintlayout:constraintlayout:$constraint_layout_version" implementation "androidx.lifecycle:lifecycle-extensions:$arch_lifecycle_version" // Glide dependencies implementation "com.github.bumptech.glide:glide:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/example/android/uamp/MainActivity.kt ================================================ /* * Copyright 2017 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp import android.media.AudioManager import android.os.Bundle import android.util.Log import android.view.Menu import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import com.example.android.uamp.fragments.MediaItemFragment import com.example.android.uamp.media.MusicService import com.example.android.uamp.utils.Event import com.example.android.uamp.utils.InjectorUtils import com.example.android.uamp.viewmodels.MainActivityViewModel import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext class MainActivity : AppCompatActivity() { private val viewModel by viewModels { InjectorUtils.provideMainActivityViewModel(this) } private var castContext: CastContext? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Initialize the Cast context. This is required so that the media route button can be // created in the AppBar castContext = CastContext.getSharedInstance(this) setContentView(R.layout.activity_main) // Since UAMP is a music player, the volume controls should adjust the music volume while // in the app. volumeControlStream = AudioManager.STREAM_MUSIC /** * Observe [MainActivityViewModel.navigateToFragment] for [Event]s that request a * fragment swap. */ viewModel.navigateToFragment.observe(this, Observer { it?.getContentIfNotHandled()?.let { fragmentRequest -> val transaction = supportFragmentManager.beginTransaction() transaction.replace( R.id.fragmentContainer, fragmentRequest.fragment, fragmentRequest.tag ) if (fragmentRequest.backStack) transaction.addToBackStack(null) transaction.commit() } }) /** * Observe changes to the [MainActivityViewModel.rootMediaId]. When the app starts, * and the UI connects to [MusicService], this will be updated and the app will show * the initial list of media items. */ viewModel.rootMediaId.observe(this, Observer { rootMediaId -> rootMediaId?.let { navigateToMediaItem(it) } }) /** * Observe [MainActivityViewModel.navigateToMediaItem] for [Event]s indicating * the user has requested to browse to a different [MediaItemData]. */ viewModel.navigateToMediaItem.observe(this, Observer { it?.getContentIfNotHandled()?.let { mediaId -> navigateToMediaItem(mediaId) } }) } @Override override fun onCreateOptionsMenu(menu: Menu?): Boolean { super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.main_activity_menu, menu) /** * Set up a MediaRouteButton to allow the user to control the current media playback route */ CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item) return true } private fun navigateToMediaItem(mediaId: String) { var fragment: MediaItemFragment? = getBrowseFragment(mediaId) if (fragment == null) { fragment = MediaItemFragment.newInstance(mediaId) // If this is not the top level media (root), we add it to the fragment // back stack, so that actionbar toggle and Back will work appropriately: viewModel.showFragment(fragment, !isRootId(mediaId), mediaId) } } private fun isRootId(mediaId: String) = mediaId == viewModel.rootMediaId.value private fun getBrowseFragment(mediaId: String): MediaItemFragment? { return supportFragmentManager.findFragmentByTag(mediaId) as? MediaItemFragment } } ================================================ FILE: app/src/main/java/com/example/android/uamp/MediaItemAdapter.kt ================================================ /* * Copyright 2017 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.example.android.uamp.MediaItemData.Companion.PLAYBACK_RES_CHANGED import com.example.android.uamp.databinding.FragmentMediaitemBinding import com.example.android.uamp.fragments.MediaItemFragment /** * [RecyclerView.Adapter] of [MediaItemData]s used by the [MediaItemFragment]. */ class MediaItemAdapter( private val itemClickedListener: (MediaItemData) -> Unit ) : ListAdapter(MediaItemData.diffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = FragmentMediaitemBinding.inflate(inflater, parent, false) return MediaViewHolder(binding, itemClickedListener) } override fun onBindViewHolder( holder: MediaViewHolder, position: Int, payloads: MutableList ) { val mediaItem = getItem(position) var fullRefresh = payloads.isEmpty() if (payloads.isNotEmpty()) { payloads.forEach { payload -> when (payload) { PLAYBACK_RES_CHANGED -> { holder.playbackState.setImageResource(mediaItem.playbackRes) } // If the payload wasn't understood, refresh the full item (to be safe). else -> fullRefresh = true } } } // Normally we only fully refresh the list item if it's being initially bound, but // we might also do it if there was a payload that wasn't understood, just to ensure // there isn't a stale item. if (fullRefresh) { holder.item = mediaItem holder.titleView.text = mediaItem.title holder.subtitleView.text = mediaItem.subtitle holder.playbackState.setImageResource(mediaItem.playbackRes) Glide.with(holder.albumArt) .load(mediaItem.albumArtUri) .placeholder(R.drawable.default_art) .into(holder.albumArt) } } override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { onBindViewHolder(holder, position, mutableListOf()) } } class MediaViewHolder( binding: FragmentMediaitemBinding, itemClickedListener: (MediaItemData) -> Unit ) : RecyclerView.ViewHolder(binding.root) { val titleView: TextView = binding.title val subtitleView: TextView = binding.subtitle val albumArt: ImageView = binding.albumArt val playbackState: ImageView = binding.itemState var item: MediaItemData? = null init { binding.root.setOnClickListener { item?.let { itemClickedListener(it) } } } } ================================================ FILE: app/src/main/java/com/example/android/uamp/MediaItemData.kt ================================================ /* * Copyright 2018 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp import android.net.Uri import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.recyclerview.widget.DiffUtil import com.example.android.uamp.viewmodels.MediaItemFragmentViewModel /** * Data class to encapsulate properties of a [MediaItem]. * * If an item is [browsable] it means that it has a list of child media items that * can be retrieved by passing the mediaId to [MediaBrowserCompat.subscribe]. * * Objects of this class are built from [MediaItem]s in * [MediaItemFragmentViewModel.subscriptionCallback]. */ data class MediaItemData( val mediaId: String, val title: String, val subtitle: String, val albumArtUri: Uri, val browsable: Boolean, var playbackRes: Int ) { companion object { /** * Indicates [playbackRes] has changed. */ const val PLAYBACK_RES_CHANGED = 1 /** * [DiffUtil.ItemCallback] for a [MediaItemData]. * * Since all [MediaItemData]s have a unique ID, it's easiest to check if two * items are the same by simply comparing that ID. * * To check if the contents are the same, we use the same ID, but it may be the * case that it's only the play state itself which has changed (from playing to * paused, or perhaps a different item is the active item now). In this case * we check both the ID and the playback resource. * * To calculate the payload, we use the simplest method possible: * - Since the title, subtitle, and albumArtUri are constant (with respect to mediaId), * there's no reason to check if they've changed. If the mediaId is the same, none of * those properties have changed. * - If the playback resource (playbackRes) has changed to reflect the change in playback * state, that's all that needs to be updated. We return [PLAYBACK_RES_CHANGED] as * the payload in this case. * - If something else changed, then refresh the full item for simplicity. */ val diffCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: MediaItemData, newItem: MediaItemData ): Boolean = oldItem.mediaId == newItem.mediaId override fun areContentsTheSame(oldItem: MediaItemData, newItem: MediaItemData) = oldItem.mediaId == newItem.mediaId && oldItem.playbackRes == newItem.playbackRes override fun getChangePayload(oldItem: MediaItemData, newItem: MediaItemData) = if (oldItem.playbackRes != newItem.playbackRes) { PLAYBACK_RES_CHANGED } else null } } } ================================================ FILE: app/src/main/java/com/example/android/uamp/cast/UampCastOptionsProvider.kt ================================================ /* * Copyright 2020 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.cast import android.content.Context import com.google.android.exoplayer2.ext.cast.DefaultCastOptionsProvider.APP_ID_DEFAULT_RECEIVER_WITH_DRM import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.SessionProvider import com.google.android.gms.cast.framework.media.CastMediaOptions class UampCastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context?): CastOptions? { return CastOptions.Builder() // Use the Default Media Receiver with DRM support. .setReceiverApplicationId(APP_ID_DEFAULT_RECEIVER_WITH_DRM) .setCastMediaOptions( CastMediaOptions.Builder() // We manage the media session and the notifications ourselves. .setMediaSessionEnabled(false) .setNotificationOptions(null) .build() ) .setStopReceiverApplicationWhenEndingSession(true).build() } override fun getAdditionalSessionProviders(context: Context?): List? { return null } } ================================================ FILE: app/src/main/java/com/example/android/uamp/fragments/MediaItemFragment.kt ================================================ /* * Copyright 2017 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.fragments import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Observer import com.example.android.uamp.MediaItemAdapter import com.example.android.uamp.databinding.FragmentMediaitemListBinding import com.example.android.uamp.utils.InjectorUtils import com.example.android.uamp.viewmodels.MainActivityViewModel import com.example.android.uamp.viewmodels.MediaItemFragmentViewModel /** * A fragment representing a list of MediaItems. */ class MediaItemFragment : Fragment() { private val mainActivityViewModel by activityViewModels { InjectorUtils.provideMainActivityViewModel(requireContext()) } private val mediaItemFragmentViewModel by viewModels { InjectorUtils.provideMediaItemFragmentViewModel(requireContext(), mediaId) } private lateinit var mediaId: String private lateinit var binding: FragmentMediaitemListBinding private val listAdapter = MediaItemAdapter { clickedItem -> mainActivityViewModel.mediaItemClicked(clickedItem) } companion object { fun newInstance(mediaId: String): MediaItemFragment { return MediaItemFragment().apply { arguments = Bundle().apply { putString(MEDIA_ID_ARG, mediaId) } } } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { binding = FragmentMediaitemListBinding.inflate(inflater, container, false) return binding.root } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) // Always true, but lets lint know that as well. mediaId = arguments?.getString(MEDIA_ID_ARG) ?: return mediaItemFragmentViewModel.mediaItems.observe(viewLifecycleOwner, Observer { list -> binding.loadingSpinner.visibility = if (list?.isNotEmpty() == true) View.GONE else View.VISIBLE listAdapter.submitList(list) }) mediaItemFragmentViewModel.networkError.observe(viewLifecycleOwner, Observer { error -> if (error) { binding.loadingSpinner.visibility = View.GONE binding.networkError.visibility = View.VISIBLE } else { binding.networkError.visibility = View.GONE } }) // Set the adapter binding.list.adapter = listAdapter } } private const val MEDIA_ID_ARG = "com.example.android.uamp.fragments.MediaItemFragment.MEDIA_ID" ================================================ FILE: app/src/main/java/com/example/android/uamp/fragments/NowPlayingFragment.kt ================================================ /* * Copyright 2019 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.fragments import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Observer import com.bumptech.glide.Glide import com.example.android.uamp.R import com.example.android.uamp.databinding.FragmentNowplayingBinding import com.example.android.uamp.utils.InjectorUtils import com.example.android.uamp.viewmodels.MainActivityViewModel import com.example.android.uamp.viewmodels.NowPlayingFragmentViewModel import com.example.android.uamp.viewmodels.NowPlayingFragmentViewModel.NowPlayingMetadata /** * A fragment representing the current media item being played. */ class NowPlayingFragment : Fragment() { private val mainActivityViewModel by activityViewModels { InjectorUtils.provideMainActivityViewModel(requireContext()) } private val nowPlayingViewModel by viewModels { InjectorUtils.provideNowPlayingFragmentViewModel(requireContext()) } lateinit var binding: FragmentNowplayingBinding companion object { fun newInstance() = NowPlayingFragment() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { binding = FragmentNowplayingBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // Always true, but lets lint know that as well. val context = activity ?: return // Attach observers to the LiveData coming from this ViewModel nowPlayingViewModel.mediaMetadata.observe(viewLifecycleOwner, Observer { mediaItem -> updateUI(view, mediaItem) }) nowPlayingViewModel.mediaButtonRes.observe(viewLifecycleOwner, Observer { res -> binding.mediaButton.setImageResource(res) }) nowPlayingViewModel.mediaPosition.observe(viewLifecycleOwner, Observer { pos -> binding.position.text = NowPlayingMetadata.timestampToMSS(context, pos) }) // Setup UI handlers for buttons binding.mediaButton.setOnClickListener { nowPlayingViewModel.mediaMetadata.value?.let { mainActivityViewModel.playMediaId(it.id) } } // Initialize playback duration and position to zero binding.duration.text = NowPlayingMetadata.timestampToMSS(context, 0L) binding.position.text = NowPlayingMetadata.timestampToMSS(context, 0L) } /** * Internal function used to update all UI elements except for the current item playback */ private fun updateUI(view: View, metadata: NowPlayingMetadata) = with(binding) { if (metadata.albumArtUri == Uri.EMPTY) { albumArt.setImageResource(R.drawable.ic_album_black_24dp) } else { Glide.with(view) .load(metadata.albumArtUri) .into(albumArt) } title.text = metadata.title subtitle.text = metadata.subtitle duration.text = metadata.duration } } ================================================ FILE: app/src/main/java/com/example/android/uamp/utils/Event.kt ================================================ /* * Copyright 2018 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.utils /** * Used as a wrapper for data that is exposed via a LiveData that represents an event. * * For more information, see: * https://medium.com/google-developers/livedata-with-events-ac2622673150 */ class Event(private val content: T) { var hasBeenHandled = false private set // Allow external read but not write /** * Returns the content and prevents its use again. */ fun getContentIfNotHandled(): T? { return if (hasBeenHandled) { null } else { hasBeenHandled = true content } } /** * Returns the content, even if it's already been handled. */ fun peekContent(): T = content } ================================================ FILE: app/src/main/java/com/example/android/uamp/utils/InjectorUtils.kt ================================================ /* * Copyright 2018 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.utils import android.app.Application import android.content.ComponentName import android.content.Context import com.example.android.uamp.common.MusicServiceConnection import com.example.android.uamp.media.MusicService import com.example.android.uamp.viewmodels.MainActivityViewModel import com.example.android.uamp.viewmodels.MediaItemFragmentViewModel import com.example.android.uamp.viewmodels.NowPlayingFragmentViewModel /** * Static methods used to inject classes needed for various Activities and Fragments. */ object InjectorUtils { private fun provideMusicServiceConnection(context: Context): MusicServiceConnection { return MusicServiceConnection.getInstance( context, ComponentName(context, MusicService::class.java) ) } fun provideMainActivityViewModel(context: Context): MainActivityViewModel.Factory { val applicationContext = context.applicationContext val musicServiceConnection = provideMusicServiceConnection(applicationContext) return MainActivityViewModel.Factory(musicServiceConnection) } fun provideMediaItemFragmentViewModel(context: Context, mediaId: String) : MediaItemFragmentViewModel.Factory { val applicationContext = context.applicationContext val musicServiceConnection = provideMusicServiceConnection(applicationContext) return MediaItemFragmentViewModel.Factory(mediaId, musicServiceConnection) } fun provideNowPlayingFragmentViewModel(context: Context) : NowPlayingFragmentViewModel.Factory { val applicationContext = context.applicationContext val musicServiceConnection = provideMusicServiceConnection(applicationContext) return NowPlayingFragmentViewModel.Factory( applicationContext as Application, musicServiceConnection ) } } ================================================ FILE: app/src/main/java/com/example/android/uamp/viewmodels/MainActivityViewModel.kt ================================================ /* * Copyright 2018 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.viewmodels import android.support.v4.media.MediaBrowserCompat import android.util.Log import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.example.android.uamp.MainActivity import com.example.android.uamp.MediaItemData import com.example.android.uamp.common.MusicServiceConnection import com.example.android.uamp.fragments.NowPlayingFragment import com.example.android.uamp.media.extensions.id import com.example.android.uamp.media.extensions.isPlayEnabled import com.example.android.uamp.media.extensions.isPlaying import com.example.android.uamp.media.extensions.isPrepared import com.example.android.uamp.utils.Event /** * Small [ViewModel] that watches a [MusicServiceConnection] to become connected * and provides the root/initial media ID of the underlying [MediaBrowserCompat]. */ class MainActivityViewModel( private val musicServiceConnection: MusicServiceConnection ) : ViewModel() { val rootMediaId: LiveData = Transformations.map(musicServiceConnection.isConnected) { isConnected -> if (isConnected) { musicServiceConnection.rootMediaId } else { null } } /** * [navigateToMediaItem] acts as an "event", rather than state. [Observer]s * are notified of the change as usual with [LiveData], but only one [Observer] * will actually read the data. For more information, check the [Event] class. */ val navigateToMediaItem: LiveData> get() = _navigateToMediaItem private val _navigateToMediaItem = MutableLiveData>() /** * This [LiveData] object is used to notify the MainActivity that the main * content fragment needs to be swapped. Information about the new fragment * is conveniently wrapped by the [Event] class. */ val navigateToFragment: LiveData> get() = _navigateToFragment private val _navigateToFragment = MutableLiveData>() /** * This method takes a [MediaItemData] and routes it depending on whether it's * browsable (i.e.: it's the parent media item of a set of other media items, * such as an album), or not. * * If the item is browsable, handle it by sending an event to the Activity to * browse to it, otherwise play it. */ fun mediaItemClicked(clickedItem: MediaItemData) { if (clickedItem.browsable) { browseToItem(clickedItem) } else { playMedia(clickedItem, pauseAllowed = false) showFragment(NowPlayingFragment.newInstance()) } } /** * Convenience method used to swap the fragment shown in the main activity * * @param fragment the fragment to show * @param backStack if true, add this transaction to the back stack * @param tag the name to use for this fragment in the stack */ fun showFragment(fragment: Fragment, backStack: Boolean = true, tag: String? = null) { _navigateToFragment.value = Event(FragmentNavigationRequest(fragment, backStack, tag)) } /** * This posts a browse [Event] that will be handled by the * observer in [MainActivity]. */ private fun browseToItem(mediaItem: MediaItemData) { _navigateToMediaItem.value = Event(mediaItem.mediaId) } /** * This method takes a [MediaItemData] and does one of the following: * - If the item is *not* the active item, then play it directly. * - If the item *is* the active item, check whether "pause" is a permitted command. If it is, * then pause playback, otherwise send "play" to resume playback. */ fun playMedia(mediaItem: MediaItemData, pauseAllowed: Boolean = true) { val nowPlaying = musicServiceConnection.nowPlaying.value val transportControls = musicServiceConnection.transportControls val isPrepared = musicServiceConnection.playbackState.value?.isPrepared ?: false if (isPrepared && mediaItem.mediaId == nowPlaying?.id) { musicServiceConnection.playbackState.value?.let { playbackState -> when { playbackState.isPlaying -> if (pauseAllowed) transportControls.pause() else Unit playbackState.isPlayEnabled -> transportControls.play() else -> { Log.w( TAG, "Playable item clicked but neither play nor pause are enabled!" + " (mediaId=${mediaItem.mediaId})" ) } } } } else { transportControls.playFromMediaId(mediaItem.mediaId, null) } } fun playMediaId(mediaId: String) { val nowPlaying = musicServiceConnection.nowPlaying.value val transportControls = musicServiceConnection.transportControls val isPrepared = musicServiceConnection.playbackState.value?.isPrepared ?: false if (isPrepared && mediaId == nowPlaying?.id) { musicServiceConnection.playbackState.value?.let { playbackState -> when { playbackState.isPlaying -> transportControls.pause() playbackState.isPlayEnabled -> transportControls.play() else -> { Log.w( TAG, "Playable item clicked but neither play nor pause are enabled!" + " (mediaId=$mediaId)" ) } } } } else { transportControls.playFromMediaId(mediaId, null) } } class Factory( private val musicServiceConnection: MusicServiceConnection ) : ViewModelProvider.NewInstanceFactory() { @Suppress("unchecked_cast") override fun create(modelClass: Class): T { return MainActivityViewModel(musicServiceConnection) as T } } } /** * Helper class used to pass fragment navigation requests between MainActivity * and its corresponding ViewModel. */ data class FragmentNavigationRequest( val fragment: Fragment, val backStack: Boolean = false, val tag: String? = null ) private const val TAG = "MainActivitytVM" ================================================ FILE: app/src/main/java/com/example/android/uamp/viewmodels/MediaItemFragmentViewModel.kt ================================================ /* * Copyright 2018 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.viewmodels import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaBrowserCompat.SubscriptionCallback import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.example.android.uamp.MediaItemData import com.example.android.uamp.R import com.example.android.uamp.common.EMPTY_PLAYBACK_STATE import com.example.android.uamp.common.MusicServiceConnection import com.example.android.uamp.common.NOTHING_PLAYING import com.example.android.uamp.fragments.MediaItemFragment import com.example.android.uamp.media.extensions.id import com.example.android.uamp.media.extensions.isPlaying /** * [ViewModel] for [MediaItemFragment]. */ class MediaItemFragmentViewModel( private val mediaId: String, musicServiceConnection: MusicServiceConnection ) : ViewModel() { /** * Use a backing property so consumers of mediaItems only get a [LiveData] instance so * they don't inadvertently modify it. */ private val _mediaItems = MutableLiveData>() val mediaItems: LiveData> = _mediaItems /** * Pass the status of the [MusicServiceConnection.networkFailure] through. */ val networkError = Transformations.map(musicServiceConnection.networkFailure) { it } private val subscriptionCallback = object : SubscriptionCallback() { override fun onChildrenLoaded(parentId: String, children: List) { val itemsList = children.map { child -> val subtitle = child.description.subtitle ?: "" MediaItemData( child.mediaId!!, child.description.title.toString(), subtitle.toString(), child.description.iconUri!!, child.isBrowsable, getResourceForMediaId(child.mediaId!!) ) } _mediaItems.postValue(itemsList) } } /** * When the session's [PlaybackStateCompat] changes, the [mediaItems] need to be updated * so the correct [MediaItemData.playbackRes] is displayed on the active item. * (i.e.: play/pause button or blank) */ private val playbackStateObserver = Observer { val playbackState = it ?: EMPTY_PLAYBACK_STATE val metadata = musicServiceConnection.nowPlaying.value ?: NOTHING_PLAYING if (metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) != null) { _mediaItems.postValue(updateState(playbackState, metadata)) } } /** * When the session's [MediaMetadataCompat] changes, the [mediaItems] need to be updated * as it means the currently active item has changed. As a result, the new, and potentially * old item (if there was one), both need to have their [MediaItemData.playbackRes] * changed. (i.e.: play/pause button or blank) */ private val mediaMetadataObserver = Observer { val playbackState = musicServiceConnection.playbackState.value ?: EMPTY_PLAYBACK_STATE val metadata = it ?: NOTHING_PLAYING if (metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) != null) { _mediaItems.postValue(updateState(playbackState, metadata)) } } /** * Because there's a complex dance between this [ViewModel] and the [MusicServiceConnection] * (which is wrapping a [MediaBrowserCompat] object), the usual guidance of using * [Transformations] doesn't quite work. * * Specifically there's three things that are watched that will cause the single piece of * [LiveData] exposed from this class to be updated. * * [subscriptionCallback] (defined above) is called if/when the children of this * ViewModel's [mediaId] changes. * * [MusicServiceConnection.playbackState] changes state based on the playback state of * the player, which can change the [MediaItemData.playbackRes]s in the list. * * [MusicServiceConnection.nowPlaying] changes based on the item that's being played, * which can also change the [MediaItemData.playbackRes]s in the list. */ private val musicServiceConnection = musicServiceConnection.also { it.subscribe(mediaId, subscriptionCallback) it.playbackState.observeForever(playbackStateObserver) it.nowPlaying.observeForever(mediaMetadataObserver) } /** * Since we use [LiveData.observeForever] above (in [musicServiceConnection]), we want * to call [LiveData.removeObserver] here to prevent leaking resources when the [ViewModel] * is not longer in use. * * For more details, see the kdoc on [musicServiceConnection] above. */ override fun onCleared() { super.onCleared() // Remove the permanent observers from the MusicServiceConnection. musicServiceConnection.playbackState.removeObserver(playbackStateObserver) musicServiceConnection.nowPlaying.removeObserver(mediaMetadataObserver) // And then, finally, unsubscribe the media ID that was being watched. musicServiceConnection.unsubscribe(mediaId, subscriptionCallback) } private fun getResourceForMediaId(mediaId: String): Int { val isActive = mediaId == musicServiceConnection.nowPlaying.value?.id val isPlaying = musicServiceConnection.playbackState.value?.isPlaying ?: false return when { !isActive -> NO_RES isPlaying -> R.drawable.ic_pause_black_24dp else -> R.drawable.ic_play_arrow_black_24dp } } private fun updateState( playbackState: PlaybackStateCompat, mediaMetadata: MediaMetadataCompat ): List { val newResId = when (playbackState.isPlaying) { true -> R.drawable.ic_pause_black_24dp else -> R.drawable.ic_play_arrow_black_24dp } return mediaItems.value?.map { val useResId = if (it.mediaId == mediaMetadata.id) newResId else NO_RES it.copy(playbackRes = useResId) } ?: emptyList() } class Factory( private val mediaId: String, private val musicServiceConnection: MusicServiceConnection ) : ViewModelProvider.NewInstanceFactory() { @Suppress("unchecked_cast") override fun create(modelClass: Class): T { return MediaItemFragmentViewModel(mediaId, musicServiceConnection) as T } } } private const val TAG = "MediaItemFragmentVM" private const val NO_RES = 0 ================================================ FILE: app/src/main/java/com/example/android/uamp/viewmodels/NowPlayingFragmentViewModel.kt ================================================ /* * Copyright 2019 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.viewmodels import android.app.Application import android.content.Context import android.net.Uri import android.os.Handler import android.os.Looper import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.example.android.uamp.R import com.example.android.uamp.common.EMPTY_PLAYBACK_STATE import com.example.android.uamp.common.MusicServiceConnection import com.example.android.uamp.common.NOTHING_PLAYING import com.example.android.uamp.fragments.NowPlayingFragment import com.example.android.uamp.media.extensions.albumArtUri import com.example.android.uamp.media.extensions.currentPlayBackPosition import com.example.android.uamp.media.extensions.displaySubtitle import com.example.android.uamp.media.extensions.duration import com.example.android.uamp.media.extensions.id import com.example.android.uamp.media.extensions.isPlaying import com.example.android.uamp.media.extensions.title /** * [ViewModel] for [NowPlayingFragment] which displays the album art in full size. * It extends AndroidViewModel and uses the [Application]'s context to be able to reference string * resources. */ class NowPlayingFragmentViewModel( private val app: Application, musicServiceConnection: MusicServiceConnection ) : AndroidViewModel(app) { /** * Utility class used to represent the metadata necessary to display the * media item currently being played. */ data class NowPlayingMetadata( val id: String, val albumArtUri: Uri, val title: String?, val subtitle: String?, val duration: String ) { companion object { /** * Utility method to convert milliseconds to a display of minutes and seconds */ fun timestampToMSS(context: Context, position: Long): String { val totalSeconds = Math.floor(position / 1E3).toInt() val minutes = totalSeconds / 60 val remainingSeconds = totalSeconds - (minutes * 60) return if (position < 0) context.getString(R.string.duration_unknown) else context.getString(R.string.duration_format).format(minutes, remainingSeconds) } } } private var playbackState: PlaybackStateCompat = EMPTY_PLAYBACK_STATE val mediaMetadata = MutableLiveData() val mediaPosition = MutableLiveData().apply { postValue(0L) } val mediaButtonRes = MutableLiveData().apply { postValue(R.drawable.ic_album_black_24dp) } private var updatePosition = true private val handler = Handler(Looper.getMainLooper()) /** * When the session's [PlaybackStateCompat] changes, the [mediaItems] need to be updated * so the correct [MediaItemData.playbackRes] is displayed on the active item. * (i.e.: play/pause button or blank) */ private val playbackStateObserver = Observer { playbackState = it ?: EMPTY_PLAYBACK_STATE val metadata = musicServiceConnection.nowPlaying.value ?: NOTHING_PLAYING updateState(playbackState, metadata) } /** * When the session's [MediaMetadataCompat] changes, the [mediaItems] need to be updated * as it means the currently active item has changed. As a result, the new, and potentially * old item (if there was one), both need to have their [MediaItemData.playbackRes] * changed. (i.e.: play/pause button or blank) */ private val mediaMetadataObserver = Observer { updateState(playbackState, it) } /** * Because there's a complex dance between this [ViewModel] and the [MusicServiceConnection] * (which is wrapping a [MediaBrowserCompat] object), the usual guidance of using * [Transformations] doesn't quite work. * * Specifically there's three things that are watched that will cause the single piece of * [LiveData] exposed from this class to be updated. * * [MusicServiceConnection.playbackState] changes state based on the playback state of * the player, which can change the [MediaItemData.playbackRes]s in the list. * * [MusicServiceConnection.nowPlaying] changes based on the item that's being played, * which can also change the [MediaItemData.playbackRes]s in the list. */ private val musicServiceConnection = musicServiceConnection.also { it.playbackState.observeForever(playbackStateObserver) it.nowPlaying.observeForever(mediaMetadataObserver) checkPlaybackPosition() } /** * Internal function that recursively calls itself every [POSITION_UPDATE_INTERVAL_MILLIS] ms * to check the current playback position and updates the corresponding LiveData object when it * has changed. */ private fun checkPlaybackPosition(): Boolean = handler.postDelayed({ val currPosition = playbackState.currentPlayBackPosition if (mediaPosition.value != currPosition) mediaPosition.postValue(currPosition) if (updatePosition) checkPlaybackPosition() }, POSITION_UPDATE_INTERVAL_MILLIS) /** * Since we use [LiveData.observeForever] above (in [musicServiceConnection]), we want * to call [LiveData.removeObserver] here to prevent leaking resources when the [ViewModel] * is not longer in use. * * For more details, see the kdoc on [musicServiceConnection] above. */ override fun onCleared() { super.onCleared() // Remove the permanent observers from the MusicServiceConnection. musicServiceConnection.playbackState.removeObserver(playbackStateObserver) musicServiceConnection.nowPlaying.removeObserver(mediaMetadataObserver) // Stop updating the position updatePosition = false } private fun updateState( playbackState: PlaybackStateCompat, mediaMetadata: MediaMetadataCompat ) { // Only update media item once we have duration available if (mediaMetadata.duration != 0L && mediaMetadata.id != null) { val nowPlayingMetadata = NowPlayingMetadata( mediaMetadata.id!!, mediaMetadata.albumArtUri, mediaMetadata.title?.trim(), mediaMetadata.displaySubtitle?.trim(), NowPlayingMetadata.timestampToMSS(app, mediaMetadata.duration) ) this.mediaMetadata.postValue(nowPlayingMetadata) } // Update the media button resource ID mediaButtonRes.postValue( when (playbackState.isPlaying) { true -> R.drawable.ic_pause_black_24dp else -> R.drawable.ic_play_arrow_black_24dp } ) } class Factory( private val app: Application, private val musicServiceConnection: MusicServiceConnection ) : ViewModelProvider.NewInstanceFactory() { @Suppress("unchecked_cast") override fun create(modelClass: Class): T { return NowPlayingFragmentViewModel(app, musicServiceConnection) as T } } } private const val TAG = "NowPlayingFragmentVM" private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L ================================================ FILE: app/src/main/res/drawable/ic_album_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pause_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_arrow_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_signal_wifi_off_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/media_item_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/media_item_mask.xml ================================================ ================================================ FILE: app/src/main/res/drawable/media_overlay_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/cast_context_error.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_mediaitem.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_mediaitem_list.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_nowplaying.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #840255 #710144 #14A5A1 #00000000 #F1F1F1 #f8f8f8 #eff0f0 #fdfdfd #ffffff ================================================ FILE: app/src/main/res/values/dimens.xml ================================================ 16dp 72dp 4dp 12dp 4dp 72dp 4dp 2dp -2dp 72dp 42dp 8dp 42dp 52dp 32dp 8dp 32dp 16dp 32sp 18sp ================================================ FILE: app/src/main/res/values/strings.xml ================================================ UAMP Skip back 10s Skip forward 10s Play Pause Queue Album art --:-- %d:%02d Failed to get Cast context. Try updating Google Play Services and restart the app. ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/main/res/xml/automotive_app_desc.xml ================================================ ================================================ FILE: automotive/build.gradle ================================================ /* * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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. */ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android-extensions' android { compileSdkVersion rootProject.compileSdkVersion defaultConfig { applicationId "com.example.android.uamp.next" minSdkVersion 21 targetSdkVersion rootProject.targetSdkVersion versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { viewBinding true } compileOptions { sourceCompatibility 1.8 targetCompatibility 1.8 } kotlinOptions { jvmTarget = "1.8" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } } dependencies { implementation project(':common') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.core:core-ktx:$androidx_core_ktx_version" implementation "androidx.preference:preference:$androidx_preference_version" implementation "androidx.car:car:$androidx_car_version" implementation "androidx.constraintlayout:constraintlayout:$constraint_layout_version" implementation "androidx.appcompat:appcompat:$androidx_app_compat_version" implementation "androidx.lifecycle:lifecycle-extensions:$arch_lifecycle_version" implementation "com.google.android.gms:play-services-auth:$play_services_auth_version" testImplementation "junit:junit:$junit_version" androidTestImplementation "androidx.test:runner:$androidx_test_runner_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } ================================================ FILE: automotive/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: automotive/src/androidTest/java/com/example/android/uamp/automotive/ExampleInstrumentedTest.java ================================================ /* * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive; import android.content.Context; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.assertEquals; /** * Instrumented test, which will execute on an Android device. * * @see Testing documentation */ @RunWith(AndroidJUnit4.class) public class ExampleInstrumentedTest { @Test public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getTargetContext(); assertEquals("com.example.automotive", appContext.getPackageName()); } } ================================================ FILE: automotive/src/main/AndroidManifest.xml ================================================ ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/AutomotiveMusicService.kt ================================================ /* * Copyright 2019 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.accounts.AccountManager import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Bundle import android.os.ResultReceiver import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import androidx.core.content.edit import com.example.android.uamp.media.MusicService import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import kotlinx.coroutines.ExperimentalCoroutinesApi /** UAMP specific command for logging into the service. */ const val LOGIN = "com.example.android.uamp.automotive.COMMAND.LOGIN" /** UAMP specific command for logging out of the service. */ const val LOGOUT = "com.example.android.uamp.automotive.COMMAND.LOGOUT" const val LOGIN_EMAIL = "com.example.android.uamp.automotive.ARGS.LOGIN_EMAIL" const val LOGIN_PASSWORD = "com.example.android.uamp.automotive.ARGS.LOGIN_PASSWORD" typealias CommandHandler = (parameters: Bundle, callback: ResultReceiver?) -> Boolean /** * Android Automotive specific extensions for [MusicService]. * * UAMP for Android Automotive OS requires the user to login in order to demonstrate * how authentication works on the system. If this doesn't apply to your application, * this class can be skipped in favor of its parent, [MusicService]. * * If you'd like to support authentication, but not prevent using the system, * comment out the calls to [requireLogin]. */ class AutomotiveMusicService : MusicService() { @ExperimentalCoroutinesApi override fun onCreate() { super.onCreate() // Register to handle login/logout commands. mediaSessionConnector.registerCustomCommandReceiver(AutomotiveCommandReceiver()) // Require the user to be logged in for demonstration purposes. if (!isAuthenticated()) { requireLogin() } } private fun onLogin(email: String, password: String): Boolean { Log.i(TAG, "User logged in: $email") getSharedPreferences(AutomotiveMusicService::class.java.name, Context.MODE_PRIVATE).edit { putString(USER_TOKEN, "$email:${password.hashCode()}") } return true } private fun onLogout(): Boolean { Log.i(TAG, "User logged out") getSharedPreferences(AutomotiveMusicService::class.java.name, Context.MODE_PRIVATE).edit { remove(USER_TOKEN) } return false } /** * Verifies if the user has logged into the system. * In a real system, credentials should probably be handled by the * [AccountManager] APIs. */ private fun isAuthenticated() = getSharedPreferences(AutomotiveMusicService::class.java.name, Context.MODE_PRIVATE) .contains(USER_TOKEN) /** * Sets [PlaybackStateCompat] values to indicate the user must login to continue. * * This routine sets the playback state and provides the resolution [PendingIntent] * that Android Automotive OS requires. */ private fun requireLogin() { val loginIntent = Intent(this, SignInActivity::class.java) val loginActivityPendingIntent = PendingIntent.getActivity(this, 0, loginIntent, 0) val extras = Bundle().apply { putString(ERROR_RESOLUTION_ACTION_LABEL, getString(R.string.error_login_button)) putParcelable(ERROR_RESOLUTION_ACTION_INTENT, loginActivityPendingIntent) } mediaSessionConnector.setCustomErrorMessage( getString(R.string.error_require_login), PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED, extras ) } /** * This is the entry point for custom commands received by ExoPlayer's * [MediaSessionConnector.customCommandReceivers]. * * The extension will call each [CommandReceiver] in turn. If the [CommandReceiver] can * handle the command, it returns `true` to indicate the command's been handled and * processing should stop. If the [CommandReceiver] cannot/doesn't want to handle the * command, it should return `false`. * * We simplify this a bit by having our own [CommandHandler] that works with a single * command (either "log in" or "log out"). Each of these returns true at the end of its * processing. * * If the command received isn't either of our commands, we just return `false`. * * Suppress the warning because the original name, `cb` is not as clear as to its purpose. */ private inner class AutomotiveCommandReceiver : MediaSessionConnector.CommandReceiver { @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") override fun onCommand( player: Player, command: String, extras: Bundle?, callback: ResultReceiver? ): Boolean = when (command) { LOGIN -> loginCommand(extras ?: Bundle.EMPTY, callback) LOGOUT -> logoutCommand(extras ?: Bundle.EMPTY, callback) else -> false } } private val loginCommand: CommandHandler = { extras, callback -> val email = extras.getString(LOGIN_EMAIL) ?: "" val password = extras.getString(LOGIN_PASSWORD) ?: "" if (onLogin(email, password)) { // Updated state (including clearing the error) now that the user has logged in. mediaSessionConnector.setCustomErrorMessage(null) mediaSessionConnector.invalidateMediaSessionPlaybackState() callback?.send(Activity.RESULT_OK, Bundle.EMPTY) } else { // Login is required - note this. requireLogin() callback?.send(Activity.RESULT_CANCELED, Bundle.EMPTY) } true } private val logoutCommand: CommandHandler = { _, callback -> // Log the user out. onLogout() // Login is required - note this. requireLogin() callback?.send(Activity.RESULT_OK, Bundle.EMPTY) true } } private const val TAG = "AutomotiveMusicService" private const val ERROR_RESOLUTION_ACTION_LABEL = "android.media.extras.ERROR_RESOLUTION_ACTION_LABEL" private const val ERROR_RESOLUTION_ACTION_INTENT = "android.media.extras.ERROR_RESOLUTION_ACTION_INTENT" private const val USER_TOKEN = "com.example.android.uamp.automotive.PREFS.USER_TOKEN" ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/PhoneSignInFragment.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.View import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import com.example.android.uamp.automotive.databinding.PhoneSignInBinding /** * Fragment that is used to facilitate phone sign-in. The fragment allows users to choose between * either the PIN or QR code sign-in flow. */ class PhoneSignInFragment : Fragment(R.layout.phone_sign_in) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val context = requireContext() val binding = PhoneSignInBinding.bind(view) binding.toolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } // Set up PIN sign in button. binding.appIcon.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.aural_logo)) binding.primaryMessage.text = getString(R.string.phone_sign_in_primary_text) binding.pinSignInButton.text = getString(R.string.pin_sign_in_button_label) binding.pinSignInButton.setOnClickListener { requireActivity().supportFragmentManager.beginTransaction() .replace(R.id.sign_in_container, PinCodeSignInFragment()) .addToBackStack("landingPage") .commit() } // Set up QR code sign in button. binding.qrSignInButton.text = getString(R.string.qr_sign_in_button_label) binding.qrSignInButton.setOnClickListener { requireActivity().supportFragmentManager.beginTransaction() .replace(R.id.sign_in_container, QrCodeSignInFragment()) .addToBackStack("landingPage") .commit() } // Links in footer text should be clickable. binding.footer.text = HtmlCompat.fromHtml( context.getString(R.string.sign_in_footer), HtmlCompat.FROM_HTML_MODE_LEGACY ) binding.footer.movementMethod = LinkMovementMethod.getInstance() } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/PinCodeSignInFragment.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import com.example.android.uamp.automotive.databinding.PinSignInBinding /** * Fragment that is used to facilitate PIN code sign-in. This fragment displayed a configurable * PIN code that users enter in a secondary device to perform sign-in. * *

This screen serves as a demo for UI best practices for PIN code sign in. Sign in implementation * will be app specific and is not included. */ class PinCodeSignInFragment : Fragment(R.layout.pin_sign_in) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val context = requireContext() val binding = PinSignInBinding.bind(view) binding.toolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } binding.appIcon.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.aural_logo)) binding.primaryMessage.text = getString(R.string.pin_sign_in_primary_text) binding.secondaryMessage.text = getString(R.string.pin_sign_in_secondary_text) // Links in footer text should be clickable. binding.footer.text = HtmlCompat.fromHtml( context.getString(R.string.sign_in_footer), HtmlCompat.FROM_HTML_MODE_LEGACY ) binding.footer.movementMethod = LinkMovementMethod.getInstance() val pin = ViewModelProvider(requireActivity()) .get(SignInActivityViewModel::class.java) .generatePin() // Remove existing PIN characters. if (binding.pinCodeContainer.childCount > 0) { binding.pinCodeContainer.removeAllViews() } for (element in pin) { val pinItem = LayoutInflater.from(context).inflate( R.layout.pin_item, binding.pinCodeContainer, false ) as TextView pinItem.text = element.toString() binding.pinCodeContainer.addView(pinItem) } } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/QrCodeSignInFragment.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.View import androidx.core.content.ContextCompat.getDrawable import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import com.bumptech.glide.Glide import com.example.android.uamp.automotive.databinding.QrSignInBinding /** * Fragment that is used to facilitate QR code sign-in. Users scan a QR code rendered by this * fragment with their phones, which performs the authentication required for sign-in * *

This screen serves as a demo for UI best practices for QR code sign in. Sign in implementation * will be app specific and is not included. */ class QrCodeSignInFragment : Fragment(R.layout.qr_sign_in) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = QrSignInBinding.bind(view) binding.toolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } binding.appIcon.setImageDrawable(getDrawable(requireContext(), R.drawable.aural_logo)) binding.primaryMessage.text = getString(R.string.qr_sign_in_primary_text) binding.secondaryMessage.text = getString(R.string.qr_sign_in_secondary_text) // Links in footer text should be clickable. binding.footer.text = HtmlCompat.fromHtml( requireContext().getString(R.string.sign_in_footer), HtmlCompat.FROM_HTML_MODE_LEGACY ) binding.footer.movementMethod = LinkMovementMethod.getInstance() Glide.with(this).load(getString(R.string.qr_code_url)).into(binding.qrCode) } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/SettingsActivity.kt ================================================ /* * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.example.android.uamp.automotive.databinding.ActivitySettingsBinding /** * This class exposes application settings * for integration with MediaCenter in Android Automotive. */ class SettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setHomeButtonEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportFragmentManager .beginTransaction() .replace(R.id.settings_container, SettingsFragment()) .commit() } override fun onBackPressed() { super.onBackPressed() finish() } override fun onSupportNavigateUp(): Boolean { onBackPressed() return true } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/SettingsFragment.kt ================================================ /* * Copyright 2019 Google Inc. 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. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.app.Application import android.content.ComponentName import android.os.Bundle import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModelProvider import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.example.android.uamp.common.MusicServiceConnection /** * Preference fragment hosted by [SettingsActivity]. Handles events to various preference changes. */ class SettingsFragment : PreferenceFragmentCompat() { private lateinit var viewModel: SettingsFragmentViewModel override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences, rootKey) viewModel = ViewModelProvider(this) .get(SettingsFragmentViewModel::class.java) } override fun onPreferenceTreeClick(preference: Preference?): Boolean { return when (preference?.key) { "logout" -> { viewModel.logout() requireActivity().finish() true } else -> { super.onPreferenceTreeClick(preference) } } } } /** * Basic ViewModel for [SettingsFragment]. */ class SettingsFragmentViewModel(application: Application) : AndroidViewModel(application) { private val applicationContext = application.applicationContext private val musicServiceConnection = MusicServiceConnection( applicationContext, ComponentName(applicationContext, AutomotiveMusicService::class.java) ) fun logout() { // Logout is fire and forget. musicServiceConnection.sendCommand(LOGOUT, null) } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/SignInActivity.kt ================================================ /* * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider class SignInActivity : AppCompatActivity() { private lateinit var viewModel: SignInActivityViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_sign_in) viewModel = ViewModelProvider(this) .get(SignInActivityViewModel::class.java) viewModel.loggedIn.observe(this, Observer { loggedIn -> if (loggedIn == true) { Toast.makeText(this, R.string.sign_in_success_message, Toast.LENGTH_SHORT).show() finish() } }) supportFragmentManager.beginTransaction() .add(R.id.sign_in_container, SignInLandingPageFragment()) .commit() } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/SignInActivityViewModel.kt ================================================ /* * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.app.Activity import android.app.Application import android.content.ComponentName import android.os.Bundle import android.text.TextUtils import android.widget.Toast import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.example.android.uamp.common.MusicServiceConnection import java.util.Random /** * Basic ViewModel for [SignInActivity]. */ class SignInActivityViewModel(application: Application) : AndroidViewModel(application) { private val applicationContext = application.applicationContext private val musicServiceConnection = MusicServiceConnection( applicationContext, ComponentName(applicationContext, AutomotiveMusicService::class.java) ) private val _loggedIn = MutableLiveData() val loggedIn: LiveData = _loggedIn fun login(email: String, password: String) { if (TextUtils.isEmpty(email) or TextUtils.isEmpty(password)) { Toast.makeText( applicationContext, applicationContext.getString(R.string.missing_fields_error), Toast.LENGTH_SHORT ).show() } else { val loginParams = Bundle().apply { putString(LOGIN_EMAIL, email) putString(LOGIN_PASSWORD, password) } musicServiceConnection.sendCommand(LOGIN, loginParams) { resultCode, _ -> _loggedIn.postValue(resultCode == Activity.RESULT_OK) } } } fun generatePin(): CharSequence { return String.format("%08d", Random().nextInt(99999999)) } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/SignInLandingPageFragment.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.content.Intent import android.os.Build import android.os.Bundle import android.text.InputType import android.text.TextUtils import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.view.View.AUTOFILL_HINT_USERNAME import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.appcompat.widget.Toolbar import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.api.ApiException import com.google.android.gms.tasks.Task import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout const val RC_SIGN_IN = 9001 const val PLAY_SERVICES_RESOLUTION_REQUEST = 9000 // Control the supported sign in flows by toggling the constants below. const val ENABLE_PIN_SIGN_IN = true const val ENABLE_QR_CODE_SIGN_IN = true const val ENABLE_GOOGLE_SIGN_IN = true const val ENABLE_USERNAME_PASSWORD_SIGN_IN = true /** * A fragment that renders the landing screen for a sign-in flow. This screen can be configured * to display third-party sign-in, PIN sign-in, QR-code sign-in and/or Google sign-in. */ class SignInLandingPageFragment : Fragment() { companion object { internal const val CAR_SIGN_IN_IDENTIFIER_KEY = "userID" } private lateinit var toolbar: Toolbar private lateinit var appIcon: ImageView private lateinit var phoneSignInButton: Button private lateinit var googleSignInButton: Button private lateinit var usernameAndPasswordSignInButton: Button private lateinit var primaryTextView: TextView private lateinit var identifierContainer: TextInputLayout private lateinit var identifierInput: TextInputEditText private lateinit var footerTextView: TextView override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val layout = if (ENABLE_USERNAME_PASSWORD_SIGN_IN) R.layout.sign_in_landing_page_with_username_and_password else R.layout.sign_in_landing_page return inflater.inflate(layout, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val context = requireContext() toolbar = view.findViewById(R.id.toolbar) appIcon = view.findViewById(R.id.app_icon) primaryTextView = view.findViewById(R.id.primary_message) footerTextView = view.findViewById(R.id.footer) phoneSignInButton = view.findViewById(R.id.phone_sign_in_button) googleSignInButton = view.findViewById(R.id.google_sign_in_button) if (ENABLE_USERNAME_PASSWORD_SIGN_IN) { usernameAndPasswordSignInButton = view.findViewById(R.id.sign_in_button) identifierContainer = view.findViewById(R.id.identifier_container) identifierInput = view.findViewById(R.id.identifier_input) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { identifierInput.setAutofillHints(AUTOFILL_HINT_USERNAME) } } toolbar.setNavigationOnClickListener { requireActivity().finish() } appIcon.setImageDrawable(context.getDrawable(R.drawable.aural_logo)) primaryTextView.text = getString(R.string.sign_in_primary_text) // Links in footer text should be clickable. footerTextView.text = HtmlCompat.fromHtml( context.getString(R.string.sign_in_footer), HtmlCompat.FROM_HTML_MODE_LEGACY ) footerTextView.movementMethod = LinkMovementMethod.getInstance() configureUsernameAndPasswordSignIn() configurePhoneSignIn() configureGoogleSignIn() } private fun configureUsernameAndPasswordSignIn() { if (!ENABLE_USERNAME_PASSWORD_SIGN_IN) { return } identifierContainer.hint = getString(R.string.sign_in_user_id_hint) identifierInput.inputType = InputType.TYPE_CLASS_TEXT usernameAndPasswordSignInButton.text = getString(R.string.sign_in_next_button_label) usernameAndPasswordSignInButton.setOnClickListener { val identifier = identifierInput.text if (TextUtils.isEmpty(identifier)) { identifierInput.error = getString(R.string.sign_in_username_error) } else { val args = Bundle() args.putString(CAR_SIGN_IN_IDENTIFIER_KEY, identifierInput.text.toString()) val fragment = UsernameAndPasswordSignInFragment() fragment.arguments = args requireActivity().supportFragmentManager.beginTransaction() .replace(R.id.sign_in_container, fragment) .addToBackStack("landingPage") .commit() } } } private fun configurePhoneSignIn() { if (!ENABLE_QR_CODE_SIGN_IN && !ENABLE_PIN_SIGN_IN) { phoneSignInButton.visibility = View.GONE return } lateinit var phoneSignInFragment: Fragment if (ENABLE_QR_CODE_SIGN_IN && ENABLE_PIN_SIGN_IN) { // Reduce the number of choices displayed to the user in a single screen. If both PIN // and QR code sign in is enabled, separate the choice between the two options to a // new screen. phoneSignInFragment = PhoneSignInFragment() } else if (ENABLE_PIN_SIGN_IN) { phoneSignInFragment = PinCodeSignInFragment() } else if (ENABLE_QR_CODE_SIGN_IN) { phoneSignInFragment = QrCodeSignInFragment() } phoneSignInButton.text = getString(R.string.phone_sign_in_button_label) phoneSignInButton.setOnClickListener { requireActivity().supportFragmentManager.beginTransaction() .replace(R.id.sign_in_container, phoneSignInFragment) .addToBackStack("landingPage") .commit() } } /** * Configure the Google sign in option on the landing page. * *

https://developers.google.com/identity/sign-in/android/start provides additional * information on integrating Google sign in into your Android app. */ private fun configureGoogleSignIn() { if (!ENABLE_GOOGLE_SIGN_IN or !checkPlayServices()) { googleSignInButton.visibility = View.GONE return } val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestIdToken(getString(R.string.server_client_id)) .requestEmail() .build() googleSignInButton.text = getString(R.string.google_sign_in_button_label) googleSignInButton.setOnClickListener { val mGoogleSignInClient = GoogleSignIn.getClient(requireContext(), gso) val signInIntent = mGoogleSignInClient.signInIntent startActivityForResult(signInIntent, RC_SIGN_IN) } } private fun checkPlayServices(): Boolean { val apiAvailability = GoogleApiAvailability.getInstance(); val resultCode = apiAvailability.isGooglePlayServicesAvailable(context); if (resultCode != ConnectionResult.SUCCESS) { if (apiAvailability.isUserResolvableError(resultCode)) { apiAvailability.getErrorDialog( activity, resultCode, PLAY_SERVICES_RESOLUTION_REQUEST ).show(); } return false; } return true; } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == RC_SIGN_IN) { val task = GoogleSignIn.getSignedInAccountFromIntent(data) handleGoogleSignIn(task) } } private fun handleGoogleSignIn(completedTask: Task) { try { val account = completedTask.getResult(ApiException::class.java) @Suppress("unused_variable") val idToken = account?.idToken // Send ID Token to server and validate. } catch (e: ApiException) { // The ApiException status code indicates the detailed failure reason. // Please refer to the GoogleSignInStatusCodes class reference for more information. Toast.makeText( requireContext(), getString(R.string.sign_in_failed_message, e.statusCode), Toast.LENGTH_SHORT ) .show() } } } ================================================ FILE: automotive/src/main/java/com/example/android/uamp/automotive/UsernameAndPasswordSignInFragment.kt ================================================ /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License 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.example.android.uamp.automotive import android.os.Build import android.os.Bundle import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import android.widget.TextView import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout /** * Fragment that is used to facilitates username and password sign-in. */ class UsernameAndPasswordSignInFragment : Fragment() { private lateinit var toolbar: Toolbar private lateinit var appIcon: ImageView private lateinit var primaryTextView: TextView private lateinit var passwordContainer: TextInputLayout private lateinit var passwordInput: TextInputEditText private lateinit var submitButton: Button private lateinit var footerTextView: TextView override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.username_and_password_sign_in, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val context = requireContext() toolbar = view.findViewById(R.id.toolbar) appIcon = view.findViewById(R.id.app_icon) primaryTextView = view.findViewById(R.id.primary_message) passwordContainer = view.findViewById(R.id.password_container) passwordInput = view.findViewById(R.id.password_input) submitButton = view.findViewById(R.id.submit_button) footerTextView = view.findViewById(R.id.footer) toolbar.setNavigationOnClickListener { requireActivity().supportFragmentManager.popBackStack() } appIcon.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.aural_logo)) primaryTextView.text = getString(R.string.username_and_password_sign_in_primary_text) passwordContainer.hint = getString(R.string.password_hint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { passwordInput.setAutofillHints(View.AUTOFILL_HINT_PASSWORD) } // Links in footer text should be clickable. footerTextView.text = HtmlCompat.fromHtml( context.getString(R.string.sign_in_footer), HtmlCompat.FROM_HTML_MODE_LEGACY ) footerTextView.movementMethod = LinkMovementMethod.getInstance() // Get user identifier from previous screen. val userId = arguments?.getString(SignInLandingPageFragment.CAR_SIGN_IN_IDENTIFIER_KEY) submitButton.text = getString(R.string.sign_in_submit_button_label) submitButton.setOnClickListener { onSignIn(userId!!, passwordInput.text.toString()) } } private fun onSignIn(userIdentifier: CharSequence, password: CharSequence) { ViewModelProvider(requireActivity()) .get(SignInActivityViewModel::class.java) .login(userIdentifier.toString(), password.toString()) } } ================================================ FILE: automotive/src/main/res/color/car_text_dark.xml ================================================ ================================================ FILE: automotive/src/main/res/color/car_text_light.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/default_button_background.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/google_sign_in_button_background.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/google_sign_in_button_logo.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/pin_background.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/sign_in_button_background.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/sign_in_toolbar_back_icon.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable/sign_in_toolbar_back_ripple_background.xml ================================================ ================================================ FILE: automotive/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: automotive/src/main/res/layout/activity_login.xml ================================================